-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Introduce blob handling for Android & iOS #8202
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
76500c3
a2b9de2
a2de621
f353ddf
79cd81e
1f01c45
b2ef3b5
e71a8d2
93efbff
b9ffb6d
e6afdc1
efe51b6
a6ba8b9
c4e53ad
7020e28
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| @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
|
||||||||||||||||||||||||||||||||
| " readBlock(offset, chunkSize, file);" + | ||||||||||||||||||||||||||||||||
| "};\n" + | ||||||||||||||||||||||||||||||||
| "(() => { let xhr = new XMLHttpRequest();" + | ||||||||||||||||||||||||||||||||
| "xhr.open('GET', '" + | ||||||||||||||||||||||||||||||||
| blobUrl + | ||||||||||||||||||||||||||||||||
| "', true);" + | ||||||||||||||||||||||||||||||||
| ((mimeType != null && mimeType.length() > 0) ? "xhr.setRequestHeader('Content-type','" + mimeType + "');" : "") + | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
| ((mimeType != null && mimeType.length() > 0) ? "xhr.setRequestHeader('Content-type','" + mimeType + "');" : "") + | |
| ((mimeType != null && mimeType.length() > 0) ? "xhr.setRequestHeader('Accept','" + mimeType + "');" : "") + |
Copilot
AI
Feb 10, 2026
There was a problem hiding this comment.
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.
| " 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
AI
Feb 10, 2026
There was a problem hiding this comment.
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.
| 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); | |
| } | |
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
receiveStreamErrorFromJavascriptcallstransitionPendingInputOperation(..., doNotStart=true), which removes the pending input but still fires a STARTED update and (because the operation was never launched)failOperationwill typically return false, preventing a FAILED notification. The error path should either start the operation before failing, or avoid firing STARTED whendoNotStartis true and ensure FAILED is still emitted/cleaned up.