@@ -13,6 +13,9 @@ import com.facebook.react.modules.core.DeviceEventManagerModule
1313import org.json.JSONArray
1414import org.json.JSONObject
1515import java.io.File
16+ import java.net.HttpURLConnection
17+ import java.net.URL
18+ import java.util.concurrent.Executors
1619
1720class 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" )
0 commit comments