Expense-Trackers

4 min read

Audit and harden the Pennywise Android WebView wrapper

Summary

Audited the feat/android-app branch (a single-Activity WebView wrapper around the Pennywise PWA, with a PennywiseAndroid JS bridge for downloads) and fixed the foundation issues before the next feature.

Committed on feat/android-app (4 commits, not pushed):

  • feeb8f9 fix(auth): sign-in now calls navigate("/record", { replace: true }), so Back no longer returns to the login form; AuthPage test covers it (fails without the fix).
  • eed0318 fix(android): shouldOverrideUrlLoading keeps only the APP_URL host in the WebView; other sites, mailto: and tel: go to the matching app, so other origins never get the JS bridge. Embedded frames load in place.
  • 1029e3c feat(android): release signing config reading the key path and passwords from ~/.gradle/gradle.properties (key at ~/keys/pennywise-release.jks, outside the repo); README documents setup, installRelease, versionCode bumps and the one-time uninstall from debug.
  • 37ebcc5 fix(android): configChanges now includes smallestScreenSize, density, uiMode, keyboard and navigation, so dark mode, display-size and window-size changes keep the page and unsaved input.

Done but uncommitted at session end:

  • onRenderProcessGone removes and destroys the dead WebView and restarts the Activity instead of crashing the app.
  • saveFile deletes the MediaStore entry if the write fails and reports the name Android actually used for duplicates; new frontend/src/lib/export.test.ts covers the bridge path of downloadBlob.
  • Network security config split: main is HTTPS-only; src/debug/ allows cleartext to 10.0.2.2 and localhost; README documents adb reverse for physical phones.

Verification: every Android fix was reproduced and then confirmed on the Pixel_8_API_35 emulator (real taps, DevTools-protocol probes, Page.crash, MediaStore queries). Lint 0 errors; AuthPage and export tests pass; tsc clean. A release APK was built and verified: signed with the release key (SHA-256 9a2dcc86…ba0828), not debuggable, https-only network config, versionCode 1.

Still open: refresh-cookie persistence across cold starts is unverified (depends on Goauth setting Expires/Max-Age; check with force-stop on a phone); no CI build for android/; no .dockerignore; lint warnings (mipmap-anydpi-v26, no monochrome icon, deprecated archivesBaseName); launcher icon double-padded; targetSdk 35 only matters for Play; switch the bridge to addWebMessageListener with origin rules when it grows. Pre-existing and unrelated: client.test.ts ‘refreshSession > stores a refreshed access token without durable storage’ fails on main too. The keystore password surfaced in the conversation transcript; rotating it (keytool -storepasswd, passwords moved to ORG_GRADLE_PROJECT_* env vars) was recommended but not done. graphify update refused to overwrite the graph (node count dropped) and was not forced.

Also added the flashmemorize MCP server (local scope) and saved Flash cards on browser history replace vs push and HttpOnly cookies.

Decisions

  • Lock navigation to the app’s own host now; defer origin-scoped addWebMessageListener until the bridge has more methods
  • Keep the signing key and passwords outside the repo (~/keys and ~/.gradle/gradle.properties) rather than a gitignored keystore.properties
  • Release builds fail loudly when signing properties are missing instead of producing an unsigned APK
  • Font-scale and locale changes still recreate the Activity so the page picks them up
  • Skipped a FileReader onerror handler in downloadBlob: reading an in-memory Blob cannot realistically fail
  • Bump versionCode by hand for now; automate it only if publishing to a store
  • One commit per fix so each can be reviewed or reverted independently

Learnings

  • WebView shouldOverrideUrlLoading also fires for subframe navigations; guard with request.isForMainFrame or third-party widgets open the browser
  • A WebView whose renderer died cannot be reused or redrawn; handling onRenderProcessGone means removing and destroying it
  • The DevTools protocol Page.crash command reliably reproduces a renderer crash on an emulator
  • AGP release builds shorten resource paths (e.g. res/8G.xml), so inspect resources via aapt2 dump resources rather than the original path
  • MediaStore renames duplicate Downloads entries (’name (1).csv’); query DISPLAY_NAME after insert to report the real name
  • Claude Code ‘!’ commands have no TTY, so interactive prompts like keytool’s password fail; run them in a real terminal
  • Tool-visible file diffs can expose secrets typed into files the agent has read or written; put secrets in files the agent never touches
  • Android shows a session cookie (no Expires/Max-Age) disappearing far more often than desktop browsers because background apps are killed routinely