diff --git a/BLOB_TRANSFER_API.md b/BLOB_TRANSFER_API.md new file mode 100644 index 000000000..85886b318 --- /dev/null +++ b/BLOB_TRANSFER_API.md @@ -0,0 +1,589 @@ +# Blob Transfer API + +## Overview + +The Blob Transfer API provides efficient binary data transfer between native code and JavaScript without base64 encoding overhead. This is particularly useful for: + +- Image data transfer +- File operations +- Audio/video data +- Large binary payloads +- Any scenario where base64 encoding is a performance bottleneck + +## Performance Benefits + +### Traditional Base64 Approach +``` +Native Binary Data (1MB) + → Base64 Encode (33% size increase = 1.33MB) + → JSON Serialize + → JavaScript receives 1.33MB string + → Base64 Decode back to binary +``` + +### Blob Transfer Approach +``` +Native Binary Data (1MB) + → Store in BlobStore + → Send blob URL (< 100 bytes) + → JavaScript receives tiny URL string + → Access blob directly via URL +``` + +**Result**: ~99% reduction in data transferred over the bridge for large binaries. + +## API Reference + +### iOS (Swift) + +#### Returning Blob Data from Plugin + +```swift +import Capacitor + +@objc(MyPlugin) +public class MyPlugin: CAPPlugin { + + @objc func getImage(_ call: CAPPluginCall) { + // Load image data + guard let image = UIImage(named: "example"), + let imageData = image.pngData() else { + call.reject("Failed to load image") + return + } + + // Return as blob - much faster than base64! + call.resolveWithBlob( + data: imageData, + mimeType: "image/png", + additionalData: [ + "width": image.size.width, + "height": image.size.height + ] + ) + } + + @objc func downloadFile(_ call: CAPPluginCall) { + let url = URL(string: call.getString("url") ?? "")! + + // Download file + URLSession.shared.dataTask(with: url) { data, response, error in + guard let data = data else { + call.reject("Download failed") + return + } + + // Get mime type from response + let mimeType = response?.mimeType ?? "application/octet-stream" + + // Return as blob + call.resolveWithBlob( + data: data, + mimeType: mimeType + ) + }.resume() + } +} +``` + +#### Receiving Blob Data in Plugin + +```swift +@objc func saveImage(_ call: CAPPluginCall) { + // JavaScript sends: { blob: "blob:capacitor://..." } + call.getBlobData(for: "blob") { data, mimeType, error in + if let error = error { + call.reject("Failed to get blob data: \(error.localizedDescription)") + return + } + + guard let data = data else { + call.reject("No data received") + return + } + + // Save the data + let documentsPath = FileManager.default.urls( + for: .documentDirectory, + in: .userDomainMask + ).first! + + let fileURL = documentsPath.appendingPathComponent("saved-image.png") + + do { + try data.write(to: fileURL) + call.resolve([ + "path": fileURL.path, + "size": data.count + ]) + } catch { + call.reject("Failed to save file: \(error.localizedDescription)") + } + } +} +``` + +#### Receiving Browser Blob from JavaScript + +```swift +@objc func processBlob(_ call: CAPPluginCall) { + // JavaScript creates a blob: const blob = new Blob([data], { type: 'image/png' }) + // Then sends the blob URL: await MyPlugin.processBlob({ blob: URL.createObjectURL(blob) }) + + call.getBlobData(for: "blob") { data, mimeType, error in + if let error = error { + call.reject(error.localizedDescription) + return + } + + // Process the binary data + // getBlobData automatically handles both: + // - Capacitor blob URLs (blob:capacitor://...) + // - Browser blob URLs (blob:http://...) + + call.resolve([ + "processed": true, + "size": data?.count ?? 0, + "type": mimeType ?? "unknown" + ]) + } +} +``` + +### Android (Java) + +#### Returning Blob Data from Plugin + +```java +import com.getcapacitor.*; +import com.getcapacitor.annotation.*; + +@CapacitorPlugin(name = "MyPlugin") +public class MyPlugin extends Plugin { + + @PluginMethod + public void getImage(PluginCall call) { + // Load image from resources + InputStream is = getContext().getResources().openRawResource(R.drawable.example); + byte[] imageData = readInputStream(is); + + // Return as blob - much faster than base64! + call.resolveWithBlob(imageData, "image/png"); + } + + @PluginMethod + public void downloadFile(PluginCall call) { + String url = call.getString("url"); + + // Download file + new Thread(() -> { + try { + URL fileUrl = new URL(url); + InputStream input = fileUrl.openStream(); + byte[] data = readInputStream(input); + String mimeType = URLConnection.guessContentTypeFromStream(input); + + // Return as blob + call.resolveWithBlob(data, mimeType); + } catch (Exception e) { + call.reject("Download failed", e); + } + }).start(); + } + + @PluginMethod + public void capturePhoto(PluginCall call) { + // Capture photo and get bitmap + Bitmap bitmap = captureBitmapFromCamera(); + + // Convert to PNG + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream); + byte[] byteArray = stream.toByteArray(); + + // Return as blob with additional metadata + JSObject additionalData = new JSObject(); + additionalData.put("width", bitmap.getWidth()); + additionalData.put("height", bitmap.getHeight()); + + call.resolveWithBlob(byteArray, "image/png", additionalData); + } +} +``` + +#### Receiving Blob Data in Plugin + +```java +@PluginMethod +public void saveImage(PluginCall call) { + // JavaScript sends: { blob: "blob:capacitor://..." } + call.getBlobData("blob", new PluginCall.BlobDataCallback() { + @Override + public void onSuccess(byte[] data, String mimeType) { + // Save the data + File file = new File(getContext().getFilesDir(), "saved-image.png"); + + try (FileOutputStream fos = new FileOutputStream(file)) { + fos.write(data); + + JSObject result = new JSObject(); + result.put("path", file.getAbsolutePath()); + result.put("size", data.length); + call.resolve(result); + } catch (IOException e) { + call.reject("Failed to save file", e); + } + } + + @Override + public void onError(String error) { + call.reject("Failed to get blob data: " + error); + } + }); +} +``` + +#### Receiving Browser Blob from JavaScript + +```java +@PluginMethod +public void processBlob(PluginCall call) { + // JavaScript creates a blob and sends its URL + call.getBlobData("blob", new PluginCall.BlobDataCallback() { + @Override + public void onSuccess(byte[] data, String mimeType) { + // Process the binary data + // getBlobData automatically handles both: + // - Capacitor blob URLs (blob:capacitor://...) + // - Browser blob URLs (blob:http://...) + + JSObject result = new JSObject(); + result.put("processed", true); + result.put("size", data.length); + result.put("type", mimeType); + call.resolve(result); + } + + @Override + public void onError(String error) { + call.reject(error); + } + }); +} +``` + +### JavaScript/TypeScript + +#### Receiving Blob from Native + +```typescript +import { registerPlugin } from '@capacitor/core'; + +interface MyPlugin { + getImage(): Promise<{ blob: string; type: string; size: number; width: number; height: number }>; + downloadFile(options: { url: string }): Promise<{ blob: string; type: string; size: number }>; +} + +const MyPlugin = registerPlugin('MyPlugin'); + +// Get image as blob +async function displayImage() { + // Get blob URL from native + const result = await MyPlugin.getImage(); + + // Use blob URL directly in DOM + const img = document.createElement('img'); + img.src = result.blob; // blob:capacitor://... + document.body.appendChild(img); + + // Or convert to browser Blob for manipulation + const response = await fetch(result.blob); + const blob = await response.blob(); + + // Now you can use standard Blob APIs + const objectUrl = URL.createObjectURL(blob); + const anotherImg = document.createElement('img'); + anotherImg.src = objectUrl; + + // Don't forget to revoke when done + URL.revokeObjectURL(objectUrl); +} + +// Download file as blob +async function downloadAndDisplay() { + const result = await MyPlugin.downloadFile({ + url: 'https://example.com/large-file.pdf' + }); + + // Create download link + const response = await fetch(result.blob); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = 'downloaded-file.pdf'; + a.click(); + + URL.revokeObjectURL(url); +} +``` + +#### Sending Blob to Native + +```typescript +interface MyPlugin { + saveImage(options: { blob: string }): Promise<{ path: string; size: number }>; + processBlob(options: { blob: string }): Promise<{ processed: boolean; size: number }>; +} + +const MyPlugin = registerPlugin('MyPlugin'); + +// Send browser blob to native +async function captureAndSave() { + // Capture from canvas + const canvas = document.querySelector('canvas'); + const blob = await new Promise(resolve => + canvas.toBlob(resolve, 'image/png') + ); + + // Create blob URL + const blobUrl = URL.createObjectURL(blob); + + // Send to native for saving + const result = await MyPlugin.saveImage({ blob: blobUrl }); + console.log('Saved to:', result.path); + + // Clean up + URL.revokeObjectURL(blobUrl); +} + +// Send file input to native +async function handleFileUpload(event: Event) { + const input = event.target as HTMLInputElement; + const file = input.files[0]; + + // Create blob URL from file + const blobUrl = URL.createObjectURL(file); + + // Process in native + const result = await MyPlugin.processBlob({ blob: blobUrl }); + console.log('Processed:', result); + + URL.revokeObjectURL(blobUrl); +} + +// Send fetch response to native +async function downloadAndProcess() { + const response = await fetch('https://example.com/image.png'); + const blob = await response.blob(); + const blobUrl = URL.createObjectURL(blob); + + // Send to native + const result = await MyPlugin.processBlob({ blob: blobUrl }); + + URL.revokeObjectURL(blobUrl); +} +``` + +## Best Practices + +### 1. Blob Lifecycle Management + +```typescript +// ✅ Good: Clean up blob URLs +async function processImage() { + const { blob } = await MyPlugin.getImage(); + const img = document.createElement('img'); + img.src = blob; + document.body.appendChild(img); + + // Blob URLs are automatically cleaned up after retrieval + // No manual cleanup needed for Capacitor blob URLs +} + +// ✅ Good: Clean up browser blob URLs +async function createBrowserBlob() { + const response = await fetch(capacitorBlobUrl); + const blob = await response.blob(); + const browserUrl = URL.createObjectURL(blob); + + // Use it + img.src = browserUrl; + + // Clean up when done + img.onload = () => URL.revokeObjectURL(browserUrl); +} +``` + +### 2. Error Handling + +```swift +// iOS +@objc func getData(_ call: CAPPluginCall) { + call.getBlobData(for: "input") { data, mimeType, error in + if let error = error { + call.reject("Blob error: \(error.localizedDescription)") + return + } + + guard let data = data, !data.isEmpty else { + call.reject("Empty data received") + return + } + + // Process data + call.resolve(["success": true]) + } +} +``` + +```java +// Android +@PluginMethod +public void getData(PluginCall call) { + call.getBlobData("input", new PluginCall.BlobDataCallback() { + @Override + public void onSuccess(byte[] data, String mimeType) { + if (data == null || data.length == 0) { + call.reject("Empty data received"); + return; + } + // Process data + call.resolve(); + } + + @Override + public void onError(String error) { + call.reject("Blob error: " + error); + } + }); +} +``` + +### 3. Memory Management + +```swift +// For large files, process in chunks +@objc func processLargeFile(_ call: CAPPluginCall) { + call.getBlobData(for: "file") { data, mimeType, error in + guard let data = data else { + call.reject("No data") + return + } + + // Process in chunks to avoid memory pressure + let chunkSize = 1024 * 1024 // 1MB chunks + var offset = 0 + + while offset < data.count { + let end = min(offset + chunkSize, data.count) + let chunk = data.subdata(in: offset.. +``` + +Example: +``` +blob:capacitor://a3d5e7f9-1234-5678-90ab-cdef12345678 +``` + +### Storage + +- Blobs are stored in memory with automatic cleanup +- Default lifetime: 5 minutes +- Default size limit: 50MB +- Configurable per-platform + +### Security + +- Blob URLs are random UUIDs (non-guessable) +- Blobs are scoped to the app instance +- Automatic cleanup prevents memory leaks +- No cross-origin access issues + +## Limitations + +1. **One-time read for browser blobs**: When fetching a browser blob URL, the conversion to base64 happens once +2. **Memory storage**: Blobs are stored in RAM, not disk +3. **App lifetime**: Blobs are cleared when app terminates +4. **Same-context only**: Blob URLs only work within the same webview instance + +## Examples + +See the test files for comprehensive examples: +- iOS: `ios/Capacitor/CapacitorTests/BlobStoreTests.swift` +- Android: `android/capacitor/src/test/java/com/getcapacitor/BlobStoreTest.java` diff --git a/android/capacitor/src/main/java/com/getcapacitor/App.java b/android/capacitor/src/main/java/com/getcapacitor/App.java index f46b6332b..b297996f1 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/App.java +++ b/android/capacitor/src/main/java/com/getcapacitor/App.java @@ -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() { @@ -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); @@ -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); + } + } } diff --git a/android/capacitor/src/main/java/com/getcapacitor/BlobStore.java b/android/capacitor/src/main/java/com/getcapacitor/BlobStore.java new file mode 100644 index 000000000..08a1983b6 --- /dev/null +++ b/android/capacitor/src/main/java/com/getcapacitor/BlobStore.java @@ -0,0 +1,264 @@ +package com.getcapacitor; + +import android.util.Log; +import android.webkit.WebView; + +import androidx.annotation.Nullable; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * Manages temporary blob storage for efficient binary data transfer between native and JavaScript + */ +public class BlobStore { + + private static final String TAG = "Capacitor/BlobStore"; + private static BlobStore instance; + + // Blob entry class + private static class BlobEntry { + final byte[] data; + final String mimeType; + final long createdAt; + int accessCount; + + BlobEntry(byte[] data, String mimeType) { + this.data = data; + this.mimeType = mimeType; + this.createdAt = System.currentTimeMillis(); + this.accessCount = 0; + } + } + + private final Map storage = new ConcurrentHashMap<>(); + private final ScheduledExecutorService cleanupExecutor = Executors.newSingleThreadScheduledExecutor(); + + // Configuration + private long maxBlobLifetime = 5 * 60 * 1000; // 5 minutes in milliseconds + private long maxStorageSize = 50 * 1024 * 1024; // 50MB + private long currentStorageSize = 0; + + private BlobStore() { + // Start cleanup timer (runs every minute) + cleanupExecutor.scheduleAtFixedRate(this::cleanupExpiredBlobs, 60, 60, TimeUnit.SECONDS); + } + + public static synchronized BlobStore getInstance() { + if (instance == null) { + instance = new BlobStore(); + } + return instance; + } + + /** + * Store binary data and return a blob URL + * @param data Binary data to store + * @param mimeType MIME type of the data + * @return Blob URL string that can be used to retrieve the data, or null if storage limit exceeded + */ + @Nullable + public synchronized String store(byte[] data, String mimeType) { + // Check size limits + if (data.length + currentStorageSize > maxStorageSize) { + Log.w(TAG, "Storage limit exceeded"); + return null; + } + + String blobId = UUID.randomUUID().toString(); + String blobUrl = "blob:capacitor://" + blobId; + + BlobEntry entry = new BlobEntry(data, mimeType); + storage.put(blobId, entry); + currentStorageSize += data.length; + + Log.d(TAG, "Stored " + data.length + " bytes as " + blobUrl); + return blobUrl; + } + + /** + * Retrieve data for a blob URL + * @param blobUrl The blob URL (format: "blob:capacitor://") + * @return BlobData object if found, null otherwise + */ + @Nullable + public BlobData retrieve(String blobUrl) { + String blobId = extractBlobId(blobUrl); + if (blobId == null) { + return null; + } + + BlobEntry entry = storage.get(blobId); + if (entry == null) { + return null; + } + + // Increment access count + entry.accessCount++; + + return new BlobData(entry.data, entry.mimeType); + } + + /** + * Remove a specific blob from storage + * @param blobUrl The blob URL to remove + */ + public synchronized void remove(String blobUrl) { + String blobId = extractBlobId(blobUrl); + if (blobId == null) { + return; + } + + BlobEntry entry = storage.remove(blobId); + if (entry != null) { + currentStorageSize -= entry.data.length; + Log.d(TAG, "Removed blob " + blobId); + } + } + + /** + * Clear all stored blobs + */ + public synchronized void clearAll() { + int count = storage.size(); + storage.clear(); + currentStorageSize = 0; + Log.d(TAG, "Cleared all " + count + " blobs"); + } + + /** + * Create a JSObject with a blob URL reference + * @param data Binary data to store + * @param mimeType MIME type of the data + * @return JSObject with blob URL and metadata, or null if storage failed + */ + @Nullable + public JSObject createBlobResponse(byte[] data, String mimeType) { + String blobUrl = store(data, mimeType); + if (blobUrl == null) { + return null; + } + + JSObject result = new JSObject(); + result.put("blob", blobUrl); + result.put("type", mimeType); + result.put("size", data.length); + + return result; + } + + /** + * Fetch a blob from a browser-created blob URL + * @param blobUrl Browser blob URL (e.g., "blob:http://...") + * @param webView The WebView that created the blob + * @param callback Called with the fetched data or error + */ + public void fetchWebViewBlob(String blobUrl, WebView webView, BlobFetchCallback callback) { + String script = String.format( + "(async function() {" + + " try {" + + " const response = await fetch('%s');" + + " const blob = await response.blob();" + + " return new Promise((resolve) => {" + + " const reader = new FileReader();" + + " reader.onloadend = () => {" + + " const base64 = reader.result.split(',')[1];" + + " resolve({" + + " data: base64," + + " type: blob.type," + + " size: blob.size" + + " });" + + " };" + + " reader.readAsDataURL(blob);" + + " });" + + " } catch (error) {" + + " return { error: error.message };" + + " }" + + "})();", + blobUrl + ); + + webView.evaluateJavascript(script, result -> { + if (result == null || result.equals("null")) { + callback.onError("No response from blob fetch"); + return; + } + + try { + JSONObject resultJson = new JSONObject(result); + + if (resultJson.has("error")) { + callback.onError(resultJson.getString("error")); + return; + } + + String base64Data = resultJson.getString("data"); + String mimeType = resultJson.getString("type"); + + byte[] data = android.util.Base64.decode(base64Data, android.util.Base64.DEFAULT); + + Log.d(TAG, "Fetched " + data.length + " bytes from browser blob"); + callback.onSuccess(data, mimeType); + + } catch (JSONException e) { + callback.onError("Failed to parse blob response: " + e.getMessage()); + } + }); + } + + // Private methods + + @Nullable + private String extractBlobId(String blobUrl) { + if (!blobUrl.startsWith("blob:capacitor://")) { + return null; + } + return blobUrl.substring("blob:capacitor://".length()); + } + + private synchronized void cleanupExpiredBlobs() { + long now = System.currentTimeMillis(); + int removedCount = 0; + long removedSize = 0; + + for (Map.Entry entry : storage.entrySet()) { + long age = now - entry.getValue().createdAt; + if (age > maxBlobLifetime) { + BlobEntry blobEntry = storage.remove(entry.getKey()); + if (blobEntry != null) { + removedCount++; + removedSize += blobEntry.data.length; + } + } + } + + if (removedCount > 0) { + currentStorageSize -= removedSize; + Log.d(TAG, "Cleaned up " + removedCount + " expired blobs (" + removedSize + " bytes)"); + } + } + + // Data classes and callbacks + + public static class BlobData { + public final byte[] data; + public final String mimeType; + + BlobData(byte[] data, String mimeType) { + this.data = data; + this.mimeType = mimeType; + } + } + + public interface BlobFetchCallback { + void onSuccess(byte[] data, String mimeType); + void onError(String error); + } +} diff --git a/android/capacitor/src/main/java/com/getcapacitor/Bridge.java b/android/capacitor/src/main/java/com/getcapacitor/Bridge.java index 3b135910c..99be0d91b 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/Bridge.java +++ b/android/capacitor/src/main/java/com/getcapacitor/Bridge.java @@ -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; @@ -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; @@ -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; } @@ -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); diff --git a/android/capacitor/src/main/java/com/getcapacitor/DownloadJSInterface.java b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSInterface.java new file mode 100644 index 000000000..bacc1e080 --- /dev/null +++ b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSInterface.java @@ -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 launcher; + private final HashMap 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);" + + " };" + + " readBlock(offset, chunkSize, file);" + + "};\n" + + "(() => { let xhr = new XMLHttpRequest();" + + "xhr.open('GET', '" + + blobUrl + + "', true);" + + ((mimeType != null && mimeType.length() > 0) ? "xhr.setRequestHeader('Content-type','" + mimeType + "');" : "") + + "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, '" + + 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); + return; + } +} diff --git a/android/capacitor/src/main/java/com/getcapacitor/DownloadJSOperationController.java b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSOperationController.java new file mode 100644 index 000000000..7c93152cb --- /dev/null +++ b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSOperationController.java @@ -0,0 +1,302 @@ +package com.getcapacitor; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.media.MediaScannerConnection; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.provider.DocumentsContract; +import android.text.TextUtils; +import android.webkit.URLUtil; +import androidx.activity.result.contract.ActivityResultContract; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.UUID; +import java.util.concurrent.Executors; + +public class DownloadJSOperationController extends ActivityResultContract { + + /* DownloadJSActivity Input */ + public static class Input { + + public String fileNameURL; + public String optionalMimeType; + public String contentDisposition; + public String operationID; + + public Input(String operationID, String fileNameURL, String optionalMimeType, String contentDisposition) { + this.operationID = operationID; + this.fileNameURL = fileNameURL; + this.optionalMimeType = optionalMimeType; + this.contentDisposition = contentDisposition; + } + } + + /* DownloadJSActivity internal operation */ + public static class Operation { + + private final Input input; + public String operationID; + public PipedOutputStream outStream; + public PipedInputStream inStream; + //state + public Boolean closed; + public Boolean started; + public Boolean pendingClose; + public Boolean failureClose; + + // + public Operation(Input input) { + this.input = input; + this.operationID = input.operationID; + this.closed = this.started = this.pendingClose = this.failureClose = false; + this.outStream = new PipedOutputStream(); + try { + this.inStream = new PipedInputStream(1024 * 64); + this.inStream.connect(this.outStream); + } catch (IOException e) { + this.failureClose = true; + this.pendingClose = true; + Logger.debug("Exception while opening/connecting DownloadJSActivity streams.", e.toString()); + } + } + } + + /* DownloadJSActivity */ + private static final String EXTRA_OPERATION_ID = "OPERATION_ID"; + private final AppCompatActivity activity; + private final HashMap operations; + private Operation pendingOperation; + + // + public DownloadJSOperationController(AppCompatActivity activity) { + this.activity = activity; + this.operations = new HashMap<>(); + } + + /* Public operations */ + public boolean appendToOperation(String operationID, String data) { + //get operation status + Operation operation = this.operations.get(operationID); + if (operation == null && this.pendingOperation.input.operationID.equals(operationID)) operation = this.pendingOperation; + if (operation == null || operation.closed) return false; //already closed? + //write + try { + operation.outStream.write(data.getBytes(StandardCharsets.ISO_8859_1)); + } catch (IOException e) { + Logger.debug("Exception while writting on DownloadJSActivity stream. Closing it!", e.toString()); + //Ask for close + operation.pendingClose = true; + } + return !operation.pendingClose; + } + + public boolean failOperation(String operationID) { + //get operation status + Operation operation = this.operations.get(operationID); + if (operation == null && this.pendingOperation.input.operationID.equals(operationID)) operation = this.pendingOperation; + if (operation == null || operation.closed) return false; //already closed? + //Ask for close + operation.failureClose = true; + operation.pendingClose = true; + // + return true; + } + + public boolean completeOperation(String operationID) { + //get operation status + Operation operation = this.operations.get(operationID); + if (operation == null && this.pendingOperation.input.operationID.equals(operationID)) operation = this.pendingOperation; + if (operation == null || operation.closed) return false; //already closed? + //Ask for close + operation.pendingClose = true; + // + return true; + } + + /* ActivityResultContract Implementation */ + @NonNull + public Intent createIntent(@NonNull Context context, DownloadJSOperationController.Input input) { + //ask path + String[] paths = + this.getUniqueDownloadFileNameFromDetails(input.fileNameURL, input.contentDisposition, input.optionalMimeType, null); + //Create/config intent to prompt for file selection + Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + if (paths != null && paths[1] != null) intent.putExtra(Intent.EXTRA_TITLE, paths[1]); + intent.putExtra(EXTRA_OPERATION_ID, input.operationID); + 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] + ); + //Add operation + this.pendingOperation = new Operation(input); + // + return intent; + } + + public Boolean parseResult(int resultCode, @Nullable Intent result) { + //get operation status + Operation operation = this.pendingOperation; + if (operation == null) return false; //double call? + //Process if resultCode is OK and have result + if (resultCode == Activity.RESULT_OK && result != null) { + this.operations.put(operation.input.operationID, operation); + this.pendingOperation = null; + this.createThreadedPipeForOperation(operation, result.getData()); + return true; + } + //Cancel pre operation (haven't started yet) + this.pendingOperation = null; //can't be used for writting anymore + this.cancelPreOperation(operation); + return false; + } + + //Thread operation that uses duplex stream + private void createThreadedPipeForOperation(Operation operation, Uri uri) { + DownloadJSOperationController upperRef = this; + Executors.newSingleThreadExecutor().execute(() -> upperRef.createPipeForOperation(operation, uri)); + } + + private void createPipeForOperation(Operation operation, Uri uri) { + //check for operation finished + if (operation.started || operation.closed) return; + //start operation + operation.started = true; + // + try { + OutputStream output = this.activity.getContentResolver().openOutputStream(uri); + int lastReadSize = 0; + boolean flushed = false; + while (!operation.pendingClose || lastReadSize > 0 || !flushed) { + //Have what to read? + lastReadSize = Math.min(operation.inStream.available(), 64 * 1024); + if (lastReadSize <= 0) { + //read size is 0, attempt to flush duplex and make sure we got everything + if (!flushed) { + operation.outStream.flush(); + flushed = true; + } + continue; + } + //Reset flushed state if we got more data + flushed = false; + //Read + byte[] bytes = new byte[lastReadSize]; + lastReadSize = operation.inStream.read(bytes, 0, lastReadSize); + output.write(bytes); + } + //Close streams + output.flush(); //IO flush + output.close(); + operation.closed = true; + operation.outStream.close(); + operation.inStream.close(); + //Release from operations + this.releaseOperation(operation.input.operationID); + //Ask for media scan + this.performMediaScan(uri); + } catch (Exception e) { + Logger.debug("Exception while running DownloadJSActivity threaded operation.", e.toString()); + //Cancel operation stream (safely) and release from operations + this.cancelPreOperation(operation); + this.releaseOperation(operation.input.operationID); + } + Logger.debug("DownloadJSActivity completed!", operation.input.operationID); + } + + /* Operation Utils */ + private void cancelPreOperation(Operation operation) { + operation.pendingClose = true; + operation.closed = true; + try { + operation.outStream.close(); + operation.inStream.close(); + } catch (IOException ignored) {} //failsafe stream close + } + + private void releaseOperation(String operationID) { + //get operation status + Operation operation = this.operations.get(operationID); + if (operation == null && this.pendingOperation.input.operationID.equals(operationID)) operation = this.pendingOperation; + if (operation == null) return; //already closed? + //Check for pending closure (loop interruption) + if (!operation.pendingClose) operation.pendingClose = true; + //Remove from operations + this.operations.remove(operation.input.operationID); + } + + /* Media utils */ + private void performMediaScan(Uri uri) { + // Tell the media scanner about the new file so that it is + // immediately available to the user. + MediaScannerConnection.scanFile( + this.activity, + new String[] { uri.toString() }, + null, + (path, uri2) -> { + // Logger.debug("ExternalStorage", "Scanned " + path + ":"); + // Logger.debug("ExternalStorage", "-> uri=" + uri2); + } + ); + } + + /* FS Utils */ + private String getDownloadFilePath(String fileName) { + return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).getAbsolutePath() + '/' + fileName; + } + + private boolean checkCreateDefaultDir() { + boolean created = false; + try { + File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); + if (!dir.exists()) { + if (dir.mkdir()) created = true; + } else created = true; + } catch (RuntimeException e) { + Logger.debug("Error while creating default download dir:", e.toString()); + } + return created; + } + + private String[] getUniqueDownloadFileNameFromDetails( + String fileDownloadURL, + String optionalCD, + String optionalMimeType, + @Nullable Integer optionalSuffix + ) { + //Auxs for filename gen. + String suggestedFilename = URLUtil.guessFileName(fileDownloadURL, optionalCD, optionalMimeType); + ArrayList fileComps = new ArrayList<>(Arrays.asList(suggestedFilename.split("."))); + String suffix = (optionalSuffix != null ? " (" + optionalSuffix + ")" : ""); + //Check for invalid filename + if (suggestedFilename.length() <= 0) suggestedFilename = UUID.randomUUID().toString(); + //Generate filename + String fileName; + if (fileComps.size() > 1) { + String fileExtension = "." + fileComps.remove(fileComps.size() - 1); + fileName = TextUtils.join(".", fileComps) + suffix + fileExtension; + } else { + fileName = suggestedFilename + suffix; + } + //Check for default dir (might not exists per official documentation) + if (!this.checkCreateDefaultDir()) return null; + //Check if file with generated name exists + String fullPath = this.getDownloadFilePath(fileName); + // + return new String[] { fullPath, fileName }; + } +} diff --git a/android/capacitor/src/main/java/com/getcapacitor/DownloadJSProxy.java b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSProxy.java new file mode 100644 index 000000000..85caa0278 --- /dev/null +++ b/android/capacitor/src/main/java/com/getcapacitor/DownloadJSProxy.java @@ -0,0 +1,82 @@ +package com.getcapacitor; + +import android.webkit.ServiceWorkerClient; +import android.webkit.ServiceWorkerController; +import android.webkit.WebResourceRequest; +import android.webkit.WebResourceResponse; + +/** + * Represents the bridge.webview download proxy to jsInterface (DownloadJSInterface class). + * Every download request from webview will be sent to the proxy, which decides to inject + * dynamic javascript upon the 'protocol' interface availability. + */ +public class DownloadJSProxy implements android.webkit.DownloadListener { + + private final Bridge bridge; + private final DownloadJSInterface downloadInterface; + + public DownloadJSProxy(Bridge bridge) { + this.bridge = bridge; + this.downloadInterface = new DownloadJSInterface(this.bridge); + this.installServiceWorkerProxy(); + } + + // + public DownloadJSInterface jsInterface() { + return this.downloadInterface; + } + + public String jsInterfaceName() { + return "CapacitorDownloadInterface"; + } + + /* Public interceptors */ + public boolean shouldOverrideLoad(String url) { + //Only override blobs URIs (do not leave up to the interface because + //it does accept http/https schemas + if (!url.startsWith("blob:")) return false; + //Debug + Logger.debug("Capacitor webview intercepted blob download request", url); + //Check if we can handle the URL.. + String bridge = this.downloadInterface.getJavascriptBridgeForURL(url, null, null); + if (bridge != null) { + this.bridge.getWebView().loadUrl(bridge); + return true; + } else { + Logger.info("Capacitor webview download has no handler for the following url", url); + return false; + } + } + + /* Public DownloadListener implementation */ + @Override + public void onDownloadStart(String url, String userAgent, String contentDisposition, String mimeType, long contentLength) { + //Debug + Logger.debug("Capacitor webview download start request", url); + Logger.debug(userAgent + " - " + contentDisposition + " - " + mimeType); + //Check if we can handle the URL.. + String bridge = this.downloadInterface.getJavascriptBridgeForURL(url, contentDisposition, mimeType); + if (bridge != null) { + this.bridge.getWebView().loadUrl(bridge); + } else { + Logger.info("Capacitor webview download has no handler for the following url", url); + } + } + + /* Private utils */ + 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); + } + } + ); + } + } +} diff --git a/android/capacitor/src/main/java/com/getcapacitor/PluginCall.java b/android/capacitor/src/main/java/com/getcapacitor/PluginCall.java index 7308f0712..edf59d933 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/PluginCall.java +++ b/android/capacitor/src/main/java/com/getcapacitor/PluginCall.java @@ -385,6 +385,99 @@ public boolean isReleased() { return isReleased; } + /** + * Resolve with binary data as a blob URL (more efficient than base64) + * @param data Binary data to return + * @param mimeType MIME type of the data + */ + public void resolveWithBlob(byte[] data, String mimeType) { + JSObject blobResponse = BlobStore.getInstance().createBlobResponse(data, mimeType); + if (blobResponse == null) { + reject("Failed to create blob storage"); + return; + } + resolve(blobResponse); + } + + /** + * Resolve with binary data as a blob URL with additional data fields + * @param data Binary data to return + * @param mimeType MIME type of the data + * @param additionalData Additional fields to include in response + */ + public void resolveWithBlob(byte[] data, String mimeType, JSObject additionalData) { + JSObject blobResponse = BlobStore.getInstance().createBlobResponse(data, mimeType); + if (blobResponse == null) { + reject("Failed to create blob storage"); + return; + } + + // Merge additional data + if (additionalData != null) { + try { + for (java.util.Iterator it = additionalData.keys(); it.hasNext();) { + String key = it.next(); + blobResponse.put(key, additionalData.get(key)); + } + } catch (Exception e) { + Logger.error(Logger.tags("Plugin"), "Error merging additional data", e); + } + } + + resolve(blobResponse); + } + + /** + * Get binary data from a blob URL parameter (from JavaScript blob) + * @param key The parameter key containing the blob URL + * @param callback Called with the fetched data and mime type + */ + public void getBlobData(String key, BlobDataCallback callback) { + String blobUrl = getString(key); + if (blobUrl == null) { + callback.onError("Missing or invalid blob URL parameter: " + key); + return; + } + + // Check if this is a Capacitor blob (already in our store) + if (blobUrl.startsWith("blob:capacitor://")) { + BlobStore.BlobData blobData = BlobStore.getInstance().retrieve(blobUrl); + if (blobData != null) { + callback.onSuccess(blobData.data, blobData.mimeType); + } else { + callback.onError("Blob not found in store"); + } + return; + } + + // Otherwise, it's a browser blob URL - fetch it from the webview + Bridge bridge = msgHandler.getBridge(); + if (bridge == null || bridge.getWebView() == null) { + callback.onError("WebView not available"); + return; + } + + BlobStore.getInstance().fetchWebViewBlob(blobUrl, bridge.getWebView(), new BlobStore.BlobFetchCallback() { + @Override + public void onSuccess(byte[] data, String mimeType) { + callback.onSuccess(data, mimeType); + } + + @Override + public void onError(String error) { + callback.onError(error); + } + }); + } + + /** + * Callback interface for getBlobData + */ + public interface BlobDataCallback { + void onSuccess(byte[] data, String mimeType); + void onError(String error); + } + class PluginCallDataTypeException extends Exception { PluginCallDataTypeException(String m) { diff --git a/android/capacitor/src/test/java/com/getcapacitor/BlobStoreTest.java b/android/capacitor/src/test/java/com/getcapacitor/BlobStoreTest.java new file mode 100644 index 000000000..64c37ec07 --- /dev/null +++ b/android/capacitor/src/test/java/com/getcapacitor/BlobStoreTest.java @@ -0,0 +1,357 @@ +package com.getcapacitor; + +import static org.junit.Assert.*; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +public class BlobStoreTest { + + private BlobStore blobStore; + + @Before + public void setUp() { + blobStore = BlobStore.getInstance(); + blobStore.clearAll(); + } + + @After + public void tearDown() { + blobStore.clearAll(); + } + + // MARK: - Storage Tests + + @Test + public void testStoreAndRetrieve() { + // Given + byte[] testData = "Hello, Blob!".getBytes(StandardCharsets.UTF_8); + String mimeType = "text/plain"; + + // When + String blobUrl = blobStore.store(testData, mimeType); + + // Then + assertNotNull("Blob URL should not be null", blobUrl); + assertTrue("Blob URL should have correct format", blobUrl.startsWith("blob:capacitor://")); + + // Retrieve and verify + BlobStore.BlobData retrieved = blobStore.retrieve(blobUrl); + assertNotNull("Retrieved data should not be null", retrieved); + assertArrayEquals("Retrieved data should match original", testData, retrieved.data); + assertEquals("Retrieved mime type should match original", mimeType, retrieved.mimeType); + } + + @Test + public void testStoreLargeBinaryData() { + // Given - 1MB of data + int dataSize = 1024 * 1024; + byte[] testData = new byte[dataSize]; + for (int i = 0; i < dataSize; i++) { + testData[i] = (byte) (i % 256); + } + String mimeType = "application/octet-stream"; + + // When + String blobUrl = blobStore.store(testData, mimeType); + + // Then + assertNotNull("Should handle large binary data", blobUrl); + BlobStore.BlobData retrieved = blobStore.retrieve(blobUrl); + assertArrayEquals("Large binary data should be retrieved correctly", testData, retrieved.data); + } + + @Test + public void testStoreImageData() { + // Given - Simulate PNG image data + byte[] pngHeader = new byte[] { + (byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A + }; + String mimeType = "image/png"; + + // When + String blobUrl = blobStore.store(pngHeader, mimeType); + + // Then + assertNotNull(blobUrl); + BlobStore.BlobData retrieved = blobStore.retrieve(blobUrl); + assertEquals("Should preserve image mime type", mimeType, retrieved.mimeType); + assertArrayEquals("Should preserve image data integrity", pngHeader, retrieved.data); + } + + @Test + public void testStoreMultipleBlobs() { + // Given + byte[] blob1 = "Blob 1".getBytes(StandardCharsets.UTF_8); + byte[] blob2 = "Blob 2".getBytes(StandardCharsets.UTF_8); + byte[] blob3 = "Blob 3".getBytes(StandardCharsets.UTF_8); + + // When + String url1 = blobStore.store(blob1, "text/plain"); + String url2 = blobStore.store(blob2, "text/plain"); + String url3 = blobStore.store(blob3, "text/plain"); + + // Then + assertNotEquals("Each blob should have unique URL", url1, url2); + assertNotEquals("Each blob should have unique URL", url2, url3); + assertNotEquals("Each blob should have unique URL", url1, url3); + + // All should be retrievable + assertNotNull(blobStore.retrieve(url1)); + assertNotNull(blobStore.retrieve(url2)); + assertNotNull(blobStore.retrieve(url3)); + } + + @Test + public void testRetrieveNonexistentBlob() { + // Given + String fakeBlobUrl = "blob:capacitor://nonexistent-uuid"; + + // When + BlobStore.BlobData result = blobStore.retrieve(fakeBlobUrl); + + // Then + assertNull("Should return null for nonexistent blob", result); + } + + @Test + public void testRetrieveInvalidBlobUrl() { + // Given + String[] invalidUrls = { + "not-a-blob-url", + "blob://wrong-scheme", + "blob:capacitor:/", // Missing ID + "" + }; + + // When/Then + for (String invalidUrl : invalidUrls) { + BlobStore.BlobData result = blobStore.retrieve(invalidUrl); + assertNull("Should return null for invalid blob URL: " + invalidUrl, result); + } + } + + // MARK: - Removal Tests + + @Test + public void testRemoveBlob() { + // Given + byte[] testData = "Remove me".getBytes(StandardCharsets.UTF_8); + String blobUrl = blobStore.store(testData, "text/plain"); + + // Verify it exists + assertNotNull(blobStore.retrieve(blobUrl)); + + // When + blobStore.remove(blobUrl); + + // Then + assertNull("Blob should be removed", blobStore.retrieve(blobUrl)); + } + + @Test + public void testClearAll() { + // Given + String blob1 = blobStore.store("Blob 1".getBytes(StandardCharsets.UTF_8), "text/plain"); + String blob2 = blobStore.store("Blob 2".getBytes(StandardCharsets.UTF_8), "text/plain"); + + // When + blobStore.clearAll(); + + // Then + assertNull("All blobs should be cleared", blobStore.retrieve(blob1)); + assertNull("All blobs should be cleared", blobStore.retrieve(blob2)); + } + + // MARK: - Response Creation Tests + + @Test + public void testCreateBlobResponse() throws Exception { + // Given + byte[] testData = "Response data".getBytes(StandardCharsets.UTF_8); + String mimeType = "text/plain"; + + // When + JSObject response = blobStore.createBlobResponse(testData, mimeType); + + // Then + assertNotNull("Response should be created", response); + assertNotNull("Response should contain blob URL", response.getString("blob")); + assertEquals("Response should contain mime type", mimeType, response.getString("type")); + assertEquals("Response should contain size", testData.length, response.getInteger("size").intValue()); + } + + // MARK: - Blob URL Format Tests + + @Test + public void testBlobUrlFormat() { + // Given + byte[] testData = "Format test".getBytes(StandardCharsets.UTF_8); + + // When + String blobUrl = blobStore.store(testData, "text/plain"); + + // Then + assertTrue("Should start with blob:capacitor://", blobUrl.startsWith("blob:capacitor://")); + + // Extract UUID and verify format + String uuid = blobUrl.replace("blob:capacitor://", ""); + assertFalse("Should contain UUID", uuid.isEmpty()); + assertEquals("UUID should be standard format", 36, uuid.length()); + assertTrue("Should contain dashes", uuid.contains("-")); + } + + // MARK: - MIME Type Tests + + @Test + public void testVariousMimeTypes() { + String[] mimeTypes = { + "text/plain", + "application/json", + "image/png", + "image/jpeg", + "video/mp4", + "application/pdf", + "application/octet-stream" + }; + + for (String mimeType : mimeTypes) { + // Given + byte[] testData = new byte[100]; + for (int i = 0; i < 100; i++) { + testData[i] = 0x42; + } + + // When + String blobUrl = blobStore.store(testData, mimeType); + + // Then + assertNotNull("Should handle mime type: " + mimeType, blobUrl); + BlobStore.BlobData retrieved = blobStore.retrieve(blobUrl); + assertEquals("Should preserve mime type: " + mimeType, mimeType, retrieved.mimeType); + } + } + + // MARK: - Thread Safety Tests + + @Test + public void testConcurrentStoreAndRetrieve() throws InterruptedException { + // Given + int threadCount = 10; + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + List urls = new ArrayList<>(); + + // When - Multiple concurrent stores + for (int i = 0; i < threadCount; i++) { + final int index = i; + new Thread(() -> { + try { + byte[] data = ("Concurrent " + index).getBytes(StandardCharsets.UTF_8); + String url = blobStore.store(data, "text/plain"); + synchronized (urls) { + urls.add(url); + } + BlobStore.BlobData retrieved = blobStore.retrieve(url); + if (retrieved != null && new String(retrieved.data, StandardCharsets.UTF_8).equals("Concurrent " + index)) { + successCount.incrementAndGet(); + } + } finally { + latch.countDown(); + } + }).start(); + } + + // Then + assertTrue("All threads should complete", latch.await(5, TimeUnit.SECONDS)); + assertEquals("All concurrent operations should succeed", threadCount, successCount.get()); + assertEquals("All URLs should be unique", threadCount, urls.stream().distinct().count()); + } + + // MARK: - Edge Cases + + @Test + public void testStoreEmptyData() { + // Given + byte[] emptyData = new byte[0]; + + // When + String blobUrl = blobStore.store(emptyData, "text/plain"); + + // Then + assertNotNull("Should handle empty data", blobUrl); + BlobStore.BlobData retrieved = blobStore.retrieve(blobUrl); + assertEquals("Empty data should be retrievable", 0, retrieved.data.length); + } + + @Test + public void testStoreWithEmptyMimeType() { + // Given + byte[] testData = "Test".getBytes(StandardCharsets.UTF_8); + + // When + String blobUrl = blobStore.store(testData, ""); + + // Then + assertNotNull(blobUrl); + BlobStore.BlobData retrieved = blobStore.retrieve(blobUrl); + assertEquals("Should preserve empty mime type", "", retrieved.mimeType); + } + + @Test + public void testStoreWithNullMimeType() { + // Given + byte[] testData = "Test".getBytes(StandardCharsets.UTF_8); + + // When + String blobUrl = blobStore.store(testData, null); + + // Then + assertNotNull("Should handle null mime type", blobUrl); + BlobStore.BlobData retrieved = blobStore.retrieve(blobUrl); + assertNull("Should preserve null mime type", retrieved.mimeType); + } + + @Test + public void testAccessCount() { + // Given + byte[] testData = "Access count test".getBytes(StandardCharsets.UTF_8); + String blobUrl = blobStore.store(testData, "text/plain"); + + // When - Retrieve multiple times + for (int i = 0; i < 5; i++) { + assertNotNull("Should be accessible multiple times", blobStore.retrieve(blobUrl)); + } + + // Then - Data should still be accessible + BlobStore.BlobData result = blobStore.retrieve(blobUrl); + assertNotNull("Should still be accessible after multiple retrievals", result); + assertArrayEquals("Data should remain intact", testData, result.data); + } + + @Test + public void testUnicodeData() { + // Given + String unicodeText = "Hello 世界 🌍 مرحبا"; + byte[] testData = unicodeText.getBytes(StandardCharsets.UTF_8); + String mimeType = "text/plain; charset=utf-8"; + + // When + String blobUrl = blobStore.store(testData, mimeType); + + // Then + assertNotNull(blobUrl); + BlobStore.BlobData retrieved = blobStore.retrieve(blobUrl); + String retrievedText = new String(retrieved.data, StandardCharsets.UTF_8); + assertEquals("Should preserve Unicode text", unicodeText, retrievedText); + assertEquals("Should preserve charset in mime type", mimeType, retrieved.mimeType); + } +} diff --git a/ios/Capacitor/Capacitor/CAPBlobStore.swift b/ios/Capacitor/Capacitor/CAPBlobStore.swift new file mode 100644 index 000000000..9b6d2042b --- /dev/null +++ b/ios/Capacitor/Capacitor/CAPBlobStore.swift @@ -0,0 +1,337 @@ +import Foundation + +/// Manages temporary blob storage for efficient binary data transfer between native and JavaScript +@objc public class CAPBlobStore: NSObject { + + // MARK: - Blob Entry + + private struct BlobEntry { + let data: Data + let mimeType: String + let createdAt: Date + var accessCount: Int + } + + // MARK: - Properties + + private var storage: [String: BlobEntry] = [:] + private let queue = DispatchQueue(label: "com.capacitorjs.blobstore", attributes: .concurrent) + private var cleanupTimer: Timer? + + /// Singleton instance + @objc public static let shared = CAPBlobStore() + + /// Maximum time a blob can exist (default: 5 minutes) + @objc public var maxBlobLifetime: TimeInterval = 300 + + /// Maximum storage size in bytes (default: 50MB) + @objc public var maxStorageSize: Int = 50 * 1024 * 1024 + + private var currentStorageSize: Int = 0 + + // MARK: - Initialization + + private override init() { + super.init() + startCleanupTimer() + } + + deinit { + cleanupTimer?.invalidate() + } + + // MARK: - Public API + + /// Store binary data and return a blob URL + /// - Parameters: + /// - data: The binary data to store + /// - mimeType: MIME type of the data (e.g., "image/png") + /// - Returns: A blob URL string that can be used to retrieve the data + @objc public func store(data: Data, mimeType: String = "application/octet-stream") -> String? { + // Check size limits + guard data.count + currentStorageSize <= maxStorageSize else { + CAPLog.print("⚠️ BlobStore: Storage limit exceeded") + return nil + } + + let blobId = UUID().uuidString + let blobUrl = "blob:capacitor://\(blobId)" + + queue.async(flags: .barrier) { [weak self] in + guard let self = self else { return } + + let entry = BlobEntry( + data: data, + mimeType: mimeType, + createdAt: Date(), + accessCount: 0 + ) + + self.storage[blobId] = entry + self.currentStorageSize += data.count + + CAPLog.print("📦 BlobStore: Stored \(data.count) bytes as \(blobUrl)") + } + + return blobUrl + } + + /// Retrieve data for a blob URL + /// - Parameter blobUrl: The blob URL (format: "blob:capacitor://") + /// - Returns: Tuple of (data, mimeType) if found, nil otherwise + @objc public func retrieve(blobUrl: String) -> (data: Data, mimeType: String)? { + guard let blobId = extractBlobId(from: blobUrl) else { + return nil + } + + var result: (Data, String)? + + queue.sync { + if let entry = storage[blobId] { + result = (entry.data, entry.mimeType) + } + } + + // Increment access count + if result != nil { + queue.async(flags: .barrier) { [weak self] in + self?.storage[blobId]?.accessCount += 1 + } + } + + return result + } + + /// Remove a specific blob from storage + /// - Parameter blobUrl: The blob URL to remove + @objc public func remove(blobUrl: String) { + guard let blobId = extractBlobId(from: blobUrl) else { + return + } + + queue.async(flags: .barrier) { [weak self] in + guard let self = self else { return } + + if let entry = self.storage.removeValue(forKey: blobId) { + self.currentStorageSize -= entry.data.count + CAPLog.print("🗑️ BlobStore: Removed blob \(blobId)") + } + } + } + + /// Clear all stored blobs + @objc public func clearAll() { + queue.async(flags: .barrier) { [weak self] in + guard let self = self else { return } + + let count = self.storage.count + self.storage.removeAll() + self.currentStorageSize = 0 + + CAPLog.print("🗑️ BlobStore: Cleared all \(count) blobs") + } + } + + // MARK: - Private Methods + + private func extractBlobId(from blobUrl: String) -> String? { + guard blobUrl.starts(with: "blob:capacitor://") else { + return nil + } + return String(blobUrl.dropFirst("blob:capacitor://".count)) + } + + private func startCleanupTimer() { + cleanupTimer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in + self?.cleanupExpiredBlobs() + } + } + + private func cleanupExpiredBlobs() { + queue.async(flags: .barrier) { [weak self] in + guard let self = self else { return } + + let now = Date() + var removedCount = 0 + var removedSize = 0 + + for (blobId, entry) in self.storage { + let age = now.timeIntervalSince(entry.createdAt) + if age > self.maxBlobLifetime { + self.storage.removeValue(forKey: blobId) + removedCount += 1 + removedSize += entry.data.count + } + } + + if removedCount > 0 { + self.currentStorageSize -= removedSize + CAPLog.print("🧹 BlobStore: Cleaned up \(removedCount) expired blobs (\(removedSize) bytes)") + } + } + } + + // MARK: - Helpers for Plugin Integration + + /// Create a JSObject with a blob URL reference + /// - Parameters: + /// - data: Binary data to store + /// - mimeType: MIME type of the data + /// - additionalFields: Optional additional fields to include in the result + /// - Returns: Dictionary with blob URL and metadata + @objc public func createBlobResponse(data: Data, mimeType: String, additionalFields: [String: Any]? = nil) -> [String: Any]? { + guard let blobUrl = store(data: data, mimeType: mimeType) else { + return nil + } + + var result: [String: Any] = [ + "blob": blobUrl, + "type": mimeType, + "size": data.count + ] + + if let fields = additionalFields { + result.merge(fields) { (_, new) in new } + } + + return result + } +} + + // 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) + // But this is a one-time conversion that happens in the browser's optimized code + let script = """ + (async function() { + try { + const response = await fetch('\(blobUrl)'); + const blob = await response.blob(); + + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => { + const base64 = reader.result.split(',')[1]; + resolve({ + data: base64, + type: blob.type, + size: blob.size + }); + }; + reader.readAsDataURL(blob); + }); + } catch (error) { + return { error: error.message }; + } + })(); + """ + + webView.evaluateJavaScript(script) { (result, error) in + if let error = error { + completion(nil, nil, error) + return + } + + guard let resultDict = result as? [String: Any], + let base64String = resultDict["data"] as? String, + let mimeType = resultDict["type"] as? String else { + if let resultDict = result as? [String: Any], + let errorMsg = resultDict["error"] as? String { + completion(nil, nil, NSError( + domain: "CAPBlobStore", + code: -1, + userInfo: [NSLocalizedDescriptionKey: errorMsg] + )) + } else { + completion(nil, nil, NSError( + domain: "CAPBlobStore", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Invalid response from blob fetch"] + )) + } + return + } + + guard let data = Data(base64Encoded: base64String) else { + completion(nil, nil, NSError( + domain: "CAPBlobStore", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Failed to decode base64 data"] + )) + return + } + + CAPLog.print("📥 BlobStore: Fetched \(data.count) bytes from browser blob") + completion(data, mimeType, nil) + } + } +} + +// MARK: - CAPPluginCall Extension + +extension CAPPluginCall { + /// Resolve with binary data as a blob URL (more efficient than base64) + /// - Parameters: + /// - data: Binary data to return + /// - mimeType: MIME type of the data + /// - additionalData: Optional additional fields to include + @objc public func resolveWithBlob(data: Data, mimeType: String, additionalData: PluginCallResultData? = nil) { + guard let blobResponse = CAPBlobStore.shared.createBlobResponse( + data: data, + mimeType: mimeType, + additionalFields: additionalData + ) else { + reject("Failed to create blob storage") + return + } + + resolve(blobResponse) + } + + /// Get binary data from a blob URL parameter (from JavaScript blob) + /// - Parameters: + /// - key: The parameter key containing the blob URL + /// - completion: Called with the fetched data and mime type + @objc public func getBlobData(for key: String, completion: @escaping (Data?, String?, Error?) -> Void) { + guard let blobUrl = getString(key) else { + completion(nil, nil, NSError( + domain: "CAPPluginCall", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Missing or invalid blob URL parameter: \(key)"] + )) + return + } + + // Check if this is a Capacitor blob (already in our store) + if blobUrl.starts(with: "blob:capacitor://") { + if let (data, mimeType) = CAPBlobStore.shared.retrieve(blobUrl: blobUrl) { + completion(data, mimeType, nil) + } else { + completion(nil, nil, NSError( + domain: "CAPPluginCall", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Blob not found in store"] + )) + } + return + } + + // Otherwise, it's a browser blob URL - fetch it from the webview + guard let webView = bridge?.webView else { + completion(nil, nil, NSError( + domain: "CAPPluginCall", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "WebView not available"] + )) + return + } + + CAPBlobStore.shared.fetchWebViewBlob(blobUrl: blobUrl, from: webView, completion: completion) + } +} diff --git a/ios/Capacitor/Capacitor/CAPBlobURLSchemeHandler.swift b/ios/Capacitor/Capacitor/CAPBlobURLSchemeHandler.swift new file mode 100644 index 000000000..e19500c9b --- /dev/null +++ b/ios/Capacitor/Capacitor/CAPBlobURLSchemeHandler.swift @@ -0,0 +1,51 @@ +import Foundation +import WebKit + +/// URL Scheme Handler for intercepting and serving Capacitor blob URLs +@available(iOS 11.0, *) +@objc public class CAPBlobURLSchemeHandler: NSObject, WKURLSchemeHandler { + + // MARK: - WKURLSchemeHandler + + public func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { + guard let url = urlSchemeTask.request.url else { + urlSchemeTask.didFailWithError(NSError( + domain: "CAPBlobURLSchemeHandler", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Invalid URL"] + )) + return + } + + // Convert blob:capacitor://uuid to the format our BlobStore expects + let blobUrl = url.absoluteString + + guard let (data, mimeType) = CAPBlobStore.shared.retrieve(blobUrl: blobUrl) else { + CAPLog.print("⚠️ BlobURLSchemeHandler: Blob not found for \(blobUrl)") + urlSchemeTask.didFailWithError(NSError( + domain: "CAPBlobURLSchemeHandler", + code: 404, + userInfo: [NSLocalizedDescriptionKey: "Blob not found"] + )) + return + } + + // Create HTTP response + let response = URLResponse( + url: url, + mimeType: mimeType, + expectedContentLength: data.count, + textEncodingName: nil + ) + + urlSchemeTask.didReceive(response) + urlSchemeTask.didReceive(data) + urlSchemeTask.didFinish() + + CAPLog.print("✅ BlobURLSchemeHandler: Served \(data.count) bytes for \(blobUrl)") + } + + public func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) { + // Request was cancelled, nothing to clean up + } +} diff --git a/ios/Capacitor/Capacitor/CAPNotifications.swift b/ios/Capacitor/Capacitor/CAPNotifications.swift index a79cbcee5..daa922690 100644 --- a/ios/Capacitor/Capacitor/CAPNotifications.swift +++ b/ios/Capacitor/Capacitor/CAPNotifications.swift @@ -9,6 +9,7 @@ extension Notification.Name { public static let capacitorOpenURL = Notification.Name(rawValue: "CapacitorOpenURLNotification") public static let capacitorOpenUniversalLink = Notification.Name(rawValue: "CapacitorOpenUniversalLinkNotification") public static let capacitorContinueActivity = Notification.Name(rawValue: "CapacitorContinueActivityNotification") + public static let capacitorDidReceiveFileDownloadUpdate = Notification.Name(rawValue: "CapacitorDidReceiveFileDownloadUpdateNotification") public static let capacitorDidRegisterForRemoteNotifications = Notification.Name(rawValue: "CapacitorDidRegisterForRemoteNotificationsNotification") public static let capacitorDidFailToRegisterForRemoteNotifications = @@ -18,10 +19,15 @@ extension Notification.Name { public static let capacitorStatusBarTapped = Notification.Name(rawValue: "CapacitorStatusBarTappedNotification") } +public enum FileDownloadNotificationStatus { + case started, completed, failed +} + @objc extension NSNotification { public static let capacitorOpenURL = Notification.Name.capacitorOpenURL public static let capacitorOpenUniversalLink = Notification.Name.capacitorOpenUniversalLink public static let capacitorContinueActivity = Notification.Name.capacitorContinueActivity + public static let capacitorDidReceiveFileDownloadUpdate = Notification.Name.capacitorDidReceiveFileDownloadUpdate public static let capacitorDidRegisterForRemoteNotifications = Notification.Name.capacitorDidRegisterForRemoteNotifications public static let capacitorDidFailToRegisterForRemoteNotifications = Notification.Name.capacitorDidFailToRegisterForRemoteNotifications public static let capacitorDecidePolicyForNavigationAction = Notification.Name.capacitorDecidePolicyForNavigationAction diff --git a/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift b/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift index d0870ad1e..7230bee8f 100644 --- a/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift +++ b/ios/Capacitor/Capacitor/WebViewDelegationHandler.swift @@ -1,10 +1,17 @@ import Foundation import WebKit +import MobileCoreServices + +// TODO: remove once Xcode 12 support is dropped +#if compiler(<5.5) + protocol WKDownloadDelegate {} +#endif // adopting a public protocol in an internal class is by design // swiftlint:disable lower_acl_than_parent @objc(CAPWebViewDelegationHandler) -open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler, UIScrollViewDelegate { +open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler, UIScrollViewDelegate, WKDownloadDelegate, + UIDocumentPickerDelegate { public internal(set) weak var bridge: CapacitorBridge? open fileprivate(set) var contentController = WKUserContentController() enum WebViewLoadingState { @@ -17,6 +24,13 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat private let handlerName = "bridge" + struct PendingDownload { + let pathSelectionCallback: ((URL?) -> Void) + let proposedFileName: String + let downloadId: Int + } + private var pendingDownload: PendingDownload? + override public init() { super.init() contentController.add(self, name: handlerName) @@ -47,6 +61,9 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat bridge?.reset() } + // TODO: remove once Xcode 12 support is dropped + #if compiler(>=5.5) + @available(iOS 15, *) open func webView( _ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin, @@ -56,6 +73,7 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat ) { decisionHandler(.grant) } + #endif open func webView(_ webView: WKWebView, requestDeviceOrientationAndMotionPermissionFor origin: WKSecurityOrigin, @@ -68,12 +86,30 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat // post a notification for any listeners NotificationCenter.default.post(name: .capacitorDecidePolicyForNavigationAction, object: navigationAction) + // TODO: remove once Xcode 12 support is dropped + #if compiler(>=5.5) + // check if we can detect file download on iOS >= 14.5 + if #available(iOS 14.5, *) { + if navigationAction.shouldPerformDownload { + decisionHandler(.download) + return + } + } + #endif + // sanity check, these shouldn't ever be nil in practice guard let bridge = bridge, let navURL = navigationAction.request.url else { decisionHandler(.allow) return } + // Handle Capacitor blob URLs + if navURL.absoluteString.starts(with: "blob:capacitor://") { + // Allow the request - it will be handled by the URL scheme handler + decisionHandler(.allow) + return + } + // first, give plugins the chance to handle the decision for pluginObject in bridge.plugins { let plugin = pluginObject.value @@ -155,12 +191,51 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat CAPLog.print("⚡️ Error: " + error.localizedDescription) } + // Make sure we do handle file downloads if webview can display it + public func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { + // Check if webview can properly display the file + if navigationResponse.canShowMIMEType { + let isBlob = navigationResponse.response.url?.absoluteString.starts(with: "blob:") ?? false + guard #available(iOS 14.5, *), isBlob else { + decisionHandler(.allow) + return + } + } + // TODO: remove once Xcode 12 support is dropped + #if compiler(>=5.5) + // Download support for iOS >= 14.5 + if #available(iOS 14.5, *) { + decisionHandler(.download) + return + } + #endif + // Deny if not recognize until now and webView can not + // show the specified MIME type + decisionHandler(.cancel) + } + open func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { CAPLog.print("⚡️ WebView process terminated") bridge?.reset() webView.reload() } + // TODO: remove once Xcode 12 support is dropped + #if compiler(>=5.5) + + @available(iOS 14.5, *) + func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { + CAPLog.print("⚡️ Initiating background download..") + download.delegate = self + } + @available(iOS 14.5, *) + func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) { + CAPLog.print("⚡️ Initiating background download..") + download.delegate = self + } + + #endif + // MARK: - WKScriptMessageHandler open func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { @@ -312,6 +387,70 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat return nil } + // MARK: - WKDownloadDelegate + // TODO: remove once Xcode 12 support is dropped + #if compiler(>=5.5) + @available(iOS 14.5, *) + public func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String, completionHandler: @escaping (URL?) -> Void) { + // Add pending download + self.pendingDownload = PendingDownload(pathSelectionCallback: completionHandler, + proposedFileName: suggestedFilename, + downloadId: download.hash) + + // Ask for document selection (it will cal the completion handler) + let documentPicker = UIDocumentPickerViewController(documentTypes: [String(kUTTypeFolder)], in: .open) + documentPicker.delegate = self + bridge?.viewController?.present(documentPicker, animated: true) + } + @available(iOS 14.5, *) + public func downloadDidFinish(_ download: WKDownload) { + CAPLog.print("⚡️ Download finished") + // notify + NotificationCenter.default.post(name: .capacitorDidReceiveFileDownloadUpdate, object: [ + "id": String(download.hash), + "status": FileDownloadNotificationStatus.completed + ]) + } + @available(iOS 14.5, *) + public func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) { + CAPLog.print("⚡️ Download failed") + CAPLog.print("⚡️ Error: " + error.localizedDescription) + // notify + NotificationCenter.default.post(name: .capacitorDidReceiveFileDownloadUpdate, object: [ + "id": String(download.hash), + "error": error.localizedDescription, + "status": FileDownloadNotificationStatus.failed + ]) + } + #endif + + // MARK: - UIDocumentPickerDelegate + + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + guard self.pendingDownload == nil else { + // cancel download + self.pendingDownload?.pathSelectionCallback(nil) + // empty refs + self.pendingDownload = nil + return + } + } + + 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 + NotificationCenter.default.post(name: .capacitorDidReceiveFileDownloadUpdate, object: [ + "id": String(pendingDownload.downloadId), "status": FileDownloadNotificationStatus.started + ]) + // empty refs + self.pendingDownload = nil + return + } + } + // MARK: - UIScrollViewDelegate // disable zooming in WKWebView ScrollView @@ -338,4 +477,25 @@ open class WebViewDelegationHandler: NSObject, WKNavigationDelegate, WKUIDelegat CAPLog.print("⚡️ \(filename):\(line):\(col)") CAPLog.print("\n⚡️ See above for help with debugging blank-screen issues") } + + private func getUniqueDownloadFileURL(_ documentsFolderURL: URL, suggestedFilename: String, optionalSuffix: Int?) -> URL { + var suffix = "" + if let optionalSuffix = optionalSuffix { suffix = String(optionalSuffix) } + var fileComps = suggestedFilename.split(separator: ".") + var fileName = "" + if fileComps.count > 1 { + let fileExtension = "." + String(fileComps.popLast() ?? "") + fileName = fileComps.joined(separator: ".") + suffix + fileExtension + } else { + fileName = suggestedFilename + suffix + } + // Check if file with generated name exists + let documentURL = documentsFolderURL.appendingPathComponent(fileName, isDirectory: false) + if fileName == "" || FileManager.default.fileExists(atPath: documentURL.path) { + var randSuffix = 1 + if let optionalSuffix = optionalSuffix { randSuffix = optionalSuffix + 1; } + return self.getUniqueDownloadFileURL(documentsFolderURL, suggestedFilename: suggestedFilename, optionalSuffix: randSuffix) + } + return documentURL + } } diff --git a/ios/Capacitor/CapacitorTests/BlobStoreTests.swift b/ios/Capacitor/CapacitorTests/BlobStoreTests.swift new file mode 100644 index 000000000..fe1f3d79e --- /dev/null +++ b/ios/Capacitor/CapacitorTests/BlobStoreTests.swift @@ -0,0 +1,325 @@ +import XCTest +@testable import Capacitor + +class BlobStoreTests: XCTestCase { + + var blobStore: CAPBlobStore! + + override func setUp() { + super.setUp() + blobStore = CAPBlobStore.shared + blobStore.clearAll() + } + + override func tearDown() { + blobStore.clearAll() + super.tearDown() + } + + // MARK: - Storage Tests + + func testStoreAndRetrieve() { + // Given + let testData = "Hello, Blob!".data(using: .utf8)! + let mimeType = "text/plain" + + // When + let blobUrl = blobStore.store(data: testData, mimeType: mimeType) + + // Then + XCTAssertNotNil(blobUrl, "Blob URL should not be nil") + XCTAssertTrue(blobUrl!.starts(with: "blob:capacitor://"), "Blob URL should have correct format") + + // Retrieve and verify + let retrieved = blobStore.retrieve(blobUrl: blobUrl!) + XCTAssertNotNil(retrieved, "Retrieved data should not be nil") + XCTAssertEqual(retrieved?.data, testData, "Retrieved data should match original") + XCTAssertEqual(retrieved?.mimeType, mimeType, "Retrieved mime type should match original") + } + + func testStoreLargeBinaryData() { + // Given - 1MB of random data + let dataSize = 1024 * 1024 + var bytes = [UInt8](repeating: 0, count: dataSize) + for i in 0..