-
Notifications
You must be signed in to change notification settings - Fork 70
fix: pre-resolve HuggingFace redirects to fix 0-byte downloads on Android #110
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bf38e1a
d0f86fe
eb50b7c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,6 +13,9 @@ | |
| import org.json.JSONArray | ||
| import org.json.JSONObject | ||
| import java.io.File | ||
| import java.net.HttpURLConnection | ||
| import java.net.URL | ||
| import java.util.concurrent.Executors | ||
|
|
||
| class DownloadManagerModule(reactContext: ReactApplicationContext) : | ||
| ReactContextBaseJavaModule(reactContext) { | ||
|
|
@@ -90,6 +93,14 @@ | |
| } | ||
| } | ||
|
|
||
| private val executor = Executors.newSingleThreadExecutor() | ||
|
|
||
| private val allowedDownloadHosts = setOf( | ||
| "huggingface.co", | ||
| "cdn-lfs.huggingface.co", | ||
| "cas-bridge.xethub.hf.co", | ||
| ) | ||
|
Comment on lines
+98
to
+102
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| private val downloadManager: DownloadManager by lazy { | ||
| reactApplicationContext.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager | ||
| } | ||
|
|
@@ -113,67 +124,89 @@ | |
|
|
||
| @ReactMethod | ||
| fun startDownload(params: ReadableMap, promise: Promise) { | ||
| try { | ||
| val url = params.getString("url") ?: throw IllegalArgumentException("URL is required") | ||
| val fileName = params.getString("fileName") ?: throw IllegalArgumentException("fileName is required") | ||
| val title = params.getString("title") ?: fileName | ||
| val description = params.getString("description") ?: "Downloading model..." | ||
| val modelId = params.getString("modelId") ?: "" | ||
| val totalBytes = if (params.hasKey("totalBytes")) params.getDouble("totalBytes").toLong() else 0L | ||
| val hideNotification = params.hasKey("hideNotification") && params.getBoolean("hideNotification") | ||
|
|
||
| // Clean up any existing file with the same name to prevent DownloadManager | ||
| // from auto-renaming (e.g., file.gguf → file-1.gguf) | ||
| val existingFile = File( | ||
| reactApplicationContext.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), | ||
| fileName | ||
| ) | ||
| if (existingFile.exists()) { | ||
| android.util.Log.d("DownloadManager", "Deleting existing file before download: ${existingFile.absolutePath}") | ||
| existingFile.delete() | ||
| } | ||
|
|
||
| // Also clean up any stale entries from previous sessions | ||
| cleanupStaleDownloads() | ||
| val url = params.getString("url") ?: run { | ||
| promise.reject("DOWNLOAD_ERROR", "URL is required") | ||
| return | ||
| } | ||
| val fileName = params.getString("fileName")?.let { File(it).name } ?: run { | ||
| promise.reject("DOWNLOAD_ERROR", "fileName is required") | ||
| return | ||
| } | ||
| val title = params.getString("title") ?: fileName | ||
| val description = params.getString("description") ?: "Downloading model..." | ||
| val modelId = params.getString("modelId") ?: "" | ||
| val totalBytes = if (params.hasKey("totalBytes")) params.getDouble("totalBytes").toLong() else 0L | ||
| val hideNotification = params.hasKey("hideNotification") && params.getBoolean("hideNotification") | ||
|
|
||
| // Validate URL against allowed download hosts to prevent SSRF | ||
| val parsedHost = try { URL(url).host } catch (_: Exception) { null } | ||
|
Check warning on line 142 in android/app/src/main/java/ai/offgridmobile/download/DownloadManagerModule.kt
|
||
| if (parsedHost == null || !allowedDownloadHosts.any { parsedHost == it || parsedHost.endsWith(".$it") }) { | ||
| promise.reject("DOWNLOAD_ERROR", "Download URL host not allowed: $parsedHost") | ||
| return | ||
|
Comment on lines
+142
to
+145
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| } | ||
|
|
||
| val request = DownloadManager.Request(Uri.parse(url)) | ||
| .setTitle(title) | ||
| .setDescription(description) | ||
| .setNotificationVisibility( | ||
| if (hideNotification) DownloadManager.Request.VISIBILITY_HIDDEN | ||
| else DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED | ||
| ) | ||
| .setDestinationInExternalFilesDir( | ||
| reactApplicationContext, | ||
| Environment.DIRECTORY_DOWNLOADS, | ||
| // Resolve redirects on a background thread (network I/O) | ||
| executor.execute { | ||
| try { | ||
|
Comment on lines
+149
to
+150
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Moving the network-intensive |
||
| // Clean up any existing file with the same name to prevent DownloadManager | ||
| // from auto-renaming (e.g., file.gguf → file-1.gguf) | ||
| val existingFile = File( | ||
| reactApplicationContext.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), | ||
| fileName | ||
| ) | ||
| .setAllowedOverMetered(true) | ||
| .setAllowedOverRoaming(true) | ||
|
|
||
| val downloadId = downloadManager.enqueue(request) | ||
|
|
||
| // Persist download info | ||
| val downloadInfo = JSONObject().apply { | ||
| put("downloadId", downloadId) | ||
| put("url", url) | ||
| put("fileName", fileName) | ||
| put("modelId", modelId) | ||
| put("title", title) | ||
| put("totalBytes", totalBytes) | ||
| put("status", "pending") | ||
| put("startedAt", System.currentTimeMillis()) | ||
| } | ||
| persistDownload(downloadId, downloadInfo) | ||
| if (existingFile.exists()) { | ||
| android.util.Log.d("DownloadManager", "Deleting existing file before download: ${existingFile.absolutePath}") | ||
| existingFile.delete() | ||
| } | ||
|
|
||
| val result = Arguments.createMap().apply { | ||
| putDouble("downloadId", downloadId.toDouble()) | ||
| putString("fileName", fileName) | ||
| putString("modelId", modelId) | ||
| // Also clean up any stale entries from previous sessions | ||
| cleanupStaleDownloads() | ||
|
|
||
| // Pre-resolve redirects so DownloadManager gets the final CDN URL directly. | ||
| // HuggingFace returns a 302 redirect to a long signed CDN URL (~1350 chars) | ||
| // that some OEM DownloadManager implementations fail to follow silently. | ||
| val resolvedUrl = resolveRedirects(url) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed — added an allowlist of trusted HuggingFace download hosts ( |
||
| android.util.Log.d("DownloadManager", "Resolved URL: ${resolvedUrl.take(120)}...") | ||
|
|
||
| val request = DownloadManager.Request(Uri.parse(resolvedUrl)) | ||
| .setTitle(title) | ||
| .setDescription(description) | ||
| .setNotificationVisibility( | ||
| if (hideNotification) DownloadManager.Request.VISIBILITY_HIDDEN | ||
| else DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED | ||
| ) | ||
| .setDestinationInExternalFilesDir( | ||
| reactApplicationContext, | ||
| Environment.DIRECTORY_DOWNLOADS, | ||
| fileName | ||
| ) | ||
| .setAllowedOverMetered(true) | ||
| .setAllowedOverRoaming(true) | ||
|
|
||
| val downloadId = downloadManager.enqueue(request) | ||
|
|
||
| // Persist download info | ||
| val downloadInfo = JSONObject().apply { | ||
| put("downloadId", downloadId) | ||
| put("url", url) | ||
| put("fileName", fileName) | ||
| put("modelId", modelId) | ||
| put("title", title) | ||
| put("totalBytes", totalBytes) | ||
| put("status", "pending") | ||
| put("startedAt", System.currentTimeMillis()) | ||
| } | ||
| persistDownload(downloadId, downloadInfo) | ||
|
|
||
| val result = Arguments.createMap().apply { | ||
| putDouble("downloadId", downloadId.toDouble()) | ||
| putString("fileName", fileName) | ||
| putString("modelId", modelId) | ||
| } | ||
| promise.resolve(result) | ||
| } catch (e: Exception) { | ||
| promise.reject("DOWNLOAD_ERROR", "Failed to start download: ${e.message}", e) | ||
| } | ||
| promise.resolve(result) | ||
| } catch (e: Exception) { | ||
| promise.reject("DOWNLOAD_ERROR", "Failed to start download: ${e.message}", e) | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -344,6 +377,52 @@ | |
| // Required for RN event emitter | ||
| } | ||
|
|
||
| /** | ||
| * Follow HTTP redirects manually and return the final URL. | ||
| * Some OEM DownloadManager implementations silently fail on 302 redirects | ||
| * to long signed CDN URLs (e.g. HuggingFace → xethub.hf.co). | ||
| * By pre-resolving, DownloadManager gets the direct URL with no redirects. | ||
| * Falls back to the original URL on any error so downloads aren't blocked. | ||
| */ | ||
| internal fun resolveRedirects(originalUrl: String, maxRedirects: Int = 5): String { | ||
|
Check failure on line 387 in android/app/src/main/java/ai/offgridmobile/download/DownloadManagerModule.kt
|
||
| var currentUrl = originalUrl | ||
| for (i in 0 until maxRedirects) { | ||
| val connection = URL(currentUrl).openConnection() as HttpURLConnection | ||
|
Check warning on line 390 in android/app/src/main/java/ai/offgridmobile/download/DownloadManagerModule.kt
|
||
| try { | ||
| connection.instanceFollowRedirects = false | ||
| connection.requestMethod = "HEAD" | ||
| connection.connectTimeout = 10_000 | ||
| connection.readTimeout = 10_000 | ||
| val responseCode = connection.responseCode | ||
| if (responseCode in 300..399) { | ||
| val location = connection.getHeaderField("Location") | ||
| if (location.isNullOrEmpty()) return currentUrl | ||
| val nextUrl = if (location.startsWith("http")) { | ||
| location | ||
| } else { | ||
| URL(URL(currentUrl), location).toString() | ||
|
Check warning on line 403 in android/app/src/main/java/ai/offgridmobile/download/DownloadManagerModule.kt
|
||
| } | ||
|
Comment on lines
+398
to
+404
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| // Re-validate redirected host against allowlist to prevent SSRF bypass | ||
| val nextHost = try { URL(nextUrl).host } catch (_: Exception) { null } | ||
|
Check warning on line 406 in android/app/src/main/java/ai/offgridmobile/download/DownloadManagerModule.kt
|
||
| if (nextHost == null || !allowedDownloadHosts.any { nextHost == it || nextHost.endsWith(".$it") }) { | ||
| android.util.Log.w("DownloadManager", "Redirect to unauthorized host blocked: $nextHost") | ||
| return currentUrl | ||
| } | ||
| currentUrl = nextUrl | ||
| } else { | ||
| return currentUrl | ||
| } | ||
| } catch (e: Exception) { | ||
| android.util.Log.w("DownloadManager", "Redirect resolution failed, using original URL", e) | ||
| return originalUrl | ||
|
Comment on lines
+415
to
+417
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| } finally { | ||
| connection.disconnect() | ||
| } | ||
| } | ||
| android.util.Log.w("DownloadManager", "Redirect resolution exceeded max redirects ($maxRedirects), using original URL") | ||
| return originalUrl | ||
| } | ||
|
Comment on lines
+387
to
+424
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The internal fun resolveRedirects(originalUrl: String, maxRedirects: Int = 5): String {
var currentUrl = originalUrl
for (i in 0 until maxRedirects) {
val connection = URL(currentUrl).openConnection() as HttpURLConnection
try {
connection.instanceFollowRedirects = false
connection.requestMethod = "HEAD"
connection.connectTimeout = 10_000
connection.readTimeout = 10_000
val responseCode = connection.responseCode
if (responseCode in 300..399) {
val location = connection.getHeaderField("Location")
if (location.isNullOrEmpty()) return currentUrl
val nextUrl = if (location.startsWith("http")) {
location
} else {
URL(URL(currentUrl), location).toString()
}
// Validate the redirected host against the whitelist
val nextHost = try { URL(nextUrl).host } catch (_: Exception) { null }
if (nextHost == null || !allowedDownloadHosts.any { nextHost == it || nextHost.endsWith(".$it") }) {
android.util.Log.w("DownloadManager", "Redirect to unauthorized host blocked: $nextHost")
return currentUrl
}
currentUrl = nextUrl
} else {
return currentUrl
}
} catch (e: Exception) {
android.util.Log.w("DownloadManager", "Redirect resolution failed, using original URL", e)
return originalUrl
} finally {
connection.disconnect()
}
}
android.util.Log.w("DownloadManager", "Redirect resolution exceeded max redirects ($maxRedirects), using original URL")
return originalUrl
} |
||
|
|
||
| private fun pollAllDownloads() { | ||
| val downloads = getAllPersistedDownloads() | ||
|
|
||
|
|
@@ -361,6 +440,7 @@ | |
| putDouble("totalBytes", statusInfo.getDouble("totalBytes").takeIf { it > 0 } | ||
| ?: download.optDouble("totalBytes", 0.0)) | ||
| putString("status", status) | ||
| putString("reason", statusInfo.getString("reason") ?: "") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| } | ||
|
|
||
| val previousStatus = download.optString("status", "pending") | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -69,12 +69,12 @@ export function useDownloadManager(): UseDownloadManagerResult { | |
| if (!metadata) return; | ||
| const key = `${metadata.modelId}/${metadata.fileName}`; | ||
| if (cancelledKeysRef.current.has(key)) return; | ||
| const existing = useAppStore.getState().downloadProgress[key]; | ||
| if (existing && existing.bytesDownloaded >= event.bytesDownloaded) return; | ||
| if ((useAppStore.getState().downloadProgress[key]?.bytesDownloaded ?? -1) >= event.bytesDownloaded) return; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The change to |
||
| setDownloadProgress(key, { | ||
| progress: event.totalBytes > 0 ? event.bytesDownloaded / event.totalBytes : 0, | ||
| bytesDownloaded: event.bytesDownloaded, | ||
| totalBytes: event.totalBytes, | ||
| reason: event.reason || undefined, | ||
| }); | ||
| }); | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
ExecutorServicecreated here is not shut down when the module is destroyed. This can lead to a thread leak, as the executor's thread may keep the application process alive unnecessarily. It's important to manage the lifecycle of the executor to ensure resources are released correctly.You should shut down the executor in the
onCatalystInstanceDestroymethod, which is called when the React Native bridge is torn down.Example:
This method needs to be added to the
DownloadManagerModuleclass.