Skip to content

Conversation

@riderx
Copy link

@riderx riderx commented Oct 29, 2025

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

  • Base64 blobs through blob:data... scheme - Handles both known and unknown mime-types
    • Both Android and iOS were opening images (like PNG) in the internal browser, causing users to be stuck on the image requiring manual app closure
  • Blob links through blob:http... scheme
  • Web worker initiated downloads - Partially addressed (browser core limitations may apply)

Implementation Details

iOS

  • Works on iOS 14.5+ (WebKit exposed necessary APIs starting from this version)
  • When a download request is received, prompts for folder selection and saves the file with a unique filename
  • Includes compiler guards for Xcode 12 compatibility

Android

  • Implements JS interface that injects JS code to download the received URL through XHR
  • Uses the JS accessible context to pipe content in chunks to an Intent for file selection
  • Implements duplex stream for piping received content to file

Common

  • Both platforms ask users for destination selection
  • Implement download notifications to be consumed by App plugin
  • Applications can decide what to do with notifications (show alert, display in list, etc.)

Test Cases

Test cases are available at: https://github.com/ikon-integration/Capacitor-Blob-Download-Issue

Changes from original PR #5498

  • Resolved all merge conflicts with current main branch
  • Maintained compatibility with existing code structure
  • Preserved all bug fixes and improvements from the original PR

Copilot AI review requested due to automatic review settings February 10, 2026 02:09
Copy link

Copilot AI left a 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 App callbacks; iOS via NotificationCenter).

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.

Comment on lines +51 to +59
@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);
}
Copy link

Copilot AI Feb 10, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines +157 to +159
this.operations.put(operation.input.operationID, operation);
this.pendingOperation = null;
this.createThreadedPipeForOperation(operation, result.getData());
Copy link

Copilot AI Feb 10, 2026

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
Comment on lines +172 to +174
if (doNotStart == null || !doNotStart) this.launcher.launch(input);
//Notify
this.bridge.getApp().fireDownloadUpdate(operationID, App.DownloadStatus.STARTED, null);
Copy link

Copilot AI Feb 10, 2026

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.

Suggested change
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);
}
);

Copilot uses AI. Check for mistakes.
Comment on lines +67 to +79
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);
}
}
);
Copy link

Copilot AI Feb 10, 2026

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.

Copilot uses AI. Check for mistakes.
//Read
byte[] bytes = new byte[lastReadSize];
lastReadSize = operation.inStream.read(bytes, 0, lastReadSize);
output.write(bytes);
Copy link

Copilot AI Feb 10, 2026

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.

Suggested change
output.write(bytes);
if (lastReadSize == -1) {
// End of stream; treat as no more data available
lastReadSize = 0;
continue;
}
output.write(bytes, 0, lastReadSize);

Copilot uses AI. Check for mistakes.
Comment on lines +451 to +456
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
Copy link

Copilot AI Feb 10, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines +3 to +8
import MobileCoreServices

// TODO: remove once Xcode 12 support is dropped
#if compiler(<5.5)
protocol WKDownloadDelegate {}
#endif
Copy link

Copilot AI Feb 10, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines +169 to +172
private void createThreadedPipeForOperation(Operation operation, Uri uri) {
DownloadJSOperationController upperRef = this;
Executors.newSingleThreadExecutor().execute(() -> upperRef.createPipeForOperation(operation, uri));
}
Copy link

Copilot AI Feb 10, 2026

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.

Copilot uses AI. Check for mistakes.
return intent;
}

public Boolean parseResult(int resultCode, @Nullable Intent result) {
Copy link

Copilot AI Feb 10, 2026

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.

Copilot uses AI. Check for mistakes.
return true;
}

/* ActivityResultContract Implementation */
Copy link

Copilot AI Feb 10, 2026

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.

Suggested change
/* ActivityResultContract Implementation */
/* ActivityResultContract Implementation */
@Override

Copilot uses AI. Check for mistakes.
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.

2 participants