utils/scripts/provision_firebase_android.sh
Krzysztof kuhy Rudnicki 5f64a8959b
Some checks failed
crdt-sync pre-commit / pre-commit (push) Has been cancelled
crdt-sync tests / test (3.10) (push) Has been cancelled
crdt-sync tests / test (3.11) (push) Has been cancelled
crdt-sync tests / test (3.12) (push) Has been cancelled
Sign in with Google, and stop trusting a revoked session
Two changes to the shared sync credential path, both driven by devices
that reported "Connected" while every sync failed.

Google sign-in (signInWithGoogle, both languages) replaces typing the
sync account's password on a fresh install. The account is linked once
from the PC with tool/link_google.py, which asserts the pinned uid --
without that assertion an unlinked identity is *signed up* rather than
refused, and the new uid is then denied every read and write by the
security rules.

The second change is the one with teeth. hasSession() is a presence
check, so a refresh token the server had revoked still read as a live
session: the desktop todo build showed "Connected" and then failed every
sync with `HTTP 400 (TOKEN_EXPIRED)`. A refresh that fails for a terminal
reason now clears the stored session, so hasSession() answers honestly.

Deliberately narrow: only TOKEN_EXPIRED, USER_DISABLED, USER_NOT_FOUND,
INVALID_REFRESH_TOKEN, INVALID_GRANT_TYPE and MISSING_REFRESH_TOKEN
clear it. A network error or a 5xx must not -- a device that signs
itself out whenever the wifi drops needs a manual sign-in on every
device to recover, which is worse than the stale banner this fixes.
Verified against the live endpoint, not just mocks: has_session flips
True -> False on a real rejection.

crdt_sync_dart 0.9.0 -> 0.10.0; the five apps pin the matching tag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0192EkoAyFBEWTvAN5vVgmSy
2026-08-11 23:58:02 +02:00

191 lines
6.3 KiB
Bash
Executable File

