diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 000000000..9368e7025 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,51 @@ +name: Android app +on: + # Feature-branch pushes are covered by the pull_request run for the same head. + push: + branches: [main] + paths: ['android/**', 'ios/Sources/Rupert/Resources/path-configuration.json', '.github/workflows/android.yml'] + pull_request: + paths: ['android/**', 'ios/Sources/Rupert/Resources/path-configuration.json', '.github/workflows/android.yml'] +permissions: + contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} +jobs: + build: + name: Android build, tests and lint + runs-on: ubuntu-latest + # Typically 6-10 minutes (most of it the first Gradle and Robolectric downloads). + timeout-minutes: 30 + defaults: + run: + working-directory: android + env: + # The Play build number; Play requires it to grow with every upload. + RUPERT_VERSION_CODE: ${{ github.run_number }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + with: + distribution: temurin + java-version: '21' + - uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 + with: + # Pull requests read the cache that main writes; they never write it. + cache-read-only: ${{ github.ref != 'refs/heads/main' }} + - name: Unit and Robolectric tests, lint + run: ./gradlew --no-daemon testDebugUnitTest lintDebug + - name: Debug APK and unsigned release bundle + # No signing secrets in CI: the release bundle is signed by the owner (android/README.md). + run: ./gradlew --no-daemon assembleDebug bundleRelease + - name: Keep the APK, bundle and reports + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: rupert-android-${{ github.sha }} + path: | + android/app/build/outputs/apk/debug/*.apk + android/app/build/outputs/bundle/release/*.aab + android/app/build/reports/ + if-no-files-found: warn + retention-days: 14 diff --git a/README.md b/README.md index 222212129..95aa9bc9a 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,9 @@ Square ───┼── sync engine ──▶ SQLite DB ──▶ GUI | Path | Purpose | | --- | --- | | `web/` | The Rails 8.1 app: sync engine services, the dashboard GUI (`app/assets/tailwind/application.css` holds the palette; `bin/check-palette-contrast` verifies it), JSON APIs | -| `mobile/` | Staff iPhone/Android app (see `mobile/README.md`) | +| `ios/` | Rupert for iPhone and iPad: Hotwire Native shell around a Rupert instance (see `ios/README.md`) | +| `android/` | Rupert for Android: the same app for Android phones and tablets (see `android/README.md`) | +| `mobile/` | Staff timesheet app for iPhone/Android, Expo (see `mobile/README.md`) | | `docs/` | Docs site, plans, audits and research (see `docs/README.md`) | | `deploy/` | SER8 (Incus) systemd units (web, jobs, backup) and host-side watcher/backup scripts (see Production and deployment) | | `bin/` | Host scripts (`backup.sh`) | diff --git a/android/.gitattributes b/android/.gitattributes new file mode 100644 index 000000000..e238014db --- /dev/null +++ b/android/.gitattributes @@ -0,0 +1,3 @@ +gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 000000000..0532039d1 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,15 @@ +.gradle/ +.kotlin/ +build/ +local.properties +captures/ +*.iml +.idea/ +.cxx/ +# Signing material never belongs in this public repository. +*.jks +*.keystore +keystore.properties +*.p12 +*.pem +play-service-account*.json diff --git a/android/PlayStore/checklist.md b/android/PlayStore/checklist.md new file mode 100644 index 000000000..bdca286f7 --- /dev/null +++ b/android/PlayStore/checklist.md @@ -0,0 +1,47 @@ +# Google Play: what the owner does + +Everything here happens in the owner's Google accounts; nothing has been created or uploaded. **Done** += in this repo or the build; **Owner** = a click in Play Console or a secret only the owner has. + +| # | Item | Status | Where | +|---|---|---|---| +| 1 | Google Play developer account (one-time US$25). Choose **Organization** (needs a D-U-N-S number) so the app skips the personal-account rule of a 12-tester, 14-day closed test before production. | Owner | play.google.com/console/signup | +| 2 | Identity and developer verification, contact email `sdolbec@rupertpos.com`, website `https://rupertpos.com` | Owner | Play Console → Account details | +| 3 | Create app: name `Rupert: Shop Back Office`, default language English (US), App, Free | Owner | Play Console → Create app | +| 4 | Upload key (`keytool`, kept outside the repo) and a signed bundle | Owner, steps in `../README.md` → Release signing | `./gradlew bundleRelease` | +| 5 | Play App Signing: accept Google-managed app signing key on the first upload | Owner | Test and release → App integrity | +| 6 | Internal testing release with the bundle; add the owner's own Google account as tester | Owner | Test and release → Internal testing | +| 7 | App Links: put the App signing key SHA-256 in `RUPERT_ANDROID_CERT_SHA256` on the server and deploy | Owner | `../README.md` → App Links | +| 8 | Store listing: texts, icon, feature graphic, phone screenshots | Done (files) · Owner pastes and uploads | `listing.md`, `icon-512.png`, `feature-graphic.png`, `screenshots/` | +| 9 | Privacy policy URL `https://pos.rupertpos.com/privacy` | Done (page) | App content → Privacy policy | +| 10 | App access: sign-in needed, review login | Notes done · Owner runs the demo task and types the password | `review-notes.md` | +| 11 | Ads: **No ads** | Owner clicks | App content → Ads | +| 12 | Content rating questionnaire (IARC) | Owner clicks, answers in `data-safety.md` | App content → Content rating | +| 13 | Target audience: **18 and over** only; not designed for children | Owner clicks | App content → Target audience | +| 14 | Data safety form | Owner clicks, answers in `data-safety.md` | App content → Data safety | +| 15 | Account deletion URL `https://pos.rupertpos.com/account/delete` (web, after signing in) and in-app path | Done | App content → Data safety → Data deletion | +| 16 | Financial features: **My app doesn't provide any financial features** (no payments in the app) | Owner clicks | App content → Financial features | +| 17 | Government app, health, news, COVID: No | Owner clicks | App content | +| 18 | Category **Business**; contact email `sdolbec@rupertpos.com` | Owner clicks | Store settings | +| 19 | Countries: United States (or all) | Owner clicks | Production → Countries | +| 20 | Production release: promote the tested build, **managed publishing on** so nothing goes live until you press Publish | Owner only | Publishing overview | + +## Before each upload + +- `./gradlew testDebugUnitTest lintDebug` and the CI job are green. +- Install the release build on a phone (`bundletool` or an internal testing link) and sign in with + email and with Google, open a workspace if you have one, scan a barcode on an inventory count, + turn the lock on and off. +- Raise `RUPERT_VERSION_CODE`. + +## Policies to keep in mind + +- **Payments:** the app sells nothing; billing and sign-up are hidden inside the app + (`hotwire_native_app?`), as on iOS. Keep it that way, or Play Billing rules apply. +- **Webview apps:** Play accepts apps built around a website when they add app functionality. This + one adds the native scanner, lock, tabs, settings, shortcuts and server picker; the review notes + list them. +- **Ask Rupert / @rupert** send the person's question and business data to a cloud AI provider that + processes it for Rupert. A provider acting for you is not "sharing" on the Data safety form, but the + privacy policy must say it (it does today). Decide before submitting whether that holds for the + provider you use. diff --git a/android/PlayStore/data-safety.md b/android/PlayStore/data-safety.md new file mode 100644 index 000000000..e5aa92259 --- /dev/null +++ b/android/PlayStore/data-safety.md @@ -0,0 +1,52 @@ +# Data safety and content rating answers + +## Data safety (Play Console → App content → Data safety) + +Google counts what Rupert's server collects through the app, not only the app's own code. The app +itself adds nothing: no analytics, ads, crash reporting or tracking SDKs, and no runtime permissions. +The barcode scanner runs on the phone inside Google Play services and sends no images to Rupert. + +**Does your app collect or share any of the required user data types?** Yes. +**Is all of the user data collected by your app encrypted in transit?** Yes (HTTPS only; plain http +only to a server on the person's own device or local network, which they choose). +**Do you provide a way for users to request that their data is deleted?** Yes: in the app (App +settings → Delete account) and on the web at `https://pos.rupertpos.com/account/delete`. + +| Category → type | Collected | Shared | Optional? | Purpose | +|---|---|---|---|---| +| Personal info → Name | Yes | No | Required | App functionality, Account management | +| Personal info → Email address | Yes | No | Required | App functionality, Account management | +| Personal info → User IDs | Yes | No | Required | App functionality, Account management | +| Photos and videos → Photos, Videos (attachments) | Yes | No | Optional | App functionality | +| Files and docs (attachments, receipts) | Yes | No | Optional | App functionality | +| Messages → Other in-app messages (Team chat) | Yes | No | Optional | App functionality | +| App activity → Other user-generated content (Ask Rupert questions, business records) | Yes | No | Optional | App functionality | +| App info and performance → Diagnostics (page details sent with Report a problem) | Yes | No | Optional | App functionality | + +Collected data is processed ephemerally? **No** (it is stored in the business's Rupert). +Not collected: location, financial info of the person, health, contacts, calendar, audio, web +browsing, installed apps, device or other IDs, crash logs. + +## Content rating (IARC questionnaire) + +Category: **Reference, News, or Educational**? No: choose **All Other App Types**. + +| Question | Answer | +|---|---| +| Violence, fear, sexuality, language, controlled substances, gambling | No | +| Users can interact or exchange content | Yes (private team chat within a business) | +| Shares the user's current location with other users | No | +| Allows purchases of digital goods | No | +| Unrestricted internet access (a browser) | No (only Rupert's own sites; other links open in the browser) | + +Merchant catalogs (for example a shop selling vapes or hemp products) can show product names in +the app; if a reviewer asks, the app is a business tool for the shop's own staff, not a store. + +## Google Play services (barcode scanner) + +The camera scanner is Google's code scanner, which runs inside Google Play services, not in Rupert. +Images stay on the phone. Google's ML Kit documentation says Play services may send Google +diagnostics about the scanner API itself (API usage and performance, no images or codes). Check +Google's current "ML Kit data disclosure" page when filling the form; if it still lists that, add +**App info and performance → Diagnostics** as collected for analytics by a third party (Google) as +the page directs. diff --git a/android/PlayStore/feature-graphic.png b/android/PlayStore/feature-graphic.png new file mode 100644 index 000000000..64fe9c08c Binary files /dev/null and b/android/PlayStore/feature-graphic.png differ diff --git a/android/PlayStore/icon-512.png b/android/PlayStore/icon-512.png new file mode 100644 index 000000000..1b64b5942 Binary files /dev/null and b/android/PlayStore/icon-512.png differ diff --git a/android/PlayStore/listing.md b/android/PlayStore/listing.md new file mode 100644 index 000000000..8876a561f --- /dev/null +++ b/android/PlayStore/listing.md @@ -0,0 +1,54 @@ +# Rupert: Google Play listing (en-US) + +Paste into Play Console → Grow users → Store presence → Main store listing. Limits in brackets. + +| Field | Value | +|---|---| +| App name [30] | `Rupert: Shop Back Office` | +| Short description [80] | `Sales, stock counts and your team, in one place for independent shops.` | +| App icon (512 × 512 PNG) | `icon-512.png` | +| Feature graphic (1024 × 500) | `feature-graphic.png` | +| Phone screenshots (2–8) | `screenshots/*.png`, in file-name order | +| Category | Business | +| Tags | Business, Productivity, Inventory | +| Contact email | `sdolbec@rupertpos.com` | +| Website | https://rupertpos.com | +| Privacy policy | https://pos.rupertpos.com/privacy | + +**Full description [4000]** + +``` +Rupert is the back office for independent shops. It brings your sales, inventory, people and bookkeeping into one place, and this app puts it in your pocket. + +TODAY AT A GLANCE +• Your start page shows today's sales, what needs attention and what your team is working on. +• Notifications tell you when a count, an order or a teammate needs you. + +COUNT STOCK WITH YOUR CAMERA +• Open an inventory count and scan barcodes with the phone's camera. Each scan saves straight to the count. +• Works alongside USB and Bluetooth scanners at the counter. + +TEAM CHAT AND ASK RUPERT +• Talk with your team in Team chat, with photos and files. +• Ask Rupert questions about your business in plain words: "How did we do last Saturday?" or "What's running low?" + +EVERYTHING IN ONE PLACE +• Sales and customers from the systems your shop already uses, such as Shopify and Square. +• Inventory, purchasing and counts across locations. +• Staff schedules, time clock and timesheets. +• Bookkeeping, cash flow and reports. + +BUILT FOR THE SHOP FLOOR +• Lock the app with your fingerprint, face or screen lock, so a shared phone does not show your numbers. +• Long-press the app icon to jump straight to Team chat, Ask Rupert, Notifications or Search. +• Follows your workspace's colours and appearance, in light and dark. +• Works with pos.rupertpos.com or your business's own Rupert server. + +Rupert is for businesses that use Rupert. Sign in with the account your business gave you, with Google or with email and password. +``` + +## Graphics + +`icon-512.png` is the iPhone app's icon. `feature-graphic.png` is generated by +`screenshots/make-feature-graphic.sh`. Screenshots come from the emulator (Pixel 8, 1080 × 2400) +against the fictional review demo shop; remake them with `adb exec-out screencap -p > file.png`. diff --git a/android/PlayStore/review-notes.md b/android/PlayStore/review-notes.md new file mode 100644 index 000000000..a4ff2f543 --- /dev/null +++ b/android/PlayStore/review-notes.md @@ -0,0 +1,28 @@ +# Play review: App access + +Play Console → App content → App access → "All or some functionality is restricted" → add +instructions. Use the same fictional demo shop as App Review (`REVIEW_PASSWORD=… bin/rails +rupert:app_review_demo` on the server; see `ios/AppStore/review-notes.md`). + +| Field | Value | +|---|---| +| Name | Rupert demo shop | +| Username | the review account's email (from `rupert:app_review_demo`) | +| Password | (the owner sets it) | +| Any other information | paste the notes below | + +``` +Rupert is back-office software for independent retail shops (sales, inventory, staff, bookkeeping). Accounts are created by a business, so we made a demo shop filled with sample data. No real customer data is in it. + +To sign in: on the first screen, tap "Sign in with email and password" and use the username and password above. (Google sign-in is for business Google accounts.) + +Native features to try: +1. Barcode scanning with the camera (Google code scanner): Home → menu → Inventory → Counts → open the draft count → Scan → "Scan with camera". The code is saved to the count. +2. App lock: tap the person icon at the top right of the Home tab → App settings → "Require screen lock". Leave and return to the app; it asks for your fingerprint, face or screen lock and hides its pages in recent apps. +3. Launcher shortcuts: long-press the Rupert icon → Team Chat, Ask Rupert, Notifications or Search. +4. Native tab bar with a separate back stack per tab, full-screen forms, pull to refresh, and a server picker (App settings → Rupert server) for businesses that run their own Rupert. + +Account deletion: person icon on the Home tab → App settings → "Delete account". Please do not delete the review account. + +The app sells nothing. Businesses subscribe on our website; the app shows no prices, plans or purchase links. +``` diff --git a/android/PlayStore/screenshots/make-feature-graphic.sh b/android/PlayStore/screenshots/make-feature-graphic.sh new file mode 100755 index 000000000..ed6fa71db --- /dev/null +++ b/android/PlayStore/screenshots/make-feature-graphic.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Makes the Play feature graphic (1024 × 500) from the app icon, with ImageMagick 7. +# android/PlayStore/screenshots/make-feature-graphic.sh [font] +set -euo pipefail +here=$(cd "$(dirname "$0")/.." && pwd) +font=${1:-/Library/Fonts/Roboto-Bold.ttf} # any TTF; Roboto is Android's own face +magick -size 1024x500 xc:'#C2D4CD' \ + \( "$here/icon-512.png" -resize 360x360 \) -gravity west -geometry +70+0 -composite \ + -font "$font" -fill '#17353A' -gravity west \ + -pointsize 84 -annotate +480-60 'Rupert' \ + -pointsize 34 -fill '#0E6F72' -annotate +484+30 'Shop back office' \ + -pointsize 26 -fill '#17353A' -annotate +484+80 'Sales, stock and team, together' \ + -strip "$here/feature-graphic.png" diff --git a/android/PlayStore/screenshots/phone-1-home.png b/android/PlayStore/screenshots/phone-1-home.png new file mode 100644 index 000000000..b63fd849a Binary files /dev/null and b/android/PlayStore/screenshots/phone-1-home.png differ diff --git a/android/PlayStore/screenshots/phone-2-team-chat.png b/android/PlayStore/screenshots/phone-2-team-chat.png new file mode 100644 index 000000000..abab2f39e Binary files /dev/null and b/android/PlayStore/screenshots/phone-2-team-chat.png differ diff --git a/android/PlayStore/screenshots/phone-3-notifications.png b/android/PlayStore/screenshots/phone-3-notifications.png new file mode 100644 index 000000000..8305076b1 Binary files /dev/null and b/android/PlayStore/screenshots/phone-3-notifications.png differ diff --git a/android/PlayStore/screenshots/phone-4-app-settings.png b/android/PlayStore/screenshots/phone-4-app-settings.png new file mode 100644 index 000000000..e424cd587 Binary files /dev/null and b/android/PlayStore/screenshots/phone-4-app-settings.png differ diff --git a/android/PlayStore/screenshots/phone-5-dark-appearance.png b/android/PlayStore/screenshots/phone-5-dark-appearance.png new file mode 100644 index 000000000..1cd13479f Binary files /dev/null and b/android/PlayStore/screenshots/phone-5-dark-appearance.png differ diff --git a/android/PlayStore/screenshots/phone-6-server-picker.png b/android/PlayStore/screenshots/phone-6-server-picker.png new file mode 100644 index 000000000..7e8aff4b3 Binary files /dev/null and b/android/PlayStore/screenshots/phone-6-server-picker.png differ diff --git a/android/PlayStore/screenshots/phone-7-app-lock.png b/android/PlayStore/screenshots/phone-7-app-lock.png new file mode 100644 index 000000000..3859e35e7 Binary files /dev/null and b/android/PlayStore/screenshots/phone-7-app-lock.png differ diff --git a/android/README.md b/android/README.md new file mode 100644 index 000000000..b820343c5 --- /dev/null +++ b/android/README.md @@ -0,0 +1,199 @@ +# Rupert for Android + +A native Android app for Rupert, built on [Hotwire Native Android](https://native.hotwired.dev) +1.3.2. It is the Android twin of the iPhone app in `ios/` and follows the same path as the website: + +1. It opens on the control plane, `pos.rupertpos.com` (`BuildConfig.CONTROL_PLANE_URL`). Signed out, + every tab shows Rupert's sign-in page. Sign in with email and password, or with Google (a + browser tab, see below). +2. Tenants the control plane serves directly stay there. +3. A customer with an isolated workspace gets "Open my workspace". The app posts the one-time + handoff to that instance itself, keeps its session and moves every tab there. The instance is + remembered across launches. +4. A business that runs its own Rupert picks it in **App settings → Rupert server**. + +| Tab | Page | +|---|---| +| Home | `/login`, which sends a signed-in person to their start page | +| Team Chat | `/chat` | +| Ask Rupert | `/ask_rupert` | +| Notifications | `/notifications` | +| Search | `/search` | + +Each tab keeps its own back stack. Forms (`…/new`, `…/edit`) open as full-screen modals with a +close button. Every Rupert site (`*.rupertpos.com`, the control plane and the current workspace) +stays in the app, including links Rupert marks `data-turbo="false"`; other sites open in a browser +tab (Custom Tabs), and `mailto:`, `tel:`, `sms:` and `geo:` links in their apps. Pages pull to +refresh, except chat, Ask Rupert and POS. + +This is separate from `mobile/`, the Expo app that only covers staff timesheets (`/employee`). + +## Why Hotwire Native + +`mobile/` is a narrow React Native client for one API (staff time and leave). The full Rupert +experience is the website, and the iPhone app already wraps it with Hotwire Native. Doing the same on +Android means: + +- **One set of navigation rules.** Both apps read the same path configuration (below). +- **Same server integration.** Rupert already recognises Hotwire Native (`hotwire_native_app?` in + turbo-rails matches `Hotwire Native Android` in the user agent), hides the web chrome the tab bar + replaces, hides billing and sign-up, and runs the Google and workspace handoffs for the app. +- **New pages need no app release.** Only the native parts below live here. + +## How Rupert treats the app + +The same as the iPhone app (see `ios/README.md`). Android specifics: + +- **Google sign-in** runs in a browser tab (Custom Tabs), because Google refuses sign-in inside + embedded web views. The app keeps a random verifier in memory and sends only its SHA-256 to + `/auth/google` (PKCE). Rupert answers `rupert://auth/google?code=…`, which Android hands to + `MainActivity`; the app trades the code at `POST /native/session` and copies the session cookie + into the web views (`GoogleSignIn.kt`, `NativeHttp.kt`). A code without the verifier is useless, + so another app claiming `rupert://` gains nothing. +- **Workspace handoff**: "Open my workspace" answers `rupert://workspace?origin=…&handoff=…`. + Hotwire Android proposes every main-frame navigation as a visit, so `Routing.kt` sees the redirect, + and `NativeHttp.exchangeHandoff` posts the handoff to `/instance/session` as the browser + form would. +- **Navigation rules**: the app loads `/native/android/path-configuration.json` from the control + plane (same rules as `/native/ios/…`), and bundles one copy for the first launch: Gradle copies + `ios/Sources/Rupert/Resources/path-configuration.json` into the app's assets at build time, so the + repository keeps a single file. `web/test/integration/native_app_test.rb` checks that it matches + the server's rules. +- **Page bridges** (`WebViewSupport.kt`): `window.rupertScanner` and `window.rupertBanner` are web + message listeners injected only into Rupert's own origins and main frames, never into payment or + other third-party frames. `inventory_count_scanner_controller.js` uses `rupertScanner` when the + iOS handler is absent. + +## Native parts + +| Feature | Where | Notes | +|---|---|---| +| Server picker with checks | `ServerActivity.kt`, `InstanceAddress.kt` | Address form, then a request for the navigation rules proves it is Rupert | +| Google sign-in (PKCE) | `GoogleSignIn.kt`, `Pkce.kt` | Custom Tab, `rupert://auth/google` callback | +| App lock | `AppLock.kt` | Fingerprint, face or screen lock (BiometricPrompt); cover over every screen; no recent-apps snapshot | +| Tabs | `Tabs.kt`, `activity_main.xml` | Same five tabs as iOS; a tab left behind by a sign-in or sign-out starts over when opened | +| Barcode scanning | `BarcodeScanner.kt` | Google code scanner (ML Kit in Play services): no camera permission, images never leave the phone | +| Banner colours | `BannerColors.kt`, `RupertWebFragment.kt` | Top bar and status bar icons follow each page's `--color-banner` and appearance changes | +| Workspace handoff | `NativeHttp.kt`, `RupertLinks.kt` | | +| Deep links | `AndroidManifest.xml` | `rupert://auth/google`; App Links for `pos.rupertpos.com` (below) | +| Launcher shortcuts | `res/xml/shortcuts.xml` | Team Chat, Ask Rupert, Notifications, Search (like iOS quick actions) | +| App settings | `SettingsActivity.kt` | Lock, workspace, server, support, report a problem, privacy, delete account, version | +| Error screen | `RupertWebFragment.kt` | Says why a page did not start, with copyable diagnostics | + +**Dark mode.** Native screens follow the phone's light or dark setting (`values-night`). Pages +follow the appearance chosen in Rupert's account menu, and the top bar takes each page's banner +colour; Chromium's forced darkening is off so pages are never double-darkened. + +**Accessibility.** Every icon-only control has a label (tabs, App settings, close, lock); headings +are marked for TalkBack; touch targets are at least 48 dp; text uses scalable `sp` sizes; while the +app is locked, the pages under the cover are hidden from screen readers. + +## Security + +- **HTTPS only.** `res/xml/network_security_config.xml` turns cleartext off and trusts only the + system's certificate authorities (no user-installed CAs). Plain http is allowed only for + `localhost`, `127.0.0.1`, the emulator's `10.0.2.2`, and `*.local`, `*.lan`, `*.internal`, + `*.home.arpa`. `InstanceAddress.kt` applies the same list to typed addresses, and + `NetworkSecurityConfigTest` keeps the two in step. A LAN server reached by bare IP address needs + https (or a local name); add its IP to the config only in a private build. +- **No runtime permissions.** The app asks for `INTERNET`; its libraries add the install-time + `USE_BIOMETRIC` (app lock) and `ACCESS_NETWORK_STATE` (Play services). No camera, microphone, + location, contacts or storage permissions: the barcode scanner runs in Play services, and photo + attachments go through the camera app or the file picker. +- **No secrets in the repository.** No keys, keystores or service accounts; `.gitignore` refuses + `*.jks`, `*.keystore`, `keystore.properties` and Play service-account JSON. Release signing reads + environment variables (below). +- **No backups.** `allowBackup="false"` and `data_extraction_rules.xml`: the session cookie and + settings never leave the phone. +- **Web views:** file and content access off, geolocation off, debugging only in debug builds, no + WebView metrics to Google. +- Card data never touches the app or Rupert: hosted payment fields only, as on the web. + +## Build + +Needs JDK 17 or later and the Android SDK (platform 36). From `android/`: + +```bash +./gradlew testDebugUnitTest lintDebug # unit and Robolectric tests, lint +./gradlew assembleDebug # app/build/outputs/apk/debug/app-debug.apk +./gradlew bundleRelease # app/build/outputs/bundle/release/app-release.aab (unsigned) +./gradlew connectedDebugAndroidTest # smoke test on a running emulator or phone +``` + +CI (`.github/workflows/android.yml`) runs the tests and lint and keeps the debug APK and unsigned +bundle as artifacts. It never signs or uploads. + +### Run against a local Rupert + +```bash +cd web && bin/rails server -b 127.0.0.1 -p 3000 # in one terminal +cd android && ./gradlew installDebug -Prupert.controlPlane=http://10.0.2.2:3000 +``` + +Or install the normal build and choose `http://10.0.2.2:3000` in App settings → Rupert server +(`10.0.2.2` is the computer, seen from the emulator). + +### Setup used for the first build (macOS, Apple silicon) + +- JDK 21 (`brew install openjdk@21`); the Android SDK in `~/Library/Android/sdk` with platform 36, + build-tools 36.1.0, platform-tools and the emulator. +- `brew install --cask android-commandlinetools`, then + `sdkmanager --sdk_root=$HOME/Library/Android/sdk "system-images;android-36;google_apis;arm64-v8a"`. +- The Homebrew `avdmanager` looks for system images next to itself, not in `--sdk_root`; either + install `cmdline-tools;latest` into the SDK and use that `avdmanager`, or create the AVD by hand + (`~/.android/avd/rupert.ini` and `rupert.avd/config.ini` pointing `image.sysdir.1` at the image). +- Robolectric needs `--add-opens java.base/jdk.internal.access` on JDK 21 for Android 16; the + build passes it to unit tests. + +## Configuration + +| Setting | Where | Value | +|---|---|---| +| Application ID | `app/build.gradle.kts` | `com.rupertpos.rupert` (cannot change after the first Play upload) | +| Control plane | `-Prupert.controlPlane` | `https://pos.rupertpos.com` | +| Version | `app/build.gradle.kts` | `1.0.0`; the version code comes from `RUPERT_VERSION_CODE` (CI run number) | +| Minimum / target Android | `app/build.gradle.kts` | 9 (API 28) / 16 (API 36) | + +## Play Store + +`PlayStore/` holds what the listing needs: `checklist.md` (the Play Console steps the owner does, +in order), `listing.md` (title, descriptions, graphics), `data-safety.md` (the Data safety form and +content rating answers), `review-notes.md` (App access instructions for Play review) and +`screenshots/`. Nothing has been uploaded to Google. + +### Release signing + +Play App Signing keeps the key that signs what people install; you sign uploads with your own +**upload key**, created once and kept outside this repository (a password manager or private +backup): + +```bash +keytool -genkeypair -v -keystore ~/private/rupert-upload.jks -alias upload \ + -keyalg RSA -keysize 4096 -validity 10000 +``` + +Then build a signed bundle: + +```bash +export RUPERT_UPLOAD_KEYSTORE=~/private/rupert-upload.jks RUPERT_UPLOAD_KEY_ALIAS=upload +read -rs RUPERT_UPLOAD_KEYSTORE_PASSWORD && export RUPERT_UPLOAD_KEYSTORE_PASSWORD +read -rs RUPERT_UPLOAD_KEY_PASSWORD && export RUPERT_UPLOAD_KEY_PASSWORD +RUPERT_VERSION_CODE=2 ./gradlew bundleRelease +``` + +Each upload needs a higher `RUPERT_VERSION_CODE`. + +### App Links + +Rupert links (`pos.rupertpos.com/chat`, `/notifications`, `/tasks`, `/inventory_counts`, `/orders`, +`/timesheets`, `/schedule`, `/ask_rupert`, `/search`, `/workspace`) open in the app once Android +verifies the site. Sign-in paths are left out on purpose. After the first upload, copy the +**App signing key certificate SHA-256** (Play Console → Test and release → App integrity) into the +server's environment and deploy: + +```bash +RUPERT_ANDROID_CERT_SHA256=AA:BB:… # comma-separate several (e.g. also the upload key) +``` + +Rupert then serves it at `/.well-known/assetlinks.json` (`AndroidAssetLinksController`). Check on a +phone with `adb shell pm get-app-links com.rupertpos.rupert`. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 000000000..f24a79b93 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,140 @@ +plugins { + alias(libs.plugins.android.application) +} + +// The control plane everyone starts on. A debug build can point at a local Rupert instead: +// ./gradlew installDebug -Prupert.controlPlane=http://10.0.2.2:3000 +val controlPlane = providers.gradleProperty("rupert.controlPlane").orElse("https://pos.rupertpos.com").get() + +// Release signing comes from outside the repository (see README.md). Without these the release +// bundle is unsigned, which is what Play App Signing's first upload needs to be signed locally. +val keystoreFile = providers.environmentVariable("RUPERT_UPLOAD_KEYSTORE").orNull +val keystorePassword = providers.environmentVariable("RUPERT_UPLOAD_KEYSTORE_PASSWORD").orNull +val keyAlias = providers.environmentVariable("RUPERT_UPLOAD_KEY_ALIAS").orNull +val keyPassword = providers.environmentVariable("RUPERT_UPLOAD_KEY_PASSWORD").orNull +val canSignRelease = listOf(keystoreFile, keystorePassword, keyAlias, keyPassword).all { !it.isNullOrBlank() } + +android { + namespace = "com.rupertpos.rupert" + compileSdk = 36 + + defaultConfig { + applicationId = "com.rupertpos.rupert" + minSdk = 28 + targetSdk = 36 + // CI stamps the build number (as the iPhone app does); local builds are 1. + versionCode = providers.environmentVariable("RUPERT_VERSION_CODE").orElse("1").get().toInt() + versionName = "1.0.0" + buildConfigField("String", "CONTROL_PLANE_URL", "\"$controlPlane\"") + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + signingConfigs { + if (canSignRelease) { + create("upload") { + storeFile = file(keystoreFile!!) + storePassword = keystorePassword + this.keyAlias = keyAlias + this.keyPassword = keyPassword + } + } + } + + buildTypes { + debug { + // Same application ID as release, so launcher shortcuts (res/xml/shortcuts.xml) work in both. + versionNameSuffix = "-debug" + } + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + signingConfig = if (canSignRelease) signingConfigs.getByName("upload") else null + } + } + + buildFeatures { + buildConfig = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + testOptions { + unitTests.isIncludeAndroidResources = true + unitTests.all { + // Robolectric's Android 16 runtime reaches into JDK internals for shared memory. + it.jvmArgs("--add-exports=java.base/jdk.internal.access=ALL-UNNAMED", "--add-opens=java.base/jdk.internal.access=ALL-UNNAMED") + } + } + + lint { + abortOnError = true + warningsAsErrors = false + // Version bumps are reviewed by hand; they are not build failures. + disable += setOf("GradleDependency", "AndroidGradlePluginVersion", "NewerVersionAvailable", "OldTargetApi") + // Hotwire inflates its own progress and error views the same way. + disable += "InflateParams" + } + + packaging { + resources.excludes += setOf("META-INF/*.kotlin_module", "META-INF/versions/**", "DebugProbesKt.bin") + } +} + +/** + * The iPhone app's path-configuration.json is the one bundled copy of the navigation rules (the + * server serves the same rules at /native/android/path-configuration.json). It is copied into + * this app's assets at build time instead of keeping a second copy in the repository. + */ +abstract class CopyPathConfiguration : DefaultTask() { + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val source: RegularFileProperty + + @get:OutputDirectory + abstract val outputDirectory: DirectoryProperty + + @TaskAction + fun copy() { + val target = outputDirectory.get().dir("json").asFile + target.mkdirs() + source.get().asFile.copyTo(File(target, "path-configuration.json"), overwrite = true) + } +} + +val copyPathConfiguration = tasks.register("copyPathConfiguration") { + source.set(rootProject.layout.projectDirectory.file("../ios/Sources/Rupert/Resources/path-configuration.json")) +} + +androidComponents { + onVariants { variant -> + variant.sources.assets?.addGeneratedSourceDirectory(copyPathConfiguration, CopyPathConfiguration::outputDirectory) + } +} + +dependencies { + implementation(libs.hotwire.core) + implementation(libs.hotwire.navigation.fragments) + implementation(libs.androidx.biometric) + implementation(libs.play.services.code.scanner) + implementation(libs.okhttp) + implementation(libs.material) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.process) + implementation(libs.androidx.browser) + + testImplementation(libs.junit) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.test.core) + testImplementation(libs.androidx.test.ext.junit) + testImplementation(libs.okhttp.mockwebserver) + + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.espresso.core) +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 000000000..2f96e3d98 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,12 @@ +# Hotwire Native ships its own keep rules (consumer ProGuard files in its AARs). + +# Hotwire finds fragment destinations by their @HotwireDestinationDeepLink annotation, with Kotlin +# reflection, so the app's destination classes and the annotation must survive shrinking. +-keepattributes RuntimeVisibleAnnotations,AnnotationDefault,*Annotation* +-keep @dev.hotwire.navigation.destinations.HotwireDestinationDeepLink class * { *; } +-keep class kotlin.Metadata { *; } + +# OkHttp's optional platform integrations. +-dontwarn org.bouncycastle.** +-dontwarn org.conscrypt.** +-dontwarn org.openjsse.** diff --git a/android/app/src/androidTest/java/com/rupertpos/rupert/SmokeTest.kt b/android/app/src/androidTest/java/com/rupertpos/rupert/SmokeTest.kt new file mode 100644 index 000000000..c5619ce02 --- /dev/null +++ b/android/app/src/androidTest/java/com/rupertpos/rupert/SmokeTest.kt @@ -0,0 +1,43 @@ +package com.rupertpos.rupert + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.closeSoftKeyboard +import androidx.test.espresso.action.ViewActions.replaceText +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.hasDescendant +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withContentDescription +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.Matchers.containsString +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Starts the real app on a device or emulator: the tab bar, App settings and the server picker's + * address check. It needs no Rupert server (the pages may fail to load; the native screens do not). + * ./gradlew connectedDebugAndroidTest + */ +@RunWith(AndroidJUnit4::class) +class SmokeTest { + @get:Rule val activity = ActivityScenarioRule(MainActivity::class.java) + + @Test fun tabsSettingsAndServerCheck() { + onView(withId(R.id.bottom_nav)).check(matches(isDisplayed())) + listOf("Home", "Team Chat", "Ask Rupert", "Notifications", "Search").forEach { title -> + onView(withId(R.id.bottom_nav)).check(matches(hasDescendant(withText(title)))) + } + + onView(withContentDescription("App settings")).perform(click()) + onView(withText("Require screen lock")).check(matches(isDisplayed())) + onView(withText("Rupert server")).perform(click()) + + onView(withId(R.id.server_input)).perform(replaceText("http://rupert.example.com"), closeSoftKeyboard()) + onView(withId(R.id.server_connect)).perform(click()) + onView(withText(containsString("Rupert needs https://"))).check(matches(isDisplayed())) + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..65a8114ca --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/rupertpos/rupert/AppLock.kt b/android/app/src/main/java/com/rupertpos/rupert/AppLock.kt new file mode 100644 index 000000000..92e3de973 --- /dev/null +++ b/android/app/src/main/java/com/rupertpos/rupert/AppLock.kt @@ -0,0 +1,197 @@ +package com.rupertpos.rupert + +import android.app.Activity +import android.app.Application +import android.content.Context +import android.content.SharedPreferences +import android.os.Build +import android.os.Bundle +import android.os.SystemClock +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK +import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.core.content.edit +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner + +/** + * Optional lock for the app (fingerprint, face or the phone's screen lock): Rupert holds sales, + * payroll and bookkeeping, and phones get handed around a shop. Turned on in App settings. + * + * The app locks whenever it leaves the screen, except for the screens it opens itself (the Google + * sign-in tab, the barcode scanner, the camera or file picker). While locked, a cover hides every + * page, and the recent-apps screen shows no snapshot of Rupert. + */ +object AppLock : Application.ActivityLifecycleCallbacks, DefaultLifecycleObserver { + private const val ENABLED_KEY = "requireDeviceAuthentication" + + private lateinit var preferences: SharedPreferences + private var locked = false + private var authenticating = false + /** + * When the app last opened another app's screen (0: none pending). The next time the app leaves + * the screen is for that, so it does not lock. Cleared when the app comes back to the front + * without having left, and after a minute at most, so a failed launch cannot keep the lock off. + */ + private var externalScreenAt = 0L + private var pausedSinceExternalScreen = false + /** Ask on its own once per lock; after a cancel the cover's Unlock button asks again. */ + private var promptOnResume = false + + fun init(application: Application) { + preferences = application.getSharedPreferences("rupert.lock", Context.MODE_PRIVATE) + locked = isEnabled + promptOnResume = locked + application.registerActivityLifecycleCallbacks(this) + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + } + + var isEnabled: Boolean + get() = preferences.getBoolean(ENABLED_KEY, false) + set(value) { + preferences.edit { putBoolean(ENABLED_KEY, value) } + } + + /** Biometrics (strong where the API allows mixing it with the screen lock) or the screen lock. */ + val authenticators: Int + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) BIOMETRIC_STRONG or DEVICE_CREDENTIAL + else BIOMETRIC_WEAK or DEVICE_CREDENTIAL + + /** False when the phone has no screen lock: there is nothing to check against. */ + fun isAvailable(context: Context): Boolean = + BiometricManager.from(context).canAuthenticate(authenticators) == BiometricManager.BIOMETRIC_SUCCESS + + /** The app is about to show a screen of another app (sign-in tab, scanner, camera, files). */ + fun allowExternalScreen() { + externalScreenAt = SystemClock.elapsedRealtime() + pausedSinceExternalScreen = false + } + + private val leftForExternalScreen: Boolean + get() = externalScreenAt != 0L && SystemClock.elapsedRealtime() - externalScreenAt < 60_000 + + /** Asks once, for turning the lock on in settings. */ + fun confirm(activity: FragmentActivity, title: String, done: (Boolean) -> Unit) { + val prompt = BiometricPrompt(activity, ContextCompat.getMainExecutor(activity), object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) = done(true) + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) = done(false) + }) + val info = BiometricPrompt.PromptInfo.Builder() + .setTitle(title) + .setAllowedAuthenticators(authenticators) + .build() + prompt.authenticate(info) + } + + // The whole app went to the background. + override fun onStop(owner: LifecycleOwner) { + if (isEnabled && !leftForExternalScreen) { + locked = true + promptOnResume = true + } + externalScreenAt = 0L + } + + override fun onStart(owner: LifecycleOwner) { + externalScreenAt = 0L + } + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + protectRecents(activity) + } + + override fun onActivityResumed(activity: Activity) { + // Back in front without the app having left (a dialog-like screen): the allowance is spent. + if (pausedSinceExternalScreen) { + externalScreenAt = 0L + pausedSinceExternalScreen = false + } + protectRecents(activity) + if (locked) { + showCover(activity) + if (promptOnResume) { + promptOnResume = false + unlock(activity) + } + } else { + removeCover(activity) + } + } + + override fun onActivityStopped(activity: Activity) { + if (locked) showCover(activity) + } + + override fun onActivityStarted(activity: Activity) {} + override fun onActivityPaused(activity: Activity) { + if (externalScreenAt != 0L) pausedSinceExternalScreen = true + } + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} + override fun onActivityDestroyed(activity: Activity) {} + + /** No business data in the recent-apps snapshot while the lock is on. */ + fun protectRecents(activity: Activity) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + activity.setRecentsScreenshotEnabled(!isEnabled) + } else if (isEnabled) { + activity.window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + } else { + activity.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } + + private fun unlock(activity: Activity) { + if (authenticating) return + val fragmentActivity = activity as? FragmentActivity ?: return + if (!isAvailable(activity)) { + // The screen lock was removed from the phone: nothing left to check against. + finishUnlock(activity) + return + } + authenticating = true + // The screen lock may open its own screen (PIN pad on older Android): not a reason to lock again. + allowExternalScreen() + confirm(fragmentActivity, activity.getString(R.string.lock_prompt_title)) { ok -> + authenticating = false + externalScreenAt = 0L + if (ok) finishUnlock(activity) + } + } + + private fun finishUnlock(activity: Activity) { + locked = false + val cover = activity.findViewById(R.id.lock_cover) ?: return + cover.animate().alpha(0f).setDuration(200).withEndAction { removeCover(activity) }.start() + } + + private fun showCover(activity: Activity) { + val content = activity.findViewById(android.R.id.content) ?: return + if (content.findViewById(R.id.lock_cover) != null) return + val cover = LayoutInflater.from(activity).inflate(R.layout.view_lock_cover, content, false) + cover.findViewById(R.id.lock_unlock).setOnClickListener { unlock(activity) } + content.addView(cover) + // Screen readers must not read the pages under the cover either. + for (index in 0 until content.childCount) { + val child = content.getChildAt(index) + if (child !== cover) child.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + } + cover.requestFocus() + } + + private fun removeCover(activity: Activity) { + val content = activity.findViewById(android.R.id.content) ?: return + content.findViewById(R.id.lock_cover)?.let(content::removeView) + for (index in 0 until content.childCount) { + content.getChildAt(index).importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_AUTO + } + } +} diff --git a/android/app/src/main/java/com/rupertpos/rupert/BannerColors.kt b/android/app/src/main/java/com/rupertpos/rupert/BannerColors.kt new file mode 100644 index 000000000..8836ce908 --- /dev/null +++ b/android/app/src/main/java/com/rupertpos/rupert/BannerColors.kt @@ -0,0 +1,80 @@ +package com.rupertpos.rupert + +import org.json.JSONObject +import org.json.JSONTokener + +/** + * The native top bar and Rupert's own header (`.app-topbar`) read as one solid banner. Rupert + * publishes the banner colour as `--color-banner` in every appearance (application.css); the app + * reads it after each page and again whenever the person switches appearance in the account menu. + * Colours are ARGB ints. + */ +data class BannerColors(val background: Int, val title: Int, val accent: Int) { + /** Dark banners get light status bar icons, light banners dark ones, whatever the phone's setting. */ + val isDark: Boolean + get() { + val red = (background shr 16 and 0xff) / 255.0 + val green = (background shr 8 and 0xff) / 255.0 + val blue = (background and 0xff) / 255.0 + return 0.2126 * red + 0.7152 * green + 0.0722 * blue < 0.5 + } + + companion object { + /** A function that returns the page's banner, text and accent colours, as a JSON string. */ + private const val READ_FUNCTION = """() => { + const css = getComputedStyle(document.documentElement); + const value = (name) => css.getPropertyValue(name).trim(); + return JSON.stringify({ banner: value("--color-banner"), ink: value("--color-ink"), accent: value("--color-olive") }); +}""" + + /** Reads the colours once (evaluateJavascript after each page). */ + const val READ_SCRIPT = "($READ_FUNCTION)()" + + /** Tells the app when the person picks another appearance (theme_controller sets data-theme). */ + const val WATCH_SCRIPT = """(() => { + const read = $READ_FUNCTION; + const post = () => { try { window.rupertBanner && window.rupertBanner.postMessage(read()); } catch (_) {} }; + const watch = () => new MutationObserver(post).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); + if (document.documentElement) watch(); else addEventListener("DOMContentLoaded", watch); +})();""" + + /** Before the first page, and for pages without the token: the app's own colours. */ + @Volatile var current: BannerColors? = null + + /** + * From READ_SCRIPT's JSON, or from evaluateJavascript's result (that JSON as a JSON string). + * Null when the page has no banner token (another site, an older Rupert). + */ + fun parse(value: String?, fallback: BannerColors? = null): BannerColors? { + if (value.isNullOrBlank() || value == "null") return null + val text = runCatching { + when (val decoded = JSONTokener(value).nextValue()) { + is String -> decoded + is JSONObject -> decoded.toString() + else -> null + } + }.getOrNull() ?: return null + val values = runCatching { JSONObject(text) }.getOrNull() ?: return null + val background = hex(values.optString("banner")) ?: return null + return BannerColors( + background = background, + title = hex(values.optString("ink")) ?: fallback?.title ?: if (isDarkColor(background)) WHITE else BLACK, + accent = hex(values.optString("accent")) ?: fallback?.accent ?: if (isDarkColor(background)) WHITE else BLACK, + ) + } + + /** "#rrggbb" or "#rgb", as CSS custom properties hold them. */ + fun hex(value: String?): Int? { + var digits = value?.trim()?.takeIf { it.startsWith("#") }?.substring(1) ?: return null + if (digits.length == 3) digits = digits.map { "$it$it" }.joinToString("") + if (digits.length != 6) return null + val rgb = digits.toIntOrNull(16) ?: return null + return (0xff shl 24) or rgb + } + + private fun isDarkColor(color: Int) = BannerColors(color, 0, 0).isDark + + private const val WHITE = -0x1 + private const val BLACK = -0x1000000 + } +} diff --git a/android/app/src/main/java/com/rupertpos/rupert/BarcodeScanner.kt b/android/app/src/main/java/com/rupertpos/rupert/BarcodeScanner.kt new file mode 100644 index 000000000..b78758f03 --- /dev/null +++ b/android/app/src/main/java/com/rupertpos/rupert/BarcodeScanner.kt @@ -0,0 +1,70 @@ +package com.rupertpos.rupert + +import android.os.Build +import android.view.HapticFeedbackConstants +import android.webkit.WebView +import androidx.appcompat.app.AppCompatActivity +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.mlkit.common.MlKitException +import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions +import com.google.mlkit.vision.codescanner.GmsBarcodeScanning +import org.json.JSONArray + +/** + * Reads barcodes with the camera for Rupert's inventory scan page (/inventory_counts/:id/scan). + * + * The page shows "Scan with camera" when the `rupertScanner` listener exists + * (inventory_count_scanner_controller.js). The app opens Google's code scanner, which runs in + * Google Play services: it needs no camera permission in this app and sends no images to Rupert. + * The code goes back to the page as a `rupert:scanned` event, which saves it. + */ +object BarcodeScanner { + fun scan(webView: WebView) { + val activity = webView.context as? AppCompatActivity ?: return + if (GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(activity) != ConnectionResult.SUCCESS) { + unavailable(activity, "This phone has no Google Play services, which Rupert uses to read barcodes. Type the code or use a barcode scanner instead.") + return + } + + val options = GmsBarcodeScannerOptions.Builder() + .enableAutoZoom() + .build() + // The scanner is its own screen: coming back from it must not lock the app. + AppLock.allowExternalScreen() + GmsBarcodeScanning.getClient(activity, options).startScan() + .addOnSuccessListener { barcode -> + val code = barcode.rawValue?.trim().orEmpty() + if (code.isEmpty()) return@addOnSuccessListener + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + webView.performHapticFeedback(HapticFeedbackConstants.CONFIRM) + } + webView.evaluateJavascript(scannedScript(code), null) + } + .addOnFailureListener { error -> + val message = if ((error as? MlKitException)?.errorCode == MlKitException.UNAVAILABLE) { + // First use on this phone: Play services fetches the scanner once, in the background. + "Google Play services is still getting the barcode scanner ready on this phone. Try again in a minute, or type the code." + } else { + "The camera scanner did not start (${error.message ?: "unknown reason"}). Type the code or use a barcode scanner instead." + } + unavailable(activity, message) + } + } + + /** The JavaScript that hands `code` to the page, with the code safely quoted. */ + fun scannedScript(code: String): String { + val detail = JSONArray().put(code).toString() + return "window.dispatchEvent(new CustomEvent('rupert:scanned', { detail: $detail[0] }))" + } + + private fun unavailable(activity: AppCompatActivity, message: String) { + if (activity.isFinishing) return + MaterialAlertDialogBuilder(activity) + .setTitle(R.string.scanner_unavailable_title) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .show() + } +} diff --git a/android/app/src/main/java/com/rupertpos/rupert/GoogleSignIn.kt b/android/app/src/main/java/com/rupertpos/rupert/GoogleSignIn.kt new file mode 100644 index 000000000..6bfbbdbca --- /dev/null +++ b/android/app/src/main/java/com/rupertpos/rupert/GoogleSignIn.kt @@ -0,0 +1,73 @@ +package com.rupertpos.rupert + +import android.app.Activity +import android.content.ActivityNotFoundException +import androidx.browser.customtabs.CustomTabsIntent +import androidx.core.net.toUri + +/** + * Google sign-in for the app. Rupert's side is web/app/services/native_sign_in.rb: + * + * 1. A random verifier stays in memory here; its SHA-256 (the challenge) goes to /auth/google. + * 2. Google signs the person in inside a browser tab (Custom Tab), because Google refuses + * sign-in inside embedded web views. Rupert answers with rupert://auth/google?code=CODE (or + * ?error= / ?message=) instead of a browser session. A sign-up form's `intent` rides along. + * 3. POST /native/session {code, verifier} returns Rupert's session cookie, which is copied into + * the web views' cookie store. A code is useless without the verifier and works once, so another + * app that claims the rupert:// scheme cannot use an intercepted code. + */ +object GoogleSignIn { + private data class Pending(val site: String, val verifier: String) + + private var pending: Pending? = null + + /** The URL the browser tab opens: Rupert's /auth/google with the challenge and any sign-up intent. */ + fun authorizationUrl(link: String, verifier: String): String? { + val site = InstanceAddress.originOf(link) ?: return null + val builder = InstanceAddress.url("/auth/google", site).toUri().buildUpon() + link.toUri().getQueryParameter("intent")?.takeIf { it.isNotBlank() }?.let { builder.appendQueryParameter("intent", it) } + builder.appendQueryParameter("native_challenge", Pkce.challenge(verifier)) + return builder.build().toString() + } + + /** `link` is Rupert's /auth/google link (a tap, or a sign-up form's redirect). */ + fun start(activity: Activity, link: String, onProblem: (String) -> Unit) { + val verifier = Pkce.randomVerifier() + val url = authorizationUrl(link, verifier) ?: return onProblem("Google sign-in needs a Rupert address. Try again.") + pending = Pending(InstanceAddress.originOf(link)!!, verifier) + AppLock.allowExternalScreen() + try { + CustomTabsIntent.Builder() + .setShowTitle(true) + .setShareState(CustomTabsIntent.SHARE_STATE_OFF) + .build() + .launchUrl(activity, url.toUri()) + } catch (_: ActivityNotFoundException) { + pending = null + onProblem("Google sign-in needs a web browser on this phone, such as Chrome.") + } + } + + /** + * The browser tab came back with rupert://auth/google?…. Returns what to do next; the code is + * redeemed by the caller (network) with the verifier this returns. + */ + fun finish(callback: String): Outcome { + val started = pending ?: return Outcome.NotStarted + pending = null + return when (val result = GoogleCallback.parse(callback)) { + is GoogleCallback.Code -> Outcome.Redeem(started.site, result.code, started.verifier) + is GoogleCallback.Refused -> Outcome.Problem(result.message) + is GoogleCallback.Notice -> Outcome.Notice(result.message) + GoogleCallback.Incomplete -> Outcome.Problem("Google sign-in did not finish. Try again.") + } + } + + sealed interface Outcome { + data class Redeem(val site: String, val code: String, val verifier: String) : Outcome + data class Problem(val message: String) : Outcome + data class Notice(val message: String) : Outcome + /** No sign-in was waiting (an old link, or another app sending one): ignore it. */ + data object NotStarted : Outcome + } +} diff --git a/android/app/src/main/java/com/rupertpos/rupert/Instance.kt b/android/app/src/main/java/com/rupertpos/rupert/Instance.kt new file mode 100644 index 000000000..924e7bd6e --- /dev/null +++ b/android/app/src/main/java/com/rupertpos/rupert/Instance.kt @@ -0,0 +1,67 @@ +package com.rupertpos.rupert + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit + +/** + * Where the app is pointed. + * + * Everyone starts on the control plane (pos.rupertpos.com, BuildConfig.CONTROL_PLANE_URL): sign + * in, sign up and pick a workspace there. A customer with an isolated workspace moves to that + * instance after the handoff (WorkspaceHandoff), and the tabs follow. A self-hosted Rupert can be + * chosen instead in App settings → Rupert server (ServerActivity); it then plays the control + * plane's part. + */ +object Instance { + private const val PREFERENCES = "rupert.instance" + private const val SERVER_KEY = "serverOrigin" + private const val CURRENT_KEY = "currentInstanceOrigin" + + private lateinit var preferences: SharedPreferences + + fun init(context: Context) { + preferences = context.applicationContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) + } + + /** The control plane this build ships with. */ + val defaultControlPlane: String = + InstanceAddress.originOf(BuildConfig.CONTROL_PLANE_URL) ?: "https://pos.rupertpos.com" + + /** The server people sign in on: the default, or the Rupert server picked in App settings. */ + val controlPlane: String + get() = preferences.getString(SERVER_KEY, null)?.let(InstanceAddress::originOf) ?: defaultControlPlane + + val usesCustomServer: Boolean + get() = preferences.contains(SERVER_KEY) + + /** The instance the tabs show. The control plane until a workspace handoff moves the app. */ + val current: String + get() = preferences.getString(CURRENT_KEY, null)?.let(InstanceAddress::originOf) ?: controlPlane + + fun setCurrent(origin: String) { + preferences.edit { + if (origin == controlPlane) remove(CURRENT_KEY) else putString(CURRENT_KEY, origin) + } + } + + /** Points the app at another Rupert server (null: back to the default), starting over there. */ + fun setServer(origin: String?) { + preferences.edit { + if (origin == null || origin == defaultControlPlane) remove(SERVER_KEY) else putString(SERVER_KEY, origin) + remove(CURRENT_KEY) + } + } + + /** A path on an instance (default: the current one), which may carry a query. */ + fun url(path: String, on: String = current): String = InstanceAddress.url(path, on) + + fun isRupert(url: String?): Boolean = InstanceAddress.isRupert(url, controlPlane, current) + + /** Origins Rupert's pages may talk to the app from (web message listeners, start scripts). */ + val trustedOriginRules: Set + get() = setOf("https://rupertpos.com", "https://*.rupertpos.com", controlPlane, current) + + val appVersion: String + get() = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})" +} diff --git a/android/app/src/main/java/com/rupertpos/rupert/InstanceAddress.kt b/android/app/src/main/java/com/rupertpos/rupert/InstanceAddress.kt new file mode 100644 index 000000000..bbcdcbace --- /dev/null +++ b/android/app/src/main/java/com/rupertpos/rupert/InstanceAddress.kt @@ -0,0 +1,125 @@ +package com.rupertpos.rupert + +import java.net.URI +import java.net.URISyntaxException + +/** + * Rules for the Rupert server addresses the app may use. Plain Kotlin (no Android types), so the + * unit tests cover every rule. + * + * Public servers must use https. Plain http is allowed only for this device and for local network + * names, and those names must also be listed in res/xml/network_security_config.xml, which is what + * actually permits the traffic (NetworkSecurityConfigTest checks the two lists agree). + */ +object InstanceAddress { + sealed interface Result { + data class Valid(val origin: String) : Result + data class Invalid(val reason: String) : Result + } + + /** Addresses that may use plain http: the phone itself and the emulator's view of the computer. */ + val localHosts = setOf("127.0.0.1", "10.0.2.2") + + /** Names (and their subdomains) that may use plain http: localhost and local network names (RFC 6761, 6762, 8375). */ + val localSuffixes = listOf("local", "lan", "internal", "home.arpa", "localhost") + + /** Turns what a person typed into a clean origin (scheme://host[:port]) or says what is wrong. */ + fun parse(input: String): Result { + var text = input.trim() + if (text.isEmpty()) { + return Result.Invalid("Enter your Rupert address, for example pos.rupertpos.com.") + } + if (!text.contains("://")) text = "https://$text" + + val uri = try { + URI(text) + } catch (_: URISyntaxException) { + return Result.Invalid("That is not a web address. Check for spaces or typos.") + } + + val scheme = uri.scheme?.lowercase() + if (scheme != "https" && scheme != "http") { + return Result.Invalid("Use an address that starts with https://.") + } + if (uri.rawUserInfo != null) { + return Result.Invalid("Leave out any user name or password. You sign in on the next page.") + } + val host = uri.host?.lowercase()?.trimEnd('.') + if (host.isNullOrEmpty()) { + return Result.Invalid("That address has no server name.") + } + val path = uri.rawPath.orEmpty() + if ((path.isNotEmpty() && path != "/") || uri.rawQuery != null || uri.rawFragment != null) { + return Result.Invalid("Enter only the server address, without a page after it.") + } + if (scheme == "http" && !isLocal(host)) { + return Result.Invalid( + "Rupert needs https:// for this address. Plain http:// works only for this device " + + "and local names ending in .local, .lan, .internal or .home.arpa." + ) + } + + val defaultPort = if (scheme == "https") 443 else 80 + val port = if (uri.port == -1 || uri.port == defaultPort) "" else ":${uri.port}" + return Result.Valid("$scheme://$host$port") + } + + fun isLocal(host: String): Boolean { + val name = host.lowercase().trimEnd('.') + return name in localHosts || localSuffixes.any { name == it || name.endsWith(".$it") } + } + + /** + * scheme://host[:port] of an http(s) URL, or null. Plain http counts only for local hosts, so a + * page can never move the app onto an unencrypted public address. + */ + fun originOf(url: String?): String? { + if (url.isNullOrBlank()) return null + val uri = try { + URI(url) + } catch (_: URISyntaxException) { + return null + } + val scheme = uri.scheme?.lowercase() ?: return null + val host = uri.host?.lowercase()?.trimEnd('.') ?: return null + if (host.isEmpty()) return null + if (scheme != "https" && !(scheme == "http" && isLocal(host))) return null + val defaultPort = if (scheme == "https") 443 else 80 + val port = if (uri.port == -1 || uri.port == defaultPort) "" else ":${uri.port}" + return "$scheme://$host$port" + } + + /** https://host[:port] of a URL, or null when it is not https (handoffs and sign-in sites). */ + fun httpsOrigin(url: String?): String? = originOf(url)?.takeIf { it.startsWith("https://") } + + /** + * Rupert's own sites stay inside the app: rupertpos.com and its subdomains over https, the + * control plane and the current instance (an isolated workspace may live on its own domain). + */ + fun isRupert(url: String?, controlPlane: String, current: String): Boolean { + val origin = originOf(url) ?: return false + if (origin == controlPlane || origin == current) return true + if (!origin.startsWith("https://")) return false + val host = hostOf(origin) + return host == "rupertpos.com" || host.endsWith(".rupertpos.com") || + host == hostOf(controlPlane) || host == hostOf(current) + } + + /** A path (which may carry a query) on an origin. */ + fun url(path: String, origin: String): String = + if (path.startsWith("http://") || path.startsWith("https://")) path + else origin.trimEnd('/') + "/" + path.trimStart('/') + + /** The path of a URL ("/" when it has none). */ + fun pathOf(url: String?): String = try { + URI(url.orEmpty()).rawPath?.ifEmpty { "/" } ?: "/" + } catch (_: URISyntaxException) { + "/" + } + + fun hostOf(origin: String): String = try { + URI(origin).host?.lowercase().orEmpty() + } catch (_: URISyntaxException) { + "" + } +} diff --git a/android/app/src/main/java/com/rupertpos/rupert/MainActivity.kt b/android/app/src/main/java/com/rupertpos/rupert/MainActivity.kt new file mode 100644 index 000000000..84a9fd39b --- /dev/null +++ b/android/app/src/main/java/com/rupertpos/rupert/MainActivity.kt @@ -0,0 +1,228 @@ +package com.rupertpos.rupert + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.lifecycle.lifecycleScope +import com.google.android.material.bottomnavigation.BottomNavigationView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dev.hotwire.core.config.Hotwire +import dev.hotwire.navigation.activities.HotwireActivity +import dev.hotwire.navigation.navigator.Navigator +import dev.hotwire.navigation.navigator.NavigatorConfiguration +import dev.hotwire.navigation.tabs.HotwireBottomNavigationController +import dev.hotwire.navigation.tabs.HotwireBottomTab +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * The one window: a tab bar where each tab keeps its own back stack of Rupert pages, on the + * instance the app is pointed at (Instance.current). Also receives what comes from outside: + * the Google sign-in answer, Rupert links (App Links) and launcher shortcuts. + */ +class MainActivity : HotwireActivity() { + private lateinit var bottomNavigation: HotwireBottomNavigationController + private val base = Instance.current + + /** + * After a sign-in or handoff, Home starts on the landing page itself (the page Rupert chose), + * instead of loading /login and then moving on. Read lazily: the intent exists only from onCreate. + */ + private val tabs by lazy { + val landing = intent.getStringExtra(EXTRA_LANDING)?.takeIf { InstanceAddress.originOf(it) == base } + RupertTab.entries.map { tab -> + val configuration = tab.configuration(base) + HotwireBottomTab( + title = getString(tab.title), + iconResId = tab.icon, + configuration = if (tab == RupertTab.HOME && landing != null) configuration.copy(startLocation = landing) else configuration, + ) + } + } + + /** A Rupert link to open once the Home tab is ready. */ + private var pendingHomeLocation: String? = null + + /** Whether the last page any tab finished was the sign-in page. A change makes other tabs stale. */ + private var onSignInPage: Boolean? = null + private val staleTabs = mutableSetOf() + + private val settings = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val data = result.data ?: return@registerForActivityResult + when { + data.getBooleanExtra(SettingsActivity.EXTRA_SERVER_CHANGED, false) -> restart(landing = null) + data.getStringExtra(SettingsActivity.EXTRA_OPEN_URL) != null -> + bottomNavigation.route(data.getStringExtra(SettingsActivity.EXTRA_OPEN_URL)!!) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + bottomNavigation = HotwireBottomNavigationController( + activity = this, + view = findViewById(R.id.bottom_nav), + lazyLoadTabs = true, + ) + bottomNavigation.load(tabs, selectedTabIndex = RupertTab.index(intent.getStringExtra(EXTRA_TAB)) ?: 0) + bottomNavigation.setOnTabSelectedListener { _, tab -> refreshIfStale(tab.configuration.name) } + + if (savedInstanceState == null) handleViewIntent(intent) + } + + override fun navigatorConfigurations(): List = tabs.map { it.configuration } + + override fun onNavigatorReady(navigator: Navigator) { + super.onNavigatorReady(navigator) + if (navigator.configuration.name == RupertTab.HOME.id) { + pendingHomeLocation?.let { location -> + pendingHomeLocation = null + // The tab's first page is still being created: route once it is laid out. + findViewById(RupertTab.HOME.hostId).post { navigator.route(location) } + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + RupertTab.index(intent.getStringExtra(EXTRA_TAB))?.let(bottomNavigation::selectTab) + handleViewIntent(intent) + } + + /** rupert://auth/google from the browser tab, or a Rupert https link. */ + private fun handleViewIntent(intent: Intent) { + if (intent.action != Intent.ACTION_VIEW) return + val url = intent.dataString ?: return + when { + GoogleCallback.isCallback(url) -> finishGoogleSignIn(url) + Instance.isRupert(url) -> openFromLink(url) + } + } + + /** A Rupert link from outside the app: open it on Home, on the instance it belongs to. */ + private fun openFromLink(url: String) { + bottomNavigation.selectTab(0) + val home = delegate.currentNavigator + if (home != null && home.isReady() && home.configuration.name == RupertTab.HOME.id) { + home.route(url) + } else { + pendingHomeLocation = url + } + } + + fun startGoogleSignIn(link: String) { + GoogleSignIn.start(this, link) { problem -> showProblem(R.string.google_sign_in, problem) } + } + + private fun finishGoogleSignIn(callback: String) { + when (val outcome = GoogleSignIn.finish(callback)) { + is GoogleSignIn.Outcome.Redeem -> lifecycleScope.launch { + try { + val session = withContext(Dispatchers.IO) { + NativeHttp.redeemGoogleCode(outcome.site, outcome.code, outcome.verifier, userAgent()) + } + NativeHttp.install(session) + show(instance = InstanceAddress.originOf(session.landing) ?: Instance.current, landing = session.landing) + } catch (error: Exception) { + showProblem(R.string.google_sign_in, error.message ?: getString(R.string.google_sign_in_failed)) + } + } + is GoogleSignIn.Outcome.Problem -> showProblem(R.string.google_sign_in, outcome.message) + is GoogleSignIn.Outcome.Notice -> showProblem(R.string.workspace_request, outcome.message) + GoogleSignIn.Outcome.NotStarted -> Unit + } + } + + private var handoffInFlight = false + + /** "Open my workspace" answered rupert://workspace?origin=…&handoff=…: sign in there natively. */ + fun openWorkspace(link: String) { + if (handoffInFlight) return + val workspace = WorkspaceLink.parse(link) + ?: return showProblem(R.string.open_workspace, getString(R.string.workspace_link_incomplete)) + handoffInFlight = true + lifecycleScope.launch { + try { + val session = withContext(Dispatchers.IO) { NativeHttp.exchangeHandoff(workspace, userAgent()) } + NativeHttp.install(session) + show(instance = workspace.origin, landing = session.landing) + } catch (error: Exception) { + showProblem(R.string.open_workspace, error.message ?: getString(R.string.workspace_failed)) + } finally { + handoffInFlight = false + } + } + } + + fun openSettings() { + settings.launch(Intent(this, SettingsActivity::class.java)) + } + + /** + * Point every tab at `instance` (after a sign-in or a workspace handoff) and open `landing` on + * Home. Each tab starts over, since they all showed the sign-in page or the control plane. + */ + private fun show(instance: String, landing: String) { + Instance.setCurrent(instance) + restart(landing.takeIf { InstanceAddress.pathOf(it) != "/" || it.contains('?') }) + } + + /** Starts the tabs over (new instance or server); the old stacks belong to the old site. */ + private fun restart(landing: String?) { + RupertApplication.loadPathConfiguration(this) + startActivity( + Intent(this, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + .putExtra(EXTRA_LANDING, landing) + ) + finish() + overridePendingTransition(0, 0) + } + + /** + * Signing in or out in one tab leaves the others on the old state (a sign-in page, or business + * pages after signing out). Mark them, and start each over when it is next opened. + */ + fun visitCompleted(navigator: Navigator, location: String) { + val tab = navigator.configuration.name + val signIn = InstanceAddress.pathOf(location) == "/login" + val previous = onSignInPage + onSignInPage = signIn + if (previous != null && previous != signIn) { + staleTabs += RupertTab.entries.map { it.id }.filter { it != tab } + // Just signed in: start this tab over, so the signed-in page is its first page (with + // the App settings button) instead of sitting on top of the sign-in form. + if (previous && !navigator.isAtStartDestination()) navigator.reset() + } + } + + private fun refreshIfStale(tab: String) { + if (staleTabs.remove(tab)) delegate.currentNavigator?.reset() + } + + private fun userAgent(): String = Hotwire.config.userAgentWithWebViewDefault(this) + + private fun showProblem(title: Int, message: String) { + if (isFinishing) return + MaterialAlertDialogBuilder(this) + .setTitle(title) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .show() + } + + companion object { + const val EXTRA_TAB = "com.rupertpos.rupert.TAB" + private const val EXTRA_LANDING = "com.rupertpos.rupert.LANDING" + + fun tabIntent(context: Context, tab: RupertTab) = + Intent(context, MainActivity::class.java).putExtra(EXTRA_TAB, tab.id) + } +} diff --git a/android/app/src/main/java/com/rupertpos/rupert/NativeHttp.kt b/android/app/src/main/java/com/rupertpos/rupert/NativeHttp.kt new file mode 100644 index 000000000..b6e18c21c --- /dev/null +++ b/android/app/src/main/java/com/rupertpos/rupert/NativeHttp.kt @@ -0,0 +1,181 @@ +package com.rupertpos.rupert + +import android.webkit.CookieManager +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.FormBody +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import java.io.IOException +import java.util.concurrent.TimeUnit +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +/** A problem to show the person, in Rupert's words. */ +class SignInProblem(message: String) : IOException(message) + +/** A Set-Cookie header and the URL that sent it, for copying into the web views' cookie store. */ +data class ReceivedCookie(val url: String, val header: String) + +/** A session Rupert started for the app: the cookies to keep and the page to open. */ +data class StartedSession(val landing: String, val cookies: List) + +/** + * The app's own requests to Rupert (Google sign-in code, workspace handoff, server check). They + * never share the web views' cookies: each call collects what it receives, and only the session + * cookie of the site that answered is handed to the web views (CookieManager). + */ +object NativeHttp { + private val json = "application/json".toMediaType() + + /** No cache, no shared cookies, no https → http redirects. */ + private val base: OkHttpClient = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .followSslRedirects(false) + .build() + + /** + * POST /native/session {code, verifier}: trades the one-time Google code for Rupert's normal + * session cookie (web/app/controllers/native/sessions_controller.rb). + */ + fun redeemGoogleCode(site: String, code: String, verifier: String, userAgent: String): StartedSession { + val endpoint = InstanceAddress.url("/native/session", site) + val body = JSONObject().put("code", code).put("verifier", verifier).toString().toRequestBody(json) + val request = Request.Builder().url(endpoint).post(body) + .header("Accept", "application/json") + .header("User-Agent", userAgent) + .build() + + val collector = CookieCollector() + base.newBuilder().cookieJar(collector).followRedirects(false).build().newCall(request).execute().use { response -> + val text = response.body.string() + val payload = runCatching { JSONObject(text) }.getOrNull() + if (response.code != 200) { + throw SignInProblem(payload?.optString("error")?.ifBlank { null } + ?: "Rupert did not accept the sign-in. Try again.") + } + val cookies = collector.forHost(InstanceAddress.hostOf(site)) + if (cookies.isEmpty()) throw SignInProblem("Rupert did not start a session. Try again.") + val location = payload?.optString("location")?.ifBlank { null } ?: "/" + val landing = InstanceAddress.url(location, site) + return StartedSession(if (InstanceAddress.originOf(landing) == site) landing else "$site/", cookies) + } + } + + /** + * POST /instance/session handoff=…: what the browser's handoff form does. The instance + * accepts the handoff only with its control plane as the Origin header. + */ + fun exchangeHandoff(link: WorkspaceLink, userAgent: String): StartedSession { + val request = Request.Builder() + .url(InstanceAddress.url("/instance/session", link.origin)) + .post(FormBody.Builder().add("handoff", link.handoff).build()) + .header("Origin", link.control) + .header("User-Agent", userAgent) + .build() + + // A private cookie jar carries the instance's session across its redirect to the landing page. + val collector = CookieCollector() + base.newBuilder().cookieJar(collector).followRedirects(true).build().newCall(request).execute().use { response -> + val text = response.body.string().trim() + if (response.code !in 200..299) { + throw SignInProblem( + if (text.isNotEmpty() && text.length < 300 && !text.startsWith("<")) text + else "The workspace did not accept the sign-in. Go back and try again." + ) + } + val cookies = collector.forHost(InstanceAddress.hostOf(link.origin)) + if (cookies.isEmpty()) throw SignInProblem("The workspace did not start a session. Try again.") + val finalUrl = response.request.url.toString() + val landing = if (InstanceAddress.originOf(finalUrl) == link.origin) finalUrl else "${link.origin}/" + return StartedSession(landing, cookies) + } + } + + /** What checking a server address found. */ + sealed interface Probe { + data object Rupert : Probe + data class Problem(val reason: String) : Probe + } + + /** Checks that `origin` answers like Rupert: it serves the app's navigation rules. */ + fun probe(origin: String, userAgent: String): Probe { + val client = base.newBuilder() + .followRedirects(false) + .connectTimeout(8, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .build() + // Older Rupert servers only publish the iPhone copy of the same rules. + val paths = listOf("/native/android/path-configuration.json", "/native/ios/path-configuration.json") + var answered = false + for (path in paths) { + val request = Request.Builder().url(InstanceAddress.url(path, origin)) + .header("Accept", "application/json") + .header("User-Agent", userAgent) + .build() + try { + client.newCall(request).execute().use { response -> + answered = true + if (response.code == 200) { + val rules = runCatching { JSONObject(response.body.string()).optJSONArray("rules") }.getOrNull() + if (rules != null) return Probe.Rupert + } + } + } catch (error: IOException) { + if (!answered) { + return Probe.Problem( + "Rupert could not reach that server. Check the address and that this phone is on " + + "the right network. (${error.message ?: error.javaClass.simpleName})" + ) + } + } + } + return Probe.Problem("That server answered, but it is not a Rupert server.") + } + + /** Copies a started session's cookies into the web views' store. */ + suspend fun install(session: StartedSession) { + val manager = CookieManager.getInstance() + for (cookie in session.cookies) { + suspendCoroutine { continuation -> + manager.setCookie(cookie.url, cookie.header) { continuation.resume(Unit) } + } + } + manager.flush() + } + + /** Keeps cookies for the requests of one call, and the raw headers to hand to the web views. */ + private class CookieCollector : CookieJar { + private val jar = mutableListOf() + private val received = mutableListOf>() + + override fun saveFromResponse(url: HttpUrl, cookies: List) { + for (cookie in cookies) { + jar.removeAll { it.name == cookie.name && it.domain == cookie.domain && it.path == cookie.path } + jar.add(cookie) + received.add(url to cookie) + } + } + + override fun loadForRequest(url: HttpUrl): List = + jar.filter { it.matches(url) && it.expiresAt > System.currentTimeMillis() } + + fun forHost(host: String): List = + received.filter { (url, _) -> url.host.equals(host, ignoreCase = true) } + .map { (url, cookie) -> ReceivedCookie(url.newBuilder().encodedPath("/").query(null).build().toString(), cookie.toSetCookieHeader()) } + } + + /** okhttp's Cookie.toString() is a Set-Cookie value; keep SameSite, which CookieManager understands. */ + private fun Cookie.toSetCookieHeader(): String { + val header = toString() + return sameSite?.let { if (header.contains("samesite", ignoreCase = true)) header else "$header; SameSite=$it" } ?: header + } + + internal fun String.toHttpUrlOrThrow(): HttpUrl = toHttpUrlOrNull() ?: throw SignInProblem("Rupert sent an address the app cannot open.") +} diff --git a/android/app/src/main/java/com/rupertpos/rupert/Pkce.kt b/android/app/src/main/java/com/rupertpos/rupert/Pkce.kt new file mode 100644 index 000000000..2ea0dcf2d --- /dev/null +++ b/android/app/src/main/java/com/rupertpos/rupert/Pkce.kt @@ -0,0 +1,21 @@ +package com.rupertpos.rupert + +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Base64 + +/** PKCE (RFC 7636) for Google sign-in: matches NativeSignIn.challenge_for on the server. */ +object Pkce { + private val random = SecureRandom() + private val encoder = Base64.getUrlEncoder().withoutPadding() + + /** 32 random bytes, base64url: 43 characters. Never leaves the app. */ + fun randomVerifier(): String { + val bytes = ByteArray(32) + random.nextBytes(bytes) + return encoder.encodeToString(bytes) + } + + fun challenge(verifier: String): String = + encoder.encodeToString(MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray(Charsets.US_ASCII))) +} diff --git a/android/app/src/main/java/com/rupertpos/rupert/Routing.kt b/android/app/src/main/java/com/rupertpos/rupert/Routing.kt new file mode 100644 index 000000000..7f11a24be --- /dev/null +++ b/android/app/src/main/java/com/rupertpos/rupert/Routing.kt @@ -0,0 +1,99 @@ +package com.rupertpos.rupert + +import android.content.ActivityNotFoundException +import android.content.Intent +import androidx.core.net.toUri +import dev.hotwire.core.turbo.visit.VisitProposal +import dev.hotwire.navigation.activities.HotwireActivity +import dev.hotwire.navigation.navigator.NavigatorConfiguration +import dev.hotwire.navigation.routing.BrowserTabRouteDecisionHandler +import dev.hotwire.navigation.routing.Router + +/** + * Where each link goes, in this order (Hotwire Android proposes every main-frame navigation, + * including plain `data-turbo="false"` links and redirects, so these see all of them): + * 1. Google sign-in and sign-up go to a browser tab (GoogleSignIn). + * 2. rupert:// links are Rupert talking to the app (workspace handoff). + * 3. Every Rupert site (control plane and workspaces) stays in the app. + * 4. Other websites open in a browser tab; mailto:, tel:, sms: and geo: in their apps. + * Anything else is dropped. + */ +object Routing { + val handlers: Array = arrayOf( + GoogleSignInRoute(), + RupertSchemeRoute(), + RupertSitesRoute(), + OtherSitesRoute(), + SystemSchemesRoute(), + ) +} + +class GoogleSignInRoute : Router.RouteDecisionHandler { + override val name = "rupert-google-sign-in" + + override fun matches(proposal: VisitProposal, configuration: NavigatorConfiguration): Boolean = + Instance.isRupert(proposal.location) && InstanceAddress.pathOf(proposal.location) == "/auth/google" + + override fun handle(proposal: VisitProposal, configuration: NavigatorConfiguration, activity: HotwireActivity): Router.Decision { + (activity as? MainActivity)?.startGoogleSignIn(proposal.location) + return Router.Decision.CANCEL + } +} + +class RupertSchemeRoute : Router.RouteDecisionHandler { + override val name = "rupert-scheme" + + override fun matches(proposal: VisitProposal, configuration: NavigatorConfiguration): Boolean = + RupertLinks.isRupertScheme(proposal.location) + + override fun handle(proposal: VisitProposal, configuration: NavigatorConfiguration, activity: HotwireActivity): Router.Decision { + if (RupertLinks.host(proposal.location) == "workspace") { + (activity as? MainActivity)?.openWorkspace(proposal.location) + } + return Router.Decision.CANCEL + } +} + +/** Hotwire's default keeps only the tab's starting host, so the control plane → workspace hop would leave the app. */ +class RupertSitesRoute : Router.RouteDecisionHandler { + override val name = "rupert-sites" + + override fun matches(proposal: VisitProposal, configuration: NavigatorConfiguration): Boolean = + Instance.isRupert(proposal.location) + + override fun handle(proposal: VisitProposal, configuration: NavigatorConfiguration, activity: HotwireActivity) = + Router.Decision.NAVIGATE +} + +class OtherSitesRoute : Router.RouteDecisionHandler { + override val name = "other-sites" + private val browserTab = BrowserTabRouteDecisionHandler() + + override fun matches(proposal: VisitProposal, configuration: NavigatorConfiguration): Boolean { + val scheme = proposal.location.toUri().scheme?.lowercase() + return scheme == "https" || scheme == "http" + } + + override fun handle(proposal: VisitProposal, configuration: NavigatorConfiguration, activity: HotwireActivity): Router.Decision { + AppLock.allowExternalScreen() + return browserTab.handle(proposal, configuration, activity) + } +} + +class SystemSchemesRoute : Router.RouteDecisionHandler { + override val name = "system-schemes" + private val schemes = setOf("mailto", "tel", "sms", "geo") + + override fun matches(proposal: VisitProposal, configuration: NavigatorConfiguration): Boolean = + proposal.location.toUri().scheme?.lowercase() in schemes + + override fun handle(proposal: VisitProposal, configuration: NavigatorConfiguration, activity: HotwireActivity): Router.Decision { + try { + AppLock.allowExternalScreen() + activity.startActivity(Intent(Intent.ACTION_VIEW, proposal.location.toUri())) + } catch (_: ActivityNotFoundException) { + // No app for it on this phone; the link does nothing, as in a browser without one. + } + return Router.Decision.CANCEL + } +} diff --git a/android/app/src/main/java/com/rupertpos/rupert/RupertApplication.kt b/android/app/src/main/java/com/rupertpos/rupert/RupertApplication.kt new file mode 100644 index 000000000..333e36555 --- /dev/null +++ b/android/app/src/main/java/com/rupertpos/rupert/RupertApplication.kt @@ -0,0 +1,68 @@ +package com.rupertpos.rupert + +import android.app.Application +import android.content.Context +import androidx.core.content.ContextCompat +import dev.hotwire.core.config.Hotwire +import dev.hotwire.core.logging.HotwireLogLevel +import dev.hotwire.core.turbo.config.PathConfiguration +import dev.hotwire.navigation.config.defaultFragmentDestination +import dev.hotwire.navigation.config.registerFragmentDestinations +import dev.hotwire.navigation.config.registerRouteDecisionHandlers + +open class RupertApplication : Application() { + /** Tests turn this off so they never reach the real server. */ + open val loadsRemotePathConfiguration: Boolean = true + + override fun onCreate() { + super.onCreate() + Instance.init(this) + AppLock.init(this) + configureHotwire() + } + + private fun configureHotwire() { + loadPathConfiguration(this) + + // Rupert recognises the app by "Hotwire Native" in the user agent (turbo-rails' + // hotwire_native_app?) and drops the web chrome the native tab bar replaces. + Hotwire.config.applicationUserAgentPrefix = "Rupert Android/${Instance.appVersion};" + Hotwire.config.makeCustomWebView = { context -> RupertWebView(context) } + Hotwire.defaultFragmentDestination = RupertWebFragment::class + Hotwire.registerFragmentDestinations(RupertWebFragment::class) + Hotwire.registerRouteDecisionHandlers(*Routing.handlers) + + if (BuildConfig.DEBUG) { + Hotwire.config.webViewDebuggingEnabled = true + Hotwire.config.logger.logLevel = HotwireLogLevel.DEBUG + } + } + + companion object { + /** + * Rules ship with the app for the first launch and offline starts (the iPhone app's + * path-configuration.json, copied in at build time); the server copy wins once it loads, so + * navigation rules change with a Rupert deploy, not a Play release. + */ + fun loadPathConfiguration(context: Context) { + val remote = (context.applicationContext as? RupertApplication)?.loadsRemotePathConfiguration != false + Hotwire.loadPathConfiguration( + context = context, + location = PathConfiguration.Location( + assetFilePath = "json/path-configuration.json", + remoteFileUrl = Instance.url("/native/android/path-configuration.json", on = Instance.controlPlane) + .takeIf { remote }, + ), + ) + } + } +} + +/** The app's own colours, for before the first page and for pages without Rupert's banner token. */ +object Theme { + fun fallbackBanner(context: Context) = BannerColors( + background = ContextCompat.getColor(context, R.color.banner), + title = ContextCompat.getColor(context, R.color.banner_title), + accent = ContextCompat.getColor(context, R.color.accent), + ) +} diff --git a/android/app/src/main/java/com/rupertpos/rupert/RupertLinks.kt b/android/app/src/main/java/com/rupertpos/rupert/RupertLinks.kt new file mode 100644 index 000000000..1ed973a6e --- /dev/null +++ b/android/app/src/main/java/com/rupertpos/rupert/RupertLinks.kt @@ -0,0 +1,84 @@ +package com.rupertpos.rupert + +import java.net.URI +import java.net.URISyntaxException +import java.net.URLDecoder + +/** + * rupert:// links: Rupert talking to the app, never opened anywhere else. + * + * - rupert://workspace?origin=…&handoff=…&control=… answers "Open my workspace" inside the app + * (web/app/controllers/workspaces_controller.rb, NativeSignIn.workspace_url). + * - rupert://auth/google?code=… (or ?error= / ?message=) ends Google sign-in in the browser tab + * (NativeSignIn.callback_url). + */ +object RupertLinks { + const val SCHEME = "rupert" + + fun isRupertScheme(url: String?): Boolean = url?.lowercase()?.startsWith("$SCHEME:") == true + + fun query(url: String): Map { + val raw = try { + URI(url).rawQuery + } catch (_: URISyntaxException) { + null + } ?: return emptyMap() + return raw.split('&').filter { it.isNotEmpty() }.associate { pair -> + val name = pair.substringBefore('=') + val value = if ('=' in pair) pair.substringAfter('=') else "" + decode(name) to decode(value) + } + } + + fun host(url: String): String? = try { + URI(url).host?.lowercase() + } catch (_: URISyntaxException) { + null + } + + fun path(url: String): String = try { + URI(url).rawPath.orEmpty() + } catch (_: URISyntaxException) { + "" + } + + private fun decode(value: String): String = URLDecoder.decode(value, "UTF-8") +} + +/** rupert://workspace?origin=INSTANCE&handoff=ONE_TIME_CODE&control=CONTROL_PLANE. */ +data class WorkspaceLink(val origin: String, val handoff: String, val control: String) { + companion object { + /** Null when the link is incomplete or names a non-https site. */ + fun parse(url: String): WorkspaceLink? { + if (!RupertLinks.isRupertScheme(url) || RupertLinks.host(url) != "workspace") return null + val query = RupertLinks.query(url) + val origin = InstanceAddress.httpsOrigin(query["origin"]) ?: return null + val control = InstanceAddress.httpsOrigin(query["control"]) ?: return null + val handoff = query["handoff"]?.takeIf { it.isNotBlank() } ?: return null + return WorkspaceLink(origin, handoff, control) + } + } +} + +/** What the browser tab answered at the end of Google sign-in. */ +sealed interface GoogleCallback { + data class Code(val code: String) : GoogleCallback + data class Refused(val message: String) : GoogleCallback + /** A sign-up receipt ("we'll email you when your workspace is ready"). */ + data class Notice(val message: String) : GoogleCallback + data object Incomplete : GoogleCallback + + companion object { + fun isCallback(url: String?): Boolean = + url != null && RupertLinks.isRupertScheme(url) && RupertLinks.host(url) == "auth" && + RupertLinks.path(url) == "/google" + + fun parse(url: String): GoogleCallback { + if (!isCallback(url)) return Incomplete + val query = RupertLinks.query(url) + query["error"]?.takeIf { it.isNotBlank() }?.let { return Refused(it) } + query["message"]?.takeIf { it.isNotBlank() }?.let { return Notice(it) } + return query["code"]?.takeIf { it.isNotBlank() }?.let(::Code) ?: Incomplete + } + } +} diff --git a/android/app/src/main/java/com/rupertpos/rupert/RupertWebFragment.kt b/android/app/src/main/java/com/rupertpos/rupert/RupertWebFragment.kt new file mode 100644 index 000000000..cf92bf768 --- /dev/null +++ b/android/app/src/main/java/com/rupertpos/rupert/RupertWebFragment.kt @@ -0,0 +1,160 @@ +package com.rupertpos.rupert + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.res.ColorStateList +import android.net.Uri +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import android.webkit.ValueCallback +import android.webkit.WebChromeClient +import android.webkit.WebView +import android.widget.Button +import android.widget.TextView +import androidx.core.view.WindowCompat +import com.google.android.material.appbar.MaterialToolbar +import dev.hotwire.core.turbo.errors.LoadError +import dev.hotwire.core.turbo.errors.VisitError +import dev.hotwire.core.turbo.session.Session +import dev.hotwire.core.turbo.webview.HotwireWebChromeClient +import dev.hotwire.core.turbo.webview.HotwireWebView +import dev.hotwire.navigation.destinations.HotwireDestinationDeepLink +import dev.hotwire.navigation.fragments.HotwireWebFragment + +/** + * Rupert pages: Hotwire's web fragment, with the top bar in the page's banner colours, short + * titles, the App settings button on each tab's first page and an error screen that says why. + */ +@HotwireDestinationDeepLink(uri = "hotwire://fragment/web") +class RupertWebFragment : HotwireWebFragment() { + private var colors: BannerColors? = BannerColors.current + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + // Page titles end in " · Rupert" for browser tabs; the app's bar does not need it. + fragmentViewModel.title.observe(viewLifecycleOwner) { title -> + toolbarForNavigation()?.title = shortTitle(title) + } + if (navigator.isAtStartDestination() && !isModal) addSettingsButton() + colors?.let(::apply) + } + + override fun onResume() { + super.onResume() + colors?.let(::apply) + } + + override fun onWebViewAttached(webView: HotwireWebView) { + super.onWebViewAttached(webView) + (webView as? RupertWebView)?.onBannerChanged = { apply(it) } + } + + override fun onWebViewDetached(webView: HotwireWebView) { + super.onWebViewDetached(webView) + (webView as? RupertWebView)?.onBannerChanged = null + } + + override fun onVisitRendered(location: String) { + super.onVisitRendered(location) + readBanner() + } + + override fun onVisitCompleted(location: String, completedOffline: Boolean) { + super.onVisitCompleted(location, completedOffline) + readBanner() + (activity as? MainActivity)?.visitCompleted(navigator, location) + } + + override fun createWebChromeClient(): HotwireWebChromeClient = RupertWebChromeClient(navigator.session) + + /** Rupert's JavaScript did not start: say why, with the page's own diagnostics. */ + override fun createErrorView(error: VisitError): View { + val view = layoutInflater.inflate(R.layout.view_error, null) + val title = view.findViewById(R.id.error_title) + val message = view.findViewById(R.id.error_message) + val details = view.findViewById(R.id.error_details) + val copy = view.findViewById