"""Capacitor device plugins — native file/folder pick + save for web/Vite apps. Pure helper module (no FastAPI import). Imported lazily by backend/app.py so unit/test harnesses can import without a server. """ from __future__ import annotations import json import re from pathlib import Path # Pinned per Capacitor major (major follows Capacitor major; --no-save install). # Verified via npm/capawesome docs: file-picker 8.x needs Cap >=8 (6.x/5.x # deprecated); filesystem 8.1.x is current Cap8; share 8.x is Cap8; file-sharer # v8 tracks Cap8; scoped-storage 0.1.x is MIT SAF/bookmarks (70/week — newest, # kept optional-best-effort, never fatal). DEVICE_PLUGIN_PINS: dict[int, dict[str, str]] = { 8: { "@capawesome/capacitor-file-picker": "8", "@capacitor/filesystem": "8", "@capacitor/share": "8", "@capacitor/file-transfer": "latest", "@capgo/capacitor-file-sharer": "8", "@daniele-rolli/capacitor-scoped-storage": "latest", "@capacitor-community/media": "9", }, 7: { "@capawesome/capacitor-file-picker": "7", "@capacitor/filesystem": "7", "@capacitor/share": "7", "@capacitor/file-transfer": "latest", "@capgo/capacitor-file-sharer": "7", "@daniele-rolli/capacitor-scoped-storage": "latest", "@capacitor-community/media": "8", }, 6: { "@capawesome/capacitor-file-picker": "6", "@capacitor/filesystem": "6", "@capacitor/share": "6", "@capacitor/file-transfer": "latest", "@capgo/capacitor-file-sharer": "6", "@daniele-rolli/capacitor-scoped-storage": "latest", "@capacitor-community/media": "7", }, } # Core set installed by the builder (media is gallery-only; installed on demand # when the web bundle references it, to keep default builds lean). CORE_DEVICE_PLUGINS = [ "@capawesome/capacitor-file-picker", "@capacitor/filesystem", "@capacitor/share", "@capacitor/file-transfer", "@capgo/capacitor-file-sharer", "@daniele-rolli/capacitor-scoped-storage", ] DEVICE_FILES_JS = """/* device-files.js — auto native pick/save bridge (builder-injected, idempotent). * No bundler needed. Browser passthrough when not native. React-SPA safe via * MutationObserver + DataTransfer backfill (React onChange fires on change). * Opt out per element: data-device-native="off". * Programmatic: window.DeviceFiles.pickFolder()/pickFiles()/saveFile(blob,name). */ (function () { if (window.DeviceFiles && window.DeviceFiles.__wired) return; var STORE_KEY = "devicefiles.folder.v1"; function plugins() { return (window.Capacitor && window.Capacitor.Plugins) || {}; } function isNative() { try { if (window.Capacitor && typeof window.Capacitor.isNativePlatform === "function") return !!window.Capacitor.isNativePlatform(); return !!(window.Capacitor && window.Capacitor.isNative); } catch (e) { return false; } } // Resolve a plugin by bridge name. Path 1: pre-registered proxy (bundled // web bundle or app import). Path 2: on-demand native proxy via the public // registerPlugin core API — covers foreign bundles that never imported the // plugin package. Returns {ref, via} or null. Never throws. function resolvePlugin(name) { try { var P = plugins(); if (P[name]) return { ref: P[name], via: "registered" }; if (window.Capacitor && typeof window.Capacitor.registerPlugin === "function") { try { var r = window.Capacitor.registerPlugin(name); if (r) return { ref: r, via: "registerPlugin" }; } catch (e) {} } } catch (e) {} return null; } function pluginAvailable(name) { try { if (window.Capacitor && typeof window.Capacitor.isPluginAvailable === "function") { try { return !!window.Capacitor.isPluginAvailable(name); } catch (e) {} } } catch (e) {} return null; // unknown (old runtime) } function statusSnapshot() { var names = ["FilePicker", "Filesystem", "Share", "FileTransfer", "FileSharer", "ScopedStorage", "Media"]; var out = {}; for (var i = 0; i < names.length; i++) { try { var hit = resolvePlugin(names[i]); out[names[i]] = hit ? hit.via : ("available:" + String(pluginAvailable(names[i]))); } catch (e) { out[names[i]] = "error"; } } var capV = null; try { capV = (window.Capacitor && (window.Capacitor.platform || null)); } catch (e) {} return { native: isNative(), platform: capV, plugins: out, lastPick: api.lastPick || null }; } function optedOut(el) { try { return el && typeof el.getAttribute === "function" && el.getAttribute("data-device-native") === "off"; } catch (e) { return false; } } function dispatch(el, type, detail) { try { var ev; if (typeof window.CustomEvent === "function") ev = new window.CustomEvent(type, { bubbles: true, detail: detail }); else if (document.createEvent) { ev = document.createEvent("CustomEvent"); if (ev.initCustomEvent) ev.initCustomEvent(type, true, true, detail); else if (ev.initEvent) ev.initEvent(type, true, true); ev.detail = detail; } else if (typeof window.Event === "function") ev = new window.Event(type, { bubbles: true }); else { ev = { type: type, detail: detail }; } if (!ev.type && type) ev.type = type; el.dispatchEvent(ev); } catch (e) { try { el.dispatchEvent({ type: type, detail: detail }); } catch (_) {} } } function loadStored() { try { var raw = window.localStorage ? window.localStorage.getItem(STORE_KEY) : null; return raw ? JSON.parse(raw) : null; } catch (e) { return null; } } function storeFolder(f) { try { if (window.localStorage && f) window.localStorage.setItem(STORE_KEY, JSON.stringify(f)); } catch (e) {} } async function scopedFolder() { var SS = resolvePlugin("ScopedStorage"); if (!SS || typeof SS.ref.pickFolder !== "function") return null; var P = { ScopedStorage: SS.ref }; var stored = loadStored(); if (stored) { try { if (typeof P.ScopedStorage.exists === "function") { var chk = await P.ScopedStorage.exists({ folder: stored }); if (chk && chk.exists) return { folder: stored, mode: "native-persistent", restored: true }; } else { return { folder: stored, mode: "native-persistent", restored: true }; } } catch (e) {} } var res = await P.ScopedStorage.pickFolder(); var folder = (res && res.folder) || res; if (folder) storeFolder(folder); return { folder: folder, mode: "native-persistent", restored: false }; } async function pickFolderNative() { var SS = resolvePlugin("ScopedStorage"); if (SS && typeof SS.ref.pickFolder === "function") { var s = await scopedFolder(); if (s && s.folder) { api.lastPick = { folder: s.folder, mode: s.mode }; return { folder: s.folder, mode: s.mode }; } } var FP = resolvePlugin("FilePicker"); if (FP && typeof FP.ref.pickDirectory === "function") { var r = await FP.ref.pickDirectory(); var path = (r && r.path) || (r && r.folder) || r; api.lastPick = { path: typeof path === "string" ? path : (path && path.id) || String(path), mode: "native" }; return { path: api.lastPick.path, mode: "native" }; } return null; } function backfillInput(input, name, blob) { try { var dt; if (typeof window.DataTransfer === "function") { dt = new window.DataTransfer(); var f = blob; if (!(f instanceof window.File) && typeof window.File === "function") { try { f = new window.File([blob], name || "file", { type: (blob && blob.type) || "" }); } catch (e) { f = blob; } } if (dt.items && typeof dt.items.add === "function") dt.items.add(f); else if (typeof dt.add === "function") dt.add(f); try { input.files = dt.files; } catch (e) { try { Object.defineProperty(input, "files", { value: dt.files, configurable: true }); } catch (_) {} } } } catch (e) {} } async function pickFilesNative(opts) { var FP = resolvePlugin("FilePicker"); if (!(FP && typeof FP.ref.pickFiles === "function")) return null; var res = await FP.ref.pickFiles(opts || {}); var files = (res && res.files) || []; return { files: files, mode: "native" }; } function blobToBase64(blob) { return new Promise(function (resolve, reject) { try { var fr = new window.FileReader(); fr.onload = function () { var s = String(fr.result || ""); var i = s.indexOf("base64,"); resolve(i >= 0 ? s.slice(i + 7) : s); }; fr.onerror = reject; fr.readAsDataURL(blob); } catch (e) { reject(e); } }); } async function saveFileNative(blob, name, opts) { var filename = name || (opts && opts.filename) || "download.bin"; var ctype = (blob && blob.type) || (opts && opts.contentType) || "application/octet-stream"; // 1. sharer first: silent MediaStore save, no sheet, no Documents write. try { var FSH = resolvePlugin("FileSharer"); if (FSH && typeof FSH.ref.save === "function") { var data = null; try { data = await blobToBase64(blob); } catch (e) { data = null; } var out = await FSH.ref.save({ filename: filename, contentType: ctype, base64Data: data }); return { ok: true, mode: "native-silent", uri: out && out.uri }; } } catch (e) {} // 2. silent own-Documents save, still no sheet. try { var FSS = resolvePlugin("Filesystem"); if (isNative() && FSS && typeof FSS.ref.writeFile === "function") { var b64 = await blobToBase64(blob); await FSS.ref.writeFile({ path: filename, data: b64, directory: "DOCUMENTS" }); return { ok: true, mode: "native-silent" }; } } catch (e) {} // 3. web fallback (browser or no native fs). try { var url = null; if (blob) { if (typeof window.URL !== "undefined" && window.URL.createObjectURL) url = window.URL.createObjectURL(blob); } if (url) { var a = document.createElement("a"); a.href = url; a.download = filename; try { document.body.appendChild(a); } catch (e) {} try { a.click(); } catch (e) {} try { setTimeout(function () { try { document.body.removeChild(a); } catch (e) {} try { window.URL.revokeObjectURL(url); } catch (e2) {} }, 4000); } catch (e) {} return { ok: true, mode: "web" }; } } catch (e) {} return { ok: !!blob, mode: "web" }; } function enhanceInput(input) { if (!input || input.__deviceWired || optedOut(input)) return; input.__deviceWired = true; var isDir = false; try { isDir = input.hasAttribute && (input.hasAttribute("webkitdirectory") || input.hasAttribute("webkitDirectory") || input.hasAttribute("directory")); } catch (e) {} // Also treat folder buttons via data-device-folder elsewhere; here only file inputs. input.addEventListener("click", function (e) { if (!isNative() || optedOut(input)) return; if (isDir) { var SSD = resolvePlugin("ScopedStorage"); if (SSD && typeof SSD.ref.pickFolder === "function") { e.preventDefault(); scopedFolder().then(function (s) { if (s && s.folder) { api.lastPick = { folder: s.folder, mode: s.mode }; dispatch(input, "devicefolder", { folder: s.folder }); } }).catch(function () {}); return; } var FPD = resolvePlugin("FilePicker"); if (FPD && typeof FPD.ref.pickDirectory === "function") { e.preventDefault(); FPD.ref.pickDirectory().then(function (r) { var path = (r && r.path) || r; api.lastPick = { path: typeof path === "string" ? path : String(path), mode: "native" }; dispatch(input, "devicefolder", { path: api.lastPick.path }); }).catch(function () {}); return; } // Clear-reason deadend: report exactly what is missing so the denial // is diagnosable (see window.DeviceFiles.status()). Never files dialog. e.preventDefault(); var why = "native=" + String(isNative()) + " scoped=" + String(!!(SSD && SSD.ref)) + " picker=" + String(!!(FPD && FPD.ref)); try { var P2 = plugins(); if (P2.Dialog && typeof P2.Dialog.alert === "function") P2.Dialog.alert({ title: "Folder picking", message: "Folder picking is not available in this build (" + why + "). Pick files instead, or rebuild with device plugins." }); else if (window.alert) window.alert("Folder picking unavailable in this build (" + why + ")."); } catch (err) {} try { if (window.console && window.console.warn) window.console.warn("[DeviceFiles] folder deadend: " + why, statusSnapshot()); } catch (err2) {} return; } var FPF = resolvePlugin("FilePicker"); if (FPF && typeof FPF.ref.pickFiles === "function") { e.preventDefault(); FPF.ref.pickFiles().then(function (res) { var files = (res && res.files) || []; if (files.length) { var f0 = files[0]; var name = f0.name || input.getAttribute("name") || "file"; var blob = null; if (f0.blob) blob = f0.blob; else if (f0.webPath) { try { fetch(f0.webPath).then(function (r) { return r.blob(); }).then(function (b) { backfillInput(input, name, b); try { input.setAttribute("data-device-picked", "1"); } catch (e2) {} dispatch(input, "change", { files: files }); try { dispatch(input, "input", { files: files }); } catch (e3) {} }).catch(function () { dispatch(input, "change", { files: files }); }); return; } catch (err) {} } else if (f0.path) { try { var conv = (window.Capacitor && typeof window.Capacitor.convertFileSrc === "function") ? window.Capacitor.convertFileSrc(f0.path) : f0.path; fetch(conv).then(function (r) { return r.blob(); }).then(function (b) { backfillInput(input, name, b); dispatch(input, "change", { files: files }); }).catch(function () { dispatch(input, "change", { files: files }); }); return; } catch (err2) {} } if (blob) backfillInput(input, name, blob); else if (typeof window.File === "function") { try { backfillInput(input, name, new window.File([""], name, { type: f0.mimeType || "" })); } catch (e4) { try { input.files = [{ name: name, path: f0.path }]; } catch (e5) {} } } else { try { input.files = [{ name: name, path: f0.path }]; } catch (e6) {} } try { input.setAttribute("data-device-picked", "1"); } catch (e7) {} dispatch(input, "change", { files: files }); try { dispatch(input, "input", { files: files }); } catch (e8) {} } }).catch(function () {}); } }); } function enhanceAnchor(a) { if (!a || a.__deviceWired || optedOut(a)) return; var href = null, dl = null; try { href = a.getAttribute("href"); dl = a.getAttribute("download"); } catch (e) {} if (!href || dl === null) return; a.__deviceWired = true; a.addEventListener("click", function (e) { if (!isNative() || optedOut(a)) return; var FSW = resolvePlugin("Filesystem"), FHW = resolvePlugin("FileSharer"); if (!(FSW && typeof FSW.ref.writeFile === "function") && !(FHW && typeof FHW.ref.save === "function")) return; e.preventDefault(); var name = dl || href.split("?")[0].split("/").pop() || "download.bin"; fetch(href).then(function (r) { return r.blob(); }).then(function (blob) { saveFileNative(blob, name).then(function () {}).catch(function () { try { a.click(); } catch (err) {} }); }).catch(function () { try { var raw = a.__deviceRawClick; if (!raw) { a.__deviceWired = false; try { a.click(); } catch (err2) {} a.__deviceWired = true; } } catch (err3) {} }); }); } function enhanceFolderButton(el) { if (!el || el.__deviceWired || optedOut(el)) return; el.__deviceWired = true; el.addEventListener("click", function (e) { if (!isNative() || optedOut(el)) return; e.preventDefault(); pickFolderNative().then(function (r) { if (r) dispatch(el, "devicefolder", r); }).catch(function () {}); }); } function enhanceAll(root) { var doc = root || document; var q = null; try { q = function (sel) { return doc.querySelectorAll ? doc.querySelectorAll(sel) : []; }; } catch (e) { return; } var inputs = [], anchors = [], btns = []; try { inputs = q('input[type="file"]'); } catch (e) { try { inputs = q("input"); } catch (e2) {} } try { anchors = q("a[download]"); } catch (e3) {} try { btns = q("[data-device-folder]"); } catch (e4) {} var i; for (i = 0; i < (inputs ? inputs.length : 0); i++) { try { enhanceInput(inputs[i]); } catch (e5) {} } for (i = 0; i < (anchors ? anchors.length : 0); i++) { try { enhanceAnchor(anchors[i]); } catch (e6) {} } for (i = 0; i < (btns ? btns.length : 0); i++) { try { enhanceFolderButton(btns[i]); } catch (e7) {} } } var api = { __wired: true, lastPick: null, enhanceAll: enhanceAll, isNative: isNative, resolve: function (name) { var h = resolvePlugin(name); return h ? h.via : null; }, status: function () { try { return statusSnapshot(); } catch (e) { return { native: false, error: String(e) }; } }, pickFolder: async function () { if (!isNative()) return { mode: "web", ok: false, reason: "not-native" }; var r = await pickFolderNative(); if (r) return Object.assign({ ok: true }, r); return { mode: "web", ok: false, reason: "no-provider" }; }, pickFiles: async function (opts) { if (!isNative()) return { mode: "web", ok: false, reason: "not-native" }; var r = await pickFilesNative(opts); if (r) return Object.assign({ ok: true }, r); return { mode: "web", ok: false, reason: "no-provider" }; }, saveFile: async function (blob, name, opts) { if (!isNative()) { try { var url = (blob && typeof window.URL !== "undefined" && window.URL.createObjectURL) ? window.URL.createObjectURL(blob) : null; if (url) { var a = document.createElement("a"); a.href = url; a.download = name || "download.bin"; try { document.body.appendChild(a); } catch (e) {} try { a.click(); } catch (e2) {} try { setTimeout(function () { try { document.body.removeChild(a); } catch (e3) {} try { window.URL.revokeObjectURL(url); } catch (e4) {} }, 4000); } catch (e5) {} } } catch (e6) {} return { ok: true, mode: "web" }; } return saveFileNative(blob, name, opts); } }; window.DeviceFiles = api; try { enhanceAll(document); if (document.readyState !== "complete" && document.addEventListener) { document.addEventListener("DOMContentLoaded", function () { try { enhanceAll(document); } catch (e) {} }); } if (typeof MutationObserver !== "undefined" && document.documentElement) { var mo = new MutationObserver(function (muts) { try { for (var i = 0; i < muts.length; i++) { var m = muts[i]; if (m.addedNodes) { for (var j = 0; j < m.addedNodes.length; j++) { var n = m.addedNodes[j]; if (!n || n.nodeType !== 1) continue; try { if (n.tagName === "INPUT" && n.type === "file") enhanceInput(n); else if (n.tagName === "A" && n.hasAttribute && n.hasAttribute("download")) enhanceAnchor(n); else if (n.hasAttribute && n.hasAttribute("data-device-folder")) enhanceFolderButton(n); else if (n.querySelectorAll) { var ins = n.querySelectorAll('input[type="file"]'); for (var k = 0; k < ins.length; k++) enhanceInput(ins[k]); var ans = n.querySelectorAll("a[download]"); for (var l = 0; l < ans.length; l++) enhanceAnchor(ans[l]); var bs = n.querySelectorAll("[data-device-folder]"); for (var q2 = 0; q2 < bs.length; q2++) enhanceFolderButton(bs[q2]); } } catch (e2) {} } } } } catch (e3) {} }); try { mo.observe(document.documentElement, { childList: true, subtree: true }); } catch (e4) {} } else if (document.addEventListener) { document.addEventListener("DOMNodeInserted", function (e) { try { enhanceAll(document); } catch (err) {} }); } } catch (e) {} })(); """ def plugin_pins_for(cap_major: int) -> dict[str, str]: try: m = int(cap_major) except Exception: m = 0 if m in DEVICE_PLUGIN_PINS: return dict(DEVICE_PLUGIN_PINS[m]) if m >= 8: return dict(DEVICE_PLUGIN_PINS[8]) if m == 7: return dict(DEVICE_PLUGIN_PINS[7]) return dict(DEVICE_PLUGIN_PINS[6]) # npm package -> (settings.gradle short id, JS bridge name for registerPlugin). # Bridge names equal the documented JS import names; registerPlugin() failures # are caught at runtime (resolvePlugin falls through), so a wrong name degrades # to "missing" instead of crashing. PLUGIN_DEFS: dict[str, tuple[str, str]] = { "@capawesome/capacitor-file-picker": ("capacitor-file-picker", "FilePicker"), "@capacitor/filesystem": ("capacitor-filesystem", "Filesystem"), "@capacitor/share": ("capacitor-share", "Share"), "@capacitor/file-transfer": ("capacitor-file-transfer", "FileTransfer"), "@capgo/capacitor-file-sharer": ("capacitor-file-sharer", "FileSharer"), "@daniele-rolli/capacitor-scoped-storage": ("capacitor-scoped-storage", "ScopedStorage"), "@capacitor-community/media": ("capacitor-media", "Media"), } # Script-tag-safe web bundle candidates inside each plugin package. # dist/plugin.js is the IIFE loader meant for ' for t in tags) head_block = "".join(f'\n ' for t in tags) if "" in txt: txt = txt.replace("", " " + head_tags + "\n", 1) elif "" in txt: txt = txt.replace("", "" + head_block, 1) elif "]*>)", r"\1" + head_block + "\n", txt, count=1, ) else: txt = "" + head_block + "\n\n" + txt html.write_text(txt, encoding="utf-8") patched_any = True except Exception as e: try: log_fn(f"device helper inject note {html.name}: {e}") except Exception: pass try: log_fn( "Device helper wired into web output: file inputs use native pickers" if patched_any else "Device helper present (already wired)" ) except Exception: pass return True except Exception as e: try: log_fn(f"device helper wiring skipped: {e}") except Exception: pass return False def ensure_file_paths_xml(android_dir: Path, log_fn) -> None: """Ensure FileProvider paths for Share (caches + external + documents).""" try: xml_dir = Path(android_dir) / "app" / "src" / "main" / "res" / "xml" xml_dir.mkdir(parents=True, exist_ok=True) fp = xml_dir / "file_paths.xml" want = ( '\n' "\n" ' \n' ' \n' ' \n' ' \n' ' \n' "\n" ) try: cur = fp.read_text(encoding="utf-8", errors="ignore") if fp.exists() else "" except Exception: cur = "" if "cache-path" not in cur or "external-files-path" not in cur: fp.write_text(want, encoding="utf-8") try: log_fn("Ensured file_paths.xml for Share/FileProvider") except Exception: pass except Exception as e: try: log_fn(f"file_paths.xml note: {e}") except Exception: pass def ensure_device_permissions(android_dir: Path, log_fn, gallery: bool = False) -> None: """Minimal Play-safe permissions (picker-granted URIs need no broad perms). Default (gallery=False, per media v7+ non-gallery guidance): only READ_EXTERNAL_STORAGE maxSdk=32 (EXIF/external read) and WRITE_EXTERNAL_STORAGE maxSdk=29 (pre-scoped). Broad READ_MEDIA_* are added ONLY for gallery-mode apps (Play rejects them otherwise). Idempotent, never duplicates. """ try: man = Path(android_dir) / "app" / "src" / "main" / "AndroidManifest.xml" if not man.is_file(): return txt = man.read_text(encoding="utf-8", errors="ignore") orig = txt def _add(perm: str, extra: str = ""): nonlocal txt if perm in txt: return tag = f' \n' if " None: """Append -keep rules for device plugins (never overwrite user rules).""" try: pro = Path(android_dir) / "app" / "proguard-rules.pro" marker = "# --- device plugins (builder) ---" rules = ( marker + "\n" "-keep class com.capacitorjs.plugins.** { *; }\n" "-keep class com.capawesome.** { *; }\n" "-keep class com.capgo.** { *; }\n" "-keep class com.daniellerolli.** { *; }\n" "-keep class com.capacitorcommunity.** { *; }\n" ) try: cur = pro.read_text(encoding="utf-8", errors="ignore") if pro.exists() else "" except Exception: cur = "" if marker not in cur: pro.parent.mkdir(parents=True, exist_ok=True) pro.write_text((cur.rstrip() + "\n" if cur.strip() else "") + rules, encoding="utf-8") try: log_fn("Appended ProGuard keeps for device plugins") except Exception: pass except Exception as e: try: log_fn(f"device proguard note: {e}") except Exception: pass