Skip to content

feat: Bidirectional Blob Transfer API for Efficient Binary Data Transfer - #8203

Open
riderx wants to merge 42 commits into
ionic-team:mainfrom
Cap-go:blob-bi-way
Open

feat: Bidirectional Blob Transfer API for Efficient Binary Data Transfer#8203
riderx wants to merge 42 commits into
ionic-team:mainfrom
Cap-go:blob-bi-way

Conversation

@riderx

@riderx riderx commented Oct 29, 2025

Copy link
Copy Markdown

Summary

This PR introduces a bidirectional blob transfer system that enables efficient binary data transfer between native code and JavaScript without base64 encoding overhead. This provides ~99% reduction in bridge traffic for large binary data like images, videos, and files.

Motivation

Currently, Capacitor transfers binary data via base64 encoding, which:

  • Increases payload size by 33%
  • Requires CPU-intensive encoding/decoding
  • Causes memory pressure and UI blocking
  • Is particularly slow for large files

This PR was inspired by capacitor-blob-writer but improves upon it by integrating directly into the core framework.

Features

1. Native → JavaScript (Return Blobs from Plugins)

iOS:

@objc func getImage(_ call: CAPPluginCall) {
    let imageData = // ... load image
    call.resolveWithBlob(data: imageData, mimeType: "image/png")
}

Android:

@PluginMethod
public void getImage(PluginCall call) {
    byte[] imageData = // ... load image
    call.resolveWithBlob(imageData, "image/png");
}

JavaScript:

const { blob } = await MyPlugin.getImage();
img.src = blob; // Direct use, no decoding!

2. JavaScript → Native (Send Blobs to Plugins)

JavaScript:

const canvas = document.querySelector('canvas');
const blob = await new Promise(resolve => canvas.toBlob(resolve));
const blobUrl = URL.createObjectURL(blob);
await MyPlugin.saveImage({ blob: blobUrl });

iOS:

@objc func saveImage(_ call: CAPPluginCall) {
    call.getBlobData(for: "blob") { data, mimeType, error in
        // Automatically handles both Capacitor and browser blob URLs
        saveToFile(data)
    }
}

Android:

@PluginMethod
public void saveImage(PluginCall call) {
    call.getBlobData("blob", new BlobDataCallback() {
        public void onSuccess(byte[] data, String mimeType) {
            saveToFile(data);
        }
    });
}

Implementation Details

Core Components

iOS:

  • `CAPBlobStore` - Thread-safe in-memory blob storage
  • `CAPBlobURLSchemeHandler` - Intercepts `blob:capacitor://` URLs
  • Extensions to `CAPPluginCall` for blob methods

Android:

  • `BlobStore` - Concurrent blob storage manager
  • Extensions to `PluginCall` for blob methods
  • JavaScript evaluation for fetching browser blobs

Blob URL Format

```
blob:capacitor://
```

Example: `blob:capacitor://a3d5e7f9-1234-5678-90ab-cdef12345678`

Automatic Source Detection

The system automatically handles:

  • Capacitor blobs (`blob:capacitor://...`) - Retrieved directly from storage
  • Browser blobs (`blob:http://...`) - Fetched via JavaScript evaluation

Memory Management

  • Default lifetime: 5 minutes (configurable)
  • Default size limit: 50MB (configurable)
  • Automatic cleanup on expiration
  • Thread-safe concurrent access
  • Manual removal supported

Security

  • Random UUID-based URLs (non-guessable)
  • App-scoped storage (no cross-app access)
  • Automatic cleanup prevents memory leaks
  • No cross-origin issues

Performance Comparison

Before (Base64)

```
1MB binary → 1.33MB base64 string → JavaScript

  • Encoding overhead
  • Memory duplication
  • UI blocking
    ```

After (Blob Transfer)

```
1MB binary → ~50 byte URL → JavaScript

  • No encoding
  • No memory duplication
  • Non-blocking
    ```

Result: ~99% reduction in bridge traffic

Testing

Comprehensive test suites included:

iOS: `BlobStoreTests.swift` (20+ tests)

  • Basic store/retrieve
  • Large binary data (1MB+)
  • Multiple concurrent blobs
  • Thread safety
  • Memory limits
  • MIME type handling
  • Edge cases

Android: `BlobStoreTest.java` (20+ tests)

  • All iOS test coverage
  • Unicode/binary integrity
  • Concurrent operations
  • Cleanup lifecycle

