From a4d7fb7f61dc12ddccf0585ee6a30ac48456d50b Mon Sep 17 00:00:00 2001 From: Clove Twilight Date: Mon, 2 Nov 2026 19:36:46 +0000 Subject: [PATCH 1/4] Build Doughmination Manager, publish a verified manifest, version apps separately Manager: a native app that reads the rolling release's manifest.json, shows each app's installed and published versions, and downloads (resumably), verifies (size, SHA-256, then package, version code and signing certificate as Android reads the APK) and installs updates through PackageInstaller. Unit tests cover the status logic, manifest validation and hashing. Release: every version, hash, size and certificate in the manifest is read back from the built APK, and the build fails if an APK isn't the version it was built as or isn't signed with its app's pinned key. Apps now get a new version only when their sources (or the shared build files) change, so the Manager doesn't offer updates for unchanged apps; release notes are each app's commits since its own last release, and version codes can never go backwards. The release is updated in place instead of deleted and recreated. The emulator job now has the Manager install Inbox from the release. Plural: light system-bar icons on the dark app, the filter sheet clears the navigation bar, and long names in relationship pairs wrap instead of truncating to the same text. --- .github/apps.json | 31 + .github/scripts/build-apps.sh | 52 +- .github/scripts/make-manifest.sh | 117 ++++ .github/scripts/manager-e2e.sh | 106 ++++ .github/workflows/apps.yml | 81 ++- README.md | 5 +- docs/manifest.md | 111 ++-- docs/releasing.md | 61 +- doughmination-manager/README.md | 51 ++ doughmination-manager/app/build.gradle.kts | 21 +- .../app/src/main/AndroidManifest.xml | 27 +- .../gay/doughmination/manager/MainActivity.kt | 45 +- .../doughmination/manager/data/Downloader.kt | 129 +++++ .../doughmination/manager/data/Manifest.kt | 83 +++ .../manager/data/ManifestSource.kt | 50 ++ .../doughmination/manager/data/Packages.kt | 66 +++ .../gay/doughmination/manager/data/Status.kt | 51 ++ .../manager/install/Installer.kt | 138 +++++ .../manager/ui/ManagerViewModel.kt | 234 ++++++++ .../gay/doughmination/manager/ui/Screens.kt | 531 ++++++++++++++++++ .../gay/doughmination/manager/ui/Theme.kt | 37 ++ .../app/src/main/res/values/themes.xml | 4 +- .../doughmination/manager/data/StatusTest.kt | 90 +++ gradle/libs.versions.toml | 2 + .../gay/doughmination/plural/MainActivity.kt | 8 +- .../gay/doughmination/plural/ui/HomeScreen.kt | 5 +- .../plural/ui/RelationshipsScreen.kt | 11 +- 27 files changed, 2012 insertions(+), 135 deletions(-) create mode 100644 .github/apps.json create mode 100644 .github/scripts/make-manifest.sh create mode 100644 .github/scripts/manager-e2e.sh create mode 100644 doughmination-manager/README.md create mode 100644 doughmination-manager/app/src/main/java/gay/doughmination/manager/data/Downloader.kt create mode 100644 doughmination-manager/app/src/main/java/gay/doughmination/manager/data/Manifest.kt create mode 100644 doughmination-manager/app/src/main/java/gay/doughmination/manager/data/ManifestSource.kt create mode 100644 doughmination-manager/app/src/main/java/gay/doughmination/manager/data/Packages.kt create mode 100644 doughmination-manager/app/src/main/java/gay/doughmination/manager/data/Status.kt create mode 100644 doughmination-manager/app/src/main/java/gay/doughmination/manager/install/Installer.kt create mode 100644 doughmination-manager/app/src/main/java/gay/doughmination/manager/ui/ManagerViewModel.kt create mode 100644 doughmination-manager/app/src/main/java/gay/doughmination/manager/ui/Screens.kt create mode 100644 doughmination-manager/app/src/main/java/gay/doughmination/manager/ui/Theme.kt create mode 100644 doughmination-manager/app/src/test/java/gay/doughmination/manager/data/StatusTest.kt diff --git a/.github/apps.json b/.github/apps.json new file mode 100644 index 0000000..f77d836 --- /dev/null +++ b/.github/apps.json @@ -0,0 +1,31 @@ +{ + "_comment": "What the Manager shows for each app, and the certificate each published APK must be signed with (docs/releasing.md). CI fails rather than publish an APK whose certificate isn't the one pinned here. Keyed by the APK's file name in the release.", + "system.apk": { + "id": "system", + "name": "System", + "summary": "The plural system: members, fronting and relationships.", + "dir": "plural", + "signingCertSha256": "F1:0D:90:FE:C9:0C:97:18:C0:06:8B:70:5A:55:92:62:05:DF:11:74:95:62:A2:7D:30:B5:16:70:40:3E:1D:C1" + }, + "inbox.apk": { + "id": "inbox", + "name": "Inbox", + "summary": "Mail for every Doughmination domain.", + "dir": "email", + "signingCertSha256": "79:7D:66:1F:F5:EE:00:FD:DB:E3:E0:7F:FF:07:C5:EF:AC:C3:50:8F:18:65:0F:60:F5:A6:A6:4B:95:30:A8:DD" + }, + "device-reporter.apk": { + "id": "device-reporter", + "name": "Device Reporter", + "summary": "Reports this phone's battery and state to the Doughmination API.", + "dir": "api-helper", + "signingCertSha256": "D1:D0:FB:74:BA:38:5E:69:55:BF:58:97:FD:E7:18:4F:4F:2F:1B:91:FD:0E:FA:2D:9E:A8:FF:C0:59:B8:CF:D5" + }, + "doughmination-manager.apk": { + "id": "manager", + "name": "Doughmination Manager", + "summary": "Installs and updates the Doughmination apps.", + "dir": "doughmination-manager", + "signingCertSha256": "22:8D:A3:BC:CF:8B:ED:60:21:B8:E6:E0:17:C2:FE:62:F1:EC:52:9C:8C:D4:2C:8D:41:A1:35:FA:62:7B:E8:AA" + } +} diff --git a/.github/scripts/build-apps.sh b/.github/scripts/build-apps.sh index beeaeed..28d3674 100644 --- a/.github/scripts/build-apps.sh +++ b/.github/scripts/build-apps.sh @@ -6,9 +6,17 @@ # # build-apps.sh # +# Everything in versions.json is read back from the built APK itself (aapt2 +# for the package and versions, apksigner for the certificate), and the build +# fails if the APK doesn't carry the version it was asked to build. So the +# version an APK reports, the one Gradle was given and the one published are +# always the same. +# # Keys are read from $KEYS/.p12 with their passwords in the environment # (see apps.yml). Without a key an app builds with the debug key (Inbox: -# unsigned), which is fine for pull requests and never published. +# unsigned), which is fine for pull requests and never published. With +# REQUIRE_PINNED_CERT=true, an APK whose certificate isn't the one pinned in +# .github/apps.json fails the build. set -euo pipefail out=$1 @@ -16,6 +24,11 @@ keys=${KEYS:-${RUNNER_TEMP:-/tmp}/keys} mkdir -p "$out" manifest="[]" +tools=$(ls -d "$ANDROID_HOME"/build-tools/*/ | sort -V | tail -1) +aapt2="${tools}aapt2" +apksigner="${tools}apksigner" +pins=.github/apps.json + # build build() { local file=$1 module=$2 dir=$3 package=$4 key=$5 password=$6 alias=$7 @@ -32,14 +45,43 @@ build() { local apk apk=$(ls "$dir"/build/outputs/apk/release/*.apk | head -1) cp "$apk" "$out/$file" + + # What the APK really is. + local badging pkg code name minsdk cert + badging=$("$aapt2" dump badging "$out/$file") + pkg=$(sed -n "s/^package: name='\([^']*\)'.*/\1/p" <<< "$badging") + code=$(sed -n "s/^package: .* versionCode='\([^']*\)'.*/\1/p" <<< "$badging") + name=$(sed -n "s/^package: .* versionName='\([^']*\)'.*/\1/p" <<< "$badging") + minsdk=$(sed -n "s/^\(min\)\?sdkVersion:'\([0-9]*\)'.*/\2/p" <<< "$badging" | head -1) + cert=$("$apksigner" verify --print-certs "$out/$file" 2>/dev/null \ + | sed -n 's/^Signer #1 certificate SHA-256 digest: //p' | head -1 || true) + + local expected_code=${VERSION_CODE:-1} expected_name=${VERSION_NAME:-} + if [ "$pkg" != "$package" ]; then + echo "::error::$file is package '$pkg', expected '$package'"; exit 1 + fi + if [ "$code" != "$expected_code" ] || { [ -n "$expected_name" ] && [ "$name" != "$expected_name" ]; }; then + echo "::error::$file reports $name ($code), but this build is ${expected_name:-?} ($expected_code)"; exit 1 + fi + if [ "${REQUIRE_PINNED_CERT:-false}" = true ]; then + local pinned + pinned=$(jq -r --arg f "$file" '.[$f].signingCertSha256 // ""' "$pins" | tr -d ':' | tr 'A-F' 'a-f') + if [ -z "$cert" ] || [ "$cert" != "$pinned" ]; then + echo "::error::$file is signed by ${cert:-nothing}, not the key pinned in $pins ($pinned)"; exit 1 + fi + fi + echo "$file: $pkg $name ($code), minSdk $minsdk, certificate ${cert:-none}" + manifest=$(jq \ --arg file "$file" \ - --arg packageName "$package" \ - --argjson versionCode "${VERSION_CODE:-1}" \ - --arg versionName "${VERSION_NAME:-dev}" \ + --arg packageName "$pkg" \ + --argjson versionCode "$code" \ + --arg versionName "$name" \ + --argjson minSdk "${minsdk:-1}" \ + --arg signingCertSha256 "$cert" \ --arg sha256 "$(sha256sum "$out/$file" | cut -d' ' -f1)" \ --argjson sizeBytes "$(stat -c %s "$out/$file")" \ - '. + [{$file, $packageName, $versionCode, $versionName, $sha256, $sizeBytes}]' <<< "$manifest") + '. + [{$file, $packageName, $versionCode, $versionName, $minSdk, $signingCertSha256, $sha256, $sizeBytes}]' <<< "$manifest") echo "::endgroup::" } diff --git a/.github/scripts/make-manifest.sh b/.github/scripts/make-manifest.sh new file mode 100644 index 0000000..7de7d28 --- /dev/null +++ b/.github/scripts/make-manifest.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# Writes /manifest.json, the document Doughmination Manager reads +# (docs/manifest.md), and /publish.txt, the APKs that need uploading. +# +# make-manifest.sh [previous manifest.json] +# +# Inputs: /versions.json (what build-apps.sh read back from each APK), +# .github/apps.json (names, summaries, pinned certificates), git history, and +# the manifest that's published now. +# +# Each app gets a new version only when its sources changed. An app's +# fingerprint covers its own directory and the shared build files; when it +# matches the published one, the app keeps its published entry and APK, so the +# Manager offers no update for it. Otherwise the freshly built APK is +# published, and: +# - its release notes are the commits that touched its directory since its +# own last release; +# - its versionCode must be higher than the published one, or the script +# fails and nothing is published. +set -euo pipefail + +dist=$1 +previous=${2:-} +[ -n "$previous" ] && [ -s "$previous" ] || previous="" +repo=${GITHUB_REPOSITORY:-doughmination/apps} +download="https://github.com/$repo/releases/latest/download" +release_url="https://github.com/$repo/releases/tag/latest" +now=$(date -u +%Y-%m-%dT%H:%M:%SZ) +head_commit=$(git rev-parse HEAD) + +# Files every app's build depends on. Changing any of them rebuilds every app. +shared="gradle build.gradle.kts settings.gradle.kts gradle.properties gradlew" + +fingerprint() { + local paths="$1 $shared" p + for p in $paths; do echo "$p $(git rev-parse "HEAD:$p")"; done | sha256sum | cut -d' ' -f1 +} + +apps="[]" +: > "$dist/publish.txt" +for file in $(jq -r '.apps[].file' "$dist/versions.json"); do + built=$(jq --arg f "$file" '.apps[] | select(.file == $f)' "$dist/versions.json") + meta=$(jq --arg f "$file" '.[$f]' .github/apps.json) + if [ "$meta" = null ]; then + echo "::error::$file has no entry in .github/apps.json"; exit 1 + fi + dir=$(jq -r '.dir' <<< "$meta") + package=$(jq -r '.packageName' <<< "$built") + code=$(jq -r '.versionCode' <<< "$built") + source_hash=$(fingerprint "$dir") + + published="null" + if [ -n "$previous" ]; then + published=$(jq --arg p "$package" '[.apps[] | select(.packageName == $p)][0] // null' "$previous") + fi + + if [ "$published" != null ] && [ "$(jq -r '.sourceHash // ""' <<< "$published")" = "$source_hash" ]; then + echo "$file: unchanged, keeping $(jq -r '.latest.versionName' <<< "$published")" + apps=$(jq --argjson app "$published" --argjson meta "$meta" \ + '. + [$app + {id: $meta.id, name: $meta.name, summary: $meta.summary}]' <<< "$apps") + continue + fi + + if [ "$published" != null ]; then + old=$(jq -r '.latest.versionCode' <<< "$published") + if [ "$code" -le "$old" ]; then + echo "::error::$file would be version code $code, but $old is already published. Version codes must only go up (docs/releasing.md)." + exit 1 + fi + fi + + # What changed in this app since its own last release. + since=$(jq -r '.sourceCommit // ""' <<< "$published") + if [ -n "$since" ] && git cat-file -e "$since^{commit}" 2>/dev/null; then + notes=$(git log --no-merges --format='- %s' "$since..$head_commit" -- "$dir" | head -40) + [ -n "$notes" ] || notes="- Rebuilt for a change to the shared build setup." + else + notes=$(git log --no-merges --format='- %s' -10 -- "$dir") + fi + + echo "$file: changed, publishing $(jq -r '.versionName' <<< "$built")" + echo "$file" >> "$dist/publish.txt" + apps=$(jq \ + --argjson built "$built" \ + --argjson meta "$meta" \ + --arg url "$download/$file" \ + --arg notes "$notes" \ + --arg now "$now" \ + --arg releaseUrl "$release_url" \ + --arg sourceHash "$source_hash" \ + --arg sourceCommit "$head_commit" \ + '. + [{ + id: $meta.id, + packageName: $built.packageName, + name: $meta.name, + summary: $meta.summary, + signingCertSha256: [$meta.signingCertSha256], + sourceHash: $sourceHash, + sourceCommit: $sourceCommit, + latest: { + versionCode: $built.versionCode, + versionName: $built.versionName, + apkUrl: $url, + sha256: $built.sha256, + sizeBytes: $built.sizeBytes, + minSdk: $built.minSdk, + releasedAt: $now, + releaseNotes: $notes, + releaseUrl: $releaseUrl + } + }]' <<< "$apps") +done + +jq -n --arg now "$now" --arg commit "$head_commit" --argjson apps "$apps" \ + '{schemaVersion: 1, generatedAt: $now, commit: $commit, apps: $apps}' > "$dist/manifest.json" +cat "$dist/manifest.json" +echo "To publish: $(tr '\n' ' ' < "$dist/publish.txt")" diff --git a/.github/scripts/manager-e2e.sh b/.github/scripts/manager-e2e.sh new file mode 100644 index 0000000..7f9ddf4 --- /dev/null +++ b/.github/scripts/manager-e2e.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# The whole update path, on the running emulator: install Doughmination +# Manager, let it read the published manifest, then have it download, verify +# and install Inbox from the rolling release, confirming Android's own install +# prompt the way a person would. Fails unless Inbox ends up installed at +# exactly the version the manifest lists. +# +# manager-e2e.sh +set -euo pipefail + +apk=$1 +out=$2 +pkg=gay.doughmination.manager +target=gay.doughmination.mail +manifest_url="https://github.com/${GITHUB_REPOSITORY:-doughmination/apps}/releases/latest/download/manifest.json" +mkdir -p "$out" + +if ! curl -fsSL "$manifest_url" -o "$out/manifest.json"; then + echo "::warning::No published manifest yet; skipping the Manager test." + exit 0 +fi +want_code=$(jq -r --arg p "$target" '.apps[] | select(.packageName == $p) | .latest.versionCode' "$out/manifest.json") +echo "The manifest lists $target at version code $want_code" + +ui() { adb exec-out uiautomator dump /dev/tty 2>/dev/null | sed 's/UI hierch*ary dumped to.*//' || true; } + +shot() { + sleep "${2:-2}" + adb exec-out screencap -p > "$out/$1.png" + ui > "$out/$1.xml" + echo "saved $1" +} + +# Taps the first element whose text or description matches the regex $1 exactly. +tap() { + local bounds + bounds=$(ui | grep -oE "(text|content-desc)=\"$1\"[^>]*bounds=\"\[[0-9]+,[0-9]+\]\[[0-9]+,[0-9]+\]\"" | head -1 \ + | grep -oE 'bounds="[^"]+"' | grep -oE '[0-9]+' | tr '\n' ' ' || true) + if [ -z "$bounds" ]; then + echo "::error::Couldn't find \"$1\" on screen" + return 1 + fi + set -- $bounds + adb shell input tap $(( ($1 + $3) / 2 )) $(( ($2 + $4) / 2 )) +} + +expect() { + if ! ui | grep -qE "text=\"$1"; then + echo "::error::Expected \"$1\" on screen at step $2" + exit 1 + fi + echo "found \"$1\"" +} + +adb uninstall "$target" > /dev/null 2>&1 || true +adb install -r "$apk" +# What a person does once in Settings: allow the Manager to install apps. +adb shell appops set "$pkg" REQUEST_INSTALL_PACKAGES allow + +adb shell am start -W -n "$pkg/.MainActivity" +shot 01-list 10 +expect "Doughmination Manager" "launch" +expect "Inbox" "the app list" +expect "Not installed" "the app list" + +tap "Inbox" +shot 02-detail 2 +expect "What's new" "Inbox's page" +tap "Install [^\"]*" +echo "Downloading and verifying..." + +# Android's confirmation prompt appears once the APK is downloaded and checked. +for i in $(seq 1 60); do + if ui | grep -qiE 'text="(Install|INSTALL|Update|UPDATE)"[^>]*package="com\.(google\.)?android\.packageinstaller"'; then + break + fi + if ui | grep -qE "doesn't match its SHA-256|isn't signed with|was interrupted|download failed|Couldn't install"; then + shot 03-failed 0 + echo "::error::The Manager reported a failure" + exit 1 + fi + sleep 2 +done +shot 03-confirm 0 +tap "(Install|INSTALL|Update|UPDATE)" + +for i in $(seq 1 30); do + if adb shell pm list packages "$target" | grep -q "$target"; then break; fi + sleep 2 +done +shot 04-installed 3 + +got_code=$(adb shell dumpsys package "$target" | grep -oE 'versionCode=[0-9]+' | head -1 | cut -d= -f2) +if [ "$got_code" != "$want_code" ]; then + echo "::error::Inbox installed at version code ${got_code:-none}, but the manifest says $want_code" + exit 1 +fi +expect "Up to date" "after installing" +echo "Manager installed Inbox $got_code, matching the manifest" + +adb logcat -d > "$out/logcat.txt" +if grep -q "FATAL EXCEPTION" "$out/logcat.txt"; then + echo "::error::An app crashed" + grep -A 30 "FATAL EXCEPTION" "$out/logcat.txt" + exit 1 +fi diff --git a/.github/workflows/apps.yml b/.github/workflows/apps.yml index de9a171..2af9fd6 100644 --- a/.github/workflows/apps.yml +++ b/.github/workflows/apps.yml @@ -24,7 +24,10 @@ jobs: PUBLISH: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }} KEYS: ${{ github.workspace }}/../keys steps: + # Full history: the release notes are the commits since the last release. - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-java@v4 with: @@ -33,7 +36,8 @@ jobs: - uses: gradle/actions/setup-gradle@v4 - # Every app gets the same version: appVersion.. Version + # This build's version: appVersion.. Only apps whose sources + # changed are published with it (make-manifest.sh). Version # codes start at 1000, above anything released before the apps shared one. - name: Version run: | @@ -76,8 +80,13 @@ jobs: exit 1 fi + - name: Unit tests + run: ./gradlew testDebugUnitTest --console=plain + - name: Build env: + # A published APK must carry the certificate pinned in .github/apps.json. + REQUIRE_PINNED_CERT: ${{ env.PUBLISH }} PLURAL_PASSWORD: ${{ secrets.PLURAL_STORE_PASSWORD }} INBOX_PASSWORD: ${{ secrets.INBOX_KEYSTORE_PASSWORD }} DEVICE_REPORTER_PASSWORD: ${{ secrets.DEVICE_REPORTER_STORE_PASSWORD }} @@ -96,6 +105,16 @@ jobs: "$apksigner" verify --print-certs "$apk" | grep "certificate SHA-256" done + # The Manager's manifest. Against the published one, so release notes + # cover what changed since then and no app's version code goes backwards. + - name: Manifest + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release download latest -p manifest.json -O "$RUNNER_TEMP/previous-manifest.json" 2>/dev/null \ + || echo "No published manifest yet." + bash .github/scripts/make-manifest.sh dist "$RUNNER_TEMP/previous-manifest.json" + - uses: actions/upload-artifact@v4 with: name: apps @@ -109,27 +128,49 @@ jobs: GH_TOKEN: ${{ github.token }} run: | base="https://github.com/$GITHUB_REPOSITORY/releases/latest/download" + # Each app's newest build, from the manifest: apps that didn't change + # keep the version they had. { - echo "Every app, built from ${GITHUB_SHA::7} on main. All four are version **$VERSION_NAME**." + echo "The newest build of every app, as of ${GITHUB_SHA::7} on main." echo - echo "| App | Install |" - echo "|---|---|" - echo "| System | [system.apk]($base/system.apk) |" - echo "| Inbox | [inbox.apk]($base/inbox.apk) |" - echo "| Device Reporter | [device-reporter.apk]($base/device-reporter.apk) |" - echo "| Doughmination Manager | [doughmination-manager.apk]($base/doughmination-manager.apk) |" + echo "| App | Version | Install | Changed in this build |" + echo "|---|---|---|---|" + jq -r --arg base "$base" --rawfile changed dist/publish.txt '.apps[] | + (.latest.apkUrl | split("/") | last) as $f | + "| \(.name) | \(.latest.versionName) | [\($f)](\($base)/\($f)) | \(if ($changed | split("\n") | index($f)) then "yes" else "" end) |"' \ + dist/manifest.json echo echo "These links always point at the newest build. Each installs over any earlier build of that app." + echo "Doughmination Manager reads [manifest.json]($base/manifest.json) to find and verify them." } > notes.md - gh release delete latest --cleanup-tag --yes || true - gh release create latest dist/*.apk dist/versions.json \ - --target "$GITHUB_SHA" \ - --title "Doughmination apps $VERSION_NAME" \ - --notes-file notes.md \ - --latest - - # Installs the System app on an emulator, walks its screens and screenshots - # each one; fails if it won't install, launch, or show what it should. + cat notes.md + mapfile -t changed < dist/publish.txt + # Updated in place rather than deleted and recreated, so the links + # never 404 between builds. Only changed apps' APKs are replaced. + # manifest.json goes up last: until it does, the Manager still sees + # the previous build, and a download that catches an APK mid-swap + # fails its checksum and is retried. + if gh release view latest > /dev/null 2>&1; then + if [ ${#changed[@]} -gt 0 ]; then + gh release upload latest "${changed[@]/#/dist/}" --clobber + fi + gh release upload latest dist/manifest.json --clobber + # Raw build facts; the manifest replaced it. + gh release delete-asset latest versions.json --yes 2>/dev/null || true + git tag -f latest "$GITHUB_SHA" + git push -f origin refs/tags/latest + gh release edit latest --title "Doughmination apps" --notes-file notes.md --latest + else + gh release create latest dist/*.apk dist/manifest.json \ + --target "$GITHUB_SHA" \ + --title "Doughmination apps" \ + --notes-file notes.md \ + --latest + fi + + # On an emulator: installs the System app and walks its screens, then has the + # Manager download, verify and install an app from the rolling release. + # Screenshots of each step are attached to the run. smoke: needs: build runs-on: ubuntu-latest @@ -157,7 +198,11 @@ jobs: profile: pixel_6 disable-animations: true emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - script: bash .github/scripts/plural-smoke.sh dist/system.apk smoke + # System app first (its own build), then the Manager installing Inbox + # from the published release. + script: | + bash .github/scripts/plural-smoke.sh dist/system.apk smoke/system + bash .github/scripts/manager-e2e.sh dist/doughmination-manager.apk smoke/manager - uses: actions/upload-artifact@v4 if: always() diff --git a/README.md b/README.md index 96133cd..d3037c1 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,9 @@ own repositories (the mail server is [doughmination/email](https://github.com/do | Doughmination Manager | [`doughmination-manager/`](doughmination-manager/) | `:doughmination-manager:app` | `gay.doughmination.manager` | | System (Plural) | [`plural/`](plural/) | `:plural:app` | `gay.doughmination.plural` | -Doughmination Manager is a placeholder for now: it builds and runs, and -nothing else. Plural is the System app; see [plural/README.md](plural/README.md). +Doughmination Manager installs and updates the others (and itself) from the +rolling release; see [doughmination-manager/README.md](doughmination-manager/README.md). +Plural is the System app; see [plural/README.md](plural/README.md). ## Layout diff --git a/docs/manifest.md b/docs/manifest.md index 10b51cb..b3925e7 100644 --- a/docs/manifest.md +++ b/docs/manifest.md @@ -1,86 +1,83 @@ -# Doughmination Manager manifest (proposed, v1) +# Doughmination Manager manifest (v1) -**Status: proposal.** Nothing serves this yet and the Manager doesn't read it -yet. Where it's hosted, and what builds it, is up to the existing backend; this -only fixes the shape so the app and whatever produces it agree. +Doughmination Manager reads one JSON document: -The Manager fetches one JSON document from a fixed URL. Publishing a new -version of any app means updating that document; the Manager itself never needs -rebuilding for it. +https://github.com/doughmination/apps/releases/latest/download/manifest.json + +CI writes it (`.github/scripts/make-manifest.sh`) on every push to `main` and +attaches it to the rolling `latest` release, last, after the APKs. Publishing a +new build of any app never needs a new Manager. ```json { "schemaVersion": 1, - "generatedAt": "2026-11-02T12:00:00Z", + "generatedAt": "2026-11-02T20:00:00Z", + "commit": "0b6f8154…", "apps": [ { "id": "inbox", "packageName": "gay.doughmination.mail", "name": "Inbox", - "summary": "Mail for every doughmination domain.", - "iconUrl": "https://…/inbox.png", - "signingCertSha256": ["AB:CD:…"], + "summary": "Mail for every Doughmination domain.", + "signingCertSha256": ["79:7D:66:…"], "latest": { - "versionCode": 112, - "versionName": "1.2.0", - "apkUrl": "https://…/inbox-1.2.0.apk", + "versionCode": 1012, + "versionName": "2.1.12", + "apkUrl": "https://github.com/doughmination/apps/releases/latest/download/inbox.apk", "sha256": "…", "sizeBytes": 8123456, "minSdk": 26, - "releasedAt": "2026-11-02T12:00:00Z", - "releaseNotes": "Markdown.", - "releaseUrl": "https://github.com/doughmination/apps/releases/tag/inbox-v1.2.0" + "releasedAt": "2026-11-02T20:00:00Z", + "releaseNotes": "- Commit subjects since the previous release that touched email/", + "releaseUrl": "https://github.com/doughmination/apps/releases/tag/latest" } } ] } ``` -## Fields +## Where each value comes from -| Field | Required | Notes | -|---|---|---| -| `schemaVersion` | yes | `1`. The Manager refuses a major version it doesn't know. | -| `apps[].id` | yes | Stable, URL-safe. Never reused. | -| `apps[].packageName` | yes | The applicationId; how the Manager finds the installed copy. | -| `apps[].name`, `summary`, `iconUrl` | name yes | For the list. | -| `apps[].signingCertSha256` | yes | SHA-256 of the signing certificate(s), as `apksigner verify --print-certs` prints them. The Manager won't install an APK signed by anything else. | -| `latest.versionCode` | yes | Compared with the installed `longVersionCode`. | -| `latest.versionName` | yes | Shown to people. | -| `latest.apkUrl` | yes | HTTPS. | -| `latest.sha256`, `sizeBytes` | yes | Checked after download, before installing. | -| `latest.minSdk` | no | Lets the Manager hide apps the phone can't run. | -| `latest.releasedAt`, `releaseNotes`, `releaseUrl` | no | Shown on the app's page. | +| Field | Source | +|---|---| +| `packageName`, `versionCode`, `versionName`, `minSdk` | Read from the built APK with `aapt2 dump badging`. The build fails if they differ from the version CI meant to build. | +| `sha256`, `sizeBytes` | The APK file as uploaded. | +| `signingCertSha256` | Pinned in `.github/apps.json`. On `main` the build fails if the APK's certificate (from `apksigner`) isn't that one. | +| `id`, `name`, `summary` | `.github/apps.json`. | +| `releaseNotes` | `git log` of the app's directory since its own previous release. | +| `sourceHash` | Fingerprint of the app's directory plus the shared build files. When it matches the published one, CI keeps the published entry and APK instead (docs/releasing.md). | +| `sourceCommit` | The commit the app was last built from; its next release notes start there. | +| `commit` | The commit this manifest was written from. | -The Manager lists itself in `apps` like any other app, which is how it updates -itself. +CI also refuses to publish if a changed app's `versionCode` would not be +higher than the one in the manifest already published, so a re-run of an old build can +never roll anything back. -## Where the values come from +## What the Manager does with it -Every release CI publishes carries `-.json` beside the APK -(see [releasing.md](releasing.md)): +- Refuses any `schemaVersion` other than 1, and drops entries without an HTTPS + URL, a 64-hex `sha256`, a size, or a certificate. +- Compares `latest.versionCode` with the installed `longVersionCode`: lower is + an update, equal is up to date, higher is "newer than the release" (a local + build); nothing is ever downgraded. +- Shows "different signing key" (with an uninstall button) when the installed + copy's certificate isn't in `signingCertSha256`: Android won't update it in + place. +- After downloading (resumable; files are named by version code), checks size + and SHA-256, then reads the APK with PackageManager and checks its package + name, version code and certificate against the manifest. Only then does it + hand it to PackageInstaller, where Android asks the person to confirm. +- Keeps the last good manifest, so the list still shows offline. -```json -{ - "packageName": "gay.doughmination.mail", - "versionCode": 112, - "versionName": "1.2.0", - "file": "inbox-1.2.0.apk", - "sha256": "…", - "sizeBytes": 8123456, - "commit": "…" -} -``` +The Manager lists itself, which is how it updates itself. -That covers `latest` apart from the URLs and notes. The rest (`name`, `summary`, -`iconUrl`, `signingCertSha256`) changes rarely and can sit in whatever -generates the manifest. +Adding an app: add it to `build-apps.sh`, to `.github/apps.json`, and to the +`` list in the Manager's `AndroidManifest.xml` (Android 11+ hides +other packages otherwise). -## Open questions +## Not in v1 -- Where the manifest is served from, and whether APKs stay on GitHub Releases. -- Whether the manifest should be signed (e.g. a detached signature checked - against a key built into the Manager). The per-app certificate check already - stops a tampered manifest from installing a foreign APK, but not from - offering an old version. -- Release channels (beta) are left out of v1. +- A signature over the manifest. The certificate check already stops a + tampered manifest from installing a foreign APK, and version codes never go + down, but a tampered manifest could still withhold an update. +- Release channels (beta). diff --git a/docs/releasing.md b/docs/releasing.md index 2ac7886..9c31932 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,16 +1,29 @@ # Releasing -One workflow, `.github/workflows/apps.yml`, builds and releases every app -together. There is one release, **`latest`**, and it always holds the newest -build of all four apps. +One workflow, `.github/workflows/apps.yml`, builds and releases every app. +There is one release, **`latest`**, and it always holds the newest build of +each app. It's updated in place, never one release per commit. ## What happens on a push -- **Every push to `main`** builds all four apps, each signed with its own key, - checks the signatures, and replaces the `latest` release with them. Then it - installs the System app on an emulator and walks its screens - (`.github/scripts/plural-smoke.sh`); screenshots are attached to the run. -- **Pull requests** build and run the emulator test, and publish nothing. +- **Every push to `main`**: + 1. runs the unit tests; + 2. builds all four apps, each signed with its own key, and reads each APK + back (`aapt2`, `apksigner`): the build fails if an APK's version isn't the + one this build set, or its certificate isn't the one pinned in + `.github/apps.json`; + 3. writes `manifest.json` for Doughmination Manager ([manifest.md](manifest.md)). + Apps whose sources changed get the new build; the rest keep their + published one (see Versions). It fails if a changed app's version code + wouldn't be higher than the published one; + 4. uploads the changed apps' APKs and, last, `manifest.json` to the + `latest` release, and moves the `latest` tag to the commit; + 5. on an emulator, walks the System app's screens + (`.github/scripts/plural-smoke.sh`), then has the Manager download, + verify and install Inbox from the published release, checking the + installed version code matches the manifest (`.github/scripts/manager-e2e.sh`). + Screenshots of each step are attached to the run. +- **Pull requests** build, test and run the emulator checks, and publish nothing. The release's files keep the same names, so these links always give the newest build: @@ -22,26 +35,38 @@ newest build: | Device Reporter | https://github.com/doughmination/apps/releases/latest/download/device-reporter.apk | | Doughmination Manager | https://github.com/doughmination/apps/releases/latest/download/doughmination-manager.apk | -Next to them, `versions.json` lists each APK's package name, version, SHA-256 -and size, for the Manager's manifest ([manifest.md](manifest.md)). The mail -site's `/download` sends people to `inbox.apk`. +Next to them is `manifest.json`, what the Manager reads. The mail site's +`/download` sends people to `inbox.apk`. ## Versions -Every app carries the same version: +Every app follows the same scheme, but moves only when it changes: ``` versionName = . e.g. 2.1.14 versionCode = 1000 + ``` -`appVersion` lives in `gradle.properties`; change it there for a new minor or -major version (`2.2`, `3.0`). The build number is the `apps.yml` run number, so -it only ever goes up, and so do version codes, which is what lets each build +The build number is the `apps.yml` run that last changed that app. So after a +push that only touches `plural/`, the System app becomes 2.1.15 while Inbox +stays at, say, 2.1.9, and the Manager offers only the System update. + +"Changed" means the app's fingerprint changed: a hash of its directory plus +the shared build files (`gradle/`, `build.gradle.kts`, `settings.gradle.kts`, +`gradle.properties`, `gradlew`). Changing a shared file, or `appVersion`, +releases every app. The fingerprint is stored in the manifest (`sourceHash`). + +There is one source of truth: CI computes the version once (`appVersion` from +`gradle.properties` and the run number) and passes it to Gradle. Every other +place a version appears (the APK, `manifest.json`, the release notes, what +the Manager shows) is read from the built APK, never typed in again. + +Change `appVersion` for a new minor or major version (`2.2`, `3.0`). Run +numbers only go up, so version codes do too, which is what lets each build install over the last. (1000 is above every code released before the apps -shared a version; the highest was 107.) **If `apps.yml` is ever renamed or -recreated, its run numbers restart: raise the 1000 in it above the last -release's code first.** +shared a scheme; the highest was 107.) **If `apps.yml` is ever renamed or +recreated, its run numbers restart: raise the 1000 in it above the highest +published code first.** The manifest step refuses to publish otherwise. Local builds use `.0` and code 1, so they never install over a release. diff --git a/doughmination-manager/README.md b/doughmination-manager/README.md new file mode 100644 index 0000000..30a34d8 --- /dev/null +++ b/doughmination-manager/README.md @@ -0,0 +1,51 @@ +# Doughmination Manager + +Installs and updates the Doughmination Android apps (System, Inbox, Device +Reporter, and itself) from the rolling `latest` release, in the spirit of +ReVanced Manager. Native Kotlin and Jetpack Compose. + +## What it does + +- Lists every app in the release's [`manifest.json`](../docs/manifest.md), + with the installed version (as Android reports it) next to the published one. +- Marks each app: not installed, up to date, update available, newer than the + release, signed with a different key, or needs a newer Android. +- Downloads APKs into its cache. An interrupted download resumes from where it + stopped, and a cancelled one can be resumed later. +- Checks every download before installing it: size and SHA-256 against the + manifest, then the APK's package name, version code and signing certificate + as Android reads them. Anything that doesn't match is deleted, not installed. +- Installs with Android's `PackageInstaller`. Android asks once to allow the + Manager as an install source, and asks the person to confirm every install; + the Manager never gets around either. +- For a copy signed with an older key (Inbox and Device Reporter from before + the move), it explains why it can't update and opens Android's uninstall prompt. +- Each app's page shows the release notes (the commits that touched it since + the last release), size, build time, SHA-256 and signing certificate. +- Keeps the last list that loaded, so it still opens offline. + +## Layout + +``` +app/src/main/java/gay/doughmination/manager/ + MainActivity.kt re-reads installed apps on every resume + data/Manifest.kt manifest model and validation + data/Status.kt installed vs published -> status (unit tested) + data/ManifestSource.kt fetch + last-good cache + data/Packages.kt installed packages and APK inspection + data/Downloader.kt resumable download, size + SHA-256 check + install/Installer.kt PackageInstaller session and status receiver + ui/ ViewModel, list and detail screens, theme +``` + +## Building + +```sh +./gradlew :doughmination-manager:app:testDebugUnitTest +./gradlew :doughmination-manager:app:assembleDebug +``` + +`-PmanifestUrl=https://…/manifest.json` points a build at another manifest. + +A debug build is signed with the debug key, so it shows itself as "different +signing key"; it can still install the other apps. diff --git a/doughmination-manager/app/build.gradle.kts b/doughmination-manager/app/build.gradle.kts index 11fa2d2..40ecac3 100644 --- a/doughmination-manager/app/build.gradle.kts +++ b/doughmination-manager/app/build.gradle.kts @@ -2,15 +2,22 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) } +// Where the Manager reads what's available: the manifest CI attaches to the +// rolling `latest` release (docs/manifest.md). -PmanifestUrl=https://... points +// a build elsewhere, e.g. for testing a manifest by hand. +val manifestUrl = (findProperty("manifestUrl") as String?) + ?: "https://github.com/doughmination/apps/releases/latest/download/manifest.json" + android { namespace = "gay.doughmination.manager" compileSdk = libs.versions.compileSdk.get().toInt() defaultConfig { - // Not released yet, so this ID can still change. Once an APK ships, - // it (and the signing key) must stay the same for updates to install. + // Released: this ID (and the signing key) must never change, or the + // Manager can't update itself over what people have. applicationId = "gay.doughmination.manager" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() @@ -18,6 +25,7 @@ android { // local builds use the fallbacks. versionCode = System.getenv("VERSION_CODE")?.toInt() ?: 1 versionName = System.getenv("VERSION_NAME") ?: "${project.property("appVersion")}.0" + buildConfigField("String", "MANIFEST_URL", "\"$manifestUrl\"") } // Same convention as Device Reporter: CI passes the key through SIGNING_* @@ -43,6 +51,7 @@ android { buildFeatures { compose = true + buildConfig = true } compileOptions { @@ -58,10 +67,18 @@ android { dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.tooling.preview) debugImplementation(libs.androidx.compose.ui.tooling) implementation(libs.androidx.core.ktx) implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.serialization.json) + implementation(libs.okhttp) + + testImplementation(libs.junit) } diff --git a/doughmination-manager/app/src/main/AndroidManifest.xml b/doughmination-manager/app/src/main/AndroidManifest.xml index 8dbf531..5a71a4f 100644 --- a/doughmination-manager/app/src/main/AndroidManifest.xml +++ b/doughmination-manager/app/src/main/AndroidManifest.xml @@ -1,20 +1,43 @@ + + + + + + + + + + + + + + + android:exported="true" + android:launchMode="singleTop"> + + + diff --git a/doughmination-manager/app/src/main/java/gay/doughmination/manager/MainActivity.kt b/doughmination-manager/app/src/main/java/gay/doughmination/manager/MainActivity.kt index 074aed8..9b25f36 100644 --- a/doughmination-manager/app/src/main/java/gay/doughmination/manager/MainActivity.kt +++ b/doughmination-manager/app/src/main/java/gay/doughmination/manager/MainActivity.kt @@ -1,41 +1,32 @@ package gay.doughmination.manager +import android.graphics.Color import android.os.Bundle import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.lightColorScheme -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import androidx.activity.viewModels +import gay.doughmination.manager.ui.ManagerApp +import gay.doughmination.manager.ui.ManagerTheme +import gay.doughmination.manager.ui.ManagerViewModel -/** Placeholder screen until the app's real features are designed. */ class MainActivity : ComponentActivity() { + private val vm: ManagerViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { + // The app is always dark, so the system bars get light icons. + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), + navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), + ) super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContent { - MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) { - Placeholder() - } - } + setContent { ManagerTheme { ManagerApp(vm) } } } -} -@Composable -private fun Placeholder() { - Scaffold { padding -> - Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { - Text(stringResource(R.string.app_name), style = MaterialTheme.typography.headlineMedium) - } + override fun onResume() { + super.onResume() + // Something may have been installed, updated or removed meanwhile. + vm.refreshInstalled() } } diff --git a/doughmination-manager/app/src/main/java/gay/doughmination/manager/data/Downloader.kt b/doughmination-manager/app/src/main/java/gay/doughmination/manager/data/Downloader.kt new file mode 100644 index 0000000..5e09839 --- /dev/null +++ b/doughmination-manager/app/src/main/java/gay/doughmination/manager/data/Downloader.kt @@ -0,0 +1,129 @@ +package gay.doughmination.manager.data + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.security.MessageDigest + +class DownloadException(message: String, val retryable: Boolean = true) : Exception(message) + +/** + * Downloads an APK into the app's cache, resuming an interrupted download + * where it stopped, and only returns a file whose size and SHA-256 match the + * manifest. Nothing unverified is ever handed to the installer. + */ +class Downloader(private val http: OkHttpClient, private val dir: File) { + + /** The file a release downloads to; named by version so a newer release never resumes an older one. */ + fun target(app: ManifestApp): File = File(dir, "${app.packageName}-${app.latest.versionCode}.apk") + + suspend fun download(app: ManifestApp, onProgress: (done: Long, total: Long) -> Unit): File = + withContext(Dispatchers.IO) { + dir.mkdirs() + // Downloads for other versions of this app are stale now. + dir.listFiles()?.filter { + it.name.startsWith("${app.packageName}-") && !it.name.startsWith(target(app).name) + }?.forEach { it.delete() } + + val release = app.latest + val done = target(app) + if (done.exists() && done.length() == release.sizeBytes && Hashes.sha256(done) == release.sha256.lowercase()) { + onProgress(release.sizeBytes, release.sizeBytes) + return@withContext done + } + done.delete() + + val part = File(dir, done.name + ".part") + if (part.length() > release.sizeBytes) part.delete() + fetch(release, part, onProgress) + + if (part.length() != release.sizeBytes) { + part.delete() + throw DownloadException( + "The download was ${part.length()} bytes, not ${release.sizeBytes}. " + + "The release may have changed; refresh and try again.", + ) + } + onProgress(release.sizeBytes, release.sizeBytes) + val actual = Hashes.sha256(part) + if (actual != release.sha256.lowercase()) { + part.delete() + throw DownloadException( + "The download doesn't match its SHA-256 checksum, so it wasn't installed. " + + "The release may have changed while downloading; refresh and try again.", + ) + } + if (!part.renameTo(done)) throw DownloadException("Couldn't save the download.") + done + } + + private suspend fun fetch(release: Release, part: File, onProgress: (Long, Long) -> Unit) { + val have = part.length() + val request = Request.Builder().url(release.apkUrl).apply { + if (have > 0) header("Range", "bytes=$have-") + }.build() + try { + http.newCall(request).execute().use { res -> + val append = when (res.code) { + 206 -> true + 200 -> false // the server ignored the range: start over + 416 -> { part.delete(); throw DownloadException("The download couldn't resume; try again.") } + 404 -> throw DownloadException("The APK isn't on the release any more; refresh the list.") + else -> throw DownloadException("The download failed (HTTP ${res.code}).") + } + val body = res.body ?: throw DownloadException("The download was empty.") + var written = if (append) have else 0L + FileOutputStream(part, append).use { out -> + copy(body.byteStream(), out) { n -> + written += n + if (written > release.sizeBytes) { + throw DownloadException("The download is bigger than the release says; refresh and try again.") + } + onProgress(written, release.sizeBytes) + } + } + } + } catch (e: IOException) { + // The partial file stays, so the next try picks up from here. + throw DownloadException("The download was interrupted. Try again to resume it.") + } + } + + private suspend fun copy(input: InputStream, out: FileOutputStream, onChunk: (Int) -> Unit) { + val buffer = ByteArray(64 * 1024) + while (true) { + kotlin.coroutines.coroutineContext.ensureActive() + val n = input.read(buffer) + if (n < 0) break + out.write(buffer, 0, n) + onChunk(n) + } + } + + /** Removes a release's download once it's installed (or abandoned). */ + fun discard(app: ManifestApp) { + target(app).delete() + File(dir, target(app).name + ".part").delete() + } +} + +object Hashes { + fun sha256(file: File): String = file.inputStream().use { sha256(it) } + + fun sha256(input: InputStream): String { + val digest = MessageDigest.getInstance("SHA-256") + val buffer = ByteArray(64 * 1024) + while (true) { + val n = input.read(buffer) + if (n < 0) break + digest.update(buffer, 0, n) + } + return digest.digest().joinToString("") { "%02x".format(it) } + } +} diff --git a/doughmination-manager/app/src/main/java/gay/doughmination/manager/data/Manifest.kt b/doughmination-manager/app/src/main/java/gay/doughmination/manager/data/Manifest.kt new file mode 100644 index 0000000..a9631d3 --- /dev/null +++ b/doughmination-manager/app/src/main/java/gay/doughmination/manager/data/Manifest.kt @@ -0,0 +1,83 @@ +package gay.doughmination.manager.data + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** + * The document CI publishes beside the APKs (docs/manifest.md). Every version, + * hash and size in it was read back from the built APK, so it describes + * exactly what the download will be. + */ +@Serializable +data class Manifest( + val schemaVersion: Int, + val generatedAt: String? = null, + val commit: String? = null, + val apps: List = emptyList(), +) + +@Serializable +data class ManifestApp( + val id: String, + val packageName: String, + val name: String, + val summary: String? = null, + val iconUrl: String? = null, + /** As apksigner prints them; colons and case don't matter. */ + val signingCertSha256: List = emptyList(), + val latest: Release, +) + +@Serializable +data class Release( + val versionCode: Long, + val versionName: String, + val apkUrl: String, + val sha256: String, + val sizeBytes: Long, + val minSdk: Int? = null, + val releasedAt: String? = null, + val releaseNotes: String? = null, + val releaseUrl: String? = null, +) + +class ManifestException(message: String) : Exception(message) + +object ManifestParser { + const val SCHEMA_VERSION = 1 + + private val json = Json { ignoreUnknownKeys = true; coerceInputValues = true } + private val hex64 = Regex("^[0-9a-fA-F]{64}$") + + /** + * Parses and checks a manifest. Apps with unusable entries (no HTTPS URL, + * a malformed hash, no certificate) are dropped rather than offered. + */ + fun parse(text: String): Manifest { + val manifest = try { + json.decodeFromString(text) + } catch (e: Exception) { + throw ManifestException("The app list is damaged or in an unknown format.") + } + if (manifest.schemaVersion != SCHEMA_VERSION) { + throw ManifestException( + "The app list uses format ${manifest.schemaVersion}; this Manager reads format $SCHEMA_VERSION. " + + "Update the Manager from the website.", + ) + } + return manifest.copy(apps = manifest.apps.filter(::usable).distinctBy { it.packageName }) + } + + fun encode(manifest: Manifest): String = json.encodeToString(Manifest.serializer(), manifest) + + private fun usable(app: ManifestApp): Boolean = with(app.latest) { + apkUrl.startsWith("https://") && + hex64.matches(sha256) && + sizeBytes > 0 && + versionCode > 0 && + app.signingCertSha256.any { normaliseDigest(it).length == 64 } + } +} + +/** "AB:cd:…" → "abcd…", so digests from apksigner and from Android compare equal. */ +fun normaliseDigest(digest: String): String = digest.replace(":", "").trim().lowercase() diff --git a/doughmination-manager/app/src/main/java/gay/doughmination/manager/data/ManifestSource.kt b/doughmination-manager/app/src/main/java/gay/doughmination/manager/data/ManifestSource.kt new file mode 100644 index 0000000..cc6a11b --- /dev/null +++ b/doughmination-manager/app/src/main/java/gay/doughmination/manager/data/ManifestSource.kt @@ -0,0 +1,50 @@ +package gay.doughmination.manager.data + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.CacheControl +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.io.IOException + +/** A manifest, and whether it came from the network just now or from the last good copy. */ +data class LoadedManifest(val manifest: Manifest, val fetchedAt: Long, val fromCache: Boolean) + +/** + * Fetches the manifest, keeping the last good copy so the list still shows + * (marked as cached) when the phone is offline. + */ +class ManifestSource( + private val http: OkHttpClient, + private val url: String, + private val cacheFile: File, +) { + /** The last good manifest, if any; never throws. */ + suspend fun cached(): LoadedManifest? = withContext(Dispatchers.IO) { + runCatching { + LoadedManifest(ManifestParser.parse(cacheFile.readText()), cacheFile.lastModified(), fromCache = true) + }.getOrNull() + } + + /** Fetches a fresh manifest; throws [ManifestException] with a message fit to show. */ + suspend fun fetch(): LoadedManifest = withContext(Dispatchers.IO) { + val request = Request.Builder().url(url).cacheControl(CacheControl.FORCE_NETWORK).build() + val text = try { + http.newCall(request).execute().use { res -> + if (res.code == 404) throw ManifestException("No apps have been published yet.") + if (!res.isSuccessful) throw ManifestException("The app list couldn't be loaded (HTTP ${res.code}).") + res.body?.string() ?: throw ManifestException("The app list was empty.") + } + } catch (e: IOException) { + throw ManifestException("Couldn't reach GitHub. Check your connection.") + } + val manifest = ManifestParser.parse(text) + runCatching { + val tmp = File(cacheFile.parentFile, cacheFile.name + ".tmp") + tmp.writeText(text) + tmp.renameTo(cacheFile) + } + LoadedManifest(manifest, System.currentTimeMillis(), fromCache = false) + } +} diff --git a/doughmination-manager/app/src/main/java/gay/doughmination/manager/data/Packages.kt b/doughmination-manager/app/src/main/java/gay/doughmination/manager/data/Packages.kt new file mode 100644 index 0000000..69ac8c3 --- /dev/null +++ b/doughmination-manager/app/src/main/java/gay/doughmination/manager/data/Packages.kt @@ -0,0 +1,66 @@ +package gay.doughmination.manager.data + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.content.pm.Signature +import android.os.Build +import androidx.core.content.pm.PackageInfoCompat +import java.io.File +import java.security.MessageDigest + +/** Reads installed packages and downloaded APKs through PackageManager. */ +class Packages(private val context: Context) { + private val pm: PackageManager get() = context.packageManager + + @Suppress("DEPRECATION") + private val signingFlags: Int = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) PackageManager.GET_SIGNING_CERTIFICATES + else PackageManager.GET_SIGNATURES + + /** The installed copy of [packageName], or null. Needs the package in the manifest's . */ + fun installed(packageName: String): InstalledApp? { + val info = try { + @Suppress("DEPRECATION") + pm.getPackageInfo(packageName, signingFlags) + } catch (e: PackageManager.NameNotFoundException) { + return null + } + return info.toInstalled() + } + + /** + * What a downloaded APK really is: its package, version and signer, read + * from the file itself. Null when Android can't parse it. + */ + fun inspectArchive(apk: File): InstalledApp? { + @Suppress("DEPRECATION") + val info = pm.getPackageArchiveInfo(apk.absolutePath, signingFlags) ?: return null + return info.toInstalled() + } + + fun launchIntent(packageName: String): Intent? = pm.getLaunchIntentForPackage(packageName) + + private fun PackageInfo.toInstalled() = InstalledApp( + packageName = packageName, + versionCode = PackageInfoCompat.getLongVersionCode(this), + versionName = versionName, + signingCerts = signers().map(::digest).toSet(), + ) + + @Suppress("DEPRECATION") + private fun PackageInfo.signers(): List = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val info = signingInfo ?: return emptyList() + // With key rotation, any certificate in the lineage is the same app. + if (info.hasMultipleSigners()) info.apkContentsSigners.toList() + else info.signingCertificateHistory.toList() + } else { + signatures?.toList().orEmpty() + } + + private fun digest(signature: Signature): String = + MessageDigest.getInstance("SHA-256").digest(signature.toByteArray()) + .joinToString("") { "%02x".format(it) } +} diff --git a/doughmination-manager/app/src/main/java/gay/doughmination/manager/data/Status.kt b/doughmination-manager/app/src/main/java/gay/doughmination/manager/data/Status.kt new file mode 100644 index 0000000..26f4d89 --- /dev/null +++ b/doughmination-manager/app/src/main/java/gay/doughmination/manager/data/Status.kt @@ -0,0 +1,51 @@ +package gay.doughmination.manager.data + +/** What Android says is installed for a package. */ +data class InstalledApp( + val packageName: String, + val versionCode: Long, + val versionName: String?, + /** SHA-256 of each signing certificate, normalised (see [normaliseDigest]). */ + val signingCerts: Set, +) + +/** Where an app stands, comparing what's installed with the manifest. */ +enum class AppStatus { + /** Not installed, and the phone can run it. */ + NotInstalled, + /** Installed at the published version. */ + UpToDate, + /** The published build is newer than the installed one. */ + UpdateAvailable, + /** The installed build is newer than the published one (a local or test build). */ + InstalledNewer, + /** + * Installed, but signed with a different key (e.g. a copy from before the + * apps got new keys). Android won't update it in place; it must be + * uninstalled first. + */ + SignatureMismatch, + /** The published build needs a newer Android than this phone has. */ + Incompatible, +} + +object Status { + fun of(app: ManifestApp, installed: InstalledApp?, deviceSdk: Int): AppStatus { + val expected = app.signingCertSha256.map(::normaliseDigest).toSet() + if (installed != null && expected.isNotEmpty() && installed.signingCerts.none { it in expected }) { + return AppStatus.SignatureMismatch + } + val runsHere = (app.latest.minSdk ?: 0) <= deviceSdk + return when { + installed == null -> if (runsHere) AppStatus.NotInstalled else AppStatus.Incompatible + installed.versionCode == app.latest.versionCode -> AppStatus.UpToDate + installed.versionCode > app.latest.versionCode -> AppStatus.InstalledNewer + !runsHere -> AppStatus.Incompatible + else -> AppStatus.UpdateAvailable + } + } + + /** True when the Manager should offer to download and install the published build. */ + fun canInstall(status: AppStatus): Boolean = + status == AppStatus.NotInstalled || status == AppStatus.UpdateAvailable +} diff --git a/doughmination-manager/app/src/main/java/gay/doughmination/manager/install/Installer.kt b/doughmination-manager/app/src/main/java/gay/doughmination/manager/install/Installer.kt new file mode 100644 index 0000000..8eda03e --- /dev/null +++ b/doughmination-manager/app/src/main/java/gay/doughmination/manager/install/Installer.kt @@ -0,0 +1,138 @@ +package gay.doughmination.manager.install + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import android.os.Build +import android.provider.Settings +import android.net.Uri +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.withContext +import java.io.File + +/** How an install ended, as PackageInstaller reported it. */ +sealed interface InstallResult { + val packageName: String + data class Success(override val packageName: String) : InstallResult + data class Failure(override val packageName: String, val message: String, val cancelled: Boolean) : InstallResult +} + +/** + * Installs verified APKs with Android's PackageInstaller. Android shows its + * own confirmation for every install, and only lets this app install at all + * once the person has allowed it as a source ("Install unknown apps"). + */ +class Installer(private val context: Context) { + + /** Whether the person has allowed this app to install apps. */ + fun canInstall(): Boolean = context.packageManager.canRequestPackageInstalls() + + /** The system screen where the person can allow it. */ + fun permissionSettings(): Intent = + Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}")) + + /** The system's uninstall prompt for [packageName]. */ + fun uninstallIntent(packageName: String): Intent = + Intent(Intent.ACTION_DELETE, Uri.parse("package:$packageName")) + + /** Starts installing [apk]; the result arrives on [results]. */ + suspend fun install(apk: File, packageName: String) = withContext(Dispatchers.IO) { + val installer = context.packageManager.packageInstaller + val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply { + setAppPackageName(packageName) + setSize(apk.length()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + setInstallReason(android.content.pm.PackageManager.INSTALL_REASON_USER) + } + } + val sessionId = installer.createSession(params) + val session = installer.openSession(sessionId) + try { + session.openWrite("base.apk", 0, apk.length()).use { out -> + apk.inputStream().use { it.copyTo(out) } + session.fsync(out) + } + val intent = Intent(context, InstallReceiver::class.java) + .setAction(ACTION) + .putExtra(EXTRA_PACKAGE, packageName) + // Mutable: PackageInstaller fills in the status extras. + val flags = PendingIntent.FLAG_UPDATE_CURRENT or + (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0) + val pending = PendingIntent.getBroadcast(context, sessionId, intent, flags) + session.commit(pending.intentSender) + } catch (e: Exception) { + session.abandon() + throw e + } finally { + session.close() + } + } + + companion object { + const val ACTION = "gay.doughmination.manager.INSTALL_STATUS" + const val EXTRA_PACKAGE = "package" + + private val _results = MutableSharedFlow(extraBufferCapacity = 8) + val results: SharedFlow = _results + + internal fun report(result: InstallResult) { + _results.tryEmit(result) + } + } +} + +/** Receives PackageInstaller's status for each session. */ +class InstallReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != Installer.ACTION) return + val packageName = intent.getStringExtra(Installer.EXTRA_PACKAGE) ?: return + val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE) + when (status) { + PackageInstaller.STATUS_PENDING_USER_ACTION -> { + // Android's own confirmation screen; the person decides. + @Suppress("DEPRECATION") + val confirm = if (Build.VERSION.SDK_INT >= 33) { + intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java) + } else { + intent.getParcelableExtra(Intent.EXTRA_INTENT) + } + if (confirm == null) { + Installer.report(InstallResult.Failure(packageName, "Android didn't ask for confirmation.", false)) + return + } + confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + try { + context.startActivity(confirm) + } catch (e: Exception) { + Installer.report(InstallResult.Failure(packageName, "Couldn't show the install prompt: ${e.message}", false)) + } + } + PackageInstaller.STATUS_SUCCESS -> Installer.report(InstallResult.Success(packageName)) + else -> { + val detail = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) + Installer.report( + InstallResult.Failure( + packageName, + describe(status, detail), + cancelled = status == PackageInstaller.STATUS_FAILURE_ABORTED, + ), + ) + } + } + } + + private fun describe(status: Int, detail: String?): String = when (status) { + PackageInstaller.STATUS_FAILURE_ABORTED -> "Install cancelled." + PackageInstaller.STATUS_FAILURE_BLOCKED -> "Android blocked the install." + PackageInstaller.STATUS_FAILURE_CONFLICT -> + "It conflicts with the installed copy (usually a different signing key). Uninstall that first." + PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> "This phone can't run that build." + PackageInstaller.STATUS_FAILURE_INVALID -> "Android says the APK is invalid." + PackageInstaller.STATUS_FAILURE_STORAGE -> "There isn't enough storage space." + else -> "The install failed." + } + (detail?.takeIf { it.isNotBlank() }?.let { " ($it)" } ?: "") +} diff --git a/doughmination-manager/app/src/main/java/gay/doughmination/manager/ui/ManagerViewModel.kt b/doughmination-manager/app/src/main/java/gay/doughmination/manager/ui/ManagerViewModel.kt new file mode 100644 index 0000000..7cf3989 --- /dev/null +++ b/doughmination-manager/app/src/main/java/gay/doughmination/manager/ui/ManagerViewModel.kt @@ -0,0 +1,234 @@ +package gay.doughmination.manager.ui + +import android.app.Application +import android.os.Build +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import gay.doughmination.manager.BuildConfig +import gay.doughmination.manager.data.AppStatus +import gay.doughmination.manager.data.DownloadException +import gay.doughmination.manager.data.Downloader +import gay.doughmination.manager.data.InstalledApp +import gay.doughmination.manager.data.LoadedManifest +import gay.doughmination.manager.data.ManifestApp +import gay.doughmination.manager.data.ManifestException +import gay.doughmination.manager.data.ManifestSource +import gay.doughmination.manager.data.Packages +import gay.doughmination.manager.data.Status +import gay.doughmination.manager.data.normaliseDigest +import gay.doughmination.manager.install.InstallResult +import gay.doughmination.manager.install.Installer +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import java.io.File +import java.util.concurrent.TimeUnit + +/** What the Manager is doing for one app right now. */ +sealed interface Task { + data class Downloading(val done: Long, val total: Long) : Task { + val fraction: Float get() = if (total > 0) (done.toFloat() / total).coerceIn(0f, 1f) else 0f + } + data object Verifying : Task + /** Handed to Android; waiting for the person to confirm and Android to finish. */ + data object Installing : Task + data class Failed(val message: String) : Task +} + +/** One app's row: the manifest entry, what's installed, and what follows from both. */ +data class AppRow( + val app: ManifestApp, + val installed: InstalledApp?, + val status: AppStatus, + val task: Task?, + val isSelf: Boolean, +) + +data class UiState( + val loading: Boolean = true, + val manifest: LoadedManifest? = null, + /** Why the latest refresh failed; the cached list (if any) still shows. */ + val error: String? = null, + val rows: List = emptyList(), + /** Ask the person to allow installs from this app. */ + val needsInstallPermission: Boolean = false, + /** A one-off message for the snackbar. */ + val message: String? = null, +) + +class ManagerViewModel(application: Application) : AndroidViewModel(application) { + private val http = OkHttpClient.Builder() + .connectTimeout(20, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .followRedirects(true) + .followSslRedirects(true) + .build() + + private val packages = Packages(application) + private val installer = Installer(application) + private val source = ManifestSource(http, BuildConfig.MANIFEST_URL, File(application.filesDir, "manifest.json")) + private val downloader = Downloader(http, File(application.cacheDir, "downloads")) + + private val _state = MutableStateFlow(UiState()) + val state: StateFlow = _state + + private val tasks = mutableMapOf() + private val jobs = mutableMapOf() + private var installed = mapOf() + + init { + viewModelScope.launch { + source.cached()?.let { cached -> _state.update { it.copy(manifest = cached) }; rebuild() } + refresh() + } + viewModelScope.launch { Installer.results.collect(::onInstallResult) } + } + + /** Fetches the manifest again and re-reads what's installed. */ + fun refresh() { + viewModelScope.launch { + _state.update { it.copy(loading = true) } + try { + val fresh = source.fetch() + _state.update { it.copy(manifest = fresh, error = null) } + } catch (e: ManifestException) { + _state.update { it.copy(error = e.message) } + } + refreshInstalled() + _state.update { it.copy(loading = false) } + } + } + + /** Re-reads installed versions (on resume: something may have been installed or removed meanwhile). */ + fun refreshInstalled() { + viewModelScope.launch { rebuild() } + } + + private suspend fun rebuild() { + val apps = _state.value.manifest?.manifest?.apps.orEmpty() + installed = withContext(Dispatchers.IO) { apps.associate { it.packageName to packages.installed(it.packageName) } } + publishRows() + } + + private fun publishRows() { + val apps = _state.value.manifest?.manifest?.apps.orEmpty() + val self = getApplication().packageName + val rows = apps.map { app -> + val inst = installed[app.packageName] + AppRow(app, inst, Status.of(app, inst, Build.VERSION.SDK_INT), tasks[app.packageName], app.packageName == self) + } + _state.update { it.copy(rows = rows) } + } + + private fun setTask(packageName: String, task: Task?) { + if (task == null) tasks.remove(packageName) else tasks[packageName] = task + publishRows() + } + + /** Downloads, verifies and installs the published build of [app]. */ + fun install(app: ManifestApp) { + if (jobs[app.packageName]?.isActive == true) return + if (!installer.canInstall()) { + _state.update { it.copy(needsInstallPermission = true) } + return + } + jobs[app.packageName] = viewModelScope.launch { + try { + setTask(app.packageName, Task.Downloading(0, app.latest.sizeBytes)) + var lastShown = 0L + val apk = downloader.download(app) { done, total -> + // Every 1% is plenty for a progress bar. + if (done - lastShown >= total / 100 || done == total) { + lastShown = done + viewModelScope.launch { setTask(app.packageName, Task.Downloading(done, total)) } + } + } + setTask(app.packageName, Task.Verifying) + verifyArchive(app, apk) + setTask(app.packageName, Task.Installing) + installer.install(apk, app.packageName) + // The rest happens in onInstallResult. + } catch (e: CancellationException) { + setTask(app.packageName, null) + throw e + } catch (e: DownloadException) { + setTask(app.packageName, Task.Failed(e.message ?: "The download failed.")) + } catch (e: VerificationException) { + downloader.discard(app) + setTask(app.packageName, Task.Failed(e.message ?: "The APK failed verification.")) + } catch (e: Exception) { + setTask(app.packageName, Task.Failed("Couldn't install: ${e.message ?: e.javaClass.simpleName}")) + } + } + } + + /** Stops a download; what's downloaded so far is kept so it can resume. */ + fun cancel(app: ManifestApp) { + jobs.remove(app.packageName)?.cancel() + setTask(app.packageName, null) + } + + fun dismissFailure(app: ManifestApp) = setTask(app.packageName, null) + + private class VerificationException(message: String) : Exception(message) + + /** + * The checksum matched the manifest; now make sure the APK is what the + * manifest says it is, as Android reads it: the right package, version + * and signing key. A mismatch means the manifest and the APK disagree, + * so nothing is installed. + */ + private suspend fun verifyArchive(app: ManifestApp, apk: File) = withContext(Dispatchers.IO) { + val archive = packages.inspectArchive(apk) + ?: throw VerificationException("Android couldn't read the downloaded APK.") + if (archive.packageName != app.packageName) { + throw VerificationException("The APK is ${archive.packageName}, not ${app.packageName}. Not installed.") + } + if (archive.versionCode != app.latest.versionCode) { + throw VerificationException( + "The APK is version code ${archive.versionCode}, but the list says ${app.latest.versionCode}. " + + "Refresh and try again.", + ) + } + val expected = app.signingCertSha256.map(::normaliseDigest).toSet() + if (archive.signingCerts.none { it in expected }) { + throw VerificationException("The APK isn't signed with ${app.name}'s key. Not installed.") + } + } + + private fun onInstallResult(result: InstallResult) { + val app = _state.value.manifest?.manifest?.apps?.firstOrNull { it.packageName == result.packageName } + jobs.remove(result.packageName) + when (result) { + is InstallResult.Success -> { + app?.let(downloader::discard) + setTask(result.packageName, null) + _state.update { it.copy(message = "${app?.name ?: result.packageName} installed.") } + } + is InstallResult.Failure -> { + // A cancelled install keeps its download, so trying again is instant. + if (result.cancelled) { + setTask(result.packageName, null) + _state.update { it.copy(message = result.message) } + } else { + setTask(result.packageName, Task.Failed(result.message)) + } + } + } + refreshInstalled() + } + + fun permissionPromptHandled() = _state.update { it.copy(needsInstallPermission = false) } + fun messageShown() = _state.update { it.copy(message = null) } + fun messageFallback(text: String) = _state.update { it.copy(message = text) } + + val installPermissionIntent get() = installer.permissionSettings() + fun uninstallIntent(app: ManifestApp) = installer.uninstallIntent(app.packageName) + fun launchIntent(app: ManifestApp) = packages.launchIntent(app.packageName) +} diff --git a/doughmination-manager/app/src/main/java/gay/doughmination/manager/ui/Screens.kt b/doughmination-manager/app/src/main/java/gay/doughmination/manager/ui/Screens.kt new file mode 100644 index 0000000..a2293bf --- /dev/null +++ b/doughmination-manager/app/src/main/java/gay/doughmination/manager/ui/Screens.kt @@ -0,0 +1,531 @@ +package gay.doughmination.manager.ui + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import android.text.format.DateUtils +import android.text.format.Formatter +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.CloudOff +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toBitmap +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import gay.doughmination.manager.BuildConfig +import gay.doughmination.manager.data.AppStatus +import gay.doughmination.manager.data.Status +import java.time.Instant + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ManagerApp(vm: ManagerViewModel) { + val state by vm.state.collectAsStateWithLifecycle() + val context = LocalContext.current + val snackbar = remember { SnackbarHostState() } + var openPackage by rememberSaveable { mutableStateOf(null) } + val open = state.rows.firstOrNull { it.app.packageName == openPackage } + + fun start(intent: Intent?) { + if (intent == null) return + try { + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + vm.messageFallback("Nothing on this phone can open that.") + } + } + + LaunchedEffect(state.message) { + state.message?.let { snackbar.showSnackbar(it); vm.messageShown() } + } + + if (state.needsInstallPermission) { + AlertDialog( + onDismissRequest = vm::permissionPromptHandled, + title = { Text("Allow installs") }, + text = { + Text( + "Android needs your permission before the Manager can install apps. " + + "Turn on \"Allow from this source\", then come back and tap install again.", + ) + }, + confirmButton = { + TextButton(onClick = { vm.permissionPromptHandled(); start(vm.installPermissionIntent) }) { + Text("Open settings") + } + }, + dismissButton = { TextButton(onClick = vm::permissionPromptHandled) { Text("Not now") } }, + ) + } + + BackHandler(enabled = open != null) { openPackage = null } + + Scaffold( + snackbarHost = { SnackbarHost(snackbar) }, + topBar = { + TopAppBar( + title = { + Column { + Text(open?.app?.name ?: "Doughmination Manager", maxLines = 1, overflow = TextOverflow.Ellipsis) + if (open == null) Freshness(state) + } + }, + navigationIcon = { + if (open != null) { + IconButton(onClick = { openPackage = null }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + } + } + }, + actions = { + if (open == null) { + IconButton(onClick = vm::refresh, enabled = !state.loading) { + Icon(Icons.Filled.Refresh, "Check for updates") + } + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.background), + ) + }, + ) { padding -> + Box(Modifier.padding(padding).fillMaxSize()) { + if (open != null) { + AppDetail( + row = open, + onInstall = { vm.install(open.app) }, + onCancel = { vm.cancel(open.app) }, + onDismiss = { vm.dismissFailure(open.app) }, + onOpen = { start(vm.launchIntent(open.app)) }, + onUninstall = { start(vm.uninstallIntent(open.app)) }, + onLink = { start(Intent(Intent.ACTION_VIEW, Uri.parse(it))) }, + ) + } else { + AppList( + state = state, + onRefresh = vm::refresh, + onSelect = { openPackage = it.app.packageName }, + onInstall = { vm.install(it.app) }, + onCancel = { vm.cancel(it.app) }, + onOpen = { start(vm.launchIntent(it.app)) }, + onUninstall = { start(vm.uninstallIntent(it.app)) }, + ) + } + } + } +} + +@Composable +private fun Freshness(state: UiState) { + val loaded = state.manifest + val text = when { + state.loading && loaded == null -> "Checking…" + loaded == null -> null + state.error != null || loaded.fromCache -> + "Saved list from ${DateUtils.getRelativeTimeSpanString(loaded.fetchedAt)}" + else -> "Checked ${DateUtils.getRelativeTimeSpanString(loaded.fetchedAt)}" + } + if (text != null) Text(text, style = MaterialTheme.typography.labelMedium, color = Muted) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AppList( + state: UiState, + onRefresh: () -> Unit, + onSelect: (AppRow) -> Unit, + onInstall: (AppRow) -> Unit, + onCancel: (AppRow) -> Unit, + onOpen: (AppRow) -> Unit, + onUninstall: (AppRow) -> Unit, +) { + PullToRefreshBox(isRefreshing = state.loading && state.manifest != null, onRefresh = onRefresh) { + when { + state.manifest == null && state.loading -> Centered { CircularProgressIndicator() } + state.manifest == null -> Centered { + Icon(Icons.Filled.CloudOff, null, tint = Muted, modifier = Modifier.size(48.dp)) + Spacer(Modifier.height(12.dp)) + Text(state.error ?: "Couldn't load the app list.", color = MaterialTheme.colorScheme.onSurface) + Spacer(Modifier.height(16.dp)) + Button(onClick = onRefresh) { Text("Try again") } + } + state.rows.isEmpty() -> Centered { Text("No apps are published yet.", color = Muted) } + else -> LazyColumn( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxSize(), + ) { + if (state.error != null) { + item { Banner(state.error) } + } + val updates = state.rows.count { it.status == AppStatus.UpdateAvailable } + if (updates > 0) { + item { + Text( + if (updates == 1) "1 update available" else "$updates updates available", + style = MaterialTheme.typography.titleSmall, + color = Accent, + ) + } + } + items(state.rows, key = { it.app.packageName }) { row -> + AppCard(row, onSelect, onInstall, onCancel, onOpen, onUninstall) + } + } + } + } +} + +@Composable +private fun Centered(content: @Composable () -> Unit) { + // Scrollable, so pull-to-refresh works on the empty and error states too. + Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { content() } +} + +@Composable +private fun Banner(text: String) { + Card( + colors = CardDefaults.cardColors(containerColor = Warn.copy(alpha = 0.12f)), + shape = RoundedCornerShape(12.dp), + ) { + Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.CloudOff, null, tint = Warn) + Spacer(Modifier.width(12.dp)) + Text("$text Showing the last list that loaded.", style = MaterialTheme.typography.bodyMedium) + } + } +} + +@Composable +private fun AppCard( + row: AppRow, + onSelect: (AppRow) -> Unit, + onInstall: (AppRow) -> Unit, + onCancel: (AppRow) -> Unit, + onOpen: (AppRow) -> Unit, + onUninstall: (AppRow) -> Unit, +) { + Card( + onClick = { onSelect(row) }, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + shape = RoundedCornerShape(16.dp), + ) { + Column(Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + AppIcon(row) + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(row.app.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + if (row.isSelf) { + Spacer(Modifier.width(8.dp)) + Text("this app", style = MaterialTheme.typography.labelSmall, color = Muted) + } + } + Text(versionLine(row), style = MaterialTheme.typography.bodySmall, color = Muted) + Spacer(Modifier.height(4.dp)) + StatusChip(row) + } + Spacer(Modifier.width(8.dp)) + ActionButton(row, onInstall, onCancel, onOpen, onUninstall) + } + TaskLine(row) + } + } +} + +private fun versionLine(row: AppRow): String { + val latest = row.app.latest.versionName + val inst = row.installed ?: return "Latest $latest" + val name = inst.versionName ?: inst.versionCode.toString() + return if (row.status == AppStatus.UpdateAvailable) "$name → $latest" else name +} + +@Composable +private fun AppIcon(row: AppRow) { + val context = LocalContext.current + // The installed app's own icon when there is one; otherwise its initial. + val bitmap = remember(row.app.packageName, row.installed?.versionCode) { + if (row.installed == null) null + else runCatching { + context.packageManager.getApplicationIcon(row.app.packageName).toBitmap(144, 144).asImageBitmap() + }.getOrNull() + } + if (bitmap != null) { + Image(bitmap, null, Modifier.size(48.dp).clip(RoundedCornerShape(12.dp))) + } else { + Box( + Modifier.size(48.dp).clip(RoundedCornerShape(12.dp)).background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Text(row.app.name.take(1).uppercase(), style = MaterialTheme.typography.titleLarge, color = Accent) + } + } +} + +@Composable +private fun StatusChip(row: AppRow) { + val (text, colour) = when (row.status) { + AppStatus.NotInstalled -> "Not installed" to Muted + AppStatus.UpToDate -> "Up to date" to Ok + AppStatus.UpdateAvailable -> "Update available" to Accent + AppStatus.InstalledNewer -> "Newer than the release" to Muted + AppStatus.SignatureMismatch -> "Different signing key" to Bad + AppStatus.Incompatible -> "Needs Android ${sdkName(row.app.latest.minSdk)}" to Warn + } + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(8.dp).clip(CircleShape).background(colour)) + Spacer(Modifier.width(6.dp)) + Text(text, style = MaterialTheme.typography.labelMedium, color = colour) + } +} + +@Composable +private fun ActionButton( + row: AppRow, + onInstall: (AppRow) -> Unit, + onCancel: (AppRow) -> Unit, + onOpen: (AppRow) -> Unit, + onUninstall: (AppRow) -> Unit, +) { + val task = row.task + when { + task is Task.Downloading -> OutlinedButton(onClick = { onCancel(row) }) { Text("Cancel") } + task == Task.Verifying || task == Task.Installing -> CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp) + Status.canInstall(row.status) -> Button(onClick = { onInstall(row) }) { + Text( + when { + task is Task.Failed -> "Retry" + row.status == AppStatus.UpdateAvailable -> "Update" + else -> "Install" + }, + ) + } + row.status == AppStatus.SignatureMismatch -> OutlinedButton(onClick = { onUninstall(row) }) { Text("Uninstall") } + row.installed != null && !row.isSelf -> OutlinedButton(onClick = { onOpen(row) }) { Text("Open") } + } +} + +@Composable +private fun TaskLine(row: AppRow) { + val context = LocalContext.current + when (val task = row.task) { + is Task.Downloading -> { + Spacer(Modifier.height(12.dp)) + LinearProgressIndicator(progress = { task.fraction }, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.height(4.dp)) + Text( + "Downloading ${Formatter.formatShortFileSize(context, task.done)} of " + + Formatter.formatShortFileSize(context, task.total), + style = MaterialTheme.typography.labelSmall, + color = Muted, + ) + } + Task.Verifying -> TaskText("Checking the SHA-256 checksum and signature…") + Task.Installing -> TaskText("Waiting for Android to install it…") + is Task.Failed -> TaskText(task.message, Bad) + null -> Unit + } +} + +@Composable +private fun TaskText(text: String, colour: Color = Muted) { + Spacer(Modifier.height(10.dp)) + Text(text, style = MaterialTheme.typography.bodySmall, color = colour) +} + +@Composable +private fun AppDetail( + row: AppRow, + onInstall: () -> Unit, + onCancel: () -> Unit, + onDismiss: () -> Unit, + onOpen: () -> Unit, + onUninstall: () -> Unit, + onLink: (String) -> Unit, +) { + val context = LocalContext.current + val latest = row.app.latest + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + AppIcon(row) + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f)) { + Text(row.app.name, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold) + row.app.summary?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = Muted) } + } + } + Spacer(Modifier.height(16.dp)) + StatusChip(row) + explanation(row)?.let { + Spacer(Modifier.height(6.dp)) + Text(it, style = MaterialTheme.typography.bodyMedium) + } + TaskLine(row) + Spacer(Modifier.height(16.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + val task = row.task + when { + task is Task.Downloading -> OutlinedButton(onClick = onCancel) { Text("Cancel download") } + task == Task.Verifying || task == Task.Installing -> Unit + Status.canInstall(row.status) -> Button(onClick = onInstall) { + Text(if (row.status == AppStatus.UpdateAvailable) "Update to ${latest.versionName}" else "Install ${latest.versionName}") + } + row.status == AppStatus.SignatureMismatch -> Button(onClick = onUninstall) { Text("Uninstall the old copy") } + } + if (task is Task.Failed) TextButton(onClick = onDismiss) { Text("Dismiss") } + if (row.installed != null && !row.isSelf) OutlinedButton(onClick = onOpen) { Text("Open") } + } + + Section("Versions") + Field("Installed", row.installed?.let { "${it.versionName ?: "?"} (${it.versionCode})" } ?: "Not installed") + Field("Latest", "${latest.versionName} (${latest.versionCode})") + latest.releasedAt?.let { Field("Built", relative(it)) } + Field("Download", Formatter.formatShortFileSize(context, latest.sizeBytes)) + latest.minSdk?.let { Field("Needs", "Android ${sdkName(it)} or newer") } + + Section("What's new") + Text( + latest.releaseNotes?.takeIf { it.isNotBlank() }?.let(::plainNotes) ?: "No notes for this build.", + style = MaterialTheme.typography.bodyMedium, + ) + latest.releaseUrl?.let { url -> + Spacer(Modifier.height(8.dp)) + TextButton(onClick = { onLink(url) }, contentPadding = PaddingValues(0.dp)) { + Text("View the release") + Spacer(Modifier.width(6.dp)) + Icon(Icons.AutoMirrored.Filled.OpenInNew, null, Modifier.size(16.dp)) + } + } + + Section("Integrity") + Text( + "Every download is checked against this SHA-256 and the signing certificate before Android is asked to install it.", + style = MaterialTheme.typography.bodySmall, + color = Muted, + ) + Spacer(Modifier.height(8.dp)) + SelectionContainer { + Column { + Field("Package", row.app.packageName, mono = true) + Field("SHA-256", latest.sha256.lowercase(), mono = true) + row.app.signingCertSha256.forEach { Field("Signing key", it, mono = true) } + } + } + Spacer(Modifier.height(24.dp)) + Text( + "Manager ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})", + style = MaterialTheme.typography.labelSmall, + color = Muted, + ) + } +} + +private fun explanation(row: AppRow): String? = when (row.status) { + AppStatus.SignatureMismatch -> + "The installed copy was signed with a different key (probably from before the apps moved to new keys), " + + "so Android won't update it in place. Uninstall it, then install the new build. Its data on this phone will be lost." + AppStatus.Incompatible -> "This build needs Android ${sdkName(row.app.latest.minSdk)} or newer." + AppStatus.InstalledNewer -> "The installed build is newer than the published one, so there's nothing to update." + else -> null +} + +@Composable +private fun Section(title: String) { + Spacer(Modifier.height(24.dp)) + Text(title, style = MaterialTheme.typography.titleSmall, color = Accent) + Spacer(Modifier.height(4.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Spacer(Modifier.height(8.dp)) +} + +@Composable +private fun Field(label: String, value: String, mono: Boolean = false) { + Column(Modifier.padding(vertical = 4.dp)) { + Text(label, style = MaterialTheme.typography.labelMedium, color = Muted) + Text( + value, + style = MaterialTheme.typography.bodyMedium, + fontFamily = if (mono) FontFamily.Monospace else null, + ) + } +} + +private fun relative(iso: String): String = runCatching { + DateUtils.getRelativeTimeSpanString(Instant.parse(iso).toEpochMilli()).toString() +}.getOrDefault(iso) + +/** Release notes are a short Markdown list; show them as plain text. */ +private fun plainNotes(md: String): String = md.lines().joinToString("\n") { line -> + line.replaceFirst(Regex("^\\s*[-*] "), "• ").replace("**", "").replace("`", "") +}.trim() + +private fun sdkName(sdk: Int?): String = when (sdk) { + null -> "?" + 26 -> "8.0"; 27 -> "8.1"; 28 -> "9"; 29 -> "10"; 30 -> "11"; 31 -> "12"; 32 -> "12L" + 33 -> "13"; 34 -> "14"; 35 -> "15"; 36 -> "16" + else -> "API $sdk" +} + diff --git a/doughmination-manager/app/src/main/java/gay/doughmination/manager/ui/Theme.kt b/doughmination-manager/app/src/main/java/gay/doughmination/manager/ui/Theme.kt new file mode 100644 index 0000000..c19af4e --- /dev/null +++ b/doughmination-manager/app/src/main/java/gay/doughmination/manager/ui/Theme.kt @@ -0,0 +1,37 @@ +package gay.doughmination.manager.ui + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +// The Doughmination palette, as the System app and the websites use it. +val Accent = Color(0xFFEB459E) +val Ok = Color(0xFF7FD98B) +val Warn = Color(0xFFF2C46D) +val Bad = Color(0xFFFF8A9A) +val Muted = Color(0xFFA49EAB) + +private val scheme = darkColorScheme( + primary = Accent, + onPrimary = Color.White, + primaryContainer = Color(0xFF3A1430), + onPrimaryContainer = Color(0xFFFFD8EC), + secondary = Color(0xFFCDB8D6), + background = Color(0xFF0F0D14), + onBackground = Color(0xFFECE9EF), + surface = Color(0xFF0F0D14), + onSurface = Color(0xFFECE9EF), + surfaceVariant = Color(0xFF1E1B2E), + onSurfaceVariant = Muted, + surfaceContainer = Color(0xFF17141F), + surfaceContainerHigh = Color(0xFF1E1B2E), + outline = Color(0xFF3B3546), + outlineVariant = Color(0xFF2A2533), + error = Bad, +) + +@Composable +fun ManagerTheme(content: @Composable () -> Unit) { + MaterialTheme(colorScheme = scheme, content = content) +} diff --git a/doughmination-manager/app/src/main/res/values/themes.xml b/doughmination-manager/app/src/main/res/values/themes.xml index c2f0dd7..67869ed 100644 --- a/doughmination-manager/app/src/main/res/values/themes.xml +++ b/doughmination-manager/app/src/main/res/values/themes.xml @@ -1,5 +1,7 @@ - #1E1B2E diff --git a/doughmination-manager/app/src/test/java/gay/doughmination/manager/data/StatusTest.kt b/doughmination-manager/app/src/test/java/gay/doughmination/manager/data/StatusTest.kt new file mode 100644 index 0000000..18c3629 --- /dev/null +++ b/doughmination-manager/app/src/test/java/gay/doughmination/manager/data/StatusTest.kt @@ -0,0 +1,90 @@ +package gay.doughmination.manager.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class StatusTest { + private val cert = "AB:CD:" + "00:".repeat(29) + "EF" + private val app = ManifestApp( + id = "system", + packageName = "gay.doughmination.plural", + name = "System", + signingCertSha256 = listOf(cert), + latest = Release( + versionCode = 1010, + versionName = "2.1.10", + apkUrl = "https://example.com/system.apk", + sha256 = "a".repeat(64), + sizeBytes = 100, + minSdk = 26, + ), + ) + private fun installed(code: Long, certs: Set = setOf(normaliseDigest(cert))) = + InstalledApp(app.packageName, code, "x", certs) + + @Test fun notInstalled() = assertEquals(AppStatus.NotInstalled, Status.of(app, null, 34)) + @Test fun upToDate() = assertEquals(AppStatus.UpToDate, Status.of(app, installed(1010), 34)) + @Test fun older() = assertEquals(AppStatus.UpdateAvailable, Status.of(app, installed(1009), 34)) + @Test fun oldPerAppVersionCodes() = assertEquals(AppStatus.UpdateAvailable, Status.of(app, installed(107), 34)) + @Test fun newer() = assertEquals(AppStatus.InstalledNewer, Status.of(app, installed(1011), 34)) + + @Test fun foreignKey() = + assertEquals(AppStatus.SignatureMismatch, Status.of(app, installed(1009, setOf("f".repeat(64))), 34)) + + @Test fun tooOldAndroid() { + val needs35 = app.copy(latest = app.latest.copy(minSdk = 35)) + assertEquals(AppStatus.Incompatible, Status.of(needs35, null, 34)) + assertEquals(AppStatus.Incompatible, Status.of(needs35, installed(1000), 34)) + // Already current: nothing to install, so it isn't "incompatible". + assertEquals(AppStatus.UpToDate, Status.of(needs35, installed(1010), 34)) + } + + @Test fun installableStates() { + assertTrue(Status.canInstall(AppStatus.NotInstalled)) + assertTrue(Status.canInstall(AppStatus.UpdateAvailable)) + assertFalse(Status.canInstall(AppStatus.UpToDate)) + assertFalse(Status.canInstall(AppStatus.SignatureMismatch)) + assertFalse(Status.canInstall(AppStatus.InstalledNewer)) + assertFalse(Status.canInstall(AppStatus.Incompatible)) + } + + @Test fun digestsCompareRegardlessOfFormat() = + assertEquals("abcdef", normaliseDigest("AB:cd:EF")) +} + +class ManifestParserTest { + private fun manifest(schema: Int = 1, url: String = "https://x/a.apk", sha: String = "b".repeat(64)) = """ + {"schemaVersion":$schema,"extra":"ignored","apps":[{"id":"a","packageName":"p.a","name":"A", + "signingCertSha256":["${"c".repeat(64)}"], + "latest":{"versionCode":5,"versionName":"2.1.5","apkUrl":"$url","sha256":"$sha","sizeBytes":10}}]} + """.trimIndent() + + @Test fun parses() { + val m = ManifestParser.parse(manifest()) + assertEquals(1, m.apps.size) + assertEquals("2.1.5", m.apps[0].latest.versionName) + } + + @Test fun refusesUnknownSchema() { + assertThrows(ManifestException::class.java) { ManifestParser.parse(manifest(schema = 2)) } + } + + @Test fun refusesGarbage() { + assertThrows(ManifestException::class.java) { ManifestParser.parse("") } + } + + @Test fun dropsInsecureOrMalformedEntries() { + assertEquals(0, ManifestParser.parse(manifest(url = "http://x/a.apk")).apps.size) + assertEquals(0, ManifestParser.parse(manifest(sha = "nothex")).apps.size) + } +} + +class HashesTest { + @Test fun sha256OfKnownInput() = assertEquals( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + Hashes.sha256("abc".byteInputStream()), + ) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2ba7bc5..a52d24a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,6 +23,7 @@ firebaseBom = "33.7.0" kotlinxCoroutines = "1.9.0" kotlinxSerialization = "1.7.3" okhttp = "4.12.0" +junit = "4.13.2" [libraries] androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" } @@ -53,6 +54,7 @@ kotlinx-coroutines-play-services = { module = "org.jetbrains.kotlinx:kotlinx-cor kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +junit = { module = "junit:junit", version.ref = "junit" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/plural/app/src/main/java/gay/doughmination/plural/MainActivity.kt b/plural/app/src/main/java/gay/doughmination/plural/MainActivity.kt index bf01652..96bf704 100644 --- a/plural/app/src/main/java/gay/doughmination/plural/MainActivity.kt +++ b/plural/app/src/main/java/gay/doughmination/plural/MainActivity.kt @@ -2,7 +2,9 @@ package gay.doughmination.plural import android.content.Intent import android.os.Bundle +import android.graphics.Color import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels @@ -17,7 +19,11 @@ class MainActivity : ComponentActivity() { private var awaitingLogin = false override fun onCreate(savedInstanceState: Bundle?) { - enableEdgeToEdge() + // The app is always dark, so the system bars need light icons whatever the phone's theme. + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), + navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), + ) super.onCreate(savedInstanceState) setContent { SystemTheme { AppRoot(vm) } } // A recreated activity (rotation) has already handled its launch intent. diff --git a/plural/app/src/main/java/gay/doughmination/plural/ui/HomeScreen.kt b/plural/app/src/main/java/gay/doughmination/plural/ui/HomeScreen.kt index 6ff2b6e..73107e5 100644 --- a/plural/app/src/main/java/gay/doughmination/plural/ui/HomeScreen.kt +++ b/plural/app/src/main/java/gay/doughmination/plural/ui/HomeScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -157,7 +158,9 @@ fun HomeScreen(vm: SystemViewModel) { if (filtersOpen) { ModalBottomSheet(onDismissRequest = { filtersOpen = false }) { Column( - Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 20.dp).padding(bottom = 32.dp), + // Clear of the navigation bar, so the last chips and Done stay reachable. + Modifier.verticalScroll(rememberScrollState()).navigationBarsPadding() + .padding(horizontal = 20.dp).padding(bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text("Filters", style = MaterialTheme.typography.titleLarge) diff --git a/plural/app/src/main/java/gay/doughmination/plural/ui/RelationshipsScreen.kt b/plural/app/src/main/java/gay/doughmination/plural/ui/RelationshipsScreen.kt index a9f5d56..1e2c134 100644 --- a/plural/app/src/main/java/gay/doughmination/plural/ui/RelationshipsScreen.kt +++ b/plural/app/src/main/java/gay/doughmination/plural/ui/RelationshipsScreen.kt @@ -48,6 +48,7 @@ import androidx.compose.ui.res.imageResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.drawText import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize @@ -121,7 +122,15 @@ private fun Who(member: Member, modifier: Modifier, open: (Member) -> Unit, end: horizontalArrangement = Arrangement.spacedBy(8.dp, if (end) Alignment.End else Alignment.Start), ) { if (!end) Avatar(member.avatarUrl, 32.dp, ring = memberColour(member.color)) - Text(member.shownName, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f, fill = false)) + // Two lines: similar long names (Serial Designation N / J) must stay tellable apart. + Text( + member.shownName, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + textAlign = if (end) TextAlign.End else TextAlign.Start, + modifier = Modifier.weight(1f, fill = false), + ) if (end) Avatar(member.avatarUrl, 32.dp, ring = memberColour(member.color)) } } From 58aeb32ac2e2de752dd6e3695645d57601ddea92 Mon Sep 17 00:00:00 2001 From: Clove Twilight Date: Mon, 2 Nov 2026 19:58:32 +0000 Subject: [PATCH 2/4] Read the certificate from newer apksigner output and the APK's minSdk apksigner now prints 'V2 Signer: certificate SHA-256 digest', and aapt2 prints minSdkVersion, so both came out empty. Also collect install results with a plain lambda. --- .github/scripts/build-apps.sh | 5 +++-- .../java/gay/doughmination/manager/ui/ManagerViewModel.kt | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/scripts/build-apps.sh b/.github/scripts/build-apps.sh index 28d3674..413404a 100644 --- a/.github/scripts/build-apps.sh +++ b/.github/scripts/build-apps.sh @@ -52,9 +52,10 @@ build() { pkg=$(sed -n "s/^package: name='\([^']*\)'.*/\1/p" <<< "$badging") code=$(sed -n "s/^package: .* versionCode='\([^']*\)'.*/\1/p" <<< "$badging") name=$(sed -n "s/^package: .* versionName='\([^']*\)'.*/\1/p" <<< "$badging") - minsdk=$(sed -n "s/^\(min\)\?sdkVersion:'\([0-9]*\)'.*/\2/p" <<< "$badging" | head -1) + minsdk=$(sed -n "s/^\(minSdkVersion\|sdkVersion\):'\([0-9]*\)'.*/\2/p" <<< "$badging" | head -1) + # "Signer #1 certificate SHA-256 digest: …" or, newer, "V2 Signer: certificate SHA-256 digest: …" cert=$("$apksigner" verify --print-certs "$out/$file" 2>/dev/null \ - | sed -n 's/^Signer #1 certificate SHA-256 digest: //p' | head -1 || true) + | sed -n 's/^.*[Ss]igner.*certificate SHA-256 digest: *//p' | head -1 || true) local expected_code=${VERSION_CODE:-1} expected_name=${VERSION_NAME:-} if [ "$pkg" != "$package" ]; then diff --git a/doughmination-manager/app/src/main/java/gay/doughmination/manager/ui/ManagerViewModel.kt b/doughmination-manager/app/src/main/java/gay/doughmination/manager/ui/ManagerViewModel.kt index 7cf3989..1cdc3d7 100644 --- a/doughmination-manager/app/src/main/java/gay/doughmination/manager/ui/ManagerViewModel.kt +++ b/doughmination-manager/app/src/main/java/gay/doughmination/manager/ui/ManagerViewModel.kt @@ -87,7 +87,7 @@ class ManagerViewModel(application: Application) : AndroidViewModel(application) source.cached()?.let { cached -> _state.update { it.copy(manifest = cached) }; rebuild() } refresh() } - viewModelScope.launch { Installer.results.collect(::onInstallResult) } + viewModelScope.launch { Installer.results.collect { onInstallResult(it) } } } /** Fetches the manifest again and re-reads what's installed. */ From d8eeca2e90a551ebad75a677b254536b735e4181 Mon Sep 17 00:00:00 2001 From: Clove Twilight Date: Mon, 2 Nov 2026 20:19:04 +0000 Subject: [PATCH 3/4] One look for every app: Inbox's theme as a shared :design module Inbox's theme (palette, IBM Plex, the wallpaper backdrop, glass section cards, eyebrow headings) moves into :design, and every app's theme comes from it, so they can't drift apart. Plural drops its near-copy, Manager its own palette, and Device Reporter's XML screen is ported to Compose with the same behaviour. Text outside a Scaffold now defaults to the light text colour instead of black (Inbox's sign-in title and much of Device Reporter were unreadable), and every app gets light system-bar icons whatever the phone's theme. --- README.md | 6 +- api-helper/app/build.gradle.kts | 13 +- .../devicereporter/MainActivity.kt | 507 +++++++++++------- .../app/src/main/res/color/switch_thumb.xml | 5 - .../app/src/main/res/color/switch_track.xml | 5 - .../main/res/color/switch_track_outline.xml | 5 - .../app/src/main/res/drawable/bg_card.xml | 6 - .../app/src/main/res/drawable/bg_code.xml | 6 - .../app/src/main/res/drawable/bg_input.xml | 17 - .../app/src/main/res/drawable/bg_pill.xml | 6 - api-helper/app/src/main/res/drawable/dot.xml | 5 - .../app/src/main/res/font/ibm_plex_sans.xml | 8 - .../src/main/res/font/ibm_plex_sans_bold.ttf | Bin 70820 -> 0 bytes .../main/res/font/ibm_plex_sans_medium.ttf | Bin 70592 -> 0 bytes .../main/res/font/ibm_plex_sans_regular.ttf | Bin 70620 -> 0 bytes .../main/res/font/ibm_plex_sans_semibold.ttf | Bin 70812 -> 0 bytes .../app/src/main/res/layout/activity_main.xml | 337 ------------ api-helper/app/src/main/res/values/colors.xml | 18 +- api-helper/app/src/main/res/values/styles.xml | 110 ---- api-helper/app/src/main/res/values/themes.xml | 32 +- build.gradle.kts | 1 + design/build.gradle.kts | 37 ++ .../java/gay/doughmination/design/Theme.kt | 218 ++++++++ .../main/res/drawable-nodpi/background.webp | Bin .../src/main/res/font/ibm_plex_sans_bold.ttf | Bin .../main/res/font/ibm_plex_sans_medium.ttf | Bin .../main/res/font/ibm_plex_sans_regular.ttf | Bin .../main/res/font/ibm_plex_sans_semibold.ttf | Bin design/src/main/res/values/design.xml | 12 + doughmination-manager/app/build.gradle.kts | 1 + .../gay/doughmination/manager/MainActivity.kt | 25 +- .../gay/doughmination/manager/ui/Screens.kt | 136 +++-- .../gay/doughmination/manager/ui/Theme.kt | 37 -- .../app/src/main/res/values/themes.xml | 6 +- email/app/build.gradle.kts | 1 + .../gay/doughmination/mail/MainActivity.kt | 4 +- .../doughmination/mail/ui/AccountPicture.kt | 1 + .../java/gay/doughmination/mail/ui/AppRoot.kt | 1 + .../doughmination/mail/ui/ComposeScreen.kt | 1 + .../doughmination/mail/ui/SettingsScreen.kt | 26 +- .../java/gay/doughmination/mail/ui/Theme.kt | 136 +---- .../doughmination/mail/ui/ThreadListScreen.kt | 1 + .../gay/doughmination/mail/ui/ThreadScreen.kt | 1 + email/app/src/main/res/values/themes.xml | 6 +- gradle/libs.versions.toml | 1 + plural/app/build.gradle.kts | 1 + .../gay/doughmination/plural/MainActivity.kt | 10 +- .../doughmination/plural/ui/AccountScreen.kt | 11 +- .../doughmination/plural/ui/AdminScreens.kt | 7 +- .../gay/doughmination/plural/ui/AppRoot.kt | 3 +- .../gay/doughmination/plural/ui/Components.kt | 17 +- .../gay/doughmination/plural/ui/HomeScreen.kt | 3 +- .../doughmination/plural/ui/MemberScreen.kt | 3 +- .../doughmination/plural/ui/MetricsScreen.kt | 3 +- .../plural/ui/RelationshipsScreen.kt | 3 +- .../java/gay/doughmination/plural/ui/Theme.kt | 134 +---- .../main/res/drawable-nodpi/background.webp | Bin 363020 -> 0 bytes .../src/main/res/font/ibm_plex_sans_bold.ttf | Bin 192072 -> 0 bytes .../main/res/font/ibm_plex_sans_medium.ttf | Bin 193548 -> 0 bytes .../main/res/font/ibm_plex_sans_regular.ttf | Bin 191980 -> 0 bytes .../main/res/font/ibm_plex_sans_semibold.ttf | Bin 193752 -> 0 bytes plural/app/src/main/res/values/themes.xml | 10 +- settings.gradle.kts | 5 +- 63 files changed, 758 insertions(+), 1190 deletions(-) delete mode 100644 api-helper/app/src/main/res/color/switch_thumb.xml delete mode 100644 api-helper/app/src/main/res/color/switch_track.xml delete mode 100644 api-helper/app/src/main/res/color/switch_track_outline.xml delete mode 100644 api-helper/app/src/main/res/drawable/bg_card.xml delete mode 100644 api-helper/app/src/main/res/drawable/bg_code.xml delete mode 100644 api-helper/app/src/main/res/drawable/bg_input.xml delete mode 100644 api-helper/app/src/main/res/drawable/bg_pill.xml delete mode 100644 api-helper/app/src/main/res/drawable/dot.xml delete mode 100644 api-helper/app/src/main/res/font/ibm_plex_sans.xml delete mode 100644 api-helper/app/src/main/res/font/ibm_plex_sans_bold.ttf delete mode 100644 api-helper/app/src/main/res/font/ibm_plex_sans_medium.ttf delete mode 100644 api-helper/app/src/main/res/font/ibm_plex_sans_regular.ttf delete mode 100644 api-helper/app/src/main/res/font/ibm_plex_sans_semibold.ttf delete mode 100644 api-helper/app/src/main/res/layout/activity_main.xml delete mode 100644 api-helper/app/src/main/res/values/styles.xml create mode 100644 design/build.gradle.kts create mode 100644 design/src/main/java/gay/doughmination/design/Theme.kt rename {email/app => design}/src/main/res/drawable-nodpi/background.webp (100%) rename {email/app => design}/src/main/res/font/ibm_plex_sans_bold.ttf (100%) rename {email/app => design}/src/main/res/font/ibm_plex_sans_medium.ttf (100%) rename {email/app => design}/src/main/res/font/ibm_plex_sans_regular.ttf (100%) rename {email/app => design}/src/main/res/font/ibm_plex_sans_semibold.ttf (100%) create mode 100644 design/src/main/res/values/design.xml delete mode 100644 doughmination-manager/app/src/main/java/gay/doughmination/manager/ui/Theme.kt delete mode 100644 plural/app/src/main/res/drawable-nodpi/background.webp delete mode 100644 plural/app/src/main/res/font/ibm_plex_sans_bold.ttf delete mode 100644 plural/app/src/main/res/font/ibm_plex_sans_medium.ttf delete mode 100644 plural/app/src/main/res/font/ibm_plex_sans_regular.ttf delete mode 100644 plural/app/src/main/res/font/ibm_plex_sans_semibold.ttf diff --git a/README.md b/README.md index d3037c1..2d07fc5 100644 --- a/README.md +++ b/README.md @@ -20,14 +20,16 @@ Plural is the System app; see [plural/README.md](plural/README.md). settings.gradle.kts every app module is included here build.gradle.kts plugin versions (apply false) gradle/libs.versions.toml one version catalog: SDK levels, plugins, libraries +design/ the shared look (Inbox's theme): palette, IBM Plex, backdrop, glass cards /app/ one Android application module per app docs/releasing.md tags, version codes, signing, CI secrets docs/manifest.md the Doughmination Manager's manifest format .github/workflows/apps.yml one workflow: builds, signs and releases every app ``` -The apps share the build and the catalog, never each other's code: no app -depends on another, and each keeps its own namespace, application ID, +The apps share the build, the catalog and the look (`:design`, which every +app's theme comes from, so they can't drift apart), never each other's code: +no app depends on another, and each keeps its own namespace, application ID, resources and signing key. ## Building diff --git a/api-helper/app/build.gradle.kts b/api-helper/app/build.gradle.kts index df90ca7..c31d831 100644 --- a/api-helper/app/build.gradle.kts +++ b/api-helper/app/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) } android { @@ -47,6 +48,10 @@ android { } } + buildFeatures { + compose = true + } + compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 @@ -58,10 +63,12 @@ android { } dependencies { + implementation(project(":design")) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.activity.compose) implementation(libs.androidx.core.ktx) - implementation(libs.androidx.activity.ktx) - implementation(libs.androidx.appcompat) implementation(libs.androidx.lifecycle.runtime.ktx) - implementation(libs.google.material) implementation(libs.kotlinx.coroutines.android) } diff --git a/api-helper/app/src/main/java/uk/doughmination/devicereporter/MainActivity.kt b/api-helper/app/src/main/java/uk/doughmination/devicereporter/MainActivity.kt index 878e10a..4efeee2 100644 --- a/api-helper/app/src/main/java/uk/doughmination/devicereporter/MainActivity.kt +++ b/api-helper/app/src/main/java/uk/doughmination/devicereporter/MainActivity.kt @@ -2,59 +2,99 @@ package uk.doughmination.devicereporter import android.Manifest import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothManager import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.content.pm.PackageManager -import android.content.res.ColorStateList -import android.graphics.Color import android.net.Uri import android.os.Build import android.os.Bundle import android.os.PowerManager import android.provider.Settings -import android.view.View -import android.widget.Button -import android.widget.EditText -import android.widget.TextView import android.widget.Toast -import androidx.activity.SystemBarStyle -import androidx.activity.enableEdgeToEdge +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.ColorRes -import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.updatePadding import androidx.lifecycle.lifecycleScope -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.materialswitch.MaterialSwitch +import gay.doughmination.design.Backdrop +import gay.doughmination.design.DoughminationTheme +import gay.doughmination.design.Eyebrow +import gay.doughmination.design.GlassSection +import gay.doughmination.design.Line +import gay.doughmination.design.Muted +import gay.doughmination.design.Ok +import gay.doughmination.design.Warn +import gay.doughmination.design.enableDoughminationEdgeToEdge import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -class MainActivity : AppCompatActivity() { +class MainActivity : ComponentActivity() { private lateinit var prefs: Prefs - private lateinit var inputBaseUrl: EditText - private lateinit var inputApiKey: EditText - private lateinit var inputDevice: EditText - private lateinit var switchWatch: MaterialSwitch - private lateinit var switchAirpods: MaterialSwitch - private lateinit var textAirpodsDevice: TextView - private lateinit var textStatus: TextView - private lateinit var textServiceState: TextView - private lateinit var textBattery: TextView - private lateinit var textLocation: TextView - private lateinit var textAlarms: TextView - private lateinit var btnBattery: Button - private lateinit var btnLocation: Button - private lateinit var btnAlarms: Button - private lateinit var btnStart: Button - private lateinit var btnStop: Button + // Screen state. Text fields and switches are saved on Save, Start and Send test. + private var baseUrl by mutableStateOf("") + private var apiKey by mutableStateOf("") + private var deviceName by mutableStateOf("") + private var watchConnected by mutableStateOf(false) + private var airpodsEnabled by mutableStateOf(false) + private var airpodsDevice by mutableStateOf("No device selected") + private var running by mutableStateOf(false) + private var lastReport by mutableStateOf("Nothing sent yet.") + private var batteryOk by mutableStateOf(true) + private var locationFine by mutableStateOf(true) + private var locationOk by mutableStateOf(true) + private var alarmsOk by mutableStateOf(true) + private var askBackgroundLocation by mutableStateOf<(() -> Unit)?>(null) + private var bluetoothChoices by mutableStateOf>?>(null) // Background location has to be asked for on its own, after foreground location is // granted, so the foreground request chains into it. @@ -72,78 +112,18 @@ class MainActivity : AppCompatActivity() { } override fun onCreate(savedInstanceState: Bundle?) { - enableEdgeToEdge( - statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), - navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT) - ) + enableDoughminationEdgeToEdge() super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) prefs = Prefs(this) - - // Edge-to-edge: keep content clear of the status bar, camera cutout, nav bar and keyboard. - val content = findViewById(R.id.content) - val basePadTop = content.paddingTop - val basePadBottom = content.paddingBottom - val basePadStart = content.paddingStart - val basePadEnd = content.paddingEnd - ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.scroll)) { _, insets -> - val bars = insets.getInsets( - WindowInsetsCompat.Type.systemBars() or - WindowInsetsCompat.Type.displayCutout() or - WindowInsetsCompat.Type.ime() - ) - content.updatePadding( - left = basePadStart + bars.left, - top = basePadTop + bars.top, - right = basePadEnd + bars.right, - bottom = basePadBottom + bars.bottom - ) - insets - } - - inputBaseUrl = findViewById(R.id.inputBaseUrl) - inputApiKey = findViewById(R.id.inputApiKey) - inputDevice = findViewById(R.id.inputDevice) - switchWatch = findViewById(R.id.switchWatch) - switchAirpods = findViewById(R.id.switchAirpods) - textAirpodsDevice = findViewById(R.id.textAirpodsDevice) - textStatus = findViewById(R.id.textStatus) - textServiceState = findViewById(R.id.textServiceState) - textBattery = findViewById(R.id.textBattery) - textLocation = findViewById(R.id.textLocation) - textAlarms = findViewById(R.id.textAlarms) - btnBattery = findViewById(R.id.btnBattery) - btnLocation = findViewById(R.id.btnLocation) - btnAlarms = findViewById(R.id.btnAlarms) - btnStart = findViewById(R.id.btnStart) - btnStop = findViewById(R.id.btnStop) - - loadIntoViews() - - findViewById