qw / native_features.py
ShadowHunter222's picture
Upload 7 files
a0454a3 verified
Raw History Blame Contribute Delete
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 ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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;">&times;</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 &times; 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}")