Skip to content

Commit 427f07e

Browse files
Merge pull request #110 from alichherawalla/fix/android-download-stuck-zero-bytes
fix: pre-resolve HuggingFace redirects to fix 0-byte downloads on Android
2 parents 903ee41 + eb50b7c commit 427f07e

File tree

5 files changed

+146
-61
lines changed

5 files changed

+146
-61
lines changed

android/app/src/main/java/ai/offgridmobile/download/DownloadManagerModule.kt

Lines changed: 136 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import com.facebook.react.modules.core.DeviceEventManagerModule
1313
import org.json.JSONArray
1414
import org.json.JSONObject
1515
import java.io.File
16+
import java.net.HttpURLConnection
17+
import java.net.URL
18+
import java.util.concurrent.Executors
1619

1720
class DownloadManagerModule(reactContext: ReactApplicationContext) :
1821
ReactContextBaseJavaModule(reactContext) {
@@ -90,6 +93,14 @@ class DownloadManagerModule(reactContext: ReactApplicationContext) :
9093
}
9194
}
9295

96+
private val executor = Executors.newSingleThreadExecutor()
97+
98+
private val allowedDownloadHosts = setOf(
99+
"huggingface.co",
100+
"cdn-lfs.huggingface.co",
101+
"cas-bridge.xethub.hf.co",
102+
)
103+
93104
private val downloadManager: DownloadManager by lazy {
94105
reactApplicationContext.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
95106
}
@@ -113,67 +124,89 @@ class DownloadManagerModule(reactContext: ReactApplicationContext) :
113124

