Skip to content

fix: pre-resolve HuggingFace redirects to fix 0-byte downloads on Android#110

Merged
alichherawalla merged 3 commits intomainfrom
fix/android-download-stuck-zero-bytes
Mar 5, 2026
Merged

fix: pre-resolve HuggingFace redirects to fix 0-byte downloads on Android#110
alichherawalla merged 3 commits intomainfrom
fix/android-download-stuck-zero-bytes

Conversation

@alichherawalla
Copy link
Owner

Summary

Fixes downloads stuck at 0 bytes on certain Android devices (Samsung S25 Ultra, OnePlus 11, Xiaomi). HuggingFace download URLs return a 302 redirect to a ~1350-char signed CDN URL (cas-bridge.xethub.hf.co). Some OEM DownloadManager implementations silently fail to follow this redirect.

Fix: Pre-resolve the redirect via a HEAD request before calling DownloadManager.enqueue(), so the system DownloadManager receives the final CDN URL directly with no redirects needed. Falls back to the original URL on any resolution error, so this change is safe for devices where downloads already work.

Also surfaces the DownloadManager reason string in the download UI for future diagnostics.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)

Screenshots / Screen Recordings

N/A — this is a backend/native fix. The only visible UI change is that the download status line now shows the DownloadManager reason when a download is stuck (e.g. "Queued · Waiting for WiFi").

Checklist

General

  • My code follows the project's coding style and conventions
  • I have performed a self-review of my code
  • I have added/updated comments where the logic isn't self-evident
  • My changes generate no new warnings or errors

Testing

  • I have tested on Android (physical device or emulator)
  • I have tested on iOS (physical device or simulator)
  • Existing tests pass locally (npm test)

React Native Specific

  • No new native module without corresponding platform implementation (Android + iOS)

Security

  • No secrets, API keys, or credentials are included in the code

Related Issues

Closes #107

Additional Notes

  • The resolveRedirects function uses HttpURLConnection (available on all Android versions) rather than OkHttp to avoid adding dependencies
  • The signed CDN URLs expire after 1 hour; since resolution happens immediately before enqueue(), this is not a concern for starting downloads
  • Pre-existing llama.rn lint failure in CI is unrelated to this change

…roid

HuggingFace download URLs return a 302 redirect to a ~1350-char signed
CDN URL (cas-bridge.xethub.hf.co). Some OEM DownloadManager implementations
silently fail to follow this redirect, causing downloads to stay at 0 bytes.
Reported on Samsung S25 Ultra, OnePlus 11, and Xiaomi devices.

- Pre-resolve redirects via HEAD request before calling DownloadManager.enqueue()
  so it receives the final CDN URL directly with no redirects needed
- Falls back to original URL on any resolution error
- Move startDownload work to background thread (resolveRedirects does network I/O)
- Surface DownloadManager reason in progress events and download UI for diagnostics

Closes #107

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@gemini-code-assist
Copy link

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request addresses a critical bug on specific Android devices where downloads from HuggingFace would get stuck at 0 bytes. The issue stemmed from the Android DownloadManager's inability to properly handle 302 redirects to lengthy CDN URLs. The solution involves proactively resolving these redirects to provide the DownloadManager with the direct content URL. Additionally, the changes enhance the user experience by exposing the underlying reason for download stalls or failures directly within the application's download interface.

Highlights

  • Fixes 0-byte downloads on Android: Implemented a mechanism to pre-resolve HuggingFace download URLs that issue 302 redirects to long CDN URLs. This prevents certain OEM Android DownloadManager implementations from silently failing to follow these redirects, which resulted in 0-byte downloads.
  • Manual Redirect Resolution: A new resolveRedirects function was added to manually follow HTTP 302 redirects using HttpURLConnection before passing the final URL to the Android DownloadManager. This function includes a fallback to the original URL if resolution fails.
  • Improved Download Status Visibility: The DownloadManager's internal reason string (e.g., 'Waiting for WiFi') is now surfaced and displayed in the download UI, providing better diagnostics for users when downloads are stuck or fail.
  • Background Thread for Download Initiation: The startDownload method now executes the download initiation logic, including redirect resolution, on a background thread to prevent blocking the main UI thread.
Changelog
  • android/app/src/main/java/ai/offgridmobile/download/DownloadManagerModule.kt
    • Added HttpURLConnection and URL imports for manual redirect resolution.
    • Wrapped the startDownload method's core logic in a new Thread to perform network I/O (redirect resolution) off the main thread.
    • Introduced a resolveRedirects function that performs HEAD requests to follow HTTP 302 redirects and returns the final URL, or the original URL on error.
    • Modified the DownloadManager.Request to use the resolvedUrl instead of the original URL.
    • Updated the persisted download information to include the reason string from the DownloadManager status.
  • src/screens/DownloadManagerScreen/items.tsx
    • Extended the DownloadItem and DownloadItemsData types to include an optional reason property.
    • Modified the buildDownloadItems function to pass the reason from the download progress to the DownloadItem.
    • Updated the ActiveDownloadCard component to conditionally display the reason alongside the download status text.
  • src/screens/DownloadManagerScreen/useDownloadManager.ts
    • Adjusted the condition for updating download progress to ensure updates only occur if bytesDownloaded has increased, preventing redundant state changes.
    • Included the reason property when updating the downloadProgress state.
  • src/services/backgroundDownloadService.ts
    • Added an optional reason field to the DownloadProgressEvent interface to accommodate the new diagnostic information.
  • src/stores/appStore.ts
    • Updated the DownloadProgressInfo type to include an optional reason property.
