Skip to content

Rupert for Android (Hotwire Native), at parity with the iPhone app - #625

Open
techmore wants to merge 9 commits into
mainfrom
claude/android-app-0612b2
Open

techmore wants to merge 9 commits into
mainfrom
claude/android-app-0612b2

Conversation

@techmore

@techmore techmore commented Nov 2, 2026

Copy link
Copy Markdown
Owner

Adds Rupert for Android (android/), the Android twin of the iPhone app: a Hotwire Native Android 1.3.2 shell (Kotlin, AGP 9.5, Gradle 9.8.2, minSdk 28, target 36, com.rupertpos.rupert) around a Rupert instance, plus the small server changes it needs. Nothing is uploaded to Google; no signing material is in the repo.

Merging deploys the server part to production (routes, two controllers, one Stimulus controller). It is additive: new endpoints and an Android fallback in the inventory scanner.

Why Hotwire Native (and not mobile/)

mobile/ is the Expo app for staff timesheets only (/employee API); it is maintained but narrow. The full Rupert app is the website, and ios/ already wraps it with Hotwire Native. Doing the same on Android reuses everything the server already does for the iPhone app (hotwire_native_app? matches Hotwire Native Android, hidden web chrome, hidden billing/sign-up, the Google PKCE handoff, the workspace handoff), shares the navigation rules, and means new pages need no app release.

Parity with iOS