Documentation

Complete API documentation in `BLOB_TRANSFER_API.md`:

  • API reference for iOS/Android/JavaScript
  • Performance benchmarks
  • Migration guide from base64
  • Best practices
  • Error handling patterns
  • Memory management tips

Backward Compatibility

Fully backward compatible

  • No breaking changes
  • Opt-in API (existing base64 code works unchanged)
  • Can use both approaches simultaneously
  • Progressive migration supported

Files Changed

New Files (7)

  1. `ios/Capacitor/Capacitor/CAPBlobStore.swift`
  2. `ios/Capacitor/Capacitor/CAPBlobURLSchemeHandler.swift`
  3. `android/capacitor/src/main/java/com/getcapacitor/BlobStore.java`
  4. `ios/Capacitor/CapacitorTests/BlobStoreTests.swift`
  5. `android/capacitor/src/test/java/com/getcapacitor/BlobStoreTest.java`
  6. `BLOB_TRANSFER_API.md`

Modified Files (2)

  1. `ios/Capacitor/Capacitor/WebViewDelegationHandler.swift` - Added blob URL handling
  2. `android/capacitor/src/main/java/com/getcapacitor/PluginCall.java` - Added blob helper methods

Use Cases

Perfect for plugins handling:

  • 📸 Camera/Photo Gallery
  • 📄 File operations
  • 🎵 Audio recording/playback
  • 🎥 Video processing
  • 📊 Large data exports
  • 🖼️ Image manipulation
  • 📦 Archive operations

Example Plugins That Would Benefit

  • `@capacitor/camera`
  • `@capacitor/filesystem`
  • `@capacitor-community/media`
  • Any plugin handling large binary data

Migration Example

Before

@objc func getImage(_ call: CAPPluginCall) {
    let imageData = ... 
    let base64 = imageData.base64EncodedString() // Slow for large images
    call.resolve(["data": base64])
}

After

@objc func getImage(_ call: CAPPluginCall) {
    let imageData = ... 
    call.resolveWithBlob(data: imageData, mimeType: "image/png") // Fast!
}

Next Steps

To complete integration:

  1. Register `CAPBlobURLSchemeHandler` in WebView configuration
  2. Update TypeScript type definitions
  3. Consider updating official plugins to use blob transfer

Related


gwdp and others added 16 commits October 29, 2025 07:36
…issing headers)), add notifications at app level for file download statuses and path
…le picker and duplex stream for javascript proxy write
…inary data handling

- Added `resolveWithBlob` methods in `PluginCall.java` to return binary data as blob URLs.
- Introduced `getBlobData` method for fetching binary data from blob URLs.
- Created `BlobStore` class in iOS to manage temporary blob storage with size limits and cleanup.
- Implemented `CAPBlobURLSchemeHandler` to intercept and serve Capacitor blob URLs.
- Enhanced `WebViewDelegationHandler` to allow blob URL requests.
- Developed comprehensive unit tests for blob storage functionality in both Android and iOS.
- Added support for additional fields in blob responses.
- Ensured thread safety and proper handling of edge cases in blob storage operations.
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

Introduces a native-backed blob URL mechanism intended to reduce base64 bridge traffic by storing binary data in-memory on iOS/Android and exchanging lightweight blob:capacitor://... references between plugins and JavaScript. The PR also adds WebView download interception utilities and corresponding documentation/tests.

Changes:

  • Add iOS/Android in-memory blob stores plus plugin-call helper APIs for resolving/reading blobs.
  • Add iOS URL scheme handler and WebView delegation changes intended to allow loading blob:capacitor://... URLs (and iOS download handling).
  • Add Android download interception (JS interface + streaming to a document URI) and new documentation/tests.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 14 comments.

