Skip to content

fix(ios): resume interrupted downloads instead of hanging, and show download progress - #45

Draft
BMichaelJ wants to merge 2 commits into
WildMeOrg:fix/network-timeout-and-signin-spinnerfrom
BMichaelJ:fix/ios-download-settle-and-foreground
Draft

BMichaelJ wants to merge 2 commits into
WildMeOrg:fix/network-timeout-and-signin-spinnerfrom
BMichaelJ:fix/ios-download-settle-and-foreground

Conversation

@BMichaelJ

Copy link
Copy Markdown
Collaborator

Summary

Stacked on #42: please review and merge that first. This PR's base is #42's branch, and it retargets to wildlife-reid when #42 merges.

#42 bounds the iOS download with a watchdog and a background session, and notes that the download fix was not yet confirmed end to end. This PR finds why the iOS transfer itself hangs, fixes it, and makes the long download visible.

Root cause: react-native-fs never settles an interrupted iOS transfer

In react-native-fs 2.20.0, Downloader.m handles a stopped transfer in URLSession:task:didCompleteWithError: and stopDownload. When iOS can produce resume data, both only call the optional resumable callback. They never resolve or reject the downloadFile() promise, and an NSURLErrorCancelled error is dropped entirely.

We never passed resumable. Azure Blob always supports resuming: I checked the pack blob, which returns a strong ETag and Accept-Ranges: bytes. So practically any interruption left the promise pending forever, and that is the endless spinner in the report. Interruptions include a request timeout, a dropped connection, the app being suspended, and stopDownload. It is the same class of bug as itinance/react-native-fs#568, open since 2018. Android's implementation always settles, which is why Android never showed it.

With #42 alone, the watchdog turns the hang into a 60 s timeout and a restart from byte zero, up to three times, with no progress shown.

Changes

  • Resume in place. Pass resumable and call RNFS.resumeDownload(jobId), so an interrupted transfer continues from where it stopped. After 5 interruptions in one attempt, the attempt fails into the existing retry loop. A transfer stopped on purpose (watchdog or abort) is never resumed.
  • Wait for the foreground before the next step. A background transfer that finishes while the phone is locked wakes the app in the background. The next request then fails, because the tokens are stored WHEN_UNLOCKED and the call reports "Not signed in". A transfer started in the background is also discretionary. On iOS only, waitForForeground() holds the next transfer and the pack lookup until the app is active.
  • Return the background-session completion handler. RNFS.completeHandlerIOS(jobId) runs after each transfer, so iOS gets back the handler that AppDelegate passes to RNFS.
  • Show progress on the Packs screen (separate commit). "Download Latest Pack" fetches a 194.6 MB model and then a 217.5 MB pack behind a single spinner. It now shows the stage, the percentage and megabytes, and "Keep EleBook open until this finishes." Only stage changes go through the live region, so screen readers aren't flooded with percentages. The UI is shared, so Android shows it too.
  • Refactors to stay under the lint limits. The native transfer moved into fileDownloadService/nativeDownload.ts. PacksScreen's status and button-title ternaries became lookup tables with the same strings.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)

Screenshots / Screen Recordings

Android (Pixel 9a, Android 17)

Before After
Spinner only, as in the iPhone report

iOS

Not captured yet. It needs a device run (see the test plan below).

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)
  • I have tested in light mode and dark mode
  • Existing tests pass locally (npm test)
  • I have added tests that prove my fix is effective or my feature works

Android: Pixel 9a, Android 17, debug build of this branch. From a fresh install, I signed in and tapped Download Latest Pack. The model went from 0 to 100%, then the pack, and it installed with "Up to date" in about 87 s. I also tested the update path with airplane mode switched on mid-pack; see the Android note below.

The JS suite passes (70 suites, 1,067 tests), along with tsc --noEmit and eslint. The Android and iOS native suites that npm test also chains were not run. Only dark mode was checked. No iOS device or Mac was available on this host.

New tests cover:

  • resuming in place
  • the 5-resume cap
  • no resume after a deliberate stop
  • the iOS-only completion handler
  • the foreground wait
  • reporting 100% before hashing
  • the progress text
  • the Packs screen progress

React Native Specific

  • No new native module without corresponding platform implementation (Android + iOS)
  • No hardcoded pixel values — uses SPACING / TYPOGRAPHY constants from the theme
  • Styles use useThemedStyles pattern (not inline or static StyleSheet.create)

The rest of this section does not apply.

Performance & Models

  • Downloads / long-running tasks report progress to the UI
  • File paths are resolved correctly on both platforms (no hardcoded / vs \\)
  • Large files (models, assets) are not committed to the repository

Security

  • No secrets, API keys, or credentials are included in the code
  • User input is validated/sanitized where applicable

iOS test plan (@blclo)

This is a JS-only change. Check out the branch (gh pr checkout <this PR> -R WildMeOrg/off-grid-mobile) and reload Metro on your dev build; no native rebuild or pod install is needed. A release build is the better final check.

  1. On fix(ios): make sign-in work, and stop failures happening in silence #42's current build, confirm the cause. Filter Console.app on RNFS download while a download hangs. Expected: didCompleteWithError … NSURLSessionDownloadTaskResumeData, and nothing after it.
  2. From a clean state, tap Download Latest Pack and stay in the app. Expected: model progress, then pack progress, then the pack installs.
  3. Lock the screen for about a minute mid-download. Expected: after unlocking, progress continues rather than restarting from 0%.
  4. Turn Wi-Fi off for about 30 s mid-pack, then back on. Expected: the download continues.
  5. Lock the phone just before the model finishes and unlock a few minutes later. Expected: the pack starts after unlock, with no "Not signed in".
  6. Turn on airplane mode for the rest of the download. Expected: after about 3 minutes, an alert naming the reason, not an endless spinner.

Please report the device and iOS version, the network, pass/fail for each step, and the Console RNFS lines for any failure.

Related Issues

Stacked on #42. Android counterpart: #44, a failed download crashes the Android app.

Additional Notes

  • Android crash found during testing. The airplane-mode test crashed the Android app. This is a pre-existing react-native-fs bug, fixed separately in fix(android): stop a failed download from crashing the app #44 because it affects the current Android field build independently of this work. With that patch, the same test shows "Download failed" and the next update succeeds.
  • Not addressed here.
    • A retry or an app restart still starts from byte zero, because resume data is not persisted.
    • Orphaned CFNetworkDownload_*.tmp files are not cleaned up.
    • The retry schedule (about 1 s, then 2 s) cannot ride out a short outage.
  • Dev setup. With both the release and debug builds on one Android phone, the sign-in redirect opens an "Open with" chooser, because both builds claim the org.ganesha.elebook scheme. "Just once" on the top suggestion goes to the release app; pick "EleBook (Debug)".

Michael and others added 2 commits September 23, 2026 14:34
On iOS, react-native-fs never settles downloadFile() when a transfer stops
and iOS can produce resume data. It only calls the optional `resumable`
callback, which we never passed. Azure Blob always supports resuming (ETag
and byte ranges), so a dropped connection, a request timeout or a
stopDownload left the Packs screen spinning forever. Android settles every
transfer, which is why it never showed there.

- Pass `resumable` and resume the transfer in place. After five
  interruptions the attempt fails, so the retry loop still takes over.
- Wait for the foreground before starting a transfer or the pack lookup.
  Tokens are stored WHEN_UNLOCKED, and iOS treats transfers started in the
  background as discretionary.
- Hand iOS its background-session completion handler back after each
  transfer.
- Report 100% once the transfer completes, before the SHA-256 pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
"Download Latest Pack" fetches a 194.6 MB model and then a 217.5 MB pack
behind a single spinner, so a slow link and a real stall looked the same.
Show the stage, the percentage and megabytes, and a hint to keep the app
open. The stage line is the live region, so screen readers hear stage
changes rather than a new percentage every second. The screen is shared,
so Android shows the same text.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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