# 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": """ """, "storage": """ """, "camera": """ """, "geolocation": """ """, "haptics": """ """, "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 = "\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 tag if "", block + "", 1) # Ensure requestLegacyExternalStorage on tag for Android 10 compatibility if " openDownloadStreams = new ConcurrentHashMap(); private static final ConcurrentHashMap openDownloadUris = new ConcurrentHashMap(); private static final ConcurrentHashMap openDownloadNames = new ConcurrentHashMap(); private static final ConcurrentHashMap openDownloadFolders = new ConcurrentHashMap(); private static final ConcurrentHashMap openDownloadLegacyFiles = new ConcurrentHashMap(); private static final ConcurrentHashMap openDownloadKnownLegacyPaths = new ConcurrentHashMap(); // 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 dirQueue = new ArrayDeque(); 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 uris = new ArrayList(); 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 dirQueue = new ArrayDeque(); 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 = '' + '' + '' + icon + '' + '' + '' + escapeHtml(filename) + '' + 'Starting download...' + '' + '' + '' + '0%' + '×' + '' + '' + '' + '' + ''; 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 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 ' if bridge_tag not in html and "capacitor-native-bridge" not in html: if '' in html: html = html.replace('', bridge_tag + '\n', 1) elif '