Show a summary per file
File Description
ios/Capacitor/Capacitor/CAPBlobStore.swift Adds iOS blob storage + plugin-call helpers (currently has critical compile/concurrency issues).
ios/Capacitor/Capacitor/CAPBlobURLSchemeHandler.swift Adds WKURLSchemeHandler intended to serve stored blobs (currently not wired into webview configuration).
ios/Capacitor/Capacitor/WebViewDelegationHandler.swift Adds blob URL allowlisting and download delegate/document picker flow.
ios/Capacitor/Capacitor/CAPNotifications.swift Adds notification name + status enum for download updates.
ios/Capacitor/CapacitorTests/BlobStoreTests.swift Adds iOS blob store test suite.
android/capacitor/src/main/java/com/getcapacitor/BlobStore.java Adds Android blob store + WebView blob fetch via evaluateJavascript.
android/capacitor/src/main/java/com/getcapacitor/PluginCall.java Adds resolveWithBlob and getBlobData helpers for plugins.
android/capacitor/src/main/java/com/getcapacitor/DownloadJSProxy.java Adds WebView download listener/proxy to inject JS download handling.
android/capacitor/src/main/java/com/getcapacitor/DownloadJSOperationController.java Adds ActivityResultContract + piped streaming to write downloaded data to a picked document URI.
android/capacitor/src/main/java/com/getcapacitor/DownloadJSInterface.java Adds JS interface endpoints and JS injector for chunked streaming downloads.
android/capacitor/src/main/java/com/getcapacitor/Bridge.java Hooks the download proxy into the WebView (JS interface + download listener).
android/capacitor/src/main/java/com/getcapacitor/App.java Adds app-level download update callback plumbing.
android/capacitor/src/test/java/com/getcapacitor/BlobStoreTest.java Adds Android blob store test suite.
BLOB_TRANSFER_API.md Adds public API documentation and usage guidance for blob transfer.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +199 to +209
}

// MARK: - Blob Fetching from WebView

/// Fetch a blob from a browser-created blob URL
/// - Parameters:
/// - blobUrl: A browser blob URL (e.g., "blob:http://...")
/// - webView: The WKWebView that created the blob
/// - completion: Called with the fetched data or error
@objc public func fetchWebViewBlob(blobUrl: String, from webView: WKWebView, completion: @escaping (Data?, String?, Error?) -> Void) {
// Use JavaScript to read the blob as base64 (we have to for cross-process transfer)

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.

fetchWebViewBlob(...) is declared at file scope (outside CAPBlobStore) but is being invoked as CAPBlobStore.shared.fetchWebViewBlob(...). As written, this won’t compile (and it also references WKWebView without importing WebKit). Move this method inside CAPBlobStore (or make it a static/free function and update call sites), and add the necessary import(s).

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: fetchWebViewBlob is now a method on CAPBlobStore (with import WebKit). Call sites using CAPBlobStore.shared.fetchWebViewBlob(...) compile.

proposedFileName: suggestedFilename,
downloadId: download.hash)

// Ask for document selection (it will cal the completion handler)

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.

Typo in comment: “it will cal the completion handler” → “it will call the completion handler”.

Suggested change
// Ask for document selection (it will cal the completion handler)
// Ask for document selection (it will call the completion handler)

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: comment now reads “it will call the completion handler”.