#!/bin/bash
# ============================================================================
# Register every com.kuhy.* / dev.kuhy.* Android app in the kuhy-syncs Firebase
# project and attach both signing fingerprints to each.
#
# Google sign-in on Android needs an Android OAuth client whose (package name,
# SHA-1) pair matches the installed APK. Getting either wrong surfaces as
# `ApiException: 10 / DEVELOPER_ERROR`, which names neither -- so this does it
# from one verified list instead of five console visits.
#
# Both fingerprints go on every app: the release key signs what CI publishes,
# the debug key signs a local `flutter build apk`. Omitting the debug one means
# sign-in works from CI builds and mysteriously fails from your own.
#
# Idempotent: existing apps are reused and existing hashes skipped, so this is
# safe to re-run after adding an app or rotating a key.
#
# Requires: firebase-tools, and `firebase login` already done (browser flow).
# ============================================================================
set -euo pipefail
readonly PROJECT="kuhy-syncs"
# Fingerprints, read from the keystores rather than pasted, so a rotated key
# cannot silently leave this script registering a stale hash.
readonly DEBUG_KEYSTORE="$HOME/.android/debug.keystore"
readonly RELEASE_KEYSTORE="$HOME/.android/release/kuhy-release.jks"
# "<display name>:<package name>" -- the package must match applicationId in
# each app's build.gradle exactly. Note todo is dev.kuhy.*, the rest com.kuhy.*.
readonly APPS=(
"todo:dev.kuhy.todo"
"workout_app:com.kuhy.workout_app"
"diet_guard_app:com.kuhy.diet_guard_app"
"home_inventory:com.kuhy.home_inventory"
"wake_alarm_sync:com.kuhy.wake_alarm_sync"
)
usage() {
echo "Usage: $(basename "$0") [--dry-run]"
echo " Registers the Android apps and their SHA-1s in $PROJECT."
echo " Run 'firebase login' first."
exit 0
}
DRY_RUN=0
require_login() {
if ! firebase login:list 2>&1 | grep -qi 'logged in'; then
echo "Error: not logged in. Run: firebase login" >&2
echo " (browser flow -- this script cannot do it for you)" >&2
exit 1
fi
}
# Prints the SHA-1 of a keystore alias, or exits with a message naming the file.
sha1_of() {
local keystore="$1" alias="$2" storepass="$3"
if [[ ! -f "$keystore" ]]; then
echo "Error: keystore not found: $keystore" >&2
exit 1
fi
local out
out="$(keytool -list -v -keystore "$keystore" -alias "$alias" \
-storepass "$storepass" 2>/dev/null | grep -m1 'SHA1:' || true)"
if [[ -z "$out" ]]; then
echo "Error: no SHA1 for alias '$alias' in $keystore" >&2
exit 1
fi
# " SHA1: AA:BB:..." -> "AA:BB:..."
echo "${out##*SHA1: }"
}
# Looks up an appId by package name, via --json.
#
# Deliberately not parsing `apps:list`'s default output: that is a rendered
# table carrying ANSI colour codes, and it shows the *display name* rather than
# the package -- so a name/package mismatch silently found nothing. The JSON
# form carries packageName explicitly.
app_id_for() {
local package="$1"
firebase apps:list ANDROID --project "$PROJECT" --json 2>/dev/null \
| python3 -c "
import json, sys
package = sys.argv[1]
try:
apps = json.load(sys.stdin).get('result') or []
except ValueError:
sys.exit(0)
for app in apps:
if app.get('packageName') == package:
print(app.get('appId', ''))
break
" "$package"
}
# Returns the appId for a package name, creating the app when absent.
ensure_app() {
local display="$1" package="$2" existing
existing="$(app_id_for "$package")"
if [[ -n "$existing" ]]; then
echo "$existing"
return
fi
if [[ "$DRY_RUN" -eq 1 ]]; then
echo "WOULD-CREATE"
return
fi
firebase apps:create ANDROID "$display" \
--package-name "$package" --project "$PROJECT" >&2
app_id_for "$package"
}
# Adds a fingerprint unless the app already carries it.
ensure_sha() {
local app_id="$1" sha="$2" label="$3"
if [[ "$app_id" == "WOULD-CREATE" ]]; then
echo " would add $label SHA-1"
return
fi
# Compare without separators and case-insensitively: the API echoes hashes
# unseparated and lowercased, while keytool prints AA:BB:CC uppercase, so a
# literal match would re-add an existing hash on every run.
local normalized="${sha//:/}"
if firebase apps:android:sha:list "$app_id" --project "$PROJECT" --json 2>/dev/null \
| tr -d '\n' | grep -qi "${normalized}"; then
echo " $label SHA-1 already present"
return
fi
if [[ "$DRY_RUN" -eq 1 ]]; then
echo " would add $label SHA-1"
return
fi
firebase apps:android:sha:create "$app_id" "$sha" --project "$PROJECT" >/dev/null
echo " added $label SHA-1"
}
main() {
local debug_sha release_sha
debug_sha="$(sha1_of "$DEBUG_KEYSTORE" androiddebugkey android)"
# The release store password lives in a key.properties committed nowhere;
# any app's copy will do since they share one keystore.
local props="$HOME/home_inventory/android/key.properties"
if [[ ! -f "$props" ]]; then
echo "Error: $props not found; needed for the release keystore password" >&2
exit 1
fi
local storepass alias
storepass="$(grep '^storePassword=' "$props" | cut -d= -f2-)"
alias="$(grep '^keyAlias=' "$props" | cut -d= -f2-)"
release_sha="$(sha1_of "$RELEASE_KEYSTORE" "$alias" "$storepass")"
echo "Project: $PROJECT"
echo "release: $release_sha"
echo "debug: $debug_sha"
echo
[[ "$DRY_RUN" -eq 0 ]] && require_login
for entry in "${APPS[@]}"; do
local display="${entry%%:*}" package="${entry#*:}"
echo "$display ($package)"
local app_id
app_id="$(ensure_app "$display" "$package")"
if [[ -z "$app_id" ]]; then
echo " Error: could not resolve an appId" >&2
exit 1
fi
echo " appId: $app_id"
ensure_sha "$app_id" "$release_sha" release
ensure_sha "$app_id" "$debug_sha" debug
done
echo
echo "Done. Next: enable Google sign-in, then read the Web client id with"
echo " firebase apps:sdkconfig WEB --project $PROJECT"
}
while [[ $# -gt 0 ]]; do
case $1 in
--dry-run) DRY_RUN=1; shift ;;
-h|--help) usage ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
main