Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
76500c3
Improved solution for iOS 14.5 >=
gwdp Mar 3, 2022
a2b9de2
Improvements to file writting (avoid collision and empty file name (m…
gwdp Mar 3, 2022
a2de621
Android code working with encoding and mime type issues (also no webh…
gwdp Mar 5, 2022
f353ddf
More changes relative to android download, implement activity with fi…
gwdp Mar 11, 2022
79cd81e
Working thread operation with content type resolution from XHR
gwdp Mar 11, 2022
1f01c45
Intercept known chrome mimetype blobs and spawn same download process
gwdp Mar 11, 2022
b2ef3b5
Fix mime type issue and copy to capacitor
gwdp Mar 11, 2022
e71a8d2
Tweaks on android + add notification to application (yet, needs plugi…
gwdp Mar 11, 2022
93efbff
Improve handler to also download common mime type blobs
gwdp Mar 11, 2022
b9ffb6d
Add folder picker for iOS
gwdp Mar 12, 2022
e6afdc1
Fix Swiftlint warnings
gwdp Apr 7, 2022
efe51b6
Fix Java ESlint
gwdp Apr 7, 2022
a6ba8b9
Fix compiler errors after fixes on swift lint
gwdp Apr 8, 2022
c4e53ad
Fix compile errors on Xcode 12.4 and bellow
gwdp Apr 8, 2022
7020e28
Merge branch 'main' into blob-handling-update
riderx Feb 10, 2026
6c04ea9
Merge branch 'main' into blob-handling-update
riderx Mar 27, 2026
74f9323
Merge branch 'main' into blob-handling-update
riderx Aug 26, 2026
55443c4
fix: stop overriding Bridge service worker handling in download proxy
TorichanCapgo Aug 26, 2026
f44fcd5
fix: address remaining blob-download review comments
TorichanCapgo Aug 26, 2026
13bc703
fix: post download status as Int raw values for ObjC consumers
TorichanCapgo Aug 26, 2026
2759939
fix: address remaining blob-download review comments
TorichanCapgo Aug 26, 2026
60a593d
fix: address remaining blob-download review comments
TorichanCapgo Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions android/capacitor/src/main/java/com/getcapacitor/App.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,28 @@ public interface AppRestoredListener {
void onAppRestored(PluginResult result);
}

public enum DownloadStatus {
STARTED,
COMPLETED,
FAILED
}

/**
* Interface for callbacks when app is receives download request from webview.
*/
public interface AppDownloadListener {
void onAppDownloadUpdate(String operationID, DownloadStatus operationStatus, @Nullable String error);
}

@Nullable
private AppStatusChangeListener statusChangeListener;

@Nullable
private AppRestoredListener appRestoredListener;

@Nullable
private AppDownloadListener appDownloadListener;

private boolean isActive = false;

public boolean isActive() {
Expand All @@ -46,6 +62,14 @@ public void setAppRestoredListener(@Nullable AppRestoredListener listener) {
this.appRestoredListener = listener;
}

/**
* Set the object to receive callbacks.
* @param listener
*/
public void setAppDownloadListener(@Nullable AppDownloadListener listener) {
this.appDownloadListener = listener;
}

protected void fireRestoredResult(PluginResult result) {
if (appRestoredListener != null) {
appRestoredListener.onAppRestored(result);
Expand All @@ -58,4 +82,10 @@ public void fireStatusChange(boolean isActive) {
statusChangeListener.onAppStatusChanged(isActive);
}
}

public void fireDownloadUpdate(String operationID, DownloadStatus operationStatus, @Nullable String error) {
if (appDownloadListener != null) {
appDownloadListener.onAppDownloadUpdate(operationID, operationStatus, error);
}
}
}
10 changes: 10 additions & 0 deletions android/capacitor/src/main/java/com/getcapacitor/Bridge.java
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ public class Bridge {
private Boolean canInjectJS = true;
// A reference to the main WebView for the app
private final WebView webView;
public final DownloadJSProxy downloadProxy;
public final MockCordovaInterfaceImpl cordovaInterface;
private CordovaWebView cordovaWebView;
private CordovaPreferences preferences;
Expand Down Expand Up @@ -207,6 +208,7 @@ private Bridge(
this.fragment = fragment;
this.webView = webView;
this.webViewClient = new BridgeWebViewClient(this);
this.downloadProxy = new DownloadJSProxy(this);
this.initialPlugins = initialPlugins;
this.pluginInstances = pluginInstances;
this.cordovaInterface = cordovaInterface;
Expand Down Expand Up @@ -417,6 +419,12 @@ public boolean launchIntent(Uri url) {
}
return true;
}

/* Maybe handle blobs URI */
if (this.downloadProxy.shouldOverrideLoad(url.toString())) {
return true;
}

return false;
}

Expand Down Expand Up @@ -581,6 +589,8 @@ public void reset() {
private void initWebView() {
WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true);
webView.addJavascriptInterface(this.downloadProxy.jsInterface(), this.downloadProxy.jsInterfaceName());
webView.setDownloadListener(this.downloadProxy);
settings.setDomStorageEnabled(true);
settings.setGeolocationEnabled(true);
settings.setMediaPlaybackRequiresUserGesture(false);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
package com.getcapacitor;

import android.app.Activity;
import android.webkit.JavascriptInterface;
import androidx.activity.result.ActivityResultLauncher;
import androidx.annotation.Nullable;
import java.util.HashMap;
import java.util.UUID;

/**
* Represents the bridge.webview exposed JS download interface + proxy interface injector.
* Every download request from webview will have their URLs + mime, content-disposition
* analyzed in order to determine if we do have a injector that supports it and return
* to the proxy in order to have that code executed exclusively for that request.
*/
public class DownloadJSInterface {

private final DownloadJSOperationController operationsController;
private final ActivityResultLauncher<DownloadJSOperationController.Input> launcher;
private final HashMap<String, DownloadJSOperationController.Input> pendingInputs;
private final Bridge bridge;

public DownloadJSInterface(Bridge bridge) {
this.operationsController = new DownloadJSOperationController(bridge.getActivity());
this.pendingInputs = new HashMap<>();
this.bridge = bridge;
this.launcher =
bridge
.getActivity()
.registerForActivityResult(
this.operationsController,
result -> Logger.debug("DownloadJSActivity result", String.valueOf(result))
);
}

/* JavascriptInterface imp. */
@JavascriptInterface
public void receiveContentTypeFromJavascript(String contentType, String operationID) {
this.transitionPendingInputOperation(operationID, contentType, false);
}

@JavascriptInterface
public void receiveStreamChunkFromJavascript(String chunk, String operationID) {
this.transitionPendingInputOperation(operationID, null, false);
this.operationsController.appendToOperation(operationID, chunk);
}

@JavascriptInterface
public void receiveStreamErrorFromJavascript(String error, String operationID) {
// Drop pending input without launching the file picker or emitting STARTED.
this.pendingInputs.remove(operationID);
this.operationsController.failOperation(operationID);
this.bridge.getApp().fireDownloadUpdate(operationID, App.DownloadStatus.FAILED, error);
}
Comment on lines +48 to +54

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.


@JavascriptInterface
public void receiveStreamCompletionFromJavascript(String operationID) {
if (!this.operationsController.completeOperation(operationID)) return;
this.bridge.getApp().fireDownloadUpdate(operationID, App.DownloadStatus.COMPLETED, null);
}

/* Proxy injector
* This code analyze incoming download requests and return appropriated JS injectors.
* Injectors, handle the download request at the browser context and call the JSInterface
* with chunks of data to be written on the disk. This technic is specially useful for
* blobs and webworker initiated downloads.
*/
public String getJavascriptBridgeForURL(String fileURL, String contentDisposition, String mimeType) {
if (fileURL.startsWith("http://") || fileURL.startsWith("https://") || fileURL.startsWith("blob:")) {
String operationID = UUID.randomUUID().toString();
DownloadJSOperationController.Input input = new DownloadJSOperationController.Input(
operationID,
fileURL,
mimeType,
contentDisposition
);
this.pendingInputs.put(operationID, input);
return this.getJavascriptInterfaceBridgeForReadyAvailableData(fileURL, mimeType, operationID);
}
return null;
}

/* Injectors */
private String getJavascriptInterfaceBridgeForReadyAvailableData(String blobUrl, String mimeType, String operationID) {
String escapedUrl = blobUrl.replace("\\", "\\\\").replace("'", "\\'");
String acceptHeader =
(mimeType != null && mimeType.length() > 0) ? "xhr.setRequestHeader('Accept','" + mimeType.replace("'", "") + "');" : "";
return (
"javascript: " +
"function parseFile(file, chunkReadCallback, errorCallback, successCallback) {\n" +
" let fileSize = file.size;" +
" let chunkSize = 64 * 1024;" +
" let offset = 0;" +
" let readBlock = null;" +
" let onLoadHandler = function(evt) {" +
" if (evt.target.error == null) {" +
" var buf = evt.target.result;" +
" var bytes = new Uint8Array(buf);" +
" offset += bytes.length;" +
" var binary = '';" +
" for (var i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); }" +
" chunkReadCallback(binary);" +
" } else {" +
" errorCallback(evt.target.error);" +
" return;" +
" }" +
" if (offset >= fileSize) {" +
" if (successCallback) successCallback();" +
" return;" +
" }" +
" readBlock(offset, chunkSize, file);" +
" };" +
" readBlock = function(_offset, length, _file) {" +
" var r = new FileReader();" +
" var blob = _file.slice(_offset, length + _offset);" +
" r.onload = onLoadHandler;" +
" r.readAsArrayBuffer(blob);" +
" };" +
Comment on lines +113 to +118

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.

FileReader.readAsBinaryString is deprecated and may be removed in future WebView/WebKit versions. Prefer reading as ArrayBuffer and base64/byte conversion on the JS side (or use readAsArrayBuffer + typed arrays) to avoid relying on deprecated APIs for chunking.

Copilot uses AI. Check for mistakes.
" readBlock(offset, chunkSize, file);" +
"};\n" +
"(() => { let xhr = new XMLHttpRequest();" +
"xhr.open('GET', '" +
escapedUrl +
"', true);" +
acceptHeader +
"xhr.responseType = 'blob';" +
"xhr.onerror = function(e) {" +
" var msg = (e && e.type) ? e.type : 'network error';" +
" console.error('[Capacitor XHR] - error:', msg);" +
" CapacitorDownloadInterface.receiveStreamErrorFromJavascript(msg, '" +
operationID +
"');" +
"};" +
"xhr.onload = function(e) {" +
" if (this.status == 200) {" +
" let contentType = this.getResponseHeader('content-type');" +
" if (contentType) { CapacitorDownloadInterface.receiveContentTypeFromJavascript(contentType, '" +
operationID +
"'); }" +
" var blob = this.response;" +
" parseFile(blob, " +
" function(chunk) { CapacitorDownloadInterface.receiveStreamChunkFromJavascript(chunk, '" +
operationID +
"'); }," +
" function(err) { console.error('[Capacitor XHR] - error:', err); CapacitorDownloadInterface.receiveStreamErrorFromJavascript(err && err.message ? err.message : 'Unknown error', '" +
operationID +
"'); }, " +
" function() { console.log('[Capacitor XHR] - Drained!'); CapacitorDownloadInterface.receiveStreamCompletionFromJavascript('" +
operationID +
"'); } " +
" );" +
" } else {" +
" var msg = 'HTTP ' + this.status;" +
" console.error('[Capacitor XHR] - error:', this.status, (e ? e.loaded : this.responseText));" +
" CapacitorDownloadInterface.receiveStreamErrorFromJavascript(msg, '" +
operationID +
"');" +
" }" +
"};" +
"xhr.send();})()"
);
}

/* Helpers */
private void transitionPendingInputOperation(String operationID, @Nullable String optionalContentType, boolean doNotStart) {
DownloadJSOperationController.Input input = this.pendingInputs.get(operationID);
if (input == null) return;
if (optionalContentType != null) {
Logger.debug("Received content type", optionalContentType);
input.optionalMimeType = optionalContentType;
}
this.pendingInputs.remove(operationID);
Activity activity = bridge.getActivity();
if (activity == null) return;
activity.runOnUiThread(
() -> {
if (!doNotStart) {
launcher.launch(input);
bridge.getApp().fireDownloadUpdate(operationID, App.DownloadStatus.STARTED, null);
}
}
);
}
}
Loading