exifcleaner-web/docs/android-apk.md
forgejo_admin b2dec037a8
All checks were successful
CI / Lint, Typecheck & Unit Tests (push) Successful in 30s
CI / E2E (Standalone single-file) (push) Successful in 1m27s
CI / E2E (Web) (push) Successful in 2m43s
feat(android): assembleRelease + env-var signing config (#165) (#185)
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.
2026-05-22 12:14:01 +04:00

32 KiB
Raw Permalink Blame History

Android Packaging: APK and AAR Research

This doc covers two questions asked together:

  1. APK — how to wrap the existing dist/web/ bundle into a self-contained Android APK that runs fully offline.
  2. 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 every file:// 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:

  1. The PWA to be hosted on a live HTTPS origin.
  2. A /.well-known/assetlinks.json file 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:

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:

  1. Build the image (first run only). Tagged metascrub-android-builder:local. Pulls node:22-bookworm-slim (pinned by digest), installs Temurin JDK 21, downloads Android cmdline-tools, installs platforms;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.
  2. Run the build inside the container as the host user's UID:GID so output files are user-owned. Bind-mounts the repo at /workspace and three repo-local cache dirs (.docker-cache/{gradle,yarn,home}) so Gradle and yarn deps persist across invocations.
  3. Executes the same steps as .github/workflows/build-android.yml: yarn install --frozen-lockfileyarn build:webnpx cap sync android./gradlew assembleDebug --no-daemon --stacktrace.
  4. Reports the APK pathandroid/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.md before running keytool -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 at dist/web as 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 ~510 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 in dist/web/ at the time of the last cap sync; it does not watch for changes.


Distribution

Personal sideloading

  1. Transfer app-debug.apk to the device (USB, Signal, email, SD card — anything except an HTTP upload would defeat the point).
  2. On the device, allow "Install from unknown sources" for the file manager being used (Android 8+ scopes this per-app rather than system-wide).
  3. 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.yml or a PR to the fdroiddata repository) 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 dependencies before 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 ~510 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 by vite.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-update plugin 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-screen addresses 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 56: API level below Capacitor 7's minimum (API 23); not supported.
  • CI integration: Shipped as a manual workflow — see §CI below.
  • android/ repo decision: The android/ directory is ~1015 MB on disk after cap add android. Whether to commit it to master or 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 ~35 min
use_prebaked_image = false Runner default actions/setup-java@v4 + android-actions/setup-android@v3 ~1015 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)

  1. Checks out the branch you select.
  2. Installs Node 22 (JDK 17 + Android SDK already in the container).
  3. Runs yarn install --frozen-lockfile and yarn build:web.
  4. Syncs dist/web/ into android/app/src/main/assets/public/ via npx cap sync android.
  5. Runs ./gradlew assembleDebug --no-daemon --stacktrace.
  6. Uploads app-debug.apk as a workflow artifact named metascrub-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 ~510 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: 814 person-weeks. PDF is the hard part — pdf-lib implements 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 .so files add 3045 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 ~23 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: 1016 person-weeks. Same PDF problem as A (Apache PDFBox is JVM-only; an iOS/Native equivalent needs a separate expect/actual implementation).
  • When it's worth it: If the goal is to publish a metascrub-android library 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: 1220 person-weeks for all five format strategies + forensic battery re-run.
  • Bundle size: ~24 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 (814 wks) Two codebases; forensic parity required
E — KMP Yes (1016 wks) Only justified if multi-platform library is the goal
F — Rust→WASM Yes (1220 wks) Full rewrite; best technical outcome if starting fresh
B — J2V8 No Abandoned; 3045 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