Android
Server / instance Default pos.rupertpos.com; App settings → Rupert server picks a self-hosted Rupert. The address is checked twice: form (InstanceAddress: https only, no paths or credentials; http only for this device and .local/.lan/.internal/.home.arpa) and that it serves Rupert's navigation rules. Workspace instances are remembered.
Sign-in Email/password in the page; Google in a Custom Tab with the existing PKCE flow (rupert://auth/google → POST /native/session, cookie copied into the web views). Sign-up intent is carried.
Workspace handoff rupert://workspace?… caught by the router; the app posts the handoff to <origin>/instance/session with the control-plane Origin, keeps only that instance's cookie, moves every tab.
App lock BiometricPrompt (fingerprint/face/screen lock), cover over every screen, no recent-apps snapshot, TalkBack hidden behind the cover; the Google tab, scanner, camera and file picker don't trigger it.
Tabs Home (/login), Team Chat, Ask Rupert, Notifications, Search; own back stacks; tabs left behind by a sign-in/out start over.
Barcode scanning Google code scanner (ML Kit in Play services): no camera permission; same rupert:scanned event.
Banners Top bar and status-bar icons follow --color-banner, live when the appearance changes.
Deep links rupert://auth/google; App Links for pos.rupertpos.com on chosen paths (sign-in paths excluded), verified once the owner sets the certificate (below).
Path configuration One file: Gradle copies ios/Sources/Rupert/Resources/path-configuration.json into the app at build time; server serves the same rules at /native/android/path-configuration.json; native_app_test.rb asserts they match.
Also Launcher shortcuts (like iOS quick actions), App settings screen, error screen with copyable page diagnostics, dark mode, labels/headings/48dp targets for accessibility.

Security

HTTPS only (network_security_config.xml: cleartext off, system CAs only; local exceptions mirror InstanceAddress, enforced by a test). No runtime permissions (INTERNET, plus install-time USE_BIOMETRIC and ACCESS_NETWORK_STATE from libraries). Page bridges are WebView message listeners limited to Rupert origins and main frames, never payment iframes. No backups, no WebView metrics, file/content access off, debugging only in debug builds. .gitignore refuses keystores and Play service-account files. Data safety answers are in android/PlayStore/data-safety.md.

Server changes (web/)

  • GET /native/android/path-configuration.json (same rules as iOS).
  • GET /.well-known/assetlinks.json (AndroidAssetLinksController): empty list until RUPERT_ANDROID_CERT_SHA256 is set.
  • inventory_count_scanner_controller.js: uses window.rupertScanner when the iOS handler is absent.
  • Access log labels Android sign-ins as "Android app". Tests added in native_app_test.rb (18 runs green locally).

CI

.github/workflows/android.yml (pinned actions): unit + Robolectric tests, lint, debug APK, unsigned release AAB as artifacts. Runs only on android/** and the shared path-configuration file.

Tested

  • ./gradlew testDebugUnitTest: 33 tests pass (address rules, PKCE vs the server's challenge_for, rupert:// parsing, network config ⇔ address rules, banner parsing, HTTP flows against MockWebServer, Robolectric screens).
  • lintDebug clean (3 minor warnings), assembleDebug, bundleRelease (unsigned, 7.1 MB), and the R8 release build runs on the emulator.
  • connectedDebugAndroidTest smoke test passes on an Android 16 emulator.
  • On the emulator against a local Rupert (http://10.0.2.2:3000, the fictional review demo shop): server picker (rejects http://192.168.x, accepts local), email sign-in, all tabs, scan page bridges, live dark appearance, app dark mode, app lock (on/off, cover, recents, unlock), and Google sign-in end to end (Custom Tab with native_challenge and intent; a code minted locally for the app's challenge redeemed → session → Home opened on Rupert's landing page; a code for another challenge was refused with 422).
  • Not run end to end: workspace handoff (needs two instances; covered by MockWebServer and the existing server tests) and a real camera scan (the emulator's Play services was still downloading the scanner module; the page → app → scanner bridge itself was exercised).
Home Team Chat Notifications App settings Dark Server check Lock

Left for the owner

See android/PlayStore/checklist.md: Play developer account (Organization avoids the 12-tester closed-test rule), upload key + signed bundle, Play App Signing, internal testing, RUPERT_ANDROID_CERT_SHA256 for App Links, listing, Data safety (check Google's ML Kit disclosure for the scanner), content rating, target audience, review login via rupert:app_review_demo.

Known follow-ups

  • The scan page keeps the previous page's document.title (seen as "#shop-floor · Team chat" after visiting from chat); a web-side title issue that also affects iOS.
  • A monochrome launcher icon layer for Android 13+ themed icons needs a hand-drawn silhouette.

🤖 Generated with Claude Code

techmore and others added 9 commits November 2, 2026 17:00
A new android/ app (Kotlin, Hotwire Native Android 1.3.2, AGP 9.5, Gradle 9.8.2,
minSdk 28, target 36) wrapping a Rupert instance the same way ios/ does:

- Same five tabs, each with its own back stack; forms as modals; pull to refresh.
- Server picker in App settings: https only, plain http only for this device and
  local names; the address must answer like Rupert before the app moves there.
- Google sign-in in a Custom Tab with the existing PKCE handoff
  (rupert://auth/google, POST /native/session); workspace handoff natively.
- App lock (BiometricPrompt, screen lock), cover over every screen, no
  recent-apps snapshot; App settings screen; launcher shortcuts.
- Barcode scanning with Google's code scanner (no camera permission).
- Top bar follows each page's banner colour, live on appearance changes; dark mode.
- Network security config: cleartext off, system CAs only; no backups.

One copy of the navigation rules: Gradle copies the iPhone app's
path-configuration.json into the app's assets; the server also serves them at
/native/android/path-configuration.json. Also on the server: an App Links
statement at /.well-known/assetlinks.json from RUPERT_ANDROID_CERT_SHA256, the
inventory scanner page talks to the Android bridge, and access logs name the app.

CI: .github/workflows/android.yml runs unit/Robolectric tests and lint and keeps
the debug APK and the unsigned release bundle. Play Console steps the owner does
are in android/PlayStore/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Home starts on the landing page after Google sign-in or a workspace handoff
  (routing it from onNavigatorReady crashed before the first page existed).
- Signing in on a tab starts that tab over, so the signed-in page is its first page.
- App lock: leaving for the Google tab, scanner, camera or file picker never locks,
  however slow the other app is to open; the allowance ends when the app returns.
- Network security config listed localhost twice, which Android rejects at start.
- Android 9-12: URL decoding without the API 33 overload (lint).
- Tab bar and loading screen use Rupert's colours; the activity follows dark mode.
- Server address errors clear while typing; friendlier message while Play services
  is still fetching the barcode scanner.
- Drop the deprecated USE_FINGERPRINT permission.
- Phone screenshots (1080x2160) from the emulator against the fictional demo shop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bundler-audit on main fails on advisories published today against
loofah 2.25.2, nokogiri 1.19.4 and rails-html-sanitizer 1.7.1.
Conservative update of only those three gems to loofah 2.25.3,
nokogiri 1.19.7 and rails-html-sanitizer 1.7.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test read /proc/loadavg and /proc/meminfo directly, so it failed on
a Mac. HostVitals now reads /proc through one small method that the
test stubs with fixed contents, checking the exact CPU and memory
percentages instead of only their types. Disk and database size still
come from the real host. The real /proc read still runs where /proc
exists (Linux CI). A new test pins that an unreadable reading is nil.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under a C/POSIX locale (a shell without LANG, as on some Macs) Ruby
returns ENV values as binary, so a label such as "SER8 · Incus" did
not equal the UTF-8 string and the platform overview test failed. The
label is now read as UTF-8, and a value that is not valid UTF-8 shows
as "Not configured" instead of raising.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PosPaymentReconciliationJob takes one machine-wide flock and skips its
sweep when the lock is held. Parallel test workers shared that file, so
when two PosCardPaymentTest cases ran the job at the same moment one
silently did nothing. That intermittently failed the overlap test
(line 72) and the rotation test (line 56) on main and on several PRs.

The lock path is now one small method, and the tests point it at a
temporary file per test. Holding the real lock from another process
reproduces both failures on the old code, and they pass on the new.
Production still uses tmp/locks/pos-payment-reconciliation.lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keeps this branch from conflicting with the support-address change in #616.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant