Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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,177 @@
package com.getcapacitor;

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) {
//Transition pending input operation to started with resolved content type
this.transitionPendingInputOperation(operationID, contentType, null);
}

@JavascriptInterface
public void receiveStreamChunkFromJavascript(String chunk, String operationID) {
//Guarantee pending input transition to started operation (when no content type is resolved)
this.transitionPendingInputOperation(operationID, null, null);
//Append data to operation
this.operationsController.appendToOperation(operationID, chunk);
}

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

@JavascriptInterface
public void receiveStreamCompletionFromJavascript(String operationID) {
//Complete operation signal
if (!this.operationsController.completeOperation(operationID)) return;
//Notify
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:")) {
//setup background operation input (not started yet)
//will wait either stream start on content-type resolution to start asking
//for file pick and stream drain
String operationID = UUID.randomUUID().toString();
DownloadJSOperationController.Input input = new DownloadJSOperationController.Input(
operationID,
fileURL,
mimeType,
contentDisposition
);
this.pendingInputs.put(operationID, input);
//Return JS bridge with operationID tagged
return this.getJavascriptInterfaceBridgeForReadyAvailableData(fileURL, mimeType, operationID);
}
return null;
}

/* Injectors */
private String getJavascriptInterfaceBridgeForReadyAvailableData(String blobUrl, String mimeType, String operationID) {
return (
"javascript: " +
"" +
"function parseFile(file, chunkReadCallback, errorCallback, successCallback) {\n" +
" let fileSize = file.size;" +
" let chunkSize = 64 * 1024;" +
" let offset = 0;" +
" let self = this;" +
" let readBlock = null;" +
" let onLoadHandler = function(evt) {" +
" if (evt.target.error == null) {" +
" offset += evt.target.result.length;" +
" chunkReadCallback(evt.target.result);" +
" } 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.readAsBinaryString(blob);" +
" };" +
Comment on lines +119 to +124
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.

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', '" +
blobUrl +
"', true);" +
((mimeType != null && mimeType.length() > 0) ? "xhr.setRequestHeader('Content-type','" + mimeType + "');" : "") +
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.

The injected JS sets a Content-type request header on a GET request. This is non-standard and can trigger CORS preflights / server rejections for cross-origin URLs. Consider removing this header and relying on the response’s Content-Type, or using Accept if you need to hint desired types.

Suggested change
((mimeType != null && mimeType.length() > 0) ? "xhr.setRequestHeader('Content-type','" + mimeType + "');" : "") +
((mimeType != null && mimeType.length() > 0) ? "xhr.setRequestHeader('Accept','" + mimeType + "');" : "") +

Copilot uses AI. Check for mistakes.
"xhr.responseType = 'blob';" +
"xhr.onerror = 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.receiveStreamChunkFromJavascript(err.message, '" +
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.

The injected JS error handler calls receiveStreamChunkFromJavascript(err.message, ...) instead of receiveStreamErrorFromJavascript(...), so native will treat an error string as file content and never transition to the FAILED state. Update the injected JS to invoke the dedicated error method.

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.message, '" +

Copilot uses AI. Check for mistakes.
operationID +
"'); }, " +
" function() { console.log('[Capacitor XHR] - Drained!'); CapacitorDownloadInterface.receiveStreamCompletionFromJavascript('" +
operationID +
"'); } " +
" );" +
" } else {" +
" console.error('[Capacitor XHR] - error:', this.status, (e ? e.loaded : this.responseText));" +
" }" +
"};" +
"xhr.send();})()"
);
}

/* Helpers */
private void transitionPendingInputOperation(String operationID, @Nullable String optionalContentType, @Nullable Boolean doNotStart) {
//Check if have pending input operation, if not, we discard this content type resolution
//for some awkward reason the chunk was received before
DownloadJSOperationController.Input input = this.pendingInputs.get(operationID);
if (input == null) return;
//Set content type if available (override, no problem with that)
if (optionalContentType != null) {
Logger.debug("Received content type", optionalContentType);
input.optionalMimeType = optionalContentType;
}
//Start operation
this.pendingInputs.remove(operationID);
if (doNotStart == null || !doNotStart) this.launcher.launch(input);
//Notify
this.bridge.getApp().fireDownloadUpdate(operationID, App.DownloadStatus.STARTED, null);
Comment on lines +172 to +174
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.
return;
}
}
Loading
Loading