Activity
  • The pull request was created by alichherawalla to address a bug related to 0-byte downloads on Android.
  • The changes include a bug fix, as indicated by the [x] Bug fix checkbox.
  • The author performed a self-review and confirmed adherence to coding style and conventions.
  • Testing was conducted on Android (physical device or emulator), and existing tests passed locally.
  • The pull request closes issue feat: MNN GPU acceleration with OpenCL #107, indicating it resolves a reported problem.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a solid fix for an issue where downloads fail on certain Android devices due to improper handling of HTTP redirects. The approach of pre-resolving the final URL before enqueuing the download is well-implemented, including performing network operations on a background thread and safely falling back to the original URL on errors. Additionally, surfacing the DownloadManager's reason string in the UI is a nice improvement for diagnostics. However, it introduces two security vulnerabilities: a high-severity Path Traversal vulnerability via the fileName parameter and a medium-severity SSRF vulnerability via the url parameter. Both issues stem from insufficient validation of user-supplied input before sensitive operations. I also have a couple of suggestions for the Android native module to further improve robustness and resource management.

Comment on lines +122 to +125
val fileName = params.getString("fileName") ?: run {
promise.reject("DOWNLOAD_ERROR", "fileName is required")
return
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The fileName parameter is used to construct file paths without sanitization. An attacker can provide a fileName containing path traversal sequences (e.g., ../) to manipulate file paths, potentially leading to arbitrary file deletion (line 143) or arbitrary file write (line 165) within the app's accessible directories.

Suggested change
val fileName = params.getString("fileName") ?: run {
promise.reject("DOWNLOAD_ERROR", "fileName is required")
return
}
val fileName = params.getString("fileName")?.let { java.io.File(it).name } ?: run {
promise.reject("DOWNLOAD_ERROR", "fileName is required")
return
}

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — fileName is now sanitized with File(it).name to strip any path traversal sequences. Good catch.

connection.disconnect()
}
}
return currentUrl

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When the for loop completes, it means the maxRedirects limit has been reached. Returning currentUrl here might still pass a redirect URL to DownloadManager, which could cause the same issue this change aims to fix if that URL is also a redirect. It would be safer to treat this as a resolution failure, log a warning, and fall back to the originalUrl as you do in the catch block. This aligns with the goal of providing a safe fallback.

        android.util.Log.w("DownloadManager", "Redirect resolution failed: Exceeded max redirects ($maxRedirects). Using original URL.")
        return originalUrl

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — now logs a warning and returns originalUrl when max redirects is exceeded, consistent with the error fallback behavior.

// 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)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The url parameter is passed to resolveRedirects, which performs a network request using HttpURLConnection. Since the URL is not validated against an allow-list of trusted domains, an attacker can use this to perform SSRF attacks, probing internal network resources or local services on the device.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added an allowlist of trusted HuggingFace download hosts (huggingface.co, cdn-lfs.huggingface.co, cas-bridge.xethub.hf.co). URLs with unrecognized hosts are now rejected before any network request.

val hideNotification = params.hasKey("hideNotification") && params.getBoolean("hideNotification")