if (input.optionalMimeType != null) intent.setType(input.optionalMimeType);
if (paths != null && paths[0] != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) intent.putExtra(
DocumentsContract.EXTRA_INITIAL_URI,
paths[0]

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.

DocumentsContract.EXTRA_INITIAL_URI expects a Uri (Parcelable), but this code passes paths[0], which is a filesystem path String. This can cause the system picker to ignore the extra or crash with a ClassCastException. Either remove this extra, or provide a real Uri (e.g., a content:// Uri pointing to a directory/document tree).

Suggested change
paths[0]
Uri.fromFile(new File(paths[0]))

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: EXTRA_INITIAL_URI is no longer set from a suggested filename path. A string like photo.jpg is not a directory URI.

" function(chunk) { CapacitorDownloadInterface.receiveStreamChunkFromJavascript(chunk, '" +
operationID +
"'); }," +
" function(err) { console.error('[Capacitor XHR] - error:', err); CapacitorDownloadInterface.receiveStreamChunkFromJavascript(err.message, '" +

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.

In the injected JS, the file-read error path calls receiveStreamChunkFromJavascript(err.message, ...) instead of receiveStreamErrorFromJavascript(...). This will treat errors as data and never trigger the native failure handling/notifications. Update the injected JS to call the error method (and ensure the native side doesn’t start/complete the operation on error).

Suggested change
" function(err) { console.error('[Capacitor XHR] - error:', err); CapacitorDownloadInterface.receiveStreamChunkFromJavascript(err.message, '" +
" function(err) { console.error('[Capacitor XHR] - error:', err); CapacitorDownloadInterface.receiveStreamErrorFromJavascript(err && err.message ? err.message : 'Unknown error', '" +

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 injected JS error path now calls receiveStreamErrorFromJavascript instead of treating the error as a data chunk. The native error handler does not emit STARTED.

Comment on lines +592 to +593
webView.addJavascriptInterface(this.downloadProxy.jsInterface(), this.downloadProxy.jsInterfaceName());
webView.setDownloadListener(this.downloadProxy);

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.

The PR description focuses on a blob transfer API, but these changes also introduce a new WebView download interception system (DownloadJSProxy + JS interface + setDownloadListener) that affects how all downloads are handled. This behavioral change should be called out explicitly in the PR description and documented as a separate feature/flag if it’s intended to ship alongside blob transfer.

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.

The download interception (DownloadJSProxy + JS interface + setDownloadListener) is intentional and ships with this blob-transfer work so native blob URLs and browser blob downloads can be saved to disk. It is the same path as #8202. I will also call this out in the PR description.

Comment on lines +24 to +25
public enum FileDownloadNotificationStatus {
case started, completed, failed

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.

FileDownloadNotificationStatus is a Swift-only enum (no raw values / @objc), but it’s being placed into the NotificationCenter payload. If any Objective-C consumers observe this notification, they’ll receive an opaque __SwiftValue, and serialization/logging is awkward. Consider making this an @objc enum with an Int raw value (or pass a string status code in the notification payload).

Suggested change
public enum FileDownloadNotificationStatus {
case started, completed, failed
@objc public enum FileDownloadNotificationStatus: Int {
case started = 0
case completed = 1
case failed = 2

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: FileDownloadNotificationStatus is now an @objc public enum with Int raw values (started = 0, completed = 1, failed = 2).

Comment on lines +428 to +431
NotificationCenter.default.post(name: .capacitorDidReceiveFileDownloadUpdate, object: [
"id": String(download.hash),
"status": FileDownloadNotificationStatus.completed
])

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.

The download update notification payload puts a Swift enum (FileDownloadNotificationStatus.*) into the posted object dictionary. That value is not stable/portable across module boundaries (and won’t be useful to ObjC listeners). Prefer posting a primitive payload (e.g., string/Int raw value) and document the contract for observers.

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: notifications now post FileDownloadNotificationStatus.*.rawValue (Int) instead of the Swift enum object.

) {
//Auxs for filename gen.
String suggestedFilename = URLUtil.guessFileName(fileDownloadURL, optionalCD, optionalMimeType);
ArrayList<String> fileComps = new ArrayList<>(Arrays.asList(suggestedFilename.split(".")));

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.

suggestedFilename.split(".") uses a regex where . matches any character, so this will split the filename into single characters and break extension handling. Use an escaped dot (e.g., split("\\.")) or a more robust filename/extension extraction approach.

Suggested change
ArrayList<String> fileComps = new ArrayList<>(Arrays.asList(suggestedFilename.split(".")));
ArrayList<String> fileComps = new ArrayList<>(Arrays.asList(suggestedFilename.split("\\.")));

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: split(".") is now split("\\.") so the last path segment is used as the extension.

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.

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: @Override is now on parseResult.

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.

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: @Override is now on createIntent.

@riderx

riderx commented Mar 27, 2026

Copy link
Copy Markdown
Author

@copilot fix all the comment and resolve them

@TorichanCapgo

Copy link
Copy Markdown

Calling out a related behavior in this PR: besides the blob transfer API, it also adds WebView download interception (DownloadJSProxy + JS interface + setDownloadListener / iOS WKDownload). Downloads prompt for a destination folder/file and emit status notifications (STARTED / COMPLETED / FAILED) for apps and plugins to consume. That is intentional and ships with the blob work.

@TorichanCapgo

Copy link
Copy Markdown

Review-comment follow-up (we cannot edit the PR body from this fork):

  • Blob URLs are capacitorblob://<uuid> (not blob:capacitor://). WebKit reserves blob, so CAPBlobURLSchemeHandler is registered for capacitorblob in CAPBridgeViewController.prepareWebView.
  • Blobs expire by time (5 min) and size (50MB), they are not deleted on first retrieve.
  • WebView download interception (DownloadJSProxy / WKDownloadDelegate) is intentional so native blob URLs can be saved to disk; same path as Introduce blob handling for Android & iOS #8202.

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