Local-only Android release signing, env-var driven. The maintainer keeps the .jks on their machine; CI age-encrypted-in-repo variant deferred to a follow-up. Adds signingConfigs.release reading 4 env vars (v1+v2+v3 enabled), scripts/build-apk-local.sh --release with .env auto-sourcing and --env-file secret handling, .env.sample template, root .gitignore for *.jks/*.keystore, peer-promoted Signed release APK section in docs/android-apk.md, and new docs/android-signing.md with the keystore ceremony, threat model, backup checklist, and rotation procedure. Closes #165.
32 KiB
Android Packaging: APK and AAR Research
This doc covers two questions asked together:
- APK — how to wrap the existing
dist/web/bundle into a self-contained Android APK that runs fully offline. - AAR — whether MetaScrub's format-stripping functionality can be exposed as an Android Archive library for embedding in third-party apps.
TL;DR: The APK path via Capacitor is viable and well-defined. The AAR path is not worth pursuing given the project's constraints — see the AAR Feasibility section for the full analysis.
Decisions (issue #153 v1)
| Q | Decision | Rationale |
|---|---|---|
| File output path | <a download> → system Downloads/ (accepted) |
Documented as a known limitation on Android ≤9 in PRIVACY_GAPS.md. Android 10+ scoped storage mitigates. A future @capacitor/filesystem adapter is the upgrade path. |
| Back button | Capacitor default (close on root) | Single-screen app; no nav stack. |
android/ placement |
Committed to master |
Matches project pattern (everything in one repo). ~12 MB on disk. |
| CI | workflow_dispatch only |
Run manually, never on push/PR. See .github/workflows/build-android.yml and the §CI section below. |
| Min API level | 23 (Capacitor 7 default) | Covers 99%+ of devices; WASM secure-context requirement is well within range. |
Note
The Capacitor APK is now MetaScrub's primary Android distribution (direction change 2026-05-21 — see
.claude/rules/project-direction.md). The deployed web PWA is no longer the recommended mobile path; it remains buildable for self-hosters but the APK is what we tell Android users to install. The signed-release-vs-personal-sideload distinction below is the only remaining axis; both produce the same artifact.
Why an APK rather than "just open the html"
The instinctive cheaper path is "give them dist/web/index.html, they double-click it." That works in Electron because Electron registers a custom file:// protocol handler that bypasses normal browser security. Plain Chromium browsers (Chrome, Brave, Edge) do not allow that pattern, and the failure is silent — blank page, no obvious cause until you open DevTools:
Access to script at 'file:///.../assets/index-XXX.js' from origin 'null'
has been blocked by CORS policy: Cross origin requests are only supported
for protocol schemes: brave, chrome, chrome-extension, chrome-untrusted,
data, http, https, isolated-app.
file: is not in that list. Modern Chromium treats every file:// page as a null origin and refuses ES module loads (and manifest loads, and service worker registration) against it. Removing the CSP meta tag doesn't help — the CORS layer kills the request before CSP is checked. Firefox treats same-folder file:// as same-origin and does work, but you can't rely on the recipient using Firefox.
The single-file standalone HTML build (yarn build:web:standalone, see standalone-html.md) sidesteps this for desktop distribution by inlining the entire bundle as a classic <script>. On Android, even that approach falls apart: Chrome Android has no UI for opening file:// URLs at all, and file managers usually can't hand HTML files to a browser that will accept them.
That's where the APK wrapper earns its complexity. Capacitor serves the bundled assets via an internal https://localhost/ scheme inside the WebView — same-origin loads, service worker can register, ES modules work, the renderer code path is identical to a real HTTPS deploy. From the WebView's perspective there's no file:// involved. Everything that breaks under file:// works because we never use file://.
What the APK adds:
- A thin native shell (Capacitor) that hosts a system WebView and serves bundled assets via
https://localhost/. - An Android Studio project (
android/) that compiles the shell + assets into a single APK. - Nothing on the JavaScript side changes.
vite.config.web.ts, the strategies, the IPC shim — all untouched. The web build is the source of truth for both the deployed PWA and the APK.
Nothing in any of this introduces a network call. CSP stays connect-src 'self'; no analytics, no auto-update, no crash reporting (see privacy-invariants.md §1, §2, §5).
Tooling: Capacitor
Capacitor is the current generation of "wrap a web app as a mobile app" tooling (it superseded Cordova; same author, cleaner architecture). It's the right fit because:
- Bundles local assets into the APK — files are served from the app's asset bundle, not over HTTP, so the user can be fully offline from first launch.
https://localhost/scheme — Capacitor's WebView interceptor serves bundled assets as if they came from a real HTTPS origin. This avoids everyfile://gotcha (ES modules, service workers, COOP/COEP) without ever touching the network.- No JS-side opt-in required — the existing renderer works as-is. You don't import a Capacitor SDK from your React tree unless you want to.
- Active maintenance — actively developed, well-documented, used by tens of thousands of production apps.
Why not TWA / Bubblewrap?
TWA (Trusted Web Activity) requires:
- The PWA to be hosted on a live HTTPS origin.
- A
/.well-known/assetlinks.jsonfile served from that origin whose SHA-256 fingerprint matches the APK's signing key.
There is no offline TWA mode. The verification step is architectural, not optional. This defeats the "fully offline / no infrastructure" goal of the APK path.
Why not PWABuilder or WebAPK?
PWABuilder (Microsoft's tool) generates a TWA wrapper — it is a front-end for the same Bubblewrap toolchain. Same limitations.
WebAPK is Chrome's internal mechanism for PWA installs. It is not developer-accessible; Chrome generates WebAPKs only for PWAs that pass its install criteria on the user's own device, and it relies on Google's WebAPK minting server. Cannot be used for distribution.
Prerequisites
There are two supported build paths. Pick whichever fits your machine:
Path A — Docker only (recommended)
| Tool | Why | Install |
|---|---|---|
| Docker | Runs the build inside a containerised JDK 21 + Android SDK 35 image | Docker Engine (Linux) or Docker Desktop 4.34+ (macOS / Windows) |
That's it. No host JDK, no host Android SDK, no host Node — everything lives inside the image. The first-run image build takes ~5 min (downloads JDK + SDK); subsequent runs reuse it. See Local Docker build below for the per-build flow.
Docker Desktop version note. The script uses --network host for docker build and docker run to dodge a default-bridge failure mode that breaks apt downloads on some hosts. --network host is fully supported on Linux Docker Engine and on Docker Desktop 4.34+ (released Aug 2024). On older Docker Desktop, the flag is silently ignored and apt downloads may hang for ~7 min before failing with E: Unable to locate package wget. If you hit this on macOS or Windows, upgrade Docker Desktop.
Path B — Native host toolchain
| Tool | Why | Install |
|---|---|---|
| Node 22 + yarn | Build dist/web/ and run Capacitor CLI |
Already a project requirement |
| JDK 21 | Capacitor 7.6+'s capacitor-android library compiles against Java 21 (sourceCompatibility = VERSION_21); JDK 17 fails javac with invalid source release: 21 |
apt install temurin-21-jdk (after adding the Adoptium apt repo), brew install openjdk@21 (macOS), or installed via Android Studio |
| Android SDK + Platform-Tools | Compile the APK | Easiest path: install Android Studio (~3 GB), which bundles SDK + Platform-Tools + an Android emulator. Headless alternative: cmdline-tools + sdkmanager if you don't want the GUI. |
Minimum Android version: Capacitor 7 requires Android API 23 (Android 6.0 Marshmallow). This covers 99%+ of active devices as of 2025. WASM support itself requires Chromium 61+ (2017), well within range on any supported Android version.
The recipient needs nothing — just the APK file and the OS-level "Install from unknown sources" permission for the file manager / browser they're installing through.
Local Docker build
The end-to-end flow for someone who only has Docker:
./scripts/build-apk-local.sh # build APK
./scripts/build-apk-local.sh --rebuild # force image rebuild (e.g. after Dockerfile change)
./scripts/build-apk-local.sh --clean # remove .docker-cache/ (gradle + yarn caches)
What the script does, in order:
- Build the image (first run only). Tagged
metascrub-android-builder:local. Pullsnode:22-bookworm-slim(pinned by digest), installs Temurin JDK 21, downloads Android cmdline-tools, installsplatforms;android-35+build-tools;35.0.0+build-tools;34.0.0(AGP 8.7.x's silent internal minimum), enables corepack. ~5 min cold; idempotent on later runs. - Run the build inside the container as the host user's UID:GID so output files are user-owned. Bind-mounts the repo at
/workspaceand three repo-local cache dirs (.docker-cache/{gradle,yarn,home}) so Gradle and yarn deps persist across invocations. - Executes the same steps as
.github/workflows/build-android.yml:yarn install --frozen-lockfile→yarn build:web→npx cap sync android→./gradlew assembleDebug --no-daemon --stacktrace. - Reports the APK path —
android/app/build/outputs/apk/debug/app-debug.apk.
Timings (measured during PR #163 verification on a Linux Docker Engine host with --network host):
| Run | Wall-clock |
|---|---|
| Cold (image not built, no caches) | ~7-10 min |
| Warm image, cold yarn + gradle caches | ~3-5 min |
| Warm image + warm caches | ~1-2 min |
The Dockerfile is in docker/android-builder/ — it mirrors the CI image's toolchain (JDK 21 + Android SDK 35 + cmdline-tools 11076708). Bump together with the CI image when refreshing the toolchain.
Signed release APK
The default ./scripts/build-apk-local.sh invocation produces a debug APK signed with Android's auto-generated per-developer debug keystore. That's the right artifact for personal sideloading and forensic verification, but it cannot install over a release APK signed by anyone else — and Play Protect surfaces a warning on a downloaded debug APK that does not appear on a release APK.
For distribution beyond personal use (a download page, F-Droid, multi-dev rebuilds that should install over each other), switch to a signed release build:
./scripts/build-apk-local.sh --release
# Output: android/app/build/outputs/apk/release/app-release.apk
The flag is enabled by populating .env at the repo root (gitignored) from the committed template:
cp .env.sample .env
$EDITOR .env # set the four METASCRUB_* variables
Four environment variables drive the signing config in android/app/build.gradle:
| Variable | Meaning |
|---|---|
METASCRUB_KEYSTORE_FILE |
Absolute path to the .jks keystore on the host. Bind-mounted read-only into the build container; never enters the repo working tree. |
METASCRUB_KEYSTORE_ALIAS |
The key alias inside the keystore (the -alias passed to keytool -genkey). |
METASCRUB_KEYSTORE_PASSWORD |
Store password. |
METASCRUB_KEY_PASSWORD |
Key password. Often identical to the store password; keep them distinct for defence in depth. |
If any variable is missing or the keystore file is unreadable, the script refuses to build — silent fallback to an unsigned APK would let you ship an artifact that fails update-install checks on user devices without ever noticing during the build.
When all four env vars are absent, ./gradlew assembleRelease (run directly, without the script) deliberately produces app-release-unsigned.apk — the artifact F-Droid's build server expects. The script's --release path always signs; the unsigned-release artifact is for F-Droid submission only.
Generating the keystore is a one-time ceremony with permanent consequences. The SHA-256 fingerprint of this keystore becomes the app's permanent identity. A lost keystore means no future APK can install as an update on top of an existing one — there is no recovery mechanism. A leaked keystore lets an attacker produce APKs that Android treats as legitimate updates to MetaScrub, inheriting all granted permissions. Read
docs/android-signing.mdbefore runningkeytool -genkey; it covers the recommended parameters, the offline-backup checklist, and the rotation procedure.
Verify the signature before distributing the APK:
apksigner verify --print-certs android/app/build/outputs/apk/release/app-release.apk
The certificate fingerprint must match the one published in docs/android-signing.md § Fingerprint.
One-time project setup
These steps add Capacitor to the repo. Run them once, commit the result, then cap sync is all you do on subsequent builds.
# 1. Add the Capacitor packages (dev-only — they don't ship to the web builds)
yarn add -D @capacitor/core@^7 @capacitor/cli@^7 @capacitor/android@^7
# 2. Initialize Capacitor — points it at our existing build output
npx cap init MetaScrub com.metascrub.app --web-dir=dist/web
# 3. Add the Android platform — generates the android/ Gradle project
npx cap add android
After this you'll have:
capacitor.config.ts(or.json) at the repo root — points atdist/webas the web dir, sets the app id and display name.android/— a real Android Studio project. Treat it like any other native Android source tree; it'll be checked into git.
capacitor.config.ts — critical settings
Ensure these are set in the config:
const config: CapacitorConfig = {
appId: 'com.metascrub.app',
appName: 'MetaScrub',
webDir: 'dist/web',
server: {
androidScheme: 'https', // Required: secure context for WASM + service workers
},
};
androidScheme: 'https' is the default in Capacitor 7, but make it explicit. WASM instantiation requires a secure context (Chrome enforces this); switching to 'http' would silently break the processing engine.
Do not add a native-level Content-Security-Policy header that omits 'wasm-unsafe-eval'. The <meta http-equiv="Content-Security-Policy"> tag injected by vite.config.web.ts is the authority for the APK path. A conflicting native-level CSP would override it and silently break WASM.
.gitignore additions for android/
After npx cap add android, add these to .gitignore (or create android/.gitignore):
android/.gradle/
android/build/
android/app/build/
android/.idea/
android/local.properties
android/app/release/ # Never commit signing keystores
Committing Gradle caches and .idea/ can easily add hundreds of MB to the repo history.
Per-build steps
Repeatable flow every time you want a new APK:
# 1. Build the web bundle (no change from existing workflow)
yarn build:web
# 2. Copy the freshly-built dist/web/ into the Android asset bundle
npx cap sync android
# 3a. Build a debug APK (unsigned, fine for personal sideloading)
cd android && ./gradlew assembleDebug
# Output: android/app/build/outputs/apk/debug/app-debug.apk
#
# OR
#
# 3b. Open the project in Android Studio and click Build > Build Bundle(s)/APK(s) > Build APK(s)
npx cap open android
The debug APK is ~5–10 MB depending on how the WebView strips assets. That's the entire deliverable.
Common gotcha: If the APK doesn't reflect your latest code changes, you forgot to re-run
yarn build:web && npx cap sync android. Capacitor serves whatever was indist/web/at the time of the lastcap sync; it does not watch for changes.
Distribution
Personal sideloading
- Transfer
app-debug.apkto the device (USB, Signal, email, SD card — anything except an HTTP upload would defeat the point). - On the device, allow "Install from unknown sources" for the file manager being used (Android 8+ scopes this per-app rather than system-wide).
- Tap the APK. Android shows an install dialog. After install, MetaScrub appears in the launcher like any other app.
Release signing
For distribution beyond personal use, see § Signed release APK above and docs/android-signing.md for the keystore ceremony, backup expectations, and rotation procedure.
F-Droid
F-Droid is the most privacy-aligned distribution channel and is technically achievable, but involves meaningfully more work than "submit the APK file":
- F-Droid builds the APK themselves from source on their own build server. You submit build metadata (a
.fdroid.ymlor a PR to thefdroiddatarepository) that tells their server how to reproduce the build. - F-Droid signs the APK with their own key. This means a F-Droid-distributed APK and a self-signed APK cannot be installed over each other — users switching distribution channels must uninstall first (Android signature mismatch).
- No non-free or Play-Services-dependent transitive Gradle dependencies are allowed. Capacitor's Android SDK uses Apache-2.0 (no Play Services); verify with
./gradlew dependenciesbefore submitting. - Review timeline is weeks to months (volunteer maintainers).
F-Droid submission is out of scope for the initial APK implementation but is achievable as a follow-on task.
What's different from the deployed PWA path (for self-hosters)
The APK is now the primary Android distribution; this table is here for self-hosters comparing the two paths.
| Concern | Deployed PWA (self-host) | Capacitor APK (primary) |
|---|---|---|
| Distribution medium | Visit a URL once, install via browser | Sideload .apk file |
| Requires network for first run | Yes (must load the page once) | No (everything in the APK) |
| Updates | Automatic on next visit, after service worker refresh | Manual — rebuild + redistribute |
| Sharing intents from gallery | Web Share Target (Android Chrome only; not supported on iOS, but iOS is out of scope anyway) | Android Intent filter via Capacitor plugin (works on every supported Android version) |
| Photos picker | Browser file input | Native Android picker (slicker UX) |
| App icon / name | Browser-managed | Real native app metadata |
| Trust signals | "It's just a web page" | "It's an installed app" — meaningful to less-technical users |
| Size on disk | ~few MB cached | ~5–10 MB APK |
| Developer-side cost | None beyond hosting the PWA | One-time Capacitor setup; rebuild on each release |
The APK is the recommended Android distribution. The deployed-PWA path exists for users who'd rather drop in through a browser on a machine they trust, or for self-hosters who maintain their own deploy.
Privacy invariants — re-verified for the APK path
- No server-side processing (§1): the WebView runs the same TypeScript strategies as the web build. Bundled assets, served from a local interceptor scheme. No remote calls.
- Fully offline in production (§2): CSP
connect-src 'self'is still injected byvite.config.web.ts. The APK doesn't include any new fetch sites. Verify before each release by running the no-network Playwright test (Phase F #67, once it lands). - Forensic verification (§3): unchanged. The strategies are the same code paths as the web build, so the existing per-format forensic runners cover the APK too.
- RAW unsupported (§4): unchanged. Same strategy registry, same coverage matrix.
- No telemetry, no auto-update (§5): Capacitor itself has no built-in telemetry. We do not install the
@capacitor/app-updateplugin or any analytics plugin. The APK build does not include an update-check pinger. - Timestamps absent or epoch (§6): unchanged. The strategies handle this; the WebView output goes to Capacitor's Filesystem plugin or the browser download path, neither of which changes the in-file timestamp policy.
File output privacy note
<a download> in a Capacitor WebView on Android drops files into the system Downloads/ folder via the Android DownloadManager. On Android 9 and below, this folder is world-readable by other apps. On Android 10+ (scoped storage), the file is isolated to the app's Downloads entry, which is meaningfully better.
Before calling the APK path shippable, decide: accept the Downloads/ behavior (document it as a known limitation for Android 9 users) or inject a Capacitor-aware adapter in web_api.ts that uses @capacitor/filesystem to offer a location picker. The adapter approach is detectable via typeof Capacitor !== 'undefined' and requires no changes to the shared renderer code.
Known issues before shipping
- File output path (privacy-relevant): See the note above. The
<a download>→Downloads/behavior on Android 9 is a real exposure for a privacy tool; resolve before distributing beyond personal use. - Drag-and-drop: Not a thing on mobile. The existing file picker path covers this, but the empty-state copy that says "drop files here" needs a mobile-aware variant — tracked in #49 / #51 (touch UX + unsupported-format messaging; both apply to the APK).
- Back button behaviour: Android's hardware back button needs to either dismiss the current view or close the app. Capacitor's default is "close app on back from root view"; verify this matches expectations before shipping.
- Splash screen flash: Capacitor shows a blank screen while the WebView initializes. On a dark-themed app this is a visible white flash.
@capacitor/splash-screenaddresses it; worth adding for any non-personal distribution. - WebView vintage: The WebView is Chromium-based and auto-updated on stock Android 7+. Platform-by-platform:
- GrapheneOS: Ships Vanadium (a maintained, up-to-date Chromium fork). Not at risk.
- Stock Android 7+: System WebView auto-updates via Play Store's WebView component. Fine.
- LineageOS: Does not bundle a WebView; the user must install one manually (Chromium WebView from F-Droid, or Mulch from DivestOS repos). If the user has an outdated WebView, WASM may fail. Worth a one-time test before distributing to a LineageOS audience.
- Android 5–6: API level below Capacitor 7's minimum (API 23); not supported.
- CI integration: Shipped as a manual workflow — see §CI below.
android/repo decision: Theandroid/directory is ~10–15 MB on disk aftercap add android. Whether to commit it tomasteror keep it on a separate build branch / sibling repo is worth deciding before the first PR lands.
CI
.github/workflows/build-android.yml builds the debug APK on demand. Trigger it from the Forgejo Actions tab → "Build Android APK" → "Run workflow".
Two execution paths
The workflow has a use_prebaked_image boolean dispatch input that toggles between a fast path and a portable fallback.
| Mode | Container | JDK + Android SDK | Cold time |
|---|---|---|---|
use_prebaked_image = true (default) |
forgejo-stack/job-android:latest |
Baked into the image | ~3–5 min |
use_prebaked_image = false |
Runner default | actions/setup-java@v4 + android-actions/setup-android@v3 |
~10–15 min |
The default fast path requires the forgejo-stack/job-android:latest image on the runner host (built by forgejo-stack's setup.sh; see runner-image-android/Dockerfile). The fallback works on any runner with Node + docker + apt — GitHub-hosted runners, vanilla act_runner, or a forgejo-stack instance without the optional Android image built.
The workflow itself has a soft dependency on forgejo-stack: the file checks into the repo regardless, and you opt out of the pre-baked image by unchecking the dispatch input. There is no hard repo-to-infra coupling.
Steps (fast path)
- Checks out the branch you select.
- Installs Node 22 (JDK 17 + Android SDK already in the container).
- Runs
yarn install --frozen-lockfileandyarn build:web. - Syncs
dist/web/intoandroid/app/src/main/assets/public/vianpx cap sync android. - Runs
./gradlew assembleDebug --no-daemon --stacktrace. - Uploads
app-debug.apkas a workflow artifact namedmetascrub-debug-apk(retained 30 days).
The fallback path additionally runs setup-java@v4 + android-actions/setup-android@v3 before step 4.
Trigger: workflow_dispatch only — no push, no PR, no schedule. The action is run by hand when an APK build is needed; it is not part of the per-PR CI matrix and does not block merges.
Why not on push/PR: Android SDK install + Gradle build add ~5–10 min per run on a cold cache, with no upside for the normal CI path (web build, lint, typecheck, unit tests, e2e are all platform-agnostic). The APK only needs to be rebuilt when there is something to distribute.
Signing: CI builds the debug APK only. Release signing is local-only today — run ./scripts/build-apk-local.sh --release from a machine with the keystore mounted; see § Signed release APK above. Wiring CI release signing (age-encrypted-in-repo keystore + Forgejo Secrets) is tracked as a follow-up in docs/android-signing.md § Future work.
Files this approach adds to the repo
After running the one-time setup, the worktree picks up:
capacitor.config.ts Capacitor project config (web dir, app id, server config)
android/ Android Studio project — Gradle, Java/Kotlin source, manifests
android/app/src/ App-specific sources (icons, splash, AndroidManifest.xml)
android/.gitignore Excludes build/, .gradle/, .idea/, local.properties
package.json (modified) Adds @capacitor/* to devDependencies
yarn.lock (modified) Lockfile updates
Nothing under src/, dist/, out/, .resources/, or any other existing project tree changes.
AAR Feasibility
An Android Archive (AAR) is a library format for distributing Android components to other Android developers. The question is: can MetaScrub's metadata-stripping functionality be exposed as an AAR that third-party Android apps embed?
What you're actually packaging
The strategies in src/infrastructure/wasm/ are pure TypeScript compiled to JavaScript by Vite/esbuild. Despite the folder name (a legacy from an earlier architecture), there are no .wasm binary files. The processing engine is a ~900 KB JS bundle. The two external deps are jszip and pdf-lib — both pure-JS libraries with no native parts. This matters for evaluating every AAR path below.
Approaches evaluated
A. Native Kotlin/Java port
Rewrite the byte-walker strategies in Kotlin. No JS or WebView involved.
- Feasibility: Fully feasible. The strategies are deterministic byte-walkers: JPEG marker parsing, PNG chunk CRC checks, MP4 box-tree traversal, ZIP reader + XML walker, PDF object model.
- Effort: 8–14 person-weeks. PDF is the hard part —
pdf-libimplements a full PDF object model (cross-reference tables, FlateDecode streams, incremental updates, AcroForms). On the JVM side you'd use Apache PDFBox (2.5 MB AAR) rather than reimplementing it. - Bundle size: Small JVM bytecode + ~2.5 MB if Apache PDFBox is included; no runtime engine.
- Maintenance cost: Two independent implementations of every format strategy. Every bug fix and forensic re-run must be done twice. The format-strategy-workflow was written because getting metadata stripping right is hard; doing it in a second language compounds that. This is the real cost, not the upfront implementation.
B. J2V8 / Embedded V8
Run the existing JS bundle inside J2V8 (a JNI binding to the V8 engine).
- Feasibility: Non-starter. J2V8 has not had a meaningful release since 2019 (V8 6.x). The project is effectively abandoned. The native
.sofiles add 30–45 MB per supported ABI. Do not use.
C. WebView-based AAR
An Android library module that embeds a WebView pre-loaded with the existing dist/web/ bundle, using AndroidX WebViewAssetLoader to serve it at https://appassets.androidplatform.net. Exposes a JavascriptInterface for passing bytes in and receiving cleaned bytes back.
- Feasibility: Technically workable in ~2–3 weeks. Single source of truth — JS bundle updates flow in automatically.
- Limitation: The AAR consumer gets a UI component, not a headless processing API. The WebView must be instantiated (even if off-screen). This is an unusual pattern for library consumers who want background processing. For most AAR use cases, this limitation is disqualifying.
D. GraalVM / Truffle
Run the JS bundle on GraalVM's Truffle JS engine.
- Feasibility: Does not run on Android. GraalVM targets the JVM and native (Linux/macOS/Windows). Android uses the ART runtime. Not viable.
E. Kotlin Multiplatform (KMP)
Rewrite strategies in Kotlin Multiplatform (shared code for Android AAR + iOS + desktop).
- Feasibility: Technically sound but costs everything Approach A costs, plus KMP scaffolding. Only adds value if a multi-platform native library is an explicit goal.
- Effort: 10–16 person-weeks. Same PDF problem as A (Apache PDFBox is JVM-only; an iOS/Native equivalent needs a separate
expect/actualimplementation). - When it's worth it: If the goal is to publish a
metascrub-androidlibrary on Maven Central as a standalone product for other developers. That is a different product from MetaScrub the PWA.
F. Rust → WASM → Android (WAMR/Wasmtime)
Rewrite strategies in Rust, compile to WASM with wasm32-wasi, run in a WASI runtime (WAMR, Wasmtime) on Android.
- Feasibility: Technically the cleanest headless API path, but requires a full rewrite in Rust. The existing TypeScript code does not compile to WASM via AssemblyScript because it uses
TextDecoder,DataView, JSZip, and pdf-lib — none available in AssemblyScript's stdlib. - Effort: 12–20 person-weeks for all five format strategies + forensic battery re-run.
- Bundle size: ~2–4 MB (WASM runtime + binaries). No WebView, no JVM dep.
- Maintenance cost: Same as Approach A — two codebases.
Verdict
| Approach | Viable? | Blocker |
|---|---|---|
| C — WebView AAR | Technically yes | Produces a UI component, not a headless API |
| A — Kotlin port | Yes (8–14 wks) | Two codebases; forensic parity required |
| E — KMP | Yes (10–16 wks) | Only justified if multi-platform library is the goal |
| F — Rust→WASM | Yes (12–20 wks) | Full rewrite; best technical outcome if starting fresh |
| B — J2V8 | No | Abandoned; 30–45 MB .so overhead |
| D — GraalVM | No | Does not run on Android ART |
Recommendation: close the AAR question as won't-do.
An AAR makes sense when you are building a library for other Android developers to embed (a "MetaScrub SDK") or when you need a headless processing API for a background service. MetaScrub is an end-user app, not a library product. Its Android distribution is the Capacitor APK (primary) with the deployed PWA available as a self-host option. Neither use case requires an AAR.
For any path that produces a true headless API (A, E, F), the maintenance cost of a second implementation is high and the audience (third-party Android developers wanting to embed metadata stripping) is small. Users who need ExifTool-equivalent functionality in an Android app are better served by shelling out to ExifTool CLI or running a local process wrapping the WASM build.
References
- Capacitor docs: https://capacitorjs.com/docs
- Capacitor Android specifics: https://capacitorjs.com/docs/android
- Capacitor configuration: https://capacitorjs.com/docs/config
- AndroidX WebViewAssetLoader: https://developer.android.com/reference/androidx/webkit/WebViewAssetLoader
- F-Droid inclusion guide: https://f-droid.org/en/docs/Inclusion_How-To/
- Android sideloading docs: https://developer.android.com/distribute/marketing-tools/alternative-distribution