Skip to content

Latest commit

 

History

History
145 lines (110 loc) · 8.04 KB

android.md

File metadata and controls

145 lines (110 loc) · 8.04 KB

Fitr Android app (Solana Mobile WebView shell)

The Android app is a thin native shell around the live site https://fitr.day. UI, pose model, wallet code and service worker all come from the website, so a web deploy updates the app without a new APK. Rebuild the APK only to change the native shell, icon, version or signing.

Project android/fitr-shell (generated by npx solana-mobile@0.5.0 webshell init)
Application id day.fitr
Start URL https://fitr.day/ (SOLANA_MOBILE_URL in android/fitr-shell/gradle.properties)
minSdk / target / compile 28 / 37 / 37 (AGP 9.3.2, Gradle 9.7.1, Kotlin 2.4.10)
Release APK android/fitr-shell/app/build/outputs/apk/release/app-release.apk (~2.0 MB)
Signing cert SHA-256 64:D0:83:FE:C3:CF:EC:9F:52:D6:0D:C8:72:F5:45:EA:7A:25:D8:F2:9A:10:91:66:E1:E7:35:D4:DF:53:5E:06

docs/twa.md (Bubblewrap TWA, app.fitr.twa) is superseded by this file.

Why a WebView shell (not a TWA)

  • Mobile Wallet Adapter: the shell opens solana-wallet: links in the installed wallet (Seed Vault on Seeker) and fires the synthetic blur the MWA library waits for.
  • Camera: a plain WebView denies getUserMedia() unless the app grants it. Fitr adds the camera patch below.

Camera patch (what differs from the template)

File Change
app/src/main/AndroidManifest.xml CAMERA permission; uses-feature android.hardware.camera, .camera.front, .camera.autofocus, all required="false" (the app still installs on devices without a camera)
app/src/main/java/day/fitr/CameraPermissionGate.kt New. Handles WebChromeClient.onPermissionRequest: grants only RESOURCE_VIDEO_CAPTURE, only to the start URL's origin (https://fitr.day, port 443; release builds never over http), and only after the runtime CAMERA permission is granted (requested via ActivityResultContracts.RequestPermission). Everything else (mic, other origins, iframes) is denied.
app/src/main/java/day/fitr/WebShellChromeClient.kt Forwards onPermissionRequest / onPermissionRequestCanceled to the gate.
app/src/main/java/day/fitr/MainActivity.kt Creates the gate in onCreate; mediaPlaybackRequiresUserGesture = false so the camera <video> plays without a tap; pull-to-refresh disabled on /workout/* so a stray swipe can't reload a live workout.
app/src/main/res/drawable-nodpi/ic_launcher_foreground_inner.png Fitr mark (from src/components/pwa/markGeometry.ts) on #0A0B0D adaptive background. The template's default mipmap-*/ic_launcher*.webp were removed (minSdk 28 always uses the adaptive icon).

If the user taps "Don't allow" (or "don't ask again"), getUserMedia rejects with NotAllowedError and the web app shows its existing camera-blocked message. To recover, the user enables Camera in Android Settings > Apps > Fitr > Permissions.

Permissions (for the dApp Store submission)

Fitr asks only for what its core purpose needs. From aapt2 dump permissions on the 1.2.0 APK:

Permission Why When it is asked
INTERNET The app loads https://app.fitr.day and talks to Solana Install (normal permission)
CAMERA On-device AI counts reps from the camera; video never leaves the phone. android.hardware.camera* are declared required="false", so devices without a camera can still install When the first workout starts
POST_NOTIFICATIONS Optional daily workout reminders before the stake deadline Only when the user turns reminders on
RECEIVE_BOOT_COMPLETED Re-schedules those reminders after a restart (otherwise they stop silently) Install (normal permission)
day.fitr.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION Added by AndroidX core; signature-level and internal to the app, never shown to users n/a

Not requested: microphone (the camera opens with audio: false), location, contacts, storage, phone, exact alarms. Check before every release with aapt2 dump permissions app/build/outputs/apk/release/app-release.apk.

