Skip to content

feat(live-update): support file:// URIs in downloadBundle (Android + iOS)#846

Open
cevcode wants to merge 3 commits intocapawesome-team:mainfrom
cevcode:feat/live-update-file-uri-android-ios
Open

feat(live-update): support file:// URIs in downloadBundle (Android + iOS)#846
cevcode wants to merge 3 commits intocapawesome-team:mainfrom
cevcode:feat/live-update-file-uri-android-ios

Conversation

@cevcode
Copy link
Copy Markdown

@cevcode cevcode commented May 4, 2026

Why

Sideloaded content delivery: a managed device (e.g. Android Enterprise via MDM, iOS via offline package install) receives a bundle zip on its filesystem and needs to register it through the existing plugin API without introducing a parallel ingestion path. With file:// support, the consumer only ever calls LiveUpdate.downloadBundle({ url, bundleId }) regardless of source — same code path for HTTPS in production and file:// in offline/sideload scenarios.

What changed

  • LiveUpdate.java / LiveUpdate.swift: scheme guard at the top of downloadBundleOfTypeZip. If url starts with file://, dispatches to a new private copyFromFileSchemeAndAddBundle method. The new method copies the source to the existing temporary-zip path, runs the unchanged verifyFile + addBundleOfTypeZip pipeline, and cleans up on both success and error. The HTTPS branch (OkHttp on Android, Alamofire on iOS) is untouched.
  • LiveUpdateFileScheme.java (Android): new pure-Java static helper for byte-copy with progress callback. No Context dependency — JVM unit tests run without Robolectric.
  • Classes/LiveUpdateFileScheme.swift (iOS): same idea in Swift. enum LiveUpdateFileScheme with a static copyAndReportProgress method. No platform Context dependency.

Tests

Android — JUnit 4 (3 cases, run via ./gradlew test)

Test Asserts
copyAndReportProgress_copiesBytesIdentically bytes copied identically (16 KiB + 7 random); final progress event reports copied == total == source.length
copyAndReportProgress_throwsWhenSourceMissing FileNotFoundException when source absent
copyAndReportProgress_acceptsNullListener no NPE with null progress callback

`./gradlew clean build test` → all 3 pass on my machine. Output: `tests="3" skipped="0" failures="0" errors="0"`.

iOS — XCTest (3 cases, mirror of Android)

Test Asserts
testCopyAndReportProgress_copiesBytesIdentically same as Android
testCopyAndReportProgress_throwsWhenSourceMissing throws FileSchemeError.sourceNotFound
testCopyAndReportProgress_acceptsNilProgress no crash with nil progress callback

xcodebuild -workspace Plugin.xcworkspace -scheme Plugin build → succeeds with the new helper.

⚠️ A note on running xcodebuild test: the existing PluginTests/LiveUpdateTests.swift::testEcho doesn't compile on mainLiveUpdate() requires config: and plugin: arguments since the public init signature changed at some point. That's pre-existing and out of scope here, but it does block running the full test target. The new LiveUpdateFileSchemeTests.swift compiles cleanly when isolated. Happy to fix the testEcho regression in a follow-up if useful.

API design

No public TS API change. downloadBundle({ url, bundleId }) already accepts a free-form string; this just adds a recognised scheme that's dispatched at the top of the platform implementation. If you'd prefer an explicit method (e.g. addBundleFromFile) instead of the scheme-overload, happy to refactor — both platforms + tests would be a small follow-up.

…iOS)

Adds `file://` URL handling to `downloadBundle({ url, bundleId })` on
both platforms. Detects the scheme at the top of `downloadBundleOfTypeZip`
and dispatches to a new private helper that copies the local file to the
plugin's temporary directory, runs the existing checksum/signature
verification, and registers the bundle through the unchanged
`addBundleOfTypeZip` path. The HTTPS/OkHttp/Alamofire branch is
untouched.

Why
---
Sideloaded content delivery: an MDM-managed device receives a bundle
zip on its filesystem (e.g. via Android Enterprise managed
configuration) and needs to register it through the existing plugin
API without re-introducing a separate ingestion path. With file://
support, the consumer only ever calls `LiveUpdate.downloadBundle({ url,
bundleId })` regardless of source.

What changed
------------
- `LiveUpdate.java` / `LiveUpdate.swift`: scheme guard at the top of
  `downloadBundleOfTypeZip` + new private
  `copyFromFileSchemeAndAddBundle` method that reuses the existing
  `verifyFile` + `addBundleOfTypeZip` + cleanup pipeline.
- New `LiveUpdateFileScheme.java` (Android) and
  `Classes/LiveUpdateFileScheme.swift` (iOS): pure-Java / pure-Swift
  static helper for the byte-copy + progress reporting. No platform
  Context dependency, so unit tests run on the JVM (no Robolectric)
  and via XCTest without instantiating the full plugin.
- JUnit (3 cases) and XCTest (3 cases) covering: identical-bytes copy
  + final progress event, FileNotFound when source missing, null/nil
  progress callback accepted.

Tests
-----
- Android: `./gradlew clean build test` → 3/3 LiveUpdateFileSchemeTest
  passes (new test class), existing tests unchanged.
- iOS: `xcodebuild build` succeeds with the new helper. The existing
  `PluginTests/LiveUpdateTests.swift` `testEcho` is broken on `main`
  (`LiveUpdate()` is missing required `config:` and `plugin:`
  arguments since the public init signature changed) — that's
  pre-existing and out of scope here. The new
  `LiveUpdateFileSchemeTests.swift` compiles cleanly on its own.

API surface
-----------
No public TS API change. `downloadBundle({ url, bundleId })` already
accepts a free-form string; this adds a recognised scheme. If the
maintainers prefer an explicit method (`addBundleFromFile`) over the
overload, happy to refactor — both platforms + tests would be a small
follow-up commit.

Downstream
----------
Already shipped as a `yarn patch` over the npm release in our
consumer repo (validated runtime on Android emulator and iOS
simulator: file:// happy path + missing-file + checksum-mismatch +
HTTPS regression all behave correctly).
@robingenz robingenz assigned robingenz and unassigned robingenz May 4, 2026
@robingenz robingenz self-assigned this May 4, 2026
cevcode added 2 commits May 5, 2026 13:29
Source fixes:

iOS — LiveUpdateFileScheme.swift:
- Move output.write inside the withUnsafeBufferPointer closure so the
  pointer never escapes its valid scope (fixes Swift undefined behaviour)
- Add resolveFileUrl(_:allowedPrefixes:) — rejects non-file schemes via
  isFileURL, malformed URIs, and any path outside the caller-supplied
  sandbox prefixes after standardizedFileURL resolution
- Make all FileSchemeError.errorDescription strings generic (no path)
- Add invalidFileUri and sourceOutsideSandbox cases so the helper owns
  the full file-scheme error vocabulary

iOS — LiveUpdate.swift:
- Log entry to the file:// branch and the error in the catch via CAPLog
- Throw FileSchemeError.invalidFileUri (not CustomError.downloadFailed)
  when the URI is rejected
- Replace `try? FileManager.default.removeItem(...)` with a
  removeTemporaryFile() helper that logs cleanup failures
- Add sandboxPrefixes() helper that collects documents/library/caches/
  applicationSupport and the temp dir, and pass it to resolveFileUrl

Android — LiveUpdateFileScheme.java:
- Add resolveSandboxedFile(uri, allowedCanonicalPrefixes...) — parses
  the URI, asserts file scheme, canonicalises the candidate, and rejects
  paths that escape the supplied prefixes. Pure JVM (no Context) so it
  is unit-testable without Robolectric/Mockito
- Drop the absolute path from FileNotFoundException message

Android — LiveUpdate.java:
- Logger.debug entry log on the file:// branch and Logger.error in catch
- Route through resolveSandboxedFile, passing canonical paths for
  filesDir, cacheDir, and noBackupFilesDir
- Replace bare destination.delete() with a removeTemporaryFile() helper
  that warns when the delete returns false

Tests:
- Android: 9 new pure-JVM tests covering scheme rejection, sandbox
  enforcement, parent-traversal, prefix collision, and the error-message
  generic-string contract
- iOS: 7 new XCTests covering the same surface against
  LiveUpdateFileScheme.resolveFileUrl
- All 12 Android tests pass locally (gradle :testDebugUnitTest).
Per review feedback — for the file:// branch the source is already on
local disk, so the buffered loop with per-chunk progress events was
overkill. Reduce to a single copy call.

Android — LiveUpdate.java:
- Replace LiveUpdateFileScheme.copyAndReportProgress(...) call with
  java.nio.file.Files.copy(source, destination, REPLACE_EXISTING).
- Drop the DownloadBundleProgressEvent emission for the file:// branch
  (the HTTPS path's progress events are unchanged).

Android — LiveUpdateFileScheme.java:
- Drop ProgressListener interface and copyAndReportProgress method.
- Keep resolveSandboxedFile (still required for path validation).

iOS — LiveUpdate.swift:
- Replace LiveUpdateFileScheme.copyAndReportProgress(...) call with
  FileManager.default.copyItem(at:to:), preceded by a removeItem when
  the destination already exists.

iOS — LiveUpdateFileScheme.swift:
- Drop ProgressCallback typealias and copyAndReportProgress method.
- Drop sourceNotFound, streamOpenFailed, streamReadFailed,
  streamWriteFailed FileSchemeError cases (no longer raised by the
  helper — Files.copy/copyItem throw their own IO errors).
- Keep resolveFileUrl (still required for path validation).

Tests:
- Android: drop the three copyAndReportProgress tests; keep the eight
  resolveSandboxedFile tests (8/8 passing locally).
- iOS: drop the four copyAndReportProgress XCTests; keep the six
  resolveFileUrl XCTests.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants