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..413404a 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,44 @@ 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/^\(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/^.*[Ss]igner.*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..97ed9e5 --- /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="design 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..2d07fc5 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 @@ -19,14 +20,16 @@ nothing else. 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