Spaces:
Running
Running
Download native_features.py from ShadowHunter222/qw: direct link, hf CLI and curl.
- Browser
- Download file 106 kB
-
https://huggingface.co/spaces/ShadowHunter222/qw/resolve/main/native_features.py
- Command line
-
hf download hf://spaces/ShadowHunter222/qw/native_features.py
-
curl -L -o native_features.py https://huggingface.co/spaces/ShadowHunter222/qw/resolve/main/native_features.py
106 kB
| # backend/native_features.py | |
| # Modular native mobile features injection for APK-Builder | |
| # Handles: | |
| # 1. SAF Folder Picking & Multi-file selection (DocumentsContract + cache staging) | |
| # 2. Zero-OOM Chunked Downloads to device MediaStore.Downloads with auto-rename | |
| # 3. Real-time floating progress bar UI during download with APK Install prompt | |
| # 4. Premature URL.revokeObjectURL race condition protection | |
| # 5. AndroidManifest.xml Google Play compliant permissions block | |
| import json | |
| import re | |
| from pathlib import Path | |
| # ─── Per-feature native model (pure-web + framework channels only) ─────────── | |
| # Each key is one user-facing toggle. Deps install iff its toggle is on; | |
| # permission stanzas likewise; the JS bridge activates matching behaviors. | |
| # Union of all deps below == the legacy all-on set (nothing dropped). | |
| _NATIVE_FEATURES_ALL = [ | |
| "downloads", | |
| "folderPicker", | |
| "camera", | |
| "geolocation", | |
| "share", | |
| "haptics", | |
| "notifications", | |
| "toast", | |
| "statusBar", | |
| "deviceInfo", | |
| ] | |
| _NATIVE_FEATURE_DEPS = { | |
| "downloads": {}, | |
| "folderPicker": { | |
| "@capawesome/capacitor-file-picker": "^6.0.0", | |
| "@capacitor/filesystem": "^6.0.0", # companion: user-bundled file APIs | |
| }, | |
| "camera": {"@capacitor/camera": "^6.0.0"}, | |
| "geolocation": {"@capacitor/geolocation": "^6.0.0"}, | |
| "share": {"@capacitor/share": "^6.0.0"}, | |
| "haptics": {"@capacitor/haptics": "^6.0.0"}, | |
| "notifications": {"@capacitor/local-notifications": "^6.0.0"}, | |
| "toast": {"@capacitor/toast": "^6.0.0"}, | |
| "statusBar": { | |
| "@capacitor/status-bar": "^6.0.0", | |
| "@capacitor/splash-screen": "^6.0.0", # companion: UI-chrome family | |
| }, | |
| "deviceInfo": { | |
| "@capacitor/device": "^6.0.0", | |
| "@capacitor/network": "^6.0.0", | |
| }, | |
| } | |
| def _deps_for_features(features) -> dict: | |
| """Union of npm deps for the selected feature keys.""" | |
| out = {} | |
| for key in sorted(set(features or [])): | |
| out.update(_NATIVE_FEATURE_DEPS.get(key, {})) | |
| return out | |
| _NATIVE_PERMISSION_STANZAS = { | |
| "base": """ | |
| <!-- Native features: network (WebView + Capacitor local server) --> | |
| <uses-permission android:name="android.permission.INTERNET" /> | |
| <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> | |
| """, | |
| "storage": """ | |
| <!-- Native features: file save/pick (scoped storage; legacy caps) --> | |
| <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" /> | |
| <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" /> | |
| <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> | |
| <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> | |
| <uses-permission android:name="android.permission.READ_MEDIA_AUDIO" /> | |
| """, | |
| "camera": """ | |
| <!-- Native features: camera & microphone --> | |
| <uses-permission android:name="android.permission.CAMERA" /> | |
| <uses-feature android:name="android.hardware.camera" android:required="false" /> | |
| <uses-feature android:name="android.hardware.camera.front" android:required="false" /> | |
| <uses-permission android:name="android.permission.RECORD_AUDIO" /> | |
| <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> | |
| """, | |
| "geolocation": """ | |
| <!-- Native features: location (GPS) --> | |
| <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> | |
| <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> | |
| """, | |
| "haptics": """ | |
| <!-- Native features: haptics/vibration --> | |
| <uses-permission android:name="android.permission.VIBRATE" /> | |
| """, | |
| "notifications": """ | |
| <!-- Native features: notifications (Android 13+) --> | |
| <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> | |
| """, | |
| } | |
| # Feature -> permission stanzas (beyond base, always emitted when any selected). | |
| _NATIVE_FEATURE_PERMS = { | |
| "downloads": ["storage"], | |
| "folderPicker": ["storage"], | |
| "camera": ["camera"], | |
| "geolocation": ["geolocation"], | |
| "share": [], | |
| "haptics": ["haptics"], | |
| "notifications": ["notifications"], | |
| "toast": [], | |
| "statusBar": [], | |
| "deviceInfo": [], | |
| } | |
| def _inject_native_permissions(android_dir: Path, log_fn, features=None): | |
| """Inject ONLY the permission stanzas for the selected features. Idempotent. | |
| features=None/empty -> no-op (safe default; scaffolds always pass explicit).""" | |
| feats = sorted({str(k) for k in (features or []) if str(k) in _NATIVE_FEATURES_ALL}) | |
| if not feats: | |
| log_fn("Native permissions: no features selected — skipping") | |
| return | |
| try: | |
| manifest = android_dir / "app" / "src" / "main" / "AndroidManifest.xml" | |
| if not manifest.exists(): | |
| log_fn("Native permissions: AndroidManifest.xml not found — skipping") | |
| return | |
| txt = manifest.read_text(encoding="utf-8", errors="ignore") | |
| if "Native features:" in txt: | |
| log_fn("Native permissions already present — skipping") | |
| return | |
| block = "<!-- Native features: " + ",".join(feats) + " -->\n" + _NATIVE_PERMISSION_STANZAS["base"] | |
| seen = set() | |
| for key in feats: | |
| for stanza in _NATIVE_FEATURE_PERMS.get(key, []): | |
| if stanza not in seen: | |
| seen.add(stanza) | |
| block += _NATIVE_PERMISSION_STANZAS[stanza] | |
| # Insert permissions before <application> tag | |
| if "<application" in txt: | |
| txt = txt.replace("<application", block + " <application", 1) | |
| else: | |
| txt = txt.replace("</manifest>", block + "</manifest>", 1) | |
| # Ensure requestLegacyExternalStorage on <application> tag for Android 10 compatibility | |
| if "<application" in txt and "android:requestLegacyExternalStorage" not in txt: | |
| txt = re.sub(r"<application(\s+)", r'<application android:requestLegacyExternalStorage="true"\1', txt, count=1) | |
| manifest.write_text(txt, encoding="utf-8") | |
| log_fn(f"Injected native permissions for: {', '.join(feats)}") | |
| except Exception as e: | |
| log_fn(f"Native permissions injection warning: {e}") | |
| _NATIVE_FOLDER_PLUGIN_JAVA = r"""package {{PACKAGE_NAME}}; | |
| import android.app.Activity; | |
| import android.content.ContentResolver; | |
| import android.content.ContentUris; | |
| import android.content.ContentValues; | |
| import android.content.Intent; | |
| import android.database.Cursor; | |
| import android.media.MediaScannerConnection; | |
| import android.net.Uri; | |
| import android.os.Build; | |
| import android.os.Environment; | |
| import android.provider.DocumentsContract; | |
| import android.provider.MediaStore; | |
| import android.provider.OpenableColumns; | |
| import android.Manifest; | |
| import android.content.pm.PackageManager; | |
| import androidx.core.content.ContextCompat; | |
| import androidx.activity.result.ActivityResult; | |
| import com.getcapacitor.JSArray; | |
| import com.getcapacitor.JSObject; | |
| import com.getcapacitor.Plugin; | |
| import com.getcapacitor.PluginCall; | |
| import com.getcapacitor.PluginMethod; | |
| import com.getcapacitor.annotation.ActivityCallback; | |
| import com.getcapacitor.annotation.CapacitorPlugin; | |
| import com.getcapacitor.annotation.Permission; | |
| import com.getcapacitor.annotation.PermissionCallback; | |
| import com.getcapacitor.PermissionState; | |
| import java.io.ByteArrayOutputStream; | |
| import java.io.File; | |
| import java.io.FileInputStream; | |
| import java.io.FileOutputStream; | |
| import java.io.InputStream; | |
| import java.io.OutputStream; | |
| import java.util.ArrayDeque; | |
| import java.util.ArrayList; | |
| import java.util.List; | |
| import java.util.Queue; | |
| import java.util.concurrent.ConcurrentHashMap; | |
| @CapacitorPlugin( | |
| name = "NativeFolderHelper", | |
| permissions = { | |
| @Permission( | |
| strings = { Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE }, | |
| alias = "storage" | |
| ) | |
| } | |
| ) | |
| public class NativeFolderHelperPlugin extends Plugin { | |
| private static final ConcurrentHashMap<String, OutputStream> openDownloadStreams = new ConcurrentHashMap<String, OutputStream>(); | |
| private static final ConcurrentHashMap<String, Uri> openDownloadUris = new ConcurrentHashMap<String, Uri>(); | |
| private static final ConcurrentHashMap<String, String> openDownloadNames = new ConcurrentHashMap<String, String>(); | |
| private static final ConcurrentHashMap<String, String> openDownloadFolders = new ConcurrentHashMap<String, String>(); | |
| private static final ConcurrentHashMap<String, File> openDownloadLegacyFiles = new ConcurrentHashMap<String, File>(); | |
| private static final ConcurrentHashMap<String, Boolean> openDownloadKnownLegacyPaths = new ConcurrentHashMap<String, Boolean>(); | |
| // Pre-Q legacy paths this plugin created (absolute path -> TRUE). Retries | |
| // overwrite our own files instead of piling " (1)", " (2)" twins. Foreign | |
| // same-named files are never touched (auto-rename path is kept for them). | |
| @PluginMethod | |
| public void checkStoragePermission(final PluginCall call) { | |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { | |
| // Android 10+ uses MediaStore scoped storage — no runtime storage permission needed | |
| JSObject ret = new JSObject(); | |
| ret.put("granted", true); | |
| call.resolve(ret); | |
| return; | |
| } | |
| if (getPermissionState("storage") == PermissionState.GRANTED || | |
| ContextCompat.checkSelfPermission(getContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) { | |
| JSObject ret = new JSObject(); | |
| ret.put("granted", true); | |
| call.resolve(ret); | |
| } else { | |
| requestPermissionForAlias("storage", call, "storagePermCallback"); | |
| } | |
| } | |
| @PermissionCallback | |
| private void storagePermCallback(final PluginCall call) { | |
| JSObject ret = new JSObject(); | |
| boolean granted = (getPermissionState("storage") == PermissionState.GRANTED || | |
| ContextCompat.checkSelfPermission(getContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED); | |
| ret.put("granted", granted); | |
| call.resolve(ret); | |
| } | |
| @PluginMethod | |
| public void pickFolder(final PluginCall call) { | |
| Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); | |
| intent.addFlags( | |
| Intent.FLAG_GRANT_READ_URI_PERMISSION | | |
| Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | | |
| Intent.FLAG_GRANT_PREFIX_URI_PERMISSION | |
| ); | |
| startActivityForResult(call, intent, "folderPickerResult"); | |
| } | |
| @ActivityCallback | |
| private void folderPickerResult(final PluginCall call, final ActivityResult result) { | |
| if (call == null) return; | |
| if (result.getResultCode() != Activity.RESULT_OK || result.getData() == null) { | |
| call.reject("Folder selection cancelled"); | |
| return; | |
| } | |
| final Intent data = result.getData(); | |
| final Uri treeUri = data.getData(); | |
| if (treeUri == null) { | |
| call.reject("No folder URI received"); | |
| return; | |
| } | |
| final int takeFlags = data.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); | |
| try { | |
| getContext().getContentResolver().takePersistableUriPermission( | |
| treeUri, | |
| takeFlags != 0 ? takeFlags : Intent.FLAG_GRANT_READ_URI_PERMISSION | |
| ); | |
| } catch (Exception ignored) {} | |
| new Thread(new Runnable() { | |
| @Override | |
| public void run() { | |
| try { | |
| ContentResolver resolver = getContext().getContentResolver(); | |
| String rootDocId = null; | |
| try { | |
| rootDocId = DocumentsContract.getTreeDocumentId(treeUri); | |
| } catch (Exception e1) { | |
| try { | |
| rootDocId = DocumentsContract.getDocumentId(treeUri); | |
| } catch (Exception ignored) {} | |
| } | |
| if (rootDocId == null) rootDocId = ""; | |
| String folderName = "folder"; | |
| try { | |
| Uri docUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, rootDocId); | |
| Cursor c = resolver.query(docUri, new String[]{DocumentsContract.Document.COLUMN_DISPLAY_NAME}, null, null, null); | |
| if (c != null) { | |
| try { | |
| if (c.moveToFirst()) { | |
| String n = c.getString(0); | |
| if (n != null && !n.isEmpty()) folderName = n; | |
| } | |
| } finally { | |
| c.close(); | |
| } | |
| } | |
| } catch (Exception ignored) {} | |
| File cacheDir = new File(getContext().getCacheDir(), "picked_uploads_" + System.currentTimeMillis()); | |
| cacheDir.mkdirs(); | |
| JSArray results = new JSArray(); | |
| Queue<String[]> dirQueue = new ArrayDeque<String[]>(); | |
| dirQueue.add(new String[]{rootDocId, ""}); | |
| int maxFiles = 1000; | |
| int count = 0; | |
| long totalBase64Bytes = 0; | |
| final long MAX_TOTAL_BASE64 = 4 * 1024 * 1024; | |
| while (!dirQueue.isEmpty() && count < maxFiles) { | |
| String[] current = dirQueue.poll(); | |
| String currentDocId = current[0]; | |
| String pathPrefix = current[1]; | |
| Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, currentDocId); | |
| Cursor cursor = resolver.query(childrenUri, new String[]{ | |
| DocumentsContract.Document.COLUMN_DOCUMENT_ID, | |
| DocumentsContract.Document.COLUMN_DISPLAY_NAME, | |
| DocumentsContract.Document.COLUMN_MIME_TYPE, | |
| DocumentsContract.Document.COLUMN_SIZE | |
| }, null, null, null); | |
| if (cursor != null) { | |
| try { | |
| while (cursor.moveToNext() && count < maxFiles) { | |
| String docId = cursor.getString(0); | |
| String name = cursor.getString(1); | |
| String mime = cursor.getString(2); | |
| long size = cursor.getLong(3); | |
| if (name == null || name.isEmpty()) name = "file_" + count; | |
| if (DocumentsContract.Document.MIME_TYPE_DIR.equals(mime)) { | |
| dirQueue.add(new String[]{docId, pathPrefix + name + "/"}); | |
| } else { | |
| Uri fileDocUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, docId); | |
| String relPath = pathPrefix + name; | |
| File localFile = new File(cacheDir, count + "_" + name.replaceAll("[^a-zA-Z0-9._-]", "_")); | |
| String base64 = null; | |
| long realSize = size; | |
| InputStream is = null; | |
| FileOutputStream fos = null; | |
| ByteArrayOutputStream baos = null; | |
| try { | |
| is = resolver.openInputStream(fileDocUri); | |
| if (is != null) { | |
| fos = new FileOutputStream(localFile); | |
| boolean captureB64 = (size <= 512 * 1024) && (totalBase64Bytes < MAX_TOTAL_BASE64); | |
| if (captureB64) baos = new ByteArrayOutputStream(); | |
| byte[] buf = new byte[16384]; | |
| int len; | |
| long written = 0; | |
| while ((len = is.read(buf)) != -1) { | |
| fos.write(buf, 0, len); | |
| if (baos != null) baos.write(buf, 0, len); | |
| written += len; | |
| } | |
| realSize = written; | |
| if (baos != null) { | |
| byte[] bytes = baos.toByteArray(); | |
| base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP); | |
| totalBase64Bytes += bytes.length; | |
| } | |
| } | |
| } catch (Exception ignored) {} | |
| finally { | |
| try { if (is != null) is.close(); } catch (Exception ignored) {} | |
| try { if (fos != null) fos.close(); } catch (Exception ignored) {} | |
| try { if (baos != null) baos.close(); } catch (Exception ignored) {} | |
| } | |
| JSObject fObj = new JSObject(); | |
| fObj.put("name", name); | |
| fObj.put("mimeType", mime != null ? mime : "application/octet-stream"); | |
| fObj.put("size", realSize); | |
| fObj.put("relativePath", relPath); | |
| fObj.put("uri", fileDocUri.toString()); | |
| if (localFile.exists()) { | |
| fObj.put("localPath", localFile.getAbsolutePath()); | |
| fObj.put("webPath", "/_capacitor_file_" + localFile.getAbsolutePath()); | |
| } | |
| if (base64 != null) { | |
| fObj.put("base64", base64); | |
| } | |
| results.put(fObj); | |
| count++; | |
| } | |
| } | |
| } finally { | |
| cursor.close(); | |
| } | |
| } | |
| } | |
| JSObject ret = new JSObject(); | |
| ret.put("folderName", folderName); | |
| ret.put("treeUri", treeUri.toString()); | |
| ret.put("files", results); | |
| ret.put("count", count); | |
| call.resolve(ret); | |
| } catch (Exception e) { | |
| call.reject("Failed to read folder: " + e.getMessage(), e); | |
| } | |
| } | |
| }).start(); | |
| } | |
| @PluginMethod | |
| public void pickFiles(final PluginCall call) { | |
| Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); | |
| intent.addCategory(Intent.CATEGORY_OPENABLE); | |
| intent.setType("*/*"); | |
| intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); | |
| intent.addFlags( | |
| Intent.FLAG_GRANT_READ_URI_PERMISSION | | |
| Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | |
| ); | |
| startActivityForResult(call, intent, "filesPickerResult"); | |
| } | |
| @ActivityCallback | |
| private void filesPickerResult(final PluginCall call, final ActivityResult result) { | |
| if (call == null) return; | |
| if (result.getResultCode() != Activity.RESULT_OK || result.getData() == null) { | |
| call.reject("File selection cancelled"); | |
| return; | |
| } | |
| final Intent data = result.getData(); | |
| new Thread(new Runnable() { | |
| @Override | |
| public void run() { | |
| try { | |
| ContentResolver resolver = getContext().getContentResolver(); | |
| List<Uri> uris = new ArrayList<Uri>(); | |
| if (data.getClipData() != null) { | |
| int cCount = data.getClipData().getItemCount(); | |
| for (int i = 0; i < cCount; i++) { | |
| Uri u = data.getClipData().getItemAt(i).getUri(); | |
| if (u != null) uris.add(u); | |
| } | |
| } else if (data.getData() != null) { | |
| uris.add(data.getData()); | |
| } | |
| File cacheDir = new File(getContext().getCacheDir(), "picked_files_" + System.currentTimeMillis()); | |
| cacheDir.mkdirs(); | |
| JSArray results = new JSArray(); | |
| long totalBase64Bytes = 0; | |
| final long MAX_TOTAL_BASE64 = 4 * 1024 * 1024; | |
| for (int idx = 0; idx < uris.size(); idx++) { | |
| Uri u = uris.get(idx); | |
| try { | |
| getContext().getContentResolver().takePersistableUriPermission( | |
| u, | |
| Intent.FLAG_GRANT_READ_URI_PERMISSION | |
| ); | |
| } catch (Exception ignored) {} | |
| String name = "file_" + idx; | |
| String mime = resolver.getType(u); | |
| long size = 0; | |
| Cursor c = resolver.query(u, new String[]{ | |
| OpenableColumns.DISPLAY_NAME, | |
| OpenableColumns.SIZE | |
| }, null, null, null); | |
| if (c != null) { | |
| try { | |
| if (c.moveToFirst()) { | |
| int nIdx = c.getColumnIndex(OpenableColumns.DISPLAY_NAME); | |
| if (nIdx != -1) name = c.getString(nIdx); | |
| int sIdx = c.getColumnIndex(OpenableColumns.SIZE); | |
| if (sIdx != -1) size = c.getLong(sIdx); | |
| } | |
| } finally { | |
| c.close(); | |
| } | |
| } | |
| File localFile = new File(cacheDir, idx + "_" + (name != null ? name.replaceAll("[^a-zA-Z0-9._-]", "_") : "file")); | |
| String base64 = null; | |
| long realSize = size; | |
| InputStream is = null; | |
| FileOutputStream fos = null; | |
| ByteArrayOutputStream baos = null; | |
| try { | |
| is = resolver.openInputStream(u); | |
| if (is != null) { | |
| fos = new FileOutputStream(localFile); | |
| boolean captureB64 = (size <= 512 * 1024) && (totalBase64Bytes < MAX_TOTAL_BASE64); | |
| if (captureB64) baos = new ByteArrayOutputStream(); | |
| byte[] buf = new byte[16384]; | |
| int len; | |
| long written = 0; | |
| while ((len = is.read(buf)) != -1) { | |
| fos.write(buf, 0, len); | |
| if (baos != null) baos.write(buf, 0, len); | |
| written += len; | |
| } | |
| realSize = written; | |
| if (baos != null) { | |
| byte[] bytes = baos.toByteArray(); | |
| base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP); | |
| totalBase64Bytes += bytes.length; | |
| } | |
| } | |
| } catch (Exception ignored) {} | |
| finally { | |
| try { if (is != null) is.close(); } catch (Exception ignored) {} | |
| try { if (fos != null) fos.close(); } catch (Exception ignored) {} | |
| try { if (baos != null) baos.close(); } catch (Exception ignored) {} | |
| } | |
| JSObject fObj = new JSObject(); | |
| fObj.put("name", name != null ? name : "file"); | |
| fObj.put("mimeType", mime != null ? mime : "application/octet-stream"); | |
| fObj.put("size", realSize); | |
| fObj.put("relativePath", name != null ? name : "file"); | |
| fObj.put("uri", u.toString()); | |
| if (localFile.exists()) { | |
| fObj.put("localPath", localFile.getAbsolutePath()); | |
| fObj.put("webPath", "/_capacitor_file_" + localFile.getAbsolutePath()); | |
| } | |
| if (base64 != null) { | |
| fObj.put("base64", base64); | |
| } | |
| results.put(fObj); | |
| } | |
| JSObject ret = new JSObject(); | |
| ret.put("files", results); | |
| ret.put("count", results.length()); | |
| call.resolve(ret); | |
| } catch (Exception e) { | |
| call.reject("Failed to pick files: " + e.getMessage(), e); | |
| } | |
| } | |
| }).start(); | |
| } | |
| @PluginMethod | |
| public void saveFileChunk(final PluginCall call) { | |
| final String downloadId = call.getString("downloadId"); | |
| final String filename = call.getString("filename", "download"); | |
| final String mimeType = call.getString("mimeType", "application/octet-stream"); | |
| final String chunk = call.getString("chunk", ""); | |
| final boolean isFirst = Boolean.TRUE.equals(call.getBoolean("isFirst", false)); | |
| final boolean isLast = Boolean.TRUE.equals(call.getBoolean("isLast", false)); | |
| if (downloadId == null || downloadId.isEmpty()) { | |
| call.reject("downloadId is required"); | |
| return; | |
| } | |
| new Thread(new Runnable() { | |
| @Override | |
| public void run() { | |
| OutputStream os = null; | |
| // Set when THIS call created a fresh row (for ghost cleanup below). | |
| boolean createdNewRow = false; | |
| // Run-scope: normalized in isFirst, read in isLast (scanner) too. | |
| String storedMime = (mimeType != null) ? mimeType : ""; | |
| try { | |
| ContentResolver resolver = getContext().getContentResolver(); | |
| if (isFirst) { | |
| Uri targetUri = null; | |
| String finalName = filename; | |
| String lowerName = (filename != null ? filename : "").toLowerCase(); | |
| String lowerMime = (mimeType != null ? mimeType : "").toLowerCase(); | |
| boolean isImage = lowerMime.startsWith("image/") || | |
| lowerName.endsWith(".png") || lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg") || | |
| lowerName.endsWith(".gif") || lowerName.endsWith(".webp") || lowerName.endsWith(".bmp") || | |
| lowerName.endsWith(".svg") || lowerName.endsWith(".heic"); | |
| boolean isVideo = lowerMime.startsWith("video/") || | |
| lowerName.endsWith(".mp4") || lowerName.endsWith(".mkv") || lowerName.endsWith(".webm") || | |
| lowerName.endsWith(".avi") || lowerName.endsWith(".mov") || lowerName.endsWith(".3gp"); | |
| boolean isAudio = lowerMime.startsWith("audio/") || | |
| lowerName.endsWith(".mp3") || lowerName.endsWith(".wav") || lowerName.endsWith(".ogg") || | |
| lowerName.endsWith(".m4a") || lowerName.endsWith(".aac") || lowerName.endsWith(".flac"); | |
| boolean isApk = lowerName.endsWith(".apk") || lowerName.endsWith(".aab") || | |
| lowerName.endsWith(".xapk") || lowerName.endsWith(".apks") || | |
| lowerMime.contains("android.package-archive"); | |
| boolean isDocument = lowerName.endsWith(".pdf") || lowerName.endsWith(".doc") || | |
| lowerName.endsWith(".docx") || lowerName.endsWith(".xls") || lowerName.endsWith(".xlsx") || | |
| lowerName.endsWith(".ppt") || lowerName.endsWith(".pptx") || lowerName.endsWith(".txt") || | |
| lowerName.endsWith(".csv") || lowerName.endsWith(".epub"); | |
| // P2 MIME truth: the MediaStore row MIME drives the tap-to-open | |
| // intent (AOSP builds ACTION_VIEW from the stored MIME; only | |
| // package-archive reaches the installer). APK-only override: | |
| // extension is truth (APKs are ZIPs internally, so servers | |
| // or CDNs often claim octet-stream or zip). Force installer | |
| // MIME regardless of network claim. AAB/XAPK/APKS and all | |
| // other types fall through to the generic block below untouched. | |
| storedMime = (mimeType != null) ? mimeType : ""; | |
| if (lowerName.endsWith(".apk")) { | |
| storedMime = "application/vnd.android.package-archive"; | |
| } | |
| if (!lowerName.endsWith(".apk") && (storedMime.isEmpty() || storedMime.split(";")[0].trim().equalsIgnoreCase("application/octet-stream"))) { | |
| if (isApk && lowerName.endsWith(".apk")) { | |
| storedMime = "application/vnd.android.package-archive"; | |
| } else if (lowerName.endsWith(".pdf")) { | |
| storedMime = "application/pdf"; | |
| } else if (lowerName.endsWith(".zip")) { | |
| storedMime = "application/zip"; | |
| } else if (lowerName.endsWith(".txt")) { | |
| storedMime = "text/plain"; | |
| } else if (lowerName.endsWith(".csv")) { | |
| storedMime = "text/csv"; | |
| } else if (lowerName.endsWith(".epub")) { | |
| storedMime = "application/epub+zip"; | |
| } else if (isImage) { | |
| if (lowerName.endsWith(".png")) storedMime = "image/png"; | |
| else if (lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg")) storedMime = "image/jpeg"; | |
| else if (lowerName.endsWith(".gif")) storedMime = "image/gif"; | |
| else if (lowerName.endsWith(".webp")) storedMime = "image/webp"; | |
| else if (lowerName.endsWith(".bmp")) storedMime = "image/bmp"; | |
| else if (lowerName.endsWith(".svg")) storedMime = "image/svg+xml"; | |
| else if (lowerName.endsWith(".heic")) storedMime = "image/heic"; | |
| else storedMime = "image/*"; | |
| } else if (isVideo) { | |
| if (lowerName.endsWith(".mp4")) storedMime = "video/mp4"; | |
| else if (lowerName.endsWith(".mkv")) storedMime = "video/x-matroska"; | |
| else if (lowerName.endsWith(".webm")) storedMime = "video/webm"; | |
| else if (lowerName.endsWith(".avi")) storedMime = "video/x-msvideo"; | |
| else if (lowerName.endsWith(".mov")) storedMime = "video/quicktime"; | |
| else if (lowerName.endsWith(".3gp")) storedMime = "video/3gpp"; | |
| else storedMime = "video/*"; | |
| } else if (isAudio) { | |
| if (lowerName.endsWith(".mp3")) storedMime = "audio/mpeg"; | |
| else if (lowerName.endsWith(".wav")) storedMime = "audio/wav"; | |
| else if (lowerName.endsWith(".ogg")) storedMime = "audio/ogg"; | |
| else if (lowerName.endsWith(".m4a")) storedMime = "audio/mp4"; | |
| else if (lowerName.endsWith(".aac")) storedMime = "audio/aac"; | |
| else if (lowerName.endsWith(".flac")) storedMime = "audio/flac"; | |
| else storedMime = "audio/*"; | |
| } else if (lowerName.endsWith(".doc")) { | |
| storedMime = "application/msword"; | |
| } else if (lowerName.endsWith(".docx")) { | |
| storedMime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; | |
| } else if (lowerName.endsWith(".xls")) { | |
| storedMime = "application/vnd.ms-excel"; | |
| } else if (lowerName.endsWith(".xlsx")) { | |
| storedMime = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; | |
| } else if (lowerName.endsWith(".ppt")) { | |
| storedMime = "application/vnd.ms-powerpoint"; | |
| } else if (lowerName.endsWith(".pptx")) { | |
| storedMime = "application/vnd.openxmlformats-officedocument.presentationml.presentation"; | |
| } | |
| } | |
| String folderDisplayName = "Downloads"; | |
| File targetLegacyFile = null; | |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { | |
| Uri targetTable = MediaStore.Downloads.EXTERNAL_CONTENT_URI; | |
| String relPath = Environment.DIRECTORY_DOWNLOADS; | |
| if (isImage) { | |
| targetTable = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; | |
| relPath = Environment.DIRECTORY_PICTURES; | |
| folderDisplayName = "Gallery"; | |
| } else if (isVideo) { | |
| targetTable = MediaStore.Video.Media.EXTERNAL_CONTENT_URI; | |
| relPath = Environment.DIRECTORY_MOVIES; | |
| folderDisplayName = "Gallery (Movies)"; | |
| } else if (isAudio) { | |
| targetTable = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; | |
| relPath = Environment.DIRECTORY_MUSIC; | |
| folderDisplayName = "Music"; | |
| } else if (isApk) { | |
| targetTable = MediaStore.Downloads.EXTERNAL_CONTENT_URI; | |
| relPath = Environment.DIRECTORY_DOWNLOADS + "/APKs"; | |
| folderDisplayName = "Downloads/APKs"; | |
| } else if (isDocument) { | |
| targetTable = MediaStore.Downloads.EXTERNAL_CONTENT_URI; | |
| relPath = Environment.DIRECTORY_DOCUMENTS; | |
| folderDisplayName = "Documents"; | |
| } else { | |
| targetTable = MediaStore.Downloads.EXTERNAL_CONTENT_URI; | |
| relPath = Environment.DIRECTORY_DOWNLOADS; | |
| folderDisplayName = "Downloads"; | |
| } | |
| // P3 single-row: reuse our own previous row for this exact | |
| // name instead of piling twins. Ownership-guarded: foreign | |
| // same-named rows (user files, other apps) are never touched. | |
| boolean reusedRow = false; | |
| try { | |
| Cursor own = resolver.query(targetTable, | |
| new String[]{ MediaStore.MediaColumns._ID }, | |
| MediaStore.MediaColumns.DISPLAY_NAME + "=? AND " + | |
| MediaStore.MediaColumns.OWNER_PACKAGE_NAME + "=?", | |
| new String[]{ filename, getContext().getPackageName() }, | |
| null); | |
| if (own != null) { | |
| try { | |
| if (own.moveToFirst()) { | |
| targetUri = ContentUris.withAppendedId(targetTable, own.getLong(0)); | |
| reusedRow = true; | |
| } | |
| } finally { | |
| own.close(); | |
| } | |
| } | |
| } catch (Exception ignored) { | |
| targetUri = null; | |
| reusedRow = false; | |
| } | |
| if (!reusedRow) { | |
| ContentValues values = new ContentValues(); | |
| values.put(MediaStore.MediaColumns.DISPLAY_NAME, filename); | |
| values.put(MediaStore.MediaColumns.MIME_TYPE, storedMime); | |
| values.put(MediaStore.MediaColumns.RELATIVE_PATH, relPath); | |
| values.put(MediaStore.MediaColumns.IS_PENDING, 1); | |
| try { | |
| targetUri = resolver.insert(targetTable, values); | |
| if (targetUri != null) { | |
| os = resolver.openOutputStream(targetUri); | |
| } | |
| createdNewRow = (targetUri != null); | |
| } catch (Exception insertErr) { | |
| targetUri = null; | |
| os = null; | |
| createdNewRow = false; | |
| try { | |
| values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS); | |
| targetUri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values); | |
| if (targetUri != null) { | |
| os = resolver.openOutputStream(targetUri); | |
| folderDisplayName = "Downloads"; | |
| } | |
| createdNewRow = (targetUri != null); | |
| } catch (Exception ignored2) { | |
| targetUri = null; | |
| os = null; | |
| createdNewRow = false; | |
| } | |
| } | |
| } else { | |
| // Clean overwrite of our own row (open truncates); | |
| // pending flag + healed MIME refreshed below. | |
| try { | |
| os = resolver.openOutputStream(targetUri); | |
| ContentValues pv = new ContentValues(); | |
| pv.put(MediaStore.MediaColumns.IS_PENDING, 1); | |
| pv.put(MediaStore.MediaColumns.MIME_TYPE, storedMime); | |
| resolver.update(targetUri, pv, null, null); | |
| } catch (Exception openErr) { | |
| os = null; | |
| } | |
| } | |
| } else { | |
| if (ContextCompat.checkSelfPermission(getContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { | |
| call.reject("File was not saved: Storage permission denied"); | |
| return; | |
| } | |
| File baseDir; | |
| if (isImage) { | |
| baseDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES); | |
| folderDisplayName = "Gallery"; | |
| } else if (isVideo) { | |
| baseDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES); | |
| folderDisplayName = "Gallery (Movies)"; | |
| } else if (isAudio) { | |
| baseDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC); | |
| folderDisplayName = "Music"; | |
| } else if (isApk) { | |
| baseDir = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "APKs"); | |
| folderDisplayName = "Downloads/APKs"; | |
| } else if (isDocument) { | |
| baseDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS); | |
| folderDisplayName = "Documents"; | |
| } else { | |
| baseDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); | |
| folderDisplayName = "Downloads"; | |
| } | |
| if (!baseDir.exists()) baseDir.mkdirs(); | |
| File targetFile = new File(baseDir, filename); | |
| if (openDownloadKnownLegacyPaths.containsKey(targetFile.getAbsolutePath())) { | |
| // Overwrite our own previous file: single location across retries. | |
| } else { | |
| String baseName = filename; | |
| String ext = ""; | |
| int dot = filename.lastIndexOf('.'); | |
| if (dot != -1) { | |
| baseName = filename.substring(0, dot); | |
| ext = filename.substring(dot); | |
| } | |
| int counter = 1; | |
| while (targetFile.exists()) { | |
| targetFile = new File(baseDir, baseName + " (" + counter + ")" + ext); | |
| counter++; | |
| } | |
| } | |
| finalName = targetFile.getName(); | |
| targetLegacyFile = targetFile; | |
| targetUri = Uri.fromFile(targetFile); | |
| os = new FileOutputStream(targetFile); | |
| openDownloadKnownLegacyPaths.put(targetFile.getAbsolutePath(), Boolean.TRUE); | |
| } | |
| if (os == null || targetUri == null) { | |
| // Single-row hygiene: a row WE created but could not open | |
| // is removed again (no empty ghosts). Reused or foreign | |
| // rows are always left intact. | |
| if (createdNewRow && targetUri != null) { | |
| try { resolver.delete(targetUri, null, null); } catch (Exception ignored) {} | |
| } | |
| call.reject("Failed to create download file stream for " + folderDisplayName); | |
| return; | |
| } | |
| openDownloadStreams.put(downloadId, os); | |
| openDownloadUris.put(downloadId, targetUri); | |
| openDownloadNames.put(downloadId, finalName); | |
| openDownloadFolders.put(downloadId, folderDisplayName); | |
| if (targetLegacyFile != null) { | |
| openDownloadLegacyFiles.put(downloadId, targetLegacyFile); | |
| } | |
| } else { | |
| os = openDownloadStreams.get(downloadId); | |
| } | |
| if (os == null) { | |
| call.reject("No open stream found for downloadId: " + downloadId); | |
| return; | |
| } | |
| if (chunk != null && !chunk.isEmpty()) { | |
| byte[] bytes = android.util.Base64.decode(chunk, android.util.Base64.NO_WRAP); | |
| os.write(bytes); | |
| } | |
| if (isLast) { | |
| os.flush(); | |
| os.close(); | |
| openDownloadStreams.remove(downloadId); | |
| Uri finalUri = openDownloadUris.remove(downloadId); | |
| String finalName = openDownloadNames.remove(downloadId); | |
| String finalFolder = openDownloadFolders.remove(downloadId); | |
| File legacyFile = openDownloadLegacyFiles.remove(downloadId); | |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && finalUri != null) { | |
| ContentValues updateValues = new ContentValues(); | |
| updateValues.put(MediaStore.MediaColumns.IS_PENDING, 0); | |
| resolver.update(finalUri, updateValues, null, null); | |
| } | |
| if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && legacyFile != null && legacyFile.exists()) { | |
| try { | |
| MediaScannerConnection.scanFile( | |
| getContext(), | |
| new String[]{ legacyFile.getAbsolutePath() }, | |
| new String[]{ storedMime }, | |
| null | |
| ); | |
| } catch (Exception ignored) {} | |
| } | |
| // No auto-prompt to install APK (per user request: "no need to ask to install automatically") | |
| JSObject ret = new JSObject(); | |
| ret.put("status", "success"); | |
| ret.put("uri", finalUri != null ? finalUri.toString() : ""); | |
| // Report the OS-final name (it appends " (1)" when a foreign | |
| // same-named row forced a rename) so the card never lies. | |
| String reportedName = (finalName != null) ? finalName : filename; | |
| try { | |
| Cursor nq = resolver.query(finalUri, | |
| new String[]{ MediaStore.MediaColumns.DISPLAY_NAME }, | |
| null, null, null); | |
| if (nq != null) { | |
| try { | |
| if (nq.moveToFirst()) { | |
| String dn = nq.getString(0); | |
| if (dn != null && !dn.isEmpty()) reportedName = dn; | |
| } | |
| } finally { | |
| nq.close(); | |
| } | |
| } | |
| } catch (Exception ignored) {} | |
| ret.put("filename", reportedName); | |
| ret.put("folder", finalFolder != null ? finalFolder : "Downloads"); | |
| call.resolve(ret); | |
| } else { | |
| JSObject ret = new JSObject(); | |
| ret.put("status", "chunk_written"); | |
| call.resolve(ret); | |
| } | |
| } catch (Exception e) { | |
| if (isLast || os != null) { | |
| try { if (os != null) os.close(); } catch (Exception ignored) {} | |
| openDownloadStreams.remove(downloadId); | |
| openDownloadUris.remove(downloadId); | |
| openDownloadNames.remove(downloadId); | |
| openDownloadFolders.remove(downloadId); | |
| openDownloadLegacyFiles.remove(downloadId); | |
| } | |
| call.reject("Save chunk error: " + e.getMessage(), e); | |
| } | |
| } | |
| }).start(); | |
| } | |
| @PluginMethod | |
| public void installApk(final PluginCall call) { | |
| final String uriStr = call.getString("uri"); | |
| if (uriStr == null || uriStr.isEmpty()) { | |
| call.reject("uri is required"); | |
| return; | |
| } | |
| try { | |
| Uri apkUri = Uri.parse(uriStr); | |
| Intent promptInstall = new Intent(Intent.ACTION_VIEW); | |
| promptInstall.setDataAndType(apkUri, "application/vnd.android.package-archive"); | |
| promptInstall.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK); | |
| getContext().startActivity(promptInstall); | |
| JSObject ret = new JSObject(); | |
| ret.put("status", "prompted"); | |
| call.resolve(ret); | |
| } catch (Exception e) { | |
| call.reject("Failed to trigger APK install: " + e.getMessage(), e); | |
| } | |
| } | |
| @PluginMethod | |
| public void listFilesInTree(final PluginCall call) { | |
| final String treeUriStr = call.getString("treeUri"); | |
| if (treeUriStr == null || treeUriStr.isEmpty()) { | |
| call.reject("treeUri is required"); | |
| return; | |
| } | |
| new Thread(new Runnable() { | |
| @Override | |
| public void run() { | |
| try { | |
| Uri treeUri = Uri.parse(treeUriStr); | |
| ContentResolver resolver = getContext().getContentResolver(); | |
| String rootDocId = DocumentsContract.getTreeDocumentId(treeUri); | |
| JSArray results = new JSArray(); | |
| Queue<String[]> dirQueue = new ArrayDeque<String[]>(); | |
| dirQueue.add(new String[]{rootDocId, ""}); | |
| int maxFiles = 1000; | |
| int count = 0; | |
| while (!dirQueue.isEmpty() && count < maxFiles) { | |
| String[] current = dirQueue.poll(); | |
| String currentDocId = current[0]; | |
| String pathPrefix = current[1]; | |
| Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, currentDocId); | |
| Cursor cursor = resolver.query(childrenUri, new String[]{ | |
| DocumentsContract.Document.COLUMN_DOCUMENT_ID, | |
| DocumentsContract.Document.COLUMN_DISPLAY_NAME, | |
| DocumentsContract.Document.COLUMN_MIME_TYPE, | |
| DocumentsContract.Document.COLUMN_SIZE | |
| }, null, null, null); | |
| if (cursor != null) { | |
| try { | |
| while (cursor.moveToNext() && count < maxFiles) { | |
| String docId = cursor.getString(0); | |
| String name = cursor.getString(1); | |
| String mime = cursor.getString(2); | |
| long size = cursor.getLong(3); | |
| if (name == null || name.isEmpty()) name = "file_" + count; | |
| if (DocumentsContract.Document.MIME_TYPE_DIR.equals(mime)) { | |
| dirQueue.add(new String[]{docId, pathPrefix + name + "/"}); | |
| } else { | |
| Uri fileDocUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, docId); | |
| JSObject fObj = new JSObject(); | |
| fObj.put("name", name); | |
| fObj.put("mimeType", mime != null ? mime : "application/octet-stream"); | |
| fObj.put("size", size); | |
| fObj.put("relativePath", pathPrefix + name); | |
| fObj.put("uri", fileDocUri.toString()); | |
| results.put(fObj); | |
| count++; | |
| } | |
| } | |
| } finally { | |
| cursor.close(); | |
| } | |
| } | |
| } | |
| JSObject ret = new JSObject(); | |
| ret.put("files", results); | |
| ret.put("count", count); | |
| call.resolve(ret); | |
| } catch (Exception e) { | |
| call.reject("Failed to list files: " + e.getMessage(), e); | |
| } | |
| } | |
| }).start(); | |
| } | |
| @PluginMethod | |
| public void readFileBase64(final PluginCall call) { | |
| final String localPath = call.getString("localPath"); | |
| final String uriStr = call.getString("uri"); | |
| new Thread(new Runnable() { | |
| @Override | |
| public void run() { | |
| InputStream is = null; | |
| ByteArrayOutputStream buffer = null; | |
| try { | |
| if (localPath != null && !localPath.isEmpty()) { | |
| File f = new File(localPath); | |
| if (f.exists()) { | |
| is = new FileInputStream(f); | |
| } | |
| } | |
| if (is == null && uriStr != null && !uriStr.isEmpty()) { | |
| Uri uri = Uri.parse(uriStr); | |
| is = getContext().getContentResolver().openInputStream(uri); | |
| } | |
| if (is == null) { | |
| call.reject("Cannot open file stream"); | |
| return; | |
| } | |
| buffer = new ByteArrayOutputStream(); | |
| byte[] data = new byte[16384]; | |
| int nRead; | |
| while ((nRead = is.read(data, 0, data.length)) != -1) { | |
| buffer.write(data, 0, nRead); | |
| } | |
| buffer.flush(); | |
| byte[] bytes = buffer.toByteArray(); | |
| String base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP); | |
| JSObject ret = new JSObject(); | |
| ret.put("data", base64); | |
| call.resolve(ret); | |
| } catch (Exception e) { | |
| call.reject("Failed to read file: " + e.getMessage(), e); | |
| } finally { | |
| try { if (is != null) is.close(); } catch (Exception ignored) {} | |
| try { if (buffer != null) buffer.close(); } catch (Exception ignored) {} | |
| } | |
| } | |
| }).start(); | |
| } | |
| } | |
| """ | |
| _NATIVE_BRIDGE_JS = r"""// capacitor-native-bridge.js — Auto-injected by APK Converter | |
| // Bridges browser APIs -> Capacitor native plugins + Android OS | |
| // so web apps get native mobile features without code changes. | |
| (function() { | |
| 'use strict'; | |
| if (typeof window === 'undefined') return; | |
| // ═══════════════════════════════════════════════ | |
| // 0. BLOB URL RETENTION HOOK | |
| // Prevents premature URL.revokeObjectURL() from destroying blobs | |
| // before native async download & save operations finish. | |
| // ═══════════════════════════════════════════════ | |
| var blobStore = new Map(); | |
| // Blob-URL retention exists solely for the native saver (downloads gate). | |
| if (featOn('downloads')) { | |
| var origCreateObjectURL = URL.createObjectURL; | |
| URL.createObjectURL = function(blob) { | |
| var url = origCreateObjectURL.call(URL, blob); | |
| if (blob instanceof Blob) { | |
| blobStore.set(url, blob); | |
| } | |
| return url; | |
| }; | |
| var origRevokeObjectURL = URL.revokeObjectURL; | |
| URL.revokeObjectURL = function(url) { | |
| // Keep blob available in memory for 60 seconds so native streamer can read it | |
| setTimeout(function() { | |
| blobStore.delete(url); | |
| try { origRevokeObjectURL.call(URL, url); } catch (e) {} | |
| }, 60000); | |
| }; | |
| } // end downloads gate (blob hooks) | |
| function escapeHtml(str) { | |
| return String(str || '').replace(/[&<>"']/g, function(m) { | |
| return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[m]; | |
| }); | |
| } | |
| // Per-feature flags injected by the builder as window.__NATIVE_FEATURES | |
| // (first line of this file). Missing/empty config -> everything below is | |
| // inert: the page behaves exactly as if the bridge never shipped. | |
| function featOn(key) { | |
| try { | |
| var feats = window.__NATIVE_FEATURES || {}; | |
| return !!feats[key]; | |
| } catch (err) { | |
| return false; | |
| } | |
| } | |
| // ═══════════════════════════════════════════════ | |
| // 0.1 DOWNLOAD CLICK TRACKER | |
| // Captures download button/link clicks across any framework (React/Vue/vanilla) | |
| // so progress UI starts the exact millisecond user taps download! | |
| // ═══════════════════════════════════════════════ | |
| var lastDownloadClick = { time: 0, filename: '', element: null }; | |
| // Download-intent tracking feeds the saver only (downloads gate). | |
| if (featOn('downloads')) { | |
| document.addEventListener('click', function(e) { | |
| var target = e.target; | |
| if (!target) return; | |
| var el = target.closest ? target.closest('a, button, [role="button"], input[type="button"], input[type="submit"]') : null; | |
| if (!el) return; | |
| var isDownload = false; | |
| var suggestedName = ''; | |
| if (el.tagName === 'A' && el.hasAttribute('download')) { | |
| isDownload = true; | |
| suggestedName = el.getAttribute('download') || ''; | |
| } | |
| var text = (el.textContent || '').trim(); | |
| var aria = el.getAttribute('aria-label') || ''; | |
| var title = el.getAttribute('title') || ''; | |
| var cls = (el.className || '').toString(); | |
| if (!isDownload) { | |
| if (/download|export|save\s+to|save\s+apk/i.test(text) || | |
| /download|export/i.test(aria) || | |
| /download|export/i.test(title) || | |
| /download/i.test(cls) || | |
| (el.querySelector && el.querySelector('svg, [class*="download"], [data-lucide="download"]'))) { | |
| isDownload = true; | |
| } | |
| } | |
| if (isDownload) { | |
| if (!suggestedName) { | |
| var match = text.match(/([\w\.\-]+\.(apk|aab|zip|tar|gz|pdf|png|jpg|jpeg|mp4|mp3))/i); | |
| if (match) suggestedName = match[1]; | |
| } | |
| lastDownloadClick = { | |
| time: Date.now(), | |
| filename: suggestedName, | |
| element: el | |
| }; | |
| console.log('[NativeBridge] Download click detected:', suggestedName || text); | |
| } | |
| }, true); | |
| } // end downloads gate (click tracker) | |
| // ═══════════════════════════════════════════════ | |
| // 0.2 BEAUTIFUL REAL-TIME DOWNLOAD PROGRESS UI | |
| // Glassmorphism dark card with close '×' button, live progress, | |
| // green checkmark, category destination subtitle, stays until dismissed. | |
| // ═══════════════════════════════════════════════ | |
| var DownloadUI = (function() { | |
| var container = null; | |
| function ensureContainer() { | |
| if (!container || !document.body.contains(container)) { | |
| container = document.createElement('div'); | |
| container.id = '__native_download_ui_container'; | |
| container.style.cssText = | |
| 'position: fixed; bottom: 20px; right: 16px; left: 16px; max-width: 420px; margin: 0 auto;' + | |
| 'z-index: 9999999; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;' + | |
| 'pointer-events: none; display: flex; flex-direction: column; gap: 10px;'; | |
| document.body.appendChild(container); | |
| } | |
| return container; | |
| } | |
| function formatBytes(bytes) { | |
| if (!bytes || bytes === 0) return '0 B'; | |
| var k = 1024; | |
| var sizes = ['B', 'KB', 'MB', 'GB']; | |
| var i = Math.floor(Math.log(bytes) / Math.log(k)); | |
| return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; | |
| } | |
| function getFileIcon(name) { | |
| var n = (name || '').toLowerCase(); | |
| if (n.endsWith('.apk') || n.endsWith('.aab')) return '📦'; | |
| if (n.match(/\.(png|jpg|jpeg|gif|webp|bmp|svg|heic)$/)) return '🖼️'; | |
| if (n.match(/\.(mp4|mkv|webm|avi|mov|3gp)$/)) return '🎬'; | |
| if (n.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/)) return '🎵'; | |
| if (n.match(/\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|csv)$/)) return '📄'; | |
| if (n.match(/\.(zip|tar|gz|rar|7z)$/)) return '🗜️'; | |
| return '💾'; | |
| } | |
| return { | |
| start: function(filename, totalBytes) { | |
| var c = ensureContainer(); | |
| var cardId = '__native_dl_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6); | |
| var card = document.createElement('div'); | |
| card.id = cardId; | |
| card.style.cssText = | |
| 'background: rgba(15, 23, 42, 0.95); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);' + | |
| 'color: #ffffff; padding: 12px 14px; border-radius: 14px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);' + | |
| 'border: 1px solid rgba(255, 255, 255, 0.14); pointer-events: auto; transform: translateY(16px);' + | |
| 'opacity: 0; transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);'; | |
| var icon = getFileIcon(filename); | |
| card.innerHTML = | |
| '<div style="display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px;">' + | |
| '<div style="display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1;">' + | |
| '<span style="font-size: 20px; line-height: 1; shrink-0;">' + icon + '</span>' + | |
| '<div style="min-width: 0; flex: 1;">' + | |
| '<div style="font-weight: 600; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #f8fafc;" class="__dl_title">' + | |
| escapeHtml(filename) + | |
| '</div>' + | |
| '<div style="font-size: 11px; color: #94a3b8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" class="__dl_sub">Starting download...</div>' + | |
| '</div>' + | |
| '</div>' + | |
| '<div style="display: flex; align-items: center; gap: 8px; shrink-0;">' + | |
| '<span style="font-size: 12.5px; font-weight: 700; color: #38bdf8;" class="__dl_pct">0%</span>' + | |
| '<button type="button" aria-label="Dismiss" class="__dl_close_btn" style="background: transparent; border: none; color: #94a3b8; font-size: 18px; line-height: 1; cursor: pointer; padding: 2px 4px; border-radius: 4px;">×</button>' + | |
| '</div>' + | |
| '</div>' + | |
| '<div style="width: 100%; height: 4px; background: rgba(255, 255, 255, 0.12); border-radius: 999px; overflow: hidden;" class="__dl_bar_track">' + | |
| '<div class="__dl_bar" style="width: 0%; height: 100%; background: linear-gradient(90deg, #38bdf8, #6366f1); border-radius: 999px; transition: width 0.12s ease-out;"></div>' + | |
| '</div>'; | |
| c.appendChild(card); | |
| var closeBtn = card.querySelector('.__dl_close_btn'); | |
| function dismissCard() { | |
| card.style.transform = 'translateY(16px)'; | |
| card.style.opacity = '0'; | |
| setTimeout(function() { | |
| if (card && card.parentElement) card.parentElement.removeChild(card); | |
| }, 250); | |
| } | |
| if (closeBtn) { | |
| closeBtn.onclick = function(e) { | |
| e.stopPropagation(); | |
| dismissCard(); | |
| }; | |
| } | |
| requestAnimationFrame(function() { | |
| card.style.transform = 'translateY(0)'; | |
| card.style.opacity = '1'; | |
| }); | |
| var barEl = card.querySelector('.__dl_bar'); | |
| var pctEl = card.querySelector('.__dl_pct'); | |
| var subEl = card.querySelector('.__dl_sub'); | |
| return { | |
| card: card, | |
| update: function(loaded, total) { | |
| var pct = total > 0 ? Math.min(100, Math.round((loaded / total) * 100)) : 0; | |
| if (barEl) barEl.style.width = pct + '%'; | |
| if (pctEl) pctEl.textContent = pct + '%'; | |
| if (subEl) { | |
| if (total > 0) { | |
| subEl.textContent = formatBytes(loaded) + ' of ' + formatBytes(total) + (pct > 0 ? ' • ' + pct + '%' : ''); | |
| } else { | |
| subEl.textContent = formatBytes(loaded) + ' downloaded'; | |
| } | |
| } | |
| }, | |
| success: function(finalName, targetFolder, uri) { | |
| if (barEl) { | |
| barEl.style.width = '100%'; | |
| barEl.style.background = '#22c55e'; | |
| } | |
| if (pctEl) { | |
| pctEl.textContent = '✓'; | |
| pctEl.style.color = '#22c55e'; | |
| pctEl.style.fontSize = '15px'; | |
| } | |
| if (subEl) { | |
| var folderDisplay = targetFolder ? 'Saved to ' + targetFolder : 'Saved successfully'; | |
| subEl.textContent = folderDisplay; | |
| subEl.style.color = '#4ade80'; | |
| } | |
| // Chip remains visible until user clicks × cross! | |
| }, | |
| error: function(err) { | |
| if (barEl) { | |
| barEl.style.width = '100%'; | |
| barEl.style.background = '#f87171'; | |
| } | |
| if (pctEl) { | |
| pctEl.textContent = '✕'; | |
| pctEl.style.color = '#f87171'; | |
| pctEl.style.fontWeight = '400'; | |
| pctEl.style.fontSize = '13.5px'; | |
| } | |
| if (subEl) { | |
| subEl.textContent = (err && err.message) || 'File was not saved'; | |
| subEl.style.color = '#fca5a5'; | |
| } | |
| }, | |
| dismiss: dismissCard | |
| }; | |
| } | |
| }; | |
| })(); | |
| // ═══════════════════════════════════════════════ | |
| // 0.3 WINDOW.FETCH STREAMING HOOK | |
| // Intercepts downloads triggered via fetch() -> res.blob() | |
| // Starts progress UI immediately and streams network chunks in real time! | |
| // ═══════════════════════════════════════════════ | |
| var origFetch = window.fetch; | |
| window.fetch = async function(resource, init) { | |
| var url = ''; | |
| if (typeof resource === 'string') { | |
| url = resource; | |
| } else if (resource && resource.url) { | |
| url = resource.url; | |
| } | |
| // Per-feature gate: without downloads, behave exactly like native fetch | |
| // (no tagging, no buffering, no cards). | |
| if (!featOn('downloads')) { | |
| return origFetch.apply(this, arguments); | |
| } | |
| var isDownloadCandidate = false; | |
| var now = Date.now(); | |
| var recentClick = (now - lastDownloadClick.time < 5000); | |
| var cleanUrl = (url || '').split('?')[0].split('#')[0]; | |
| var isDownloadExt = /\.(apk|aab|zip|tar|gz|pdf|png|jpg|jpeg|webp|mp4|mp3|wav)$/i.test(cleanUrl); | |
| var isDownloadEndpoint = /\/(download|artifacts)\b/i.test(cleanUrl); | |
| if (recentClick || isDownloadExt || isDownloadEndpoint) { | |
| isDownloadCandidate = true; | |
| } | |
| var response = await origFetch.apply(this, arguments); | |
| var contentType = (response.headers.get('content-type') || '').toLowerCase(); | |
| var disposition = response.headers.get('content-disposition') || ''; | |
| var contentLength = parseInt(response.headers.get('content-length') || '0', 10); | |
| var isAttachment = disposition.toLowerCase().indexOf('attachment') !== -1; | |
| var isBinaryMime = contentType.indexOf('application/vnd.android.package-archive') !== -1 || | |
| contentType.indexOf('application/octet-stream') !== -1 || | |
| contentType.indexOf('application/zip') !== -1 || | |
| contentType.indexOf('application/pdf') !== -1 || | |
| contentType.indexOf('image/') === 0 || | |
| contentType.indexOf('video/') === 0 || | |
| contentType.indexOf('audio/') === 0; | |
| // Skip JSON or HTML API requests unless explicitly attachment | |
| if (!isAttachment && !isDownloadExt && !isBinaryMime && (contentType.indexOf('json') !== -1 || contentType.indexOf('html') !== -1)) { | |
| return response; | |
| } | |
| if (isDownloadCandidate || isAttachment || isBinaryMime) { | |
| var filename = ''; | |
| if (disposition) { | |
| var fnMatch = disposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/i); | |
| if (fnMatch && fnMatch[1]) { | |
| filename = fnMatch[1].replace(/['"]/g, '').trim(); | |
| } | |
| } | |
| if (!filename && lastDownloadClick.filename && recentClick) { | |
| filename = lastDownloadClick.filename; | |
| } | |
| if (!filename && cleanUrl) { | |
| var segments = cleanUrl.split('/'); | |
| var lastSeg = segments[segments.length - 1]; | |
| if (lastSeg && lastSeg.indexOf('.') !== -1) { | |
| filename = decodeURIComponent(lastSeg); | |
| } | |
| } | |
| if (!filename) { | |
| filename = 'download_' + Date.now(); | |
| } | |
| var origResBlob = response.blob.bind(response); | |
| // Tag-only wrapper: NEVER opens a DownloadUI card here. The saver | |
| // (saveDownloadToDevice) owns the single card per download; a card | |
| // here would strand a second stuck card whenever the blob is saved | |
| // through any other path (clone, XHR, no-reader fallback). | |
| response.blob = async function() { | |
| console.log('[NativeBridge] Tagged download blob:', filename, 'Content-Length:', contentLength); | |
| if (!response.body || typeof response.body.getReader !== 'function') { | |
| var b = await origResBlob(); | |
| b.__nativeHandled = { filename: filename, totalSize: b.size, mimeType: contentType || 'application/octet-stream' }; | |
| return b; | |
| } | |
| try { | |
| var reader = response.body.getReader(); | |
| var chunks = []; | |
| var loaded = 0; | |
| while (true) { | |
| var r = await reader.read(); | |
| if (r.done) break; | |
| if (r.value) { | |
| chunks.push(r.value); | |
| loaded += r.value.length; | |
| } | |
| } | |
| var finalBlob = new Blob(chunks, { type: contentType || 'application/octet-stream' }); | |
| finalBlob.__nativeHandled = { | |
| filename: filename, | |
| totalSize: loaded, | |
| mimeType: contentType || 'application/octet-stream' | |
| }; | |
| return finalBlob; | |
| } catch (streamErr) { | |
| throw streamErr; | |
| } | |
| }; | |
| } | |
| return response; | |
| }; | |
| function onCapReady(fn) { | |
| if (window.Capacitor && window.Capacitor.isNativePlatform && window.Capacitor.isNativePlatform()) { | |
| fn(); | |
| } else { | |
| document.addEventListener('DOMContentLoaded', function() { | |
| if (window.Capacitor && window.Capacitor.isNativePlatform && window.Capacitor.isNativePlatform()) { | |
| fn(); | |
| } | |
| }); | |
| } | |
| } | |
| onCapReady(function() { | |
| console.log('[NativeBridge] Capacitor native bridge active — enhancing browser APIs'); | |
| function callNative(plugin, method, data) { | |
| if (!window.Capacitor) return Promise.reject(new Error('Capacitor not available')); | |
| if (typeof window.Capacitor.nativePromise === 'function') { | |
| return window.Capacitor.nativePromise(plugin, method, data || {}); | |
| } | |
| if (typeof window.Capacitor.registerPlugin === 'function') { | |
| try { | |
| var p = window.Capacitor.registerPlugin(plugin); | |
| if (p && typeof p[method] === 'function') return p[method](data || {}); | |
| } catch (e) {} | |
| } | |
| if (window.Capacitor.Plugins && window.Capacitor.Plugins[plugin] && typeof window.Capacitor.Plugins[plugin][method] === 'function') { | |
| return window.Capacitor.Plugins[plugin][method](data || {}); | |
| } | |
| return Promise.reject(new Error('Plugin ' + plugin + '.' + method + ' not available')); | |
| } | |
| async function nativeEntryToFile(tf) { | |
| var blob = null; | |
| var mimeType = tf.mimeType || 'application/octet-stream'; | |
| if (tf.base64) { | |
| try { | |
| var binary = atob(tf.base64); | |
| var len = binary.length; | |
| var bytes = new Uint8Array(len); | |
| for (var i = 0; i < len; i++) { | |
| bytes[i] = binary.charCodeAt(i); | |
| } | |
| blob = new Blob([bytes], { type: mimeType }); | |
| } catch (bErr) { | |
| console.warn('[NativeBridge] Base64 decode error:', bErr); | |
| } | |
| } | |
| if (!blob && tf.localPath && window.Capacitor && window.Capacitor.convertFileSrc) { | |
| try { | |
| var webUrl = window.Capacitor.convertFileSrc(tf.localPath); | |
| var resp = await fetch(webUrl); | |
| if (resp.ok) { | |
| blob = await resp.blob(); | |
| } | |
| } catch (fErr) { | |
| console.warn('[NativeBridge] Fetch cache file error:', fErr); | |
| } | |
| } | |
| if (!blob && (tf.localPath || tf.uri)) { | |
| try { | |
| var res = await callNative('NativeFolderHelper', 'readFileBase64', { | |
| localPath: tf.localPath || '', | |
| uri: tf.uri || '' | |
| }); | |
| if (res && res.data) { | |
| var bin = atob(res.data); | |
| var l = bin.length; | |
| var by = new Uint8Array(l); | |
| for (var j = 0; j < l; j++) { | |
| by[j] = bin.charCodeAt(j); | |
| } | |
| blob = new Blob([by], { type: mimeType }); | |
| } | |
| } catch (rErr) { | |
| console.warn('[NativeBridge] readFileBase64 error:', rErr); | |
| } | |
| } | |
| if (!blob) { | |
| blob = new Blob([''], { type: mimeType }); | |
| } | |
| var fileName = tf.name || 'file'; | |
| var file = new File([blob], fileName, { | |
| type: mimeType || blob.type || 'application/octet-stream', | |
| lastModified: Date.now() | |
| }); | |
| var relPath = tf.relativePath || fileName; | |
| try { | |
| Object.defineProperty(file, 'webkitRelativePath', { | |
| value: relPath, | |
| writable: true, | |
| configurable: true | |
| }); | |
| } catch (e) { | |
| file.webkitRelativePath = relPath; | |
| } | |
| return file; | |
| } | |
| // ═══════════════════════════════════════════════ | |
| // 1. FILE & FOLDER INPUT ENHANCEMENT | |
| // ═══════════════════════════════════════════════ | |
| // Whole section gated: without folderPicker the page keeps its stock | |
| // file inputs, File System Access APIs stay undefined, no overrides. | |
| if (featOn('folderPicker')) { | |
| async function processFolderSelection(fileInput) { | |
| console.log('[NativeBridge] Initiating native folder selection...'); | |
| var filesList = []; | |
| var folderName = 'folder'; | |
| try { | |
| var res = await callNative('NativeFolderHelper', 'pickFolder', {}); | |
| console.log('[NativeBridge] NativeFolderHelper.pickFolder count:', res && res.count); | |
| if (res && res.files && res.files.length > 0) { | |
| if (res.folderName) folderName = res.folderName; | |
| for (var i = 0; i < res.files.length; i++) { | |
| var f = await nativeEntryToFile(res.files[i]); | |
| filesList.push(f); | |
| } | |
| } | |
| } catch (folderErr) { | |
| console.warn('[NativeBridge] pickFolder cancelled or failed:', folderErr); | |
| } | |
| if (filesList.length === 0) { | |
| console.log('[NativeBridge] Falling back to native pickFiles...'); | |
| try { | |
| var filesRes = await callNative('NativeFolderHelper', 'pickFiles', {}); | |
| if (filesRes && filesRes.files && filesRes.files.length > 0) { | |
| for (var j = 0; j < filesRes.files.length; j++) { | |
| var pf = await nativeEntryToFile(filesRes.files[j]); | |
| filesList.push(pf); | |
| } | |
| } | |
| } catch (fErr) { | |
| try { | |
| var capRes = await callNative('FilePicker', 'pickFiles', { multiple: true, limit: 0 }); | |
| if (capRes && capRes.files && capRes.files.length > 0) { | |
| for (var k = 0; k < capRes.files.length; k++) { | |
| var cf = capRes.files[k]; | |
| var cfFile = await nativeEntryToFile({ | |
| name: cf.name, | |
| mimeType: cf.mimeType, | |
| size: cf.size, | |
| localPath: cf.path || cf.webPath, | |
| uri: cf.path | |
| }); | |
| filesList.push(cfFile); | |
| } | |
| } | |
| } catch (capErr) {} | |
| } | |
| } | |
| if (filesList.length === 0) { | |
| console.log('[NativeBridge] No files selected'); | |
| return; | |
| } | |
| console.log('[NativeBridge] Successfully populated ' + filesList.length + ' real binary file(s)'); | |
| try { | |
| var dt = new DataTransfer(); | |
| for (var idx = 0; idx < filesList.length; idx++) { | |
| dt.items.add(filesList[idx]); | |
| } | |
| fileInput.files = dt.files; | |
| } catch (dtErr) { | |
| console.warn('[NativeBridge] DataTransfer population warning:', dtErr); | |
| } | |
| fileInput.dispatchEvent(new CustomEvent('nativeFolderSelected', { | |
| detail: { files: filesList, count: filesList.length, folderName: folderName }, | |
| bubbles: true | |
| })); | |
| fileInput.dispatchEvent(new Event('input', { bubbles: true, composed: true })); | |
| fileInput.dispatchEvent(new Event('change', { bubbles: true, composed: true })); | |
| } | |
| function isFolderInput(el) { | |
| return el && el.tagName === 'INPUT' && el.type === 'file' && | |
| (el.hasAttribute('webkitdirectory') || el.hasAttribute('directory') || | |
| el.hasAttribute('mozdirectory') || el.hasAttribute('nwdirectory')); | |
| } | |
| var origInputClick = HTMLInputElement.prototype.click; | |
| HTMLInputElement.prototype.click = function() { | |
| if (isFolderInput(this)) { | |
| processFolderSelection(this); | |
| return; | |
| } | |
| return origInputClick.apply(this, arguments); | |
| }; | |
| document.addEventListener('click', function(e) { | |
| var target = e.target; | |
| var fileInput = null; | |
| if (target && target.tagName === 'INPUT' && target.type === 'file') { | |
| fileInput = target; | |
| } else if (target) { | |
| var label = target.closest('label'); | |
| if (label) { | |
| fileInput = label.querySelector('input[type="file"]'); | |
| if (!fileInput && label.htmlFor) { | |
| fileInput = document.getElementById(label.htmlFor); | |
| } | |
| } | |
| } | |
| if (fileInput && isFolderInput(fileInput)) { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| processFolderSelection(fileInput); | |
| } | |
| }, true); | |
| window.nativePickDirectory = function() { | |
| return callNative('NativeFolderHelper', 'pickFolder', {}); | |
| }; | |
| window.nativePickFiles = function(opts) { | |
| return callNative('NativeFolderHelper', 'pickFiles', opts || {}); | |
| }; | |
| if (!window.showDirectoryPicker) { | |
| window.showDirectoryPicker = async function() { | |
| console.log('[NativeBridge] showDirectoryPicker called'); | |
| var folderRes = null; | |
| try { | |
| folderRes = await callNative('NativeFolderHelper', 'pickFolder', {}); | |
| } catch (err) { | |
| var abortErr = new Error('The user aborted a request.'); | |
| abortErr.name = 'AbortError'; | |
| throw abortErr; | |
| } | |
| var collectedFiles = []; | |
| var folderName = (folderRes && folderRes.folderName) || 'folder'; | |
| if (folderRes && folderRes.files && folderRes.files.length > 0) { | |
| for (var i = 0; i < folderRes.files.length; i++) { | |
| var f = await nativeEntryToFile(folderRes.files[i]); | |
| collectedFiles.push(f); | |
| } | |
| } | |
| if (collectedFiles.length === 0) { | |
| try { | |
| var fRes = await callNative('NativeFolderHelper', 'pickFiles', {}); | |
| if (fRes && fRes.files && fRes.files.length > 0) { | |
| for (var j = 0; j < fRes.files.length; j++) { | |
| var pf = await nativeEntryToFile(fRes.files[j]); | |
| collectedFiles.push(pf); | |
| } | |
| } | |
| } catch (e2) {} | |
| } | |
| return { | |
| kind: 'directory', | |
| name: folderName, | |
| async *values() { | |
| for (var idx = 0; idx < collectedFiles.length; idx++) { | |
| var file = collectedFiles[idx]; | |
| yield { | |
| kind: 'file', | |
| name: file.name, | |
| async getFile() { | |
| return file; | |
| } | |
| }; | |
| } | |
| }, | |
| async *entries() { | |
| for (var idx = 0; idx < collectedFiles.length; idx++) { | |
| var file = collectedFiles[idx]; | |
| yield [file.name, { | |
| kind: 'file', | |
| name: file.name, | |
| async getFile() { | |
| return file; | |
| } | |
| }]; | |
| } | |
| }, | |
| async *keys() { | |
| for (var idx = 0; idx < collectedFiles.length; idx++) { | |
| yield collectedFiles[idx].name; | |
| } | |
| } | |
| }; | |
| }; | |
| } | |
| if (!window.showOpenFilePicker) { | |
| window.showOpenFilePicker = async function(opts) { | |
| var res = await callNative('NativeFolderHelper', 'pickFiles', opts || {}); | |
| var files = []; | |
| if (res && res.files) { | |
| for (var i = 0; i < res.files.length; i++) { | |
| var f = await nativeEntryToFile(res.files[i]); | |
| files.push({ | |
| kind: 'file', | |
| name: f.name, | |
| async getFile() { | |
| return f; | |
| } | |
| }); | |
| } | |
| } | |
| return files; | |
| }; | |
| } | |
| } // end folderPicker gate | |
| // ═══════════════════════════════════════════════ | |
| // 2. ROBUST CHUNKED DOWNLOAD & SAVE TO DEVICE | |
| // ═══════════════════════════════════════════════ | |
| // Whole section gated: without downloads no saver, no UI, and anchor | |
| // clicks keep stock browser behavior. | |
| if (featOn('downloads')) { | |
| // Intercepts <a download> and blob:/data: URLs. | |
| // Streams in 512KB chunks to MediaStore.Downloads. | |
| // Zero-OOM, auto-renames duplicates, and shows real-time progress UI! | |
| // ═══════════════════════════════════════════════ | |
| // Single source of truth for saver filenames (P1). Order: | |
| // explicit attr (unless placeholder) -> hook-detected -> URL segment -> | |
| // timestamped fallback; extension recovered from URL then MIME so the | |
| // native folder gate always sees the real type. | |
| function urlFileSegment(url) { | |
| try { | |
| var cu = String(url || '').split('?')[0].split('#')[0]; | |
| var seg = (cu.split('/').pop() || '').split(';')[0]; | |
| if (seg && seg.length < 128 && seg.indexOf('.') !== -1) return decodeURIComponent(seg); | |
| } catch (_) {} | |
| return ''; | |
| } | |
| function mimeToExt(mime) { | |
| var m = String(mime || '').toLowerCase(); | |
| if (m.indexOf('android.package-archive') !== -1) return '.apk'; | |
| if (m.indexOf('pdf') !== -1) return '.pdf'; | |
| if (m.indexOf('gzip') !== -1) return '.gz'; | |
| if (m.indexOf('zip') !== -1) return '.zip'; | |
| if (m === 'image/png') return '.png'; | |
| if (m === 'image/jpeg') return '.jpg'; | |
| if (m === 'image/gif') return '.gif'; | |
| if (m === 'image/webp') return '.webp'; | |
| if (m === 'video/mp4') return '.mp4'; | |
| if (m === 'audio/mpeg') return '.mp3'; | |
| if (m === 'text/plain') return '.txt'; | |
| if (m.indexOf('csv') !== -1) return '.csv'; | |
| return ''; | |
| } | |
| function ensureFileExtension(name, url, mime) { | |
| if (/\.[a-z0-9]{2,5}$/i.test(name)) return name; | |
| var seg = urlFileSegment(url); | |
| var mExt = seg.match(/(\.[a-z0-9]{2,5})$/i); | |
| if (mExt) return name + mExt[1].toLowerCase(); | |
| var e2 = mimeToExt(mime); | |
| if (e2) return name + e2; | |
| return name; | |
| } | |
| function resolveDownloadFilename(suggested, url, hookInfo, mimeHint) { | |
| var s = (suggested || '').trim(); | |
| // The anchor call-sites pass literal 'download' when no attr exists; | |
| // it is a placeholder, never a real name — must not shadow the truth. | |
| var placeholder = !s || s === 'download' || /^download_\d+$/.test(s); | |
| var name = !placeholder ? s : ''; | |
| if (!name && hookInfo && hookInfo.filename) name = String(hookInfo.filename); | |
| if (!name) name = urlFileSegment(url); | |
| if (!name) name = 'download_' + Date.now(); | |
| name = ensureFileExtension(name, url, mimeHint || (hookInfo ? hookInfo.mimeType : '')); | |
| return name.replace(/[/\\?%*:|"<>]/g, '_'); | |
| } | |
| // Single-flight per URL+ask (P3-JS): concurrent duplicate saves coalesce | |
| // to one network fetch, one native row, one card. | |
| var activeSaves = {}; | |
| async function saveDownloadToDevice(url, suggestedFilename) { | |
| var saveKey = String(url || '') + '|' + String(suggestedFilename || ''); | |
| if (activeSaves[saveKey]) { | |
| console.log('[NativeBridge] Coalescing duplicate save for:', String(url || '').slice(0, 60)); | |
| return activeSaves[saveKey]; | |
| } | |
| var savePromise = saveDownloadToDeviceInner(url, suggestedFilename); | |
| activeSaves[saveKey] = savePromise; | |
| try { | |
| return await savePromise; | |
| } finally { | |
| if (activeSaves[saveKey] === savePromise) delete activeSaves[saveKey]; | |
| } | |
| } | |
| async function saveDownloadToDeviceInner(url, suggestedFilename) { | |
| console.log('[NativeBridge] Intercepted download:', suggestedFilename, (url || '').slice(0, 50)); | |
| var blob = null; | |
| // 1. Check in-memory blobStore first (immune to premature URL.revokeObjectURL!) | |
| if (blobStore && blobStore.has(url)) { | |
| blob = blobStore.get(url); | |
| } | |
| var hookInfo = (blob && blob.__nativeHandled) ? blob.__nativeHandled : null; | |
| var filename = resolveDownloadFilename(suggestedFilename, url, hookInfo, blob ? blob.type : ''); | |
| var ui = DownloadUI.start(filename, blob ? blob.size : 0); | |
| try { | |
| // 2. Fetch if not in blobStore (handles data: or remote http/https) | |
| if (!blob) { | |
| var resp = await fetch(url); | |
| if (!resp.ok) throw new Error('Fetch failed with status ' + resp.status); | |
| var totalBytes = parseInt(resp.headers.get('content-length') || '0', 10); | |
| var mimeType = resp.headers.get('content-type') || 'application/octet-stream'; | |
| // Late truth: server MIME can supply the extension the URL lacked. | |
| // Must happen before the first native chunk so the folder gate is right. | |
| var fixedAfterFetch = ensureFileExtension(filename, url, mimeType); | |
| if (fixedAfterFetch !== filename) { | |
| filename = fixedAfterFetch; | |
| try { var _tt = ui.card && ui.card.querySelector('.__dl_title'); if (_tt) _tt.textContent = filename; } catch (_) {} | |
| } | |
| ui.update(0, totalBytes); | |
| if (resp.body && typeof resp.body.getReader === 'function') { | |
| var reader = resp.body.getReader(); | |
| var netChunks = []; | |
| var netLoaded = 0; | |
| while (true) { | |
| var nr = await reader.read(); | |
| if (nr.done) break; | |
| if (nr.value) { | |
| netChunks.push(nr.value); | |
| netLoaded += nr.value.length; | |
| ui.update(netLoaded, totalBytes || netLoaded); | |
| } | |
| } | |
| blob = new Blob(netChunks, { type: mimeType }); | |
| } else { | |
| blob = await resp.blob(); | |
| ui.update(blob.size, blob.size); | |
| } | |
| } | |
| var totalSize = blob.size; | |
| var mimeType = blob.type || 'application/octet-stream'; | |
| // APK-only: extension is truth (server/CDN may say octet-stream or | |
| // zip since APKs are ZIPs internally). Force installer MIME so the | |
| // MediaStore tap opens Package Installer, not ZIP viewer. Other | |
| // types (aab/xapk/images/docs/...) untouched. | |
| try { | |
| if (String(filename || '').toLowerCase().endsWith('.apk')) { | |
| mimeType = 'application/vnd.android.package-archive'; | |
| } | |
| } catch (_) {} | |
| ui.update(0, totalSize); | |
| if (window.Capacitor && window.Capacitor.isNativePlatform && window.Capacitor.isNativePlatform()) { | |
| try { | |
| var permRes = await callNative('NativeFolderHelper', 'checkStoragePermission', {}); | |
| if (permRes && permRes.granted === false) { | |
| ui.error({ message: 'File was not saved: Storage permission denied' }); | |
| return null; | |
| } | |
| } catch (permErr) {} | |
| } | |
| var downloadId = 'dl_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7); | |
| var chunkSize = 512 * 1024; // 512 KB chunks for 0% OOM risk | |
| var offset = 0; | |
| var lastRes = null; | |
| while (offset < totalSize) { | |
| var sliceEnd = Math.min(offset + chunkSize, totalSize); | |
| var chunkBlob = blob.slice(offset, sliceEnd); | |
| var isFirst = (offset === 0); | |
| var isLast = (sliceEnd >= totalSize); | |
| var chunkBase64 = await new Promise(function(resolve, reject) { | |
| var reader = new FileReader(); | |
| reader.onloadend = function() { | |
| var r = reader.result || ''; | |
| var commaIdx = r.indexOf(','); | |
| resolve(commaIdx !== -1 ? r.slice(commaIdx + 1) : r); | |
| }; | |
| reader.onerror = reject; | |
| reader.readAsDataURL(chunkBlob); | |
| }); | |
| lastRes = await callNative('NativeFolderHelper', 'saveFileChunk', { | |
| downloadId: downloadId, | |
| filename: filename, | |
| mimeType: mimeType, | |
| chunk: chunkBase64, | |
| isFirst: isFirst, | |
| isLast: isLast, | |
| totalSize: totalSize | |
| }); | |
| offset = sliceEnd; | |
| ui.update(offset, totalSize); | |
| } | |
| var savedUri = (lastRes && lastRes.uri) || ''; | |
| var finalName = (lastRes && lastRes.filename) || filename; | |
| var targetFolder = (lastRes && lastRes.folder) || 'Downloads'; | |
| ui.success(finalName, targetFolder, savedUri); | |
| return lastRes; | |
| } catch (err) { | |
| console.error('[NativeBridge] Save download failed:', err); | |
| ui.error(err); | |
| if (window.nativeToast) { | |
| window.nativeToast('Download failed: ' + (err.message || err), 'short'); | |
| } | |
| throw err; | |
| } | |
| } | |
| var origAnchorClick = HTMLAnchorElement.prototype.click; | |
| HTMLAnchorElement.prototype.click = function() { | |
| var href = this.href || ''; | |
| var hasDownload = this.hasAttribute('download'); | |
| if (hasDownload || href.startsWith('blob:') || href.startsWith('data:')) { | |
| saveDownloadToDevice(href, this.getAttribute('download') || 'download'); | |
| return; | |
| } | |
| return origAnchorClick.apply(this, arguments); | |
| }; | |
| document.addEventListener('click', function(e) { | |
| var a = e.target && e.target.closest ? e.target.closest('a') : null; | |
| if (!a) return; | |
| var href = a.href || ''; | |
| var hasDownload = a.hasAttribute('download'); | |
| if (hasDownload || href.startsWith('blob:') || href.startsWith('data:')) { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| saveDownloadToDevice(href, a.getAttribute('download') || 'download'); | |
| } | |
| }, true); | |
| } // end downloads gate | |
| // ═══════════════════════════════════════════════ | |
| // 3. GEOLOCATION ENHANCEMENT (gated: stock browser API otherwise) | |
| // ═══════════════════════════════════════════════ | |
| if (featOn('geolocation') && navigator.geolocation) { | |
| var origGetPos = navigator.geolocation.getCurrentPosition.bind(navigator.geolocation); | |
| var origWatch = navigator.geolocation.watchPosition.bind(navigator.geolocation); | |
| navigator.geolocation.getCurrentPosition = function(success, error, options) { | |
| callNative('Geolocation', 'getCurrentPosition', options || {}).then(function(pos) { | |
| success(pos); | |
| }).catch(function(err) { | |
| console.warn('[NativeBridge] Geolocation native call failed, falling back:', err); | |
| origGetPos(success, error, options); | |
| }); | |
| }; | |
| navigator.geolocation.watchPosition = function(success, error, options) { | |
| var watchId = 1; | |
| callNative('Geolocation', 'watchPosition', options || {}).then(function(res) { | |
| if (res && res.id) watchId = res.id; | |
| }).catch(function() {}); | |
| return watchId; | |
| }; | |
| } | |
| // ═══════════════════════════════════════════════ | |
| // 4. SHARE API (gated: defining it would lie to feature detection) | |
| // ═══════════════════════════════════════════════ | |
| if (featOn('share')) { | |
| navigator.share = function(data) { | |
| var shareData = data || {}; | |
| var rawUrl = shareData.url || ''; | |
| var cleanUrl = (rawUrl.indexOf('localhost') !== -1 || rawUrl.indexOf('127.0.0.1') !== -1) ? '' : rawUrl; | |
| var opts = {}; | |
| if (shareData.title) opts.title = shareData.title; | |
| if (shareData.text) opts.text = shareData.text; | |
| if (cleanUrl) opts.url = cleanUrl; | |
| if (!opts.text && !opts.url) opts.text = shareData.title || 'Shared from app'; | |
| opts.dialogTitle = shareData.title || 'Share'; | |
| return callNative('Share', 'share', opts); | |
| }; | |
| navigator.canShare = function() { return true; }; | |
| } // end share gate | |
| // ═══════════════════════════════════════════════ | |
| // 5. VIBRATION / HAPTICS (gated: stock navigator.vibrate otherwise) | |
| // ═══════════════════════════════════════════════ | |
| if (featOn('haptics')) { | |
| var origVibrate = navigator.vibrate ? navigator.vibrate.bind(navigator) : null; | |
| navigator.vibrate = function(pattern) { | |
| try { | |
| callNative('Haptics', 'impact', { style: 'MEDIUM' }); | |
| return true; | |
| } catch (e) { | |
| return origVibrate ? origVibrate(pattern) : false; | |
| } | |
| }; | |
| } // end haptics gate | |
| // ═══════════════════════════════════════════════ | |
| // 6. STATUS BAR (gated) | |
| // ═══════════════════════════════════════════════ | |
| if (featOn('statusBar')) { | |
| try { | |
| callNative('StatusBar', 'setStyle', { style: 'LIGHT' }); | |
| callNative('StatusBar', 'setBackgroundColor', { color: '#ffffff' }); | |
| } catch (e) {} | |
| } // end statusBar gate | |
| // ═══════════════════════════════════════════════ | |
| // 7. TOAST (gated: helper exists only with its plugin) | |
| // ═══════════════════════════════════════════════ | |
| if (featOn('toast')) { | |
| window.nativeToast = function(text, duration) { | |
| return callNative('Toast', 'show', { | |
| text: text || '', | |
| duration: duration || 'short', | |
| position: 'bottom' | |
| }); | |
| }; | |
| } // end toast gate | |
| // ═══════════════════════════════════════════════ | |
| // 8. DEVICE INFO (gated) | |
| // ═══════════════════════════════════════════════ | |
| if (featOn('deviceInfo')) { | |
| window.nativeDeviceInfo = function() { | |
| return callNative('Device', 'getInfo', {}); | |
| }; | |
| } // end deviceInfo gate | |
| // ═══════════════════════════════════════════════ | |
| // 9. NETWORK STATUS (gated) | |
| // ═══════════════════════════════════════════════ | |
| if (featOn('deviceInfo')) { | |
| window.nativeNetworkStatus = function() { | |
| return callNative('Network', 'getStatus', {}); | |
| }; | |
| } // end deviceInfo gate | |
| console.log('[NativeBridge] Active features:', JSON.stringify(window.__NATIVE_FEATURES || {})); | |
| }); | |
| })(); | |
| """ | |
| def _inject_native_folder_plugin(android_dir: Path, log_fn): | |
| """Inject NativeFolderHelperPlugin.java into Android app and register it in MainActivity. Idempotent.""" | |
| try: | |
| java_root = android_dir / "app" / "src" / "main" / "java" | |
| if not java_root.exists(): | |
| log_fn("Native folder plugin: java dir not found — skipping") | |
| return | |
| main_files = list(java_root.rglob("MainActivity.java")) | |
| if not main_files: | |
| main_files = list(java_root.rglob("MainActivity.*")) | |
| if not main_files: | |
| log_fn("Native folder plugin: MainActivity not found — skipping") | |
| return | |
| main_activity = main_files[0] | |
| main_txt = main_activity.read_text(encoding="utf-8", errors="ignore") | |
| # Extract package name from MainActivity | |
| pkg_match = re.search(r"^\s*package\s+([\w\.]+);", main_txt, flags=re.MULTILINE) | |
| if not pkg_match: | |
| log_fn("Native folder plugin: package name not found in MainActivity — skipping") | |
| return | |
| pkg_name = pkg_match.group(1) | |
| # 1. Write NativeFolderHelperPlugin.java in the same package directory | |
| plugin_file = main_activity.parent / "NativeFolderHelperPlugin.java" | |
| plugin_code = _NATIVE_FOLDER_PLUGIN_JAVA.replace("{{PACKAGE_NAME}}", pkg_name) | |
| plugin_file.write_text(plugin_code, encoding="utf-8") | |
| log_fn(f"Created NativeFolderHelperPlugin.java in {pkg_name} (SAF folder picker + MediaStore chunked download + APK installer)") | |
| # 2. Register plugin in MainActivity | |
| if "NativeFolderHelperPlugin" not in main_txt: | |
| # Ensure Bundle import | |
| if "import android.os.Bundle;" not in main_txt: | |
| if "import com.getcapacitor.BridgeActivity;" in main_txt: | |
| main_txt = main_txt.replace( | |
| "import com.getcapacitor.BridgeActivity;", | |
| "import android.os.Bundle;\nimport com.getcapacitor.BridgeActivity;" | |
| ) | |
| else: | |
| main_txt = re.sub( | |
| r"(package\s+[\w\.]+;)", | |
| r"\1\n\nimport android.os.Bundle;", | |
| main_txt, | |
| count=1 | |
| ) | |
| # Register in onCreate before super.onCreate | |
| if "onCreate(" in main_txt: | |
| if "super.onCreate(" in main_txt: | |
| main_txt = main_txt.replace( | |
| "super.onCreate(", | |
| "registerPlugin(NativeFolderHelperPlugin.class);\n super.onCreate(" | |
| ) | |
| else: | |
| main_txt = re.sub( | |
| r"(public\s+void\s+onCreate\s*\([^)]*\)\s*\{)", | |
| r"\1\n registerPlugin(NativeFolderHelperPlugin.class);", | |
| main_txt, | |
| count=1 | |
| ) | |
| else: | |
| on_create_code = ( | |
| "\n @Override\n" | |
| " public void onCreate(Bundle savedInstanceState) {\n" | |
| " registerPlugin(NativeFolderHelperPlugin.class);\n" | |
| " super.onCreate(savedInstanceState);\n" | |
| " }\n" | |
| ) | |
| main_txt = re.sub( | |
| r"(public\s+class\s+MainActivity\s+extends\s+BridgeActivity\s*\{)", | |
| r"\1" + on_create_code, | |
| main_txt, | |
| count=1 | |
| ) | |
| main_activity.write_text(main_txt, encoding="utf-8") | |
| log_fn("Registered NativeFolderHelperPlugin in MainActivity") | |
| # 3. Add ProGuard rule to keep the plugin class | |
| pro_file = android_dir / "app" / "proguard-rules.pro" | |
| if pro_file.exists(): | |
| pro_txt = pro_file.read_text(encoding="utf-8", errors="ignore") | |
| if "NativeFolderHelperPlugin" not in pro_txt: | |
| pro_txt += "\n# Keep custom NativeFolderHelperPlugin\n-keep class " + pkg_name + ".NativeFolderHelperPlugin { *; }\n" | |
| pro_file.write_text(pro_txt, encoding="utf-8") | |
| log_fn("Added ProGuard keep rule for NativeFolderHelperPlugin") | |
| except Exception as e: | |
| log_fn(f"Native folder plugin injection warning: {e}") | |
| def _inject_native_bridge_shim(www_dir: Path, log_fn, android_assets_dir: Path = None, features=None): | |
| """Write the native bridge JS shim (+ feature flags) into the web root and | |
| inject a <script> tag into index.html. Idempotent. features=None/empty -> | |
| bridge file still written but fully inert (all behaviors gated on flags).""" | |
| feats = sorted({str(k) for k in (features or []) if str(k) in _NATIVE_FEATURES_ALL}) | |
| bridge_js = "window.__NATIVE_FEATURES=" + json.dumps({k: True for k in feats}) + ";\n" + _NATIVE_BRIDGE_JS | |
| dirs_to_patch = [www_dir] | |
| if android_assets_dir and android_assets_dir.exists() and android_assets_dir != www_dir: | |
| dirs_to_patch.append(android_assets_dir) | |
| for target_dir in dirs_to_patch: | |
| try: | |
| bridge_file = target_dir / "capacitor-native-bridge.js" | |
| bridge_file.write_text(bridge_js, encoding="utf-8") | |
| log_fn(f"Wrote capacitor-native-bridge.js into {target_dir.name}") | |
| # Inject <script> tag into index.html | |
| index_html = target_dir / "index.html" | |
| if index_html.exists(): | |
| html = index_html.read_text(encoding="utf-8", errors="ignore") | |
| bridge_tag = '<script src="capacitor-native-bridge.js" defer></script>' | |
| if bridge_tag not in html and "capacitor-native-bridge" not in html: | |
| if '</head>' in html: | |
| html = html.replace('</head>', bridge_tag + '\n</head>', 1) | |
| elif '</body>' in html: | |
| html = html.replace('</body>', bridge_tag + '\n</body>', 1) | |
| else: | |
| html = bridge_tag + '\n' + html | |
| index_html.write_text(html, encoding="utf-8") | |
| log_fn(f"Injected native bridge <script> into {target_dir.name}/index.html") | |
| except Exception as e: | |
| log_fn(f"Native bridge shim injection warning for {target_dir}: {e}") | |