// Resolve redirects on a background thread (network I/O)
Thread {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

While creating a new Thread for each download is functional, it's generally better practice to use an ExecutorService to manage background threads. This provides better control over thread lifecycle and resource usage, preventing potential issues from creating an unbounded number of threads if downloads are initiated in quick succession.

Consider defining a class-level executor, for example:
private val executor = java.util.concurrent.Executors.newSingleThreadExecutor()

And then using it here:
executor.execute { ... }

This would serialize download initiations, which is often desirable.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — replaced raw Thread with a class-level Executors.newSingleThreadExecutor() to serialize download initiations and manage thread lifecycle properly.

…e executor

- Sanitize fileName with File(it).name to prevent path traversal (high)
- Validate download URL against allowlist of HuggingFace hosts to prevent SSRF (medium)
- Replace raw Thread with single-thread ExecutorService for better resource management (medium)
- Fall back to originalUrl when max redirects exceeded instead of returning last redirect (high)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@alichherawalla
Copy link
Owner Author

/gemini review

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request addresses the 0-byte download issue on certain Android devices by pre-resolving HuggingFace redirects. While it correctly adds an initial whitelist for allowed download hosts to prevent Server-Side Request Forgery (SSRF), a critical bypass is introduced in the manual redirect resolution logic by failing to re-validate the host after following a redirect. Additionally, the PR improves security by sanitizing the fileName parameter to prevent path traversal, moves network operations to a background thread to prevent ANRs, and enhances diagnostic capabilities by surfacing DownloadManager reason strings in the UI.

Comment on lines +98 to +102
private val allowedDownloadHosts = setOf(
"huggingface.co",
"cdn-lfs.huggingface.co",
"cas-bridge.xethub.hf.co",
)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The introduction of allowedDownloadHosts is a critical security enhancement. It helps prevent Server-Side Request Forgery (SSRF) attacks by ensuring that the application only attempts to download from trusted domains. This is a highly recommended security practice.

Comment on lines +142 to +145
val parsedHost = try { URL(url).host } catch (_: Exception) { null }
if (parsedHost == null || !allowedDownloadHosts.any { parsedHost == it || parsedHost.endsWith(".$it") }) {
promise.reject("DOWNLOAD_ERROR", "Download URL host not allowed: $parsedHost")
return

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

This URL validation against allowedDownloadHosts is a crucial security check. It directly leverages the allowedDownloadHosts set to prevent the application from making requests to arbitrary external URLs, which could be exploited for SSRF. This is an excellent addition.

Comment on lines +149 to +150
executor.execute {
try {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Moving the network-intensive resolveRedirects call and subsequent DownloadManager.enqueue operation to a background thread using executor.execute is a significant improvement. This prevents potential Application Not Responding (ANR) errors and keeps the UI responsive, adhering to Android's best practices for network operations.

Comment on lines +387 to +417
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
currentUrl = if (location.startsWith("http")) {
location
} else {
URL(URL(currentUrl), location).toString()
}
} 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
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The resolveRedirects function, while efficiently using HEAD requests and reasonable connectTimeout/readTimeout values (10 seconds), introduces a critical Server-Side Request Forgery (SSRF) vulnerability. It manually follows HTTP redirects without re-validating the host of the redirected URLs against the allowedDownloadHosts whitelist. This allows an attacker to bypass the initial SSRF protection by redirecting to internal or restricted resources. To remediate this, the host of the redirected URL should be validated against the allowedDownloadHosts whitelist inside the resolveRedirects loop before making the next request.

    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
    }

Comment on lines +398 to +404
val location = connection.getHeaderField("Location")
if (location.isNullOrEmpty()) return currentUrl
currentUrl = if (location.startsWith("http")) {
location
} else {
URL(URL(currentUrl), location).toString()
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic for handling the Location header, specifically distinguishing between absolute and relative URLs, is well-implemented. This ensures that redirects are followed correctly regardless of the format of the Location header, which is a common source of bugs in manual redirect handling.

Comment on lines +408 to +410
} catch (e: Exception) {
android.util.Log.w("DownloadManager", "Redirect resolution failed, using original URL", e)
return originalUrl

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The try-catch block around the network operations in resolveRedirects with a fallback to the originalUrl ensures resilience. If any error occurs during redirect resolution, the download can still proceed with the initial URL, preventing complete failure and improving user experience.

putDouble("totalBytes", statusInfo.getDouble("totalBytes").takeIf { it > 0 }
?: download.optDouble("totalBytes", 0.0))
putString("status", status)
putString("reason", statusInfo.getString("reason") ?: "")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Adding the reason string to the download progress event is a valuable diagnostic improvement. This allows the UI to display more specific information about why a download might be paused or failed, enhancing the user's understanding of the download status.

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;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The change to if ((useAppStore.getState().downloadProgress[key]?.bytesDownloaded ?? -1) >= event.bytesDownloaded) return; makes the progress update logic more robust. By using the null-safe operator ?. and providing a default value of -1, it correctly handles cases where downloadProgress[key] might be undefined or null, preventing potential issues with missed or incorrect progress updates.

Re-validate each redirected URL's host against allowedDownloadHosts
inside the resolveRedirects loop, preventing an attacker from using
a trusted initial URL that redirects to an internal/restricted resource.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@alichherawalla
Copy link
Owner Author

/gemini review

@sonarqubecloud
Copy link

sonarqubecloud bot commented Mar 5, 2026

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request effectively addresses a bug with HuggingFace downloads on certain Android devices by pre-resolving redirects. The implementation is robust, using HttpURLConnection to manually follow redirects and re-validating hosts at each step to prevent security vulnerabilities like SSRF. The networking and file operations are correctly moved to a background thread using an ExecutorService, preventing ANRs. Additionally, the changes improve diagnostics by surfacing the DownloadManager reason string in the UI. My review includes one high-severity suggestion to prevent a potential thread leak by ensuring the ExecutorService is properly shut down.

}
}

private val executor = Executors.newSingleThreadExecutor()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The ExecutorService created 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 onCatalystInstanceDestroy method, which is called when the React Native bridge is torn down.

Example:

    override fun onCatalystInstanceDestroy() {
        super.onCatalystInstanceDestroy()
        if (!executor.isShutdown) {
            executor.shutdown()
        }
    }

This method needs to be added to the DownloadManagerModule class.

@alichherawalla alichherawalla merged commit 427f07e into main Mar 5, 2026
5 checks passed
@alichherawalla
Copy link
Owner Author

Addressed the executor lifecycle comment — added onCatalystInstanceDestroy to shut down the executor when the RN bridge is torn down. All Gemini feedback has been addressed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant