-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Introduce blob handling for Android & iOS #8202
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
base: main
Are you sure you want to change the base?
Conversation
…issing headers)), add notifications at app level for file download statuses and path
…ook initiated downloads for now (stream))
…le picker and duplex stream for javascript proxy write
…n implementation)
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.
Pull request overview
Adds first-party blob/download handling for Capacitor webviews on iOS and Android, prompting users to choose a destination and emitting download status updates for app/plugin consumption.
Changes:
- iOS: route eligible navigation actions/responses into
WKDownload, prompt for a destination folder, and post download lifecycle notifications. - Android: add a WebView download listener + JS interface that XHR-fetches
blob:/HTTP(S) resources and streams them to a user-selected document via SAF. - Android/iOS: introduce download status notification primitives (Android via
Appcallbacks; iOS viaNotificationCenter).
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 19 comments.
Show a summary per file
| File | Description |
|---|---|
| ios/Capacitor/Capacitor/WebViewDelegationHandler.swift | Adds WKDownload handling + document picker destination selection + notification posts. |
| ios/Capacitor/Capacitor/CAPNotifications.swift | Adds a new download update notification name + status enum. |
| android/capacitor/src/main/java/com/getcapacitor/DownloadJSProxy.java | New proxy implementing DownloadListener and optional blob override + service worker interception. |
| android/capacitor/src/main/java/com/getcapacitor/DownloadJSOperationController.java | New ActivityResultContract that creates the SAF file, manages piped streams, and writes downloaded content. |
| android/capacitor/src/main/java/com/getcapacitor/DownloadJSInterface.java | New JS interface + injector for chunking XHR blob/HTTP downloads into native streaming writes. |
| android/capacitor/src/main/java/com/getcapacitor/Bridge.java | Wires the download proxy into the WebView (JS interface + download listener) and blob interception. |
| android/capacitor/src/main/java/com/getcapacitor/App.java | Adds download status callback API (AppDownloadListener). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @JavascriptInterface | ||
| public void receiveStreamErrorFromJavascript(String error, String operationID) { | ||
| //Guarantee pending input transition to 'started-but-stale' operation before actually failing | ||
| this.transitionPendingInputOperation(operationID, null, true); | ||
| //Fail operation signal | ||
| if (!this.operationsController.failOperation(operationID)) return; | ||
| //Notify | ||
| this.bridge.getApp().fireDownloadUpdate(operationID, App.DownloadStatus.FAILED, error); | ||
| } |
Copilot
AI
Feb 10, 2026
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.
receiveStreamErrorFromJavascript calls transitionPendingInputOperation(..., doNotStart=true), which removes the pending input but still fires a STARTED update and (because the operation was never launched) failOperation will typically return false, preventing a FAILED notification. The error path should either start the operation before failing, or avoid firing STARTED when doNotStart is true and ensure FAILED is still emitted/cleaned up.
| this.operations.put(operation.input.operationID, operation); | ||
| this.pendingOperation = null; | ||
| this.createThreadedPipeForOperation(operation, result.getData()); |
Copilot
AI
Feb 10, 2026
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.
parseResult passes result.getData() directly into the pipe setup without checking for null. ACTION_CREATE_DOCUMENT can return RESULT_OK with a null data Uri on some OEMs/flows; guard against null and treat it as a failure/cancel to avoid a crash later.
| this.operations.put(operation.input.operationID, operation); | |
| this.pendingOperation = null; | |
| this.createThreadedPipeForOperation(operation, result.getData()); | |
| Uri uri = result.getData(); | |
| if (uri == null) { | |
| // Treat null Uri as a failure/cancel to avoid crashes | |
| this.pendingOperation = null; // can't be used for writing anymore | |
| this.cancelPreOperation(operation); | |
| return false; | |
| } | |
| this.operations.put(operation.input.operationID, operation); | |
| this.pendingOperation = null; | |
| this.createThreadedPipeForOperation(operation, uri); |
| if (doNotStart == null || !doNotStart) this.launcher.launch(input); | ||
| //Notify | ||
| this.bridge.getApp().fireDownloadUpdate(operationID, App.DownloadStatus.STARTED, null); |
Copilot
AI
Feb 10, 2026
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.
transitionPendingInputOperation calls ActivityResultLauncher.launch(...) directly. Since @JavascriptInterface methods may be invoked off the main thread, launching an activity from here can crash (CalledFromWrongThreadException) or behave inconsistently. Consider wrapping launcher.launch(input) (and the fireDownloadUpdate callback) in bridge.getActivity().runOnUiThread(...) (or activity.runOnUiThread) to ensure it executes on the UI thread.
| if (doNotStart == null || !doNotStart) this.launcher.launch(input); | |
| //Notify | |
| this.bridge.getApp().fireDownloadUpdate(operationID, App.DownloadStatus.STARTED, null); | |
| final boolean shouldStart = (doNotStart == null || !doNotStart); | |
| bridge | |
| .getActivity() | |
| .runOnUiThread( | |
| () -> { | |
| if (shouldStart) { | |
| launcher.launch(input); | |
| } | |
| //Notify | |
| bridge.getApp().fireDownloadUpdate(operationID, App.DownloadStatus.STARTED, null); | |
| } | |
| ); |
| private void installServiceWorkerProxy() { | ||
| //Downloads can be done via webworker, webworkers might need local resources, we enable that | ||
| if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { | ||
| ServiceWorkerController swController = ServiceWorkerController.getInstance(); | ||
| swController.setServiceWorkerClient( | ||
| new ServiceWorkerClient() { | ||
| @Override | ||
| public WebResourceResponse shouldInterceptRequest(WebResourceRequest request) { | ||
| Logger.debug("ServiceWorker Request", request.getUrl().toString()); | ||
| return bridge.getLocalServer().shouldInterceptRequest(request); | ||
| } | ||
| } | ||
| ); |
Copilot
AI
Feb 10, 2026
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.
installServiceWorkerProxy() unconditionally calls ServiceWorkerController.setServiceWorkerClient(...), which overrides Bridge's existing service worker handling (and ignores config.isResolveServiceWorkerRequests()). This can break existing service worker interception behavior and may also run before localServer is initialized, causing a null dereference when bridge.getLocalServer() is used. Consider removing this override and relying on Bridge's existing config.isResolveServiceWorkerRequests() logic, or integrate with it so only one client is installed and it is safe when localServer is null.
| //Read | ||
| byte[] bytes = new byte[lastReadSize]; | ||
| lastReadSize = operation.inStream.read(bytes, 0, lastReadSize); | ||
| output.write(bytes); |
Copilot
AI
Feb 10, 2026
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.
output.write(bytes) writes the full buffer, even if inStream.read(...) returns fewer bytes than requested. This can corrupt the downloaded file by appending zero-padding/garbage. Use the actual byte count returned from read(...) (and handle -1) when writing to the output stream.
| output.write(bytes); | |
| if (lastReadSize == -1) { | |
| // End of stream; treat as no more data available | |
| lastReadSize = 0; | |
| continue; | |
| } | |
| output.write(bytes, 0, lastReadSize); |
| func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentAt url: URL) { | ||
| if let pendingDownload = self.pendingDownload { | ||
| // Generate unique file name on the choosen directory | ||
| let fileName: URL = self.getUniqueDownloadFileURL(url, suggestedFilename: pendingDownload.proposedFileName, optionalSuffix: nil) | ||
| pendingDownload.pathSelectionCallback(fileName) | ||
| // Notify |
Copilot
AI
Feb 10, 2026
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.
UIDocumentPickerDelegate on iOS 11+ primarily uses documentPicker(_:didPickDocumentsAt:). Implementing only the deprecated documentPicker(_:didPickDocumentAt:) risks never receiving the callback on modern iOS versions (which would leave the download’s completionHandler uncalled). Implement didPickDocumentsAt and forward the first URL to the existing logic.
| import MobileCoreServices | ||
|
|
||
| // TODO: remove once Xcode 12 support is dropped | ||
| #if compiler(<5.5) | ||
| protocol WKDownloadDelegate {} | ||
| #endif |
Copilot
AI
Feb 10, 2026
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.
MobileCoreServices is deprecated; for iOS 14+ the preferred API is UniformTypeIdentifiers (UTType.folder) with UIDocumentPickerViewController(forOpeningContentTypes:). If Xcode 12 support is still needed, consider conditional compilation so newer toolchains use UTType and only older compilers fall back to kUTTypeFolder.
| private void createThreadedPipeForOperation(Operation operation, Uri uri) { | ||
| DownloadJSOperationController upperRef = this; | ||
| Executors.newSingleThreadExecutor().execute(() -> upperRef.createPipeForOperation(operation, uri)); | ||
| } |
Copilot
AI
Feb 10, 2026
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.
createThreadedPipeForOperation creates a new Executors.newSingleThreadExecutor() per download and never shuts it down, which can leak threads over time if multiple downloads occur. Prefer a shared executor (e.g., a single cached/thread pool owned by the controller) and shut it down appropriately.
| return intent; | ||
| } | ||
|
|
||
| public Boolean parseResult(int resultCode, @Nullable Intent result) { |
Copilot
AI
Feb 10, 2026
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.
This method overrides ActivityResultContract<Input,Boolean>.parseResult; it is advisable to add an Override annotation.
| return true; | ||
| } | ||
|
|
||
| /* ActivityResultContract Implementation */ |
Copilot
AI
Feb 10, 2026
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.
This method overrides ActivityResultContract<Input,Boolean>.createIntent; it is advisable to add an Override annotation.
| /* ActivityResultContract Implementation */ | |
| /* ActivityResultContract Implementation */ | |
| @Override |
Summary
This PR introduces blob handling for Android & iOS webviews, based on the work from PR #5498 and updated to work with the latest main branch.
Whenever a
blob:/URL or normal download is requested to be 'opened', a folder selection is prompted and the file is saved.This addresses issue #5478
Features
blob:data...scheme - Handles both known and unknown mime-typesblob:http...schemeImplementation Details
iOS
Android
Common
Test Cases
Test cases are available at: https://github.com/ikon-integration/Capacitor-Blob-Download-Issue
Changes from original PR #5498