Toolchain (macOS, no sudo)

brew install openjdk@17
brew install --cask android-commandlinetools

export JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home
export ANDROID_HOME=/opt/homebrew/share/android-commandlinetools
export PATH="$JAVA_HOME/bin:$ANDROID_HOME/platform-tools:$PATH"

yes | sdkmanager --licenses
sdkmanager "platform-tools" "platforms;android-37.0" "build-tools;37.0.0"

The exports are not written to your shell profile. android/build-release.sh sets the same defaults itself.

Signing key: back it up now

Keystore fitr-release.keystore (PKCS12, RSA 2048, valid until 2054)
Alias fitr-release
Passwords in KEYSTORE.txt next to the keystore (store password = key password)

Both files were created in the session scratchpad (/private/tmp/claude-501/.../scratchpad/keys/), which is wiped on reboot. Move them now:

mkdir -p ~/.fitr && chmod 700 ~/.fitr
# already copied to ~/.fitr/keys/ (fitr-release.keystore + KEYSTORE.txt)
chmod 600 ~/.fitr/*

Then store the keystore and password in a password manager, and keep one offline copy. If the key is lost, the dApp Store listing can never be updated: a new key means a new app. Never commit it (android/fitr-shell/.gitignore blocks *.keystore, *.jks, KEYSTORE.txt).

Build the release APK

FITR_KEYSTORE_PATH=~/.fitr/keys/fitr-release.keystore \
FITR_KEYSTORE_PASSWORD='<from KEYSTORE.txt>' \
android/build-release.sh                     # add --version-code 2 --version-name 1.0.1 for updates

The script runs ./gradlew assembleRelease with the signing properties, then prints the APK path and its certificate SHA-256. Equivalent CLI route: SOLANA_MOBILE_KEYSTORE_PASSWORD=... SOLANA_MOBILE_KEY_PASSWORD=... npx solana-mobile@0.5.0 webshell build android/fitr-shell (it reads the keystore path from android/fitr-shell/twa-manifest.json; update signingKey.path there after moving the keystore).

Every APK you publish needs a higher versionCode. Bump it in gradle.properties (SOLANA_MOBILE_VERSION_CODE) or pass --version-code.

Verify a build:

$ANDROID_HOME/build-tools/37.0.0/apksigner verify --print-certs android/fitr-shell/app/build/outputs/apk/release/app-release.apk
$ANDROID_HOME/build-tools/37.0.0/aapt2 dump badging android/fitr-shell/app/build/outputs/apk/release/app-release.apk | grep -E "package|permission|feature"

Digital Asset Links

public/.well-known/assetlinks.json declares day.fitr with the release certificate fingerprint above. It must be served at https://fitr.day/.well-known/assetlinks.json with Content-Type: application/json (next.config.ts already sets this). A WebView shell does not strictly need it, but it proves domain ownership for the dApp Store and Android App Links. If you ever re-key, update the fingerprint there and redeploy the site.

Install and test on the Seeker

adb devices                                   # enable USB debugging first
adb install -r android/fitr-shell/app/build/outputs/apk/release/app-release.apk
adb logcat -s WebShell FitrCamera chromium    # release builds log little; see below for a debug build

For WebView DevTools (chrome://inspect), build a debug APK: cd android/fitr-shell && ./gradlew assembleDebug (debug builds enable setWebContentsDebuggingEnabled; the debug key differs from the release key).

Checklist (site must be live at https://fitr.day):

  1. Launch shows the Fitr icon and dark splash, then the home page.
  2. Start a workout: the Android camera prompt appears once; after Allow the preview shows and reps count.
  3. Deny, then retry: the app shows its camera-blocked message (no crash). Re-enable in Settings and it works.
  4. Connect wallet: Seed Vault opens, approve, and you are returned to Fitr connected.
  5. Sign a transaction (stake) and confirm it returns to the app.
  6. A link to another site opens in the system browser, not inside the app.
  7. Back button navigates history; on the home page it exits.
  8. Pull-to-refresh works on home/history, never on a workout screen.