114125
@ReactMethod
115126
fun startDownload(params: ReadableMap, promise: Promise) {
116-
try {
117-
val url = params.getString("url") ?: throw IllegalArgumentException("URL is required")
118-
val fileName = params.getString("fileName") ?: throw IllegalArgumentException("fileName is required")
119-
val title = params.getString("title") ?: fileName
120-
val description = params.getString("description") ?: "Downloading model..."
121-
val modelId = params.getString("modelId") ?: ""
122-
val totalBytes = if (params.hasKey("totalBytes")) params.getDouble("totalBytes").toLong() else 0L
123-
val hideNotification = params.hasKey("hideNotification") && params.getBoolean("hideNotification")
124-
125-
// Clean up any existing file with the same name to prevent DownloadManager
126-
// from auto-renaming (e.g., file.gguf → file-1.gguf)
127-
val existingFile = File(
128-
reactApplicationContext.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
129-
fileName
130-
)
131-
if (existingFile.exists()) {
132-
android.util.Log.d("DownloadManager", "Deleting existing file before download: ${existingFile.absolutePath}")
133-
existingFile.delete()
134-
}
135-
136-
// Also clean up any stale entries from previous sessions
137-
cleanupStaleDownloads()
127+
val url = params.getString("url") ?: run {
128+
promise.reject("DOWNLOAD_ERROR", "URL is required")
129+
return
130+
}
131+
val fileName = params.getString("fileName")?.let { File(it).name } ?: run {
132+
promise.reject("DOWNLOAD_ERROR", "fileName is required")
133+
return
134+
}
135+
val title = params.getString("title") ?: fileName
136+
val description = params.getString("description") ?: "Downloading model..."
137+
val modelId = params.getString("modelId") ?: ""
138+
val totalBytes = if (params.hasKey("totalBytes")) params.getDouble("totalBytes").toLong() else 0L
139+
val hideNotification = params.hasKey("hideNotification") && params.getBoolean("hideNotification")
140+
141+
// Validate URL against allowed download hosts to prevent SSRF
142+
val parsedHost = try { URL(url).host } catch (_: Exception) { null }
143+
if (parsedHost == null || !allowedDownloadHosts.any { parsedHost == it || parsedHost.endsWith(".$it") }) {
144+
promise.reject("DOWNLOAD_ERROR", "Download URL host not allowed: $parsedHost")
145+
return
146+
}
138147

139-
val request = DownloadManager.Request(Uri.parse(url))
140-
.setTitle(title)
141-
.setDescription(description)
142-
.setNotificationVisibility(
143-
if (hideNotification) DownloadManager.Request.VISIBILITY_HIDDEN
144-
else DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED
145-
)
146-
.setDestinationInExternalFilesDir(
147-
reactApplicationContext,
148-
Environment.DIRECTORY_DOWNLOADS,
148+
// Resolve redirects on a background thread (network I/O)
149+
executor.execute {
150+
try {
151+
// Clean up any existing file with the same name to prevent DownloadManager
152+
// from auto-renaming (e.g., file.gguf → file-1.gguf)
153+
val existingFile = File(
154+
reactApplicationContext.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
149155
fileName
150156
)
151-
.setAllowedOverMetered(true)
152-
.setAllowedOverRoaming(true)
153-
154-
val downloadId = downloadManager.enqueue(request)
155-
156-
// Persist download info
157-
val downloadInfo = JSONObject().apply {
158-
put("downloadId", downloadId)
159-
put("url", url)
160-
put("fileName", fileName)
161-
put("modelId", modelId)
162-
put("title", title)
163-
put("totalBytes", totalBytes)
164-
put("status", "pending")
165-
put("startedAt", System.currentTimeMillis())
166-
}
167-
persistDownload(downloadId, downloadInfo)
157+
if (existingFile.exists()) {
158+
android.util.Log.d("DownloadManager", "Deleting existing file before download: ${existingFile.absolutePath}")
159+
existingFile.delete()
160+
}
168161

169-
val result = Arguments.createMap().apply {
170-
putDouble("downloadId", downloadId.toDouble())
171-
putString("fileName", fileName)
172-
putString("modelId", modelId)
162+
// Also clean up any stale entries from previous sessions
163+
cleanupStaleDownloads()
164+
165+
// Pre-resolve redirects so DownloadManager gets the final CDN URL directly.
166+
// HuggingFace returns a 302 redirect to a long signed CDN URL (~1350 chars)
167+
// that some OEM DownloadManager implementations fail to follow silently.
168+
val resolvedUrl = resolveRedirects(url)
169+
android.util.Log.d("DownloadManager", "Resolved URL: ${resolvedUrl.take(120)}...")
170+
171+
val request = DownloadManager.Request(Uri.parse(resolvedUrl))
172+
.setTitle(title)
173+
.setDescription(description)
174+
.setNotificationVisibility(
175+
if (hideNotification) DownloadManager.Request.VISIBILITY_HIDDEN
176+
else DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED
177+
)
178+
.setDestinationInExternalFilesDir(
179+
reactApplicationContext,
180+
Environment.DIRECTORY_DOWNLOADS,
181+
fileName
182+
)
183+
.setAllowedOverMetered(true)
184+
.setAllowedOverRoaming(true)
185+
186+
val downloadId = downloadManager.enqueue(request)
187+
188+
// Persist download info
189+
val downloadInfo = JSONObject().apply {
190+
put("downloadId", downloadId)
191+
put("url", url)
192+
put("fileName", fileName)
193+
put("modelId", modelId)
194+
put("title", title)
195+
put("totalBytes", totalBytes)
196+
put("status", "pending")
197+
put("startedAt", System.currentTimeMillis())
198+
}
199+
persistDownload(downloadId, downloadInfo)
200+
201+
val result = Arguments.createMap().apply {
202+
putDouble("downloadId", downloadId.toDouble())
203+
putString("fileName", fileName)
204+
putString("modelId", modelId)
205+
}
206+
promise.resolve(result)
207+
} catch (e: Exception) {
208+
promise.reject("DOWNLOAD_ERROR", "Failed to start download: ${e.message}", e)
173209
}
174-
promise.resolve(result)
175-
} catch (e: Exception) {
176-
promise.reject("DOWNLOAD_ERROR", "Failed to start download: ${e.message}", e)
177210
}
178211
}
179212

@@ -344,6 +377,52 @@ class DownloadManagerModule(reactContext: ReactApplicationContext) :
344377
// Required for RN event emitter
345378
}
346379

380+
/**
381+
* Follow HTTP redirects manually and return the final URL.
382+
* Some OEM DownloadManager implementations silently fail on 302 redirects
383+
* to long signed CDN URLs (e.g. HuggingFace → xethub.hf.co).
384+
* By pre-resolving, DownloadManager gets the direct URL with no redirects.
385+
* Falls back to the original URL on any error so downloads aren't blocked.
386+
*/
387+
internal fun resolveRedirects(originalUrl: String, maxRedirects: Int = 5): String {
388+
var currentUrl = originalUrl
389+
for (i in 0 until maxRedirects) {
390+
val connection = URL(currentUrl).openConnection() as HttpURLConnection
391+
try {
392+
connection.instanceFollowRedirects = false
393+
connection.requestMethod = "HEAD"
394+
connection.connectTimeout = 10_000
395+
connection.readTimeout = 10_000
396+
val responseCode = connection.responseCode
397+
if (responseCode in 300..399) {
398+
val location = connection.getHeaderField("Location")
399+
if (location.isNullOrEmpty()) return currentUrl
400+
val nextUrl = if (location.startsWith("http")) {
401+
location
402+
} else {
403+
URL(URL(currentUrl), location).toString()
404+
}
405+
// Re-validate redirected host against allowlist to prevent SSRF bypass
406+
val nextHost = try { URL(nextUrl).host } catch (_: Exception) { null }
407+
if (nextHost == null || !allowedDownloadHosts.any { nextHost == it || nextHost.endsWith(".$it") }) {
408+
android.util.Log.w("DownloadManager", "Redirect to unauthorized host blocked: $nextHost")
409+
return currentUrl
410+
}
411+
currentUrl = nextUrl
412+
} else {
413+
return currentUrl
414+
}
415+
} catch (e: Exception) {
416+
android.util.Log.w("DownloadManager", "Redirect resolution failed, using original URL", e)
417+
return originalUrl
418+
} finally {
419+
connection.disconnect()
420+
}
421+
}
422+
android.util.Log.w("DownloadManager", "Redirect resolution exceeded max redirects ($maxRedirects), using original URL")
423+
return originalUrl
424+
}
425+
347426
private fun pollAllDownloads() {
348427
val downloads = getAllPersistedDownloads()
349428

@@ -361,6 +440,7 @@ class DownloadManagerModule(reactContext: ReactApplicationContext) :
361440
putDouble("totalBytes", statusInfo.getDouble("totalBytes").takeIf { it > 0 }
362441
?: download.optDouble("totalBytes", 0.0))
363442
putString("status", status)
443+
putString("reason", statusInfo.getString("reason") ?: "")
364444
}
365445

366446
val previousStatus = download.optString("status", "pending")

src/screens/DownloadManagerScreen/items.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,11 @@ export type DownloadItem = {
2525
filePath?: string;
2626
isVisionModel?: boolean;
2727
mmProjPath?: string;
28+
reason?: string;
2829
};
2930

3031
export interface DownloadItemsData {
31-
downloadProgress: Record<string, { progress: number; bytesDownloaded: number; totalBytes: number }>;
32+
downloadProgress: Record<string, { progress: number; bytesDownloaded: number; totalBytes: number; reason?: string }>;
3233
activeDownloads: BackgroundDownloadInfo[];
3334
activeBackgroundDownloads: Record<number, { modelId: string; fileName: string; author: string; quantization: string; totalBytes: number } | null>;
3435
downloadedModels: DownloadedModel[];
@@ -87,6 +88,7 @@ export function buildDownloadItems(data: DownloadItemsData): DownloadItem[] {
8788
bytesDownloaded: progress.bytesDownloaded,
8889
progress: progress.progress,
8990
status: 'downloading',
91+
reason: progress.reason,
9092
});
9193
});
9294

@@ -191,7 +193,9 @@ export const ActiveDownloadCard: React.FC<ActiveDownloadCardProps> = ({ item, on
191193
<View style={styles.quantBadge}>
192194
<Text style={styles.quantText}>{item.quantization}</Text>
193195
</View>
194-
<Text style={styles.statusText}>{getStatusText(item.status)}</Text>
196+
<Text style={styles.statusText}>
197+
{getStatusText(item.status)}{item.reason ? ` · ${item.reason}` : ''}
198+
</Text>
195199
</View>
196200
</Card>
197201
);

src/screens/DownloadManagerScreen/useDownloadManager.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,12 @@ export function useDownloadManager(): UseDownloadManagerResult {
6969
if (!metadata) return;
7070
const key = `${metadata.modelId}/${metadata.fileName}`;
7171
if (cancelledKeysRef.current.has(key)) return;
72-
const existing = useAppStore.getState().downloadProgress[key];
73-
if (existing && existing.bytesDownloaded >= event.bytesDownloaded) return;
72+
if ((useAppStore.getState().downloadProgress[key]?.bytesDownloaded ?? -1) >= event.bytesDownloaded) return;
7473
setDownloadProgress(key, {
7574
progress: event.totalBytes > 0 ? event.bytesDownloaded / event.totalBytes : 0,
7675
bytesDownloaded: event.bytesDownloaded,
7776
totalBytes: event.totalBytes,
77+
reason: event.reason || undefined,
7878
});
7979
});
8080

src/services/backgroundDownloadService.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ interface DownloadProgressEvent {
2727
bytesDownloaded: number;
2828
totalBytes: number;
2929
status: BackgroundDownloadStatus;
30+
reason?: string;
3031
}
3132

3233
interface DownloadCompleteEvent {

src/stores/appStore.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Platform } from 'react-native';
44
import AsyncStorage from '@react-native-async-storage/async-storage';
55
import { DeviceInfo, DownloadedModel, ModelRecommendation, ONNXImageModel, ImageGenerationMode, AutoDetectMethod, ModelLoadingStrategy, CacheType, GeneratedImage, PersistedDownloadInfo } from '../types';
66

7-
type DownloadProgressInfo = { progress: number; bytesDownloaded: number; totalBytes: number };
7+
type DownloadProgressInfo = { progress: number; bytesDownloaded: number; totalBytes: number; reason?: string };
88

99
type OnboardingChecklist = {
1010
downloadedModel: boolean; loadedModel: boolean; sentMessage: boolean;

0 commit comments

Comments
 (0)