Skip to content

Introduce blob handling for Android & iOS - #8202

Open
riderx wants to merge 22 commits into
ionic-team:mainfrom
Cap-go:blob-handling-update
Open

Introduce blob handling for Android & iOS#8202
riderx wants to merge 22 commits into
ionic-team:mainfrom
Cap-go:blob-handling-update

Conversation

@riderx

@riderx riderx commented Oct 29, 2025

Copy link
Copy Markdown

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

Copilot AI left a comment

Copy link
Copy Markdown

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

Copilot AI Feb 10, 2026

Copy link

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed: errors no longer launch the picker or emit STARTED. receiveStreamErrorFromJavascript drops the pending input and fires FAILED.

Comment on lines +157 to +159
this.operations.put(operation.input.operationID, operation);
this.pendingOperation = null;
this.createThreadedPipeForOperation(operation, result.getData());

Copilot AI Feb 10, 2026

Copy link

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed: parseResult now treats a null data Uri as cancel/failure.

Comment on lines +172 to +174
if (doNotStart == null || !doNotStart) this.launcher.launch(input);
//Notify
this.bridge.getApp().fireDownloadUpdate(operationID, App.DownloadStatus.STARTED, null);

Copilot AI Feb 10, 2026

Copy link

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed: launcher.launch and the STARTED notification now run on the UI thread.

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

Copilot AI Feb 10, 2026

Copy link

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Removed the ServiceWorkerClient override. Bridge already installs one when config.isResolveServiceWorkerRequests() is true.

//Read
byte[] bytes = new byte[lastReadSize];
lastReadSize = operation.inStream.read(bytes, 0, lastReadSize);
output.write(bytes);

Copilot AI Feb 10, 2026

Copy link

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed: the pipe now uses a blocking read and writes only lastReadSize bytes. Filename split uses \\.. Null pendingOperation / OutputStream are guarded. Shared cached executor replaces per-download executors. @Override added on createIntent/parseResult. EXTRA_INITIAL_URI is no longer set from a filesystem path.

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

Copilot AI Feb 10, 2026

Copy link

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed: implemented documentPicker(_:didPickDocumentsAt:) and switched newer toolchains to UTType.folder. Download status is posted as FileDownloadNotificationStatus Int raw values.

Comment on lines +3 to +8
import MobileCoreServices

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

Copilot AI Feb 10, 2026

Copy link

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

Copilot AI Feb 10, 2026

Copy link

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

Copilot AI Feb 10, 2026

Copy link

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

Copilot AI Feb 10, 2026

Copy link

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.
@riderx

riderx commented Mar 27, 2026

Copy link
Copy Markdown
Author

@copilot fix all comments and resolve them

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.

4 participants