Activate the skills plugin for this project with a project profile at .claude/dev-cycle-profile.yml: server-focused inner-loop build/test, scripts/check.sh as the audit regression gate, and the Gitea tracker wired as the boss forward queue. Set the naming policy to stable_per_directory_4digit (0001-slug.md) for docs/specs and docs/plans, and rename the existing date-prefixed artefacts to match. The one internal spec cross-link is updated.
43 KiB
Watch Multi-Profile Build Mechanism Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Enable safe parallel testing of the Wear OS app across three named build profiles (brummel-dev, brummel-minerva, krey-minerva) with a hard-locked watch-serial whitelist, so Brummel can switch between dev and minerva on his watch and Dr. Krey can use her own watch against minerva without risk of cross-contamination.
Architecture: Each profile is one bash-source-bare file under clients/wearos/profiles.d/<name>.sh (gitignored) holding SERVER_URL, API_KEY, and ALLOWED_SERIALS[]. run.sh takes the profile as a required second positional after watch, sources the file, refuses to proceed if the attached watch's ADB serial is not in ALLOWED_SERIALS, then injects the values into Gradle as -Pdoctate.serverUrl=, -Pdoctate.apiKey=, and -Pdoctate.profileName=. Build-time BuildConfig.PROFILE_NAME + IS_DEV_PROFILE plus a manifest-placeholder app label make the active profile visible on the watch.
Tech Stack: Bash (zero new deps), Gradle/Kotlin DSL, Kotlin/Compose for the optional dev badge, Android manifest placeholders.
Spec: docs/specs/0001-watch-multi-profile-design.md
File Structure
New files:
clients/wearos/profiles.d/— directory holding one bash file per profileclients/wearos/profiles.d/example-profile.sh.example— checked-in templateclients/wearos/profiles.d/brummel-dev.sh— gitignored (created in Task 1)clients/wearos/profiles.d/brummel-minerva.sh— gitignored (created in Task 7)clients/wearos/profiles.d/krey-minerva.sh— gitignored (created in Task 7)clients/wearos/app/src/main/java/com/doctate/watch/presentation/components/DevBadge.kt— optional, Task 10
Modified files:
clients/wearos/.gitignore— addprofiles.d/*.shrule plus example exceptionclients/wearos/run.sh— new helpers, argument parser extension, deprecated env-vars removed (Tasks 2–4)clients/wearos/local.properties— removedoctate.apiKeyline (Task 5)clients/wearos/app/build.gradle.kts—PROFILE_NAME+IS_DEV_PROFILEBuildConfig fields,appLabelmanifest placeholder (Task 8)clients/wearos/app/src/main/AndroidManifest.xml—android:label="${appLabel}"(Task 9)clients/wearos/app/src/main/java/com/doctate/watch/presentation/CaseListScreen.kt— mountDevBadge(optional, Task 10)clients/wearos/app/src/main/java/com/doctate/watch/presentation/RecordingScreen.kt— mountDevBadge(optional, Task 10)
Prerequisites (external, manual — not code edits)
These three pre-requisite tasks happen outside this repo. They can run in parallel with each other; Prereq 1 and Prereq 2 must complete before the smoke-test in Task 11 can succeed for any minerva profile.
Prereq 1: Resolve Issue #1 — minerva users.toml plaintext keys
- SSH to minerva, open
/opt/stacks/doctate-server/config/users.toml - For each existing
[[user]]block whoseapi_keyfield starts with$2a$,$2b$, or$2y$: replace the bcrypt-shaped value with a fresh plaintext value generated byopenssl rand -base64 24. Keep one entry per user (currently:brummel). - Record each new plaintext value — you will paste it into the corresponding profile file in Task 1 / Task 7. These plaintext values never go into the repo.
- Reload the doctate container (Dockge UI on
minerva.lan:5001or the equivalentdocker composecommand in the deployment skill) - Verify with
curl -i -H "X-API-Key: <new-plaintext>" http://minerva.lan:3000/api/oneliners→ expect HTTP 200
Prereq 2: Add Krey user on minerva
- In the same
/opt/stacks/doctate-server/config/users.tomlon minerva, add a new[[user]]block:
[[user]]
slug = "krey"
display_name = "Dr. Krey"
api_key = "<plaintext from openssl rand -base64 24>"
web_password = "<bcrypt hash — fill in via Prereq 2b>"
role = "doctor"
- Prereq 2b: Generate the bcrypt hash for
web_passwordby running thehash-passwordCLI from the server container (or locally) —cargo run --bin hash-password -- --user kreyif running locally, otherwise the equivalentdocker execinvocation. Paste the resulting hash into theweb_passwordfield of thekreyblock, replacing the placeholder. - Reload the doctate container
- Verify by logging into
http://minerva.lan:3000/in a browser askreywith the chosen password → expect to land on the case list withrole=doctorUI (no Reset/Bulk/Purge buttons visible) - Record Krey's plaintext
api_keyfor Task 7
Prereq 3: Collect ADB serials of both watches
- For Brummel's watch:
cd clients/wearos && ./run.sh connect <watch-host:port>(use the IP from the watch's ADB Debug Bridge screen). After connect, run./run.sh devicesand copy the line that looks likeadb-XXXXXXXX-yyyy._adb-tls-connect._tcp— this is the serial. - Repeat for Krey's watch when physically available, recording the second serial.
- Save both serials in your notes (or
/tmp/) — you will paste them into profile files in Task 1 / Task 7.
Implementation Tasks
Task 1: Create profiles.d/ skeleton (template, .gitignore, first profile)
This task adds the directory structure, the gitignore rule, the checked-in *.sh.example template, and the first real profile (brummel-dev) so the run.sh changes in Task 2/3 have something to load.
Files:
-
Create:
clients/wearos/profiles.d/example-profile.sh.example -
Create:
clients/wearos/profiles.d/brummel-dev.sh(gitignored, local only) -
Modify:
clients/wearos/.gitignore -
Step 1: Create the profiles.d/ directory
mkdir -p clients/wearos/profiles.d
- Step 2: Write the checked-in template
Create clients/wearos/profiles.d/example-profile.sh.example with this exact content:
# Watch build profile template. Copy this file to <profile-name>.sh in the
# same directory (the .sh files are gitignored). The profile name (the file
# basename without .sh) is what you pass to ./run.sh, e.g.:
#
# ./run.sh watch brummel-dev install
#
# Naming convention:
# <user>-<server> e.g. brummel-dev, brummel-minerva, krey-minerva
# Profiles whose name ends in "-dev" are treated as development builds
# (launcher label "Doctate (Dev)" + red "DEV" badge in the UI). All other
# profiles render the production-clean UI.
# Backend URL the watch talks to. Plain HTTP is fine on the LAN.
SERVER_URL="http://minerva.lan:3000"
# Plaintext api_key matching the slug in the server's users.toml.
# Generate with: openssl rand -base64 24
API_KEY="REPLACE_ME_WITH_PLAINTEXT_API_KEY"
# Bash array of ADB serials (one per attached watch this profile may target).
# Find a watch's serial with: ./run.sh devices
# Brummel's watch can appear in multiple profiles; Krey's watch only in hers.
ALLOWED_SERIALS=(
"adb-XXXXXXXX-yyyy._adb-tls-connect._tcp"
)
- Step 3: Add gitignore rules
Append to clients/wearos/.gitignore (existing content is preserved):
profiles.d/*.sh
!profiles.d/*.sh.example
Verify with git check-ignore -v clients/wearos/profiles.d/brummel-dev.sh (after the file exists in Step 4) — expect a match line pointing to profiles.d/*.sh.
- Step 4: Create the brummel-dev profile
Create clients/wearos/profiles.d/brummel-dev.sh filled with real values (substituting <...> placeholders below with values from Prereq 3 and the dev server's plaintext key):
SERVER_URL="http://192.168.178.27:3000"
API_KEY="<plaintext from server's users.toml (laptop) under slug 'brummel'>"
ALLOWED_SERIALS=(
"<Brummel watch serial from Prereq 3>"
)
If the laptop's server/users.toml does not yet have a plaintext api_key for brummel, set one now: replace the current value with openssl rand -base64 24 output and use the same value here.
- Step 5: Verify gitignore handles it correctly
git status clients/wearos/profiles.d/
Expected output (no .sh listed as untracked, only the .sh.example):
?? clients/wearos/profiles.d/example-profile.sh.example
- Step 6: Commit the template + gitignore rule (NOT the local profile)
git add clients/wearos/profiles.d/example-profile.sh.example clients/wearos/.gitignore
git commit -m "feat(wear): add profiles.d/ scaffolding for multi-profile builds
Adds the directory, gitignore rule, and checked-in template. Real profile
files (e.g. brummel-dev.sh) live alongside but are gitignored as they
contain plaintext api_keys.
refs #3"
Task 2: Add load_profile + verify_serial_allowed helpers to run.sh
The two helpers are pure functions that operate on the profile filename and the resolved ADB serial. They die loudly on any inconsistency. They do not yet wire into the dispatch path — that comes in Task 3.
Files:
-
Modify:
clients/wearos/run.sh(insert helpers between the "Target resolution" section ending around line 240 andapply_targetstarting around line 280) -
Step 1: Insert load_profile and verify_serial_allowed before apply_target
Find this block in clients/wearos/run.sh (search for apply_target() {, somewhere around line 280):
apply_target() {
case "$DOCTATE_TARGET" in
watch)
Immediately ABOVE the apply_target() { line, insert:
# --- Profile loading (watch target only) -----------------------------------
# Profiles live in profiles.d/<name>.sh and define SERVER_URL, API_KEY, and
# ALLOWED_SERIALS as a bash array. The .sh files are gitignored — see
# profiles.d/example-profile.sh.example for the format.
PROFILE_NAME=""
SERVER_URL=""
API_KEY=""
ALLOWED_SERIALS=()
load_profile() {
local name="$1"
local file="$SCRIPT_DIR/profiles.d/$name.sh"
if [[ ! -f "$file" ]]; then
local available
available="$(cd "$SCRIPT_DIR/profiles.d" 2>/dev/null && \
ls *.sh 2>/dev/null | sed 's/\.sh$//' | tr '\n' ' ')"
[[ -z "$available" ]] && available="<none — create one in profiles.d/ from the .sh.example template>"
die "Unknown profile '$name'. Available: ${available}"
fi
# Reset to detect missing fields in the profile file.
SERVER_URL=""; API_KEY=""; ALLOWED_SERIALS=()
# shellcheck disable=SC1090
source "$file"
[[ -n "$SERVER_URL" ]] || die "Profile '$name': SERVER_URL missing"
[[ -n "$API_KEY" ]] || die "Profile '$name': API_KEY missing"
[[ "${#ALLOWED_SERIALS[@]}" -gt 0 ]] || die "Profile '$name': ALLOWED_SERIALS empty"
PROFILE_NAME="$name"
}
# Aborts (via die) if $1 is not present in ALLOWED_SERIALS. The error message
# names BOTH the attached serial and the allow-list, so the user can see at a
# glance whether they grabbed the wrong watch or typed the wrong profile.
verify_serial_allowed() {
local serial="$1"
local s
for s in "${ALLOWED_SERIALS[@]}"; do
[[ "$s" == "$serial" ]] && return 0
done
die "Watch serial '$serial' is NOT in ALLOWED_SERIALS for profile '$PROFILE_NAME'.
Allowed: ${ALLOWED_SERIALS[*]}
This refusal protects against installing the wrong key on the wrong watch
(e.g. Brummel-key on Krey's watch → patient data goes to dev server)."
}
- Step 2: Smoke-verify load_profile against the brummel-dev file
The function does not yet run as part of the main dispatch. To verify in isolation, run from clients/wearos/:
bash -c 'source ./run.sh; ' 2>&1 | head -5
This will error out because run.sh runs apply_target at the bottom even when sourced. Instead, use this isolated probe (paste at the shell, replace <expected-url> with the URL you put into brummel-dev.sh):
bash -ec '
SCRIPT_DIR="'"$PWD"'"
die() { echo "ERR: $*" >&2; exit 1; }
PROFILE_NAME=""; SERVER_URL=""; API_KEY=""; ALLOWED_SERIALS=()
load_profile() {
local name="$1"
local file="$SCRIPT_DIR/profiles.d/$name.sh"
[[ -f "$file" ]] || die "no such profile: $name"
SERVER_URL=""; API_KEY=""; ALLOWED_SERIALS=()
source "$file"
[[ -n "$SERVER_URL" ]] || die "SERVER_URL missing"
[[ -n "$API_KEY" ]] || die "API_KEY missing"
[[ "${#ALLOWED_SERIALS[@]}" -gt 0 ]] || die "ALLOWED_SERIALS empty"
PROFILE_NAME="$name"
}
load_profile brummel-dev
echo "OK profile=$PROFILE_NAME url=$SERVER_URL serials=${ALLOWED_SERIALS[*]}"
'
Expected: OK profile=brummel-dev url=http://192.168.178.27:3000 serials=adb-...
- Step 3: Smoke-verify the unknown-profile error path
Reuse the probe above but call load_profile no-such-profile. Expected: ERR: no such profile: no-such-profile, exit code non-zero.
- Step 4: Commit the helper functions
git add clients/wearos/run.sh
git commit -m "feat(wear): add load_profile + verify_serial_allowed helpers
Helpers source profiles.d/<name>.sh and assert SERVER_URL, API_KEY, and
ALLOWED_SERIALS. verify_serial_allowed dies with an explicit cross-watch
warning when the attached serial is not in the profile's allow-list. Not
yet wired into apply_target — that follows in the next commit.
refs #3"
Task 3: Rewire apply_target for watch + extend argument parser
The dispatcher at the bottom of run.sh learns to consume the second positional argument as the profile name when target is watch. apply_target calls load_profile and verify_serial_allowed before any Gradle invocation.
Files:
-
Modify:
clients/wearos/run.sh(dispatch block around line 530,apply_targetbody around line 280) -
Step 1: Replace the watch branch of apply_target
Find this block in apply_target:
watch)
if [[ -z "$ADB_SERIAL" ]]; then
resolve_watch_serial
ADB_SERIAL="$RESOLVED_SERIAL"
fi
local server="${DOCTATE_DEV_SERVER:-$DEFAULT_DEV_SERVER}"
GRADLE_PROPS+=("-Pdoctate.serverUrl=$server")
info "Target: watch (serial=$ADB_SERIAL, server=$server)"
;;
Replace it with:
watch)
[[ -n "$DOCTATE_PROFILE" ]] || die \
"Watch target requires a profile. Usage: ./run.sh watch <profile> <subcmd>
Available profiles: $(cd "$SCRIPT_DIR/profiles.d" 2>/dev/null && ls *.sh 2>/dev/null | sed 's/\.sh$//' | tr '\n' ' ')"
load_profile "$DOCTATE_PROFILE"
if [[ -z "$ADB_SERIAL" ]]; then
resolve_watch_serial
ADB_SERIAL="$RESOLVED_SERIAL"
fi
verify_serial_allowed "$ADB_SERIAL"
GRADLE_PROPS+=(
"-Pdoctate.serverUrl=$SERVER_URL"
"-Pdoctate.apiKey=$API_KEY"
"-Pdoctate.profileName=$DOCTATE_PROFILE"
)
info "Target: watch (profile=$DOCTATE_PROFILE, serial=$ADB_SERIAL, server=$SERVER_URL)"
;;
- Step 2: Drop the deprecated DOCTATE_API_KEY pass-through
Find this block, also in apply_target (a few lines below the watch case, after the emulator/'' cases):
if [[ -n "${DOCTATE_API_KEY:-}" ]]; then
GRADLE_PROPS+=("-Pdoctate.apiKey=$DOCTATE_API_KEY")
fi
Delete it entirely. (The api_key is now sourced from the profile, never from an env var.)
- Step 3: Add DOCTATE_PROFILE declaration to the config section
Find the line near the top of run.sh:
DOCTATE_TARGET="${DOCTATE_TARGET:-}"
Add a new line immediately below it:
DOCTATE_PROFILE="${DOCTATE_PROFILE:-}"
- Step 4: Extend the dispatcher to consume the profile positional
Find this block around line 530 (search for if [[ "${1:-}" == "watch"):
if [[ "${1:-}" == "watch" || "${1:-}" == "emulator" ]]; then
DOCTATE_TARGET="$1"
shift
fi
Replace it with:
if [[ "${1:-}" == "watch" || "${1:-}" == "emulator" ]]; then
DOCTATE_TARGET="$1"
shift
# The watch target requires a profile name as the next positional. The
# emulator target does not — it always uses the hardcoded loopback URL.
if [[ "$DOCTATE_TARGET" == "watch" ]]; then
# Target-free subcommands that follow `watch` directly (none today,
# but kept defensively) should not be misread as a profile name.
case "${1:-}" in
""|help|-h|--help|devices|connect|clean)
# No profile, fall through — apply_target will die with a
# clear usage message if the subcommand actually needs one.
;;
*)
DOCTATE_PROFILE="$1"
shift
;;
esac
fi
fi
- Step 5: Smoke-verify the new dispatch — happy path
From clients/wearos/, run a build-only command (which exercises argument parsing + load_profile + verify_serial_allowed but does not require a connected watch):
./run.sh watch brummel-dev help
Expected: the help text prints normally (since help is a target-free subcommand that short-circuits before apply_target). This proves the profile positional doesn't break the short-circuit path.
- Step 6: Smoke-verify the missing-profile error
./run.sh watch install 2>&1 | head -5
Expected: error message starting with ERR Watch target requires a profile. Usage:... and the list of available profiles. Exit code non-zero.
- Step 7: Smoke-verify the unknown-profile error
./run.sh watch bogus-profile install 2>&1 | head -3
Expected: error message starting with ERR Unknown profile 'bogus-profile'. Available: brummel-dev. Exit code non-zero. (apply_target is reached but load_profile dies before Gradle.)
- Step 8: Commit the dispatch changes
git add clients/wearos/run.sh
git commit -m "feat(wear): wire watch profile into apply_target + argument parser
./run.sh watch <profile> <subcmd> [args]
The second positional after \`watch\` is now the profile name. apply_target
calls load_profile + verify_serial_allowed before Gradle, so a wrong-watch
install refuses fast without burning a build. DOCTATE_API_KEY pass-through
is gone — keys come from the profile file only.
refs #3"
Task 4: Rewrite the help-text block and drop deprecated env vars
The help/usage comment block at the top of run.sh still references the old DOCTATE_DEV_SERVER, DOCTATE_API_KEY, and the (server=$DOCTATE_DEV_SERVER) watch-target description. This task aligns the documentation with the new CLI form and removes the now-unused DEFAULT_DEV_SERVER constant.
Files:
-
Modify:
clients/wearos/run.sh(the leading comment block, lines 1–55, plus theDEFAULT_DEV_SERVERline around 64) -
Step 1: Replace the help-text block
Replace lines 1–55 (everything from #!/usr/bin/env bash through the last # SKIP_INSTRUMENTED set to 1 ... line, inclusive) with:
#!/usr/bin/env bash
# Build / install / run helper for the Wear OS app.
#
# Usage:
# ./run.sh <target> [profile] <subcommand> [args]
#
# A target prefix is REQUIRED for every device-bound subcommand. The script
# never silently picks "the first attached device" — if more than one candidate
# exists it asks, and if ADB_SERIAL/WEAR_AVD pins a choice it uses that.
#
# Target prefix (or DOCTATE_TARGET env var):
# watch <profile>
# Real Pixel Watch over ADB-WiFi. <profile> is the basename of
# a file in profiles.d/<profile>.sh that holds SERVER_URL,
# API_KEY, and ALLOWED_SERIALS (see profiles.d/*.sh.example).
# The build refuses to proceed if the attached watch's ADB
# serial is not listed in the profile's ALLOWED_SERIALS — this
# protects against installing the wrong key on the wrong watch.
# emulator Wear OS AVD on this host. No profile — uses the hardcoded
# emulator-loopback URL (10.0.2.2:3000) and the api_key from
# the profile file 'emulator-default.sh' if present, otherwise
# the build's MISSING_API_KEY sentinel.
# - 0 emulators running → menu of AVDs from `emulator -list-avds`
# - ≥1 running → menu of running serials + "Start a new AVD"
# To bypass the menu pin the choice via ADB_SERIAL=<serial> or
# WEAR_AVD=<name>.
#
# Subcommands that need a target:
# (none) install + start (default inner loop)
# build ./gradlew :app:assembleDebug
# install ./gradlew :app:installDebug
# start adb shell am start ...
# stop adb shell am force-stop ...
# logcat adb logcat, filtered to the app's PID
# shot [path] screencap to PNG (default /tmp/wear_screen.png)
# test run JVM unit tests, plus instrumented tests on the target
# (set SKIP_INSTRUMENTED=1 to skip the on-device tier)
#
# Target-free subcommands (run without a prefix):
# devices list attached devices
# connect [host:port]
# adb connect to a Pixel Watch over WiFi and remember
# the serial in .watch_serial. Without an argument:
# reuse the cached serial.
# clean ./gradlew clean
# help show this help
#
# Env vars:
# DOCTATE_TARGET Same as the <target> prefix (watch|emulator).
# The CLI prefix takes precedence when both are set.
# DOCTATE_PROFILE Same as the [profile] positional. The CLI value wins.
# ADB_SERIAL Pin a specific device serial. Skips the menu entirely
# AND the profile allow-list (it is still verified — pinning
# to a non-allowed serial dies with the same refusal).
# WEAR_AVD AVD name to auto-boot when no emulator is attached
# (skips the AVD menu; useful for CI / scripted runs).
# SKIP_INSTRUMENTED set to 1 to limit `test` to JVM unit tests (no device needed).
(Note: the line about emulator-default.sh is forward-looking — the emulator path keeps its hardcoded URL today; if you later want emulator-specific keys, add profiles.d/emulator-default.sh and adapt the emulator branch in apply_target. Out of scope for this plan.)
- Step 2: Remove the DEFAULT_DEV_SERVER constant
Find this line (around line 64):
DEFAULT_DEV_SERVER="http://192.168.178.27:3000"
Delete it. It is no longer referenced (Task 3 already removed the use site).
- Step 3: Verify the script still parses and the help works
./run.sh help | head -20
Expected: the new help text from Step 1, no shell-parse errors.
- Step 4: Verify no remaining references to deprecated symbols
grep -nE "DOCTATE_DEV_SERVER|DOCTATE_API_KEY|DEFAULT_DEV_SERVER" clients/wearos/run.sh
Expected: no matches.
- Step 5: Commit
git add clients/wearos/run.sh
git commit -m "docs(wear): rewrite run.sh help-text for profile-based builds
Drop DOCTATE_DEV_SERVER, DOCTATE_API_KEY, and DEFAULT_DEV_SERVER — the
profile file is the only source for URL + key now.
refs #3"
Task 5: Clean up local.properties
The doctate.apiKey=... line in clients/wearos/local.properties was the sticky per-developer default for the old single-profile setup. With profiles canonical, it becomes a confusing second source of truth. The build.gradle.kts fallback chain (Gradle property → local.properties → hardcoded sentinel) still reads it if present, so leaving stale values in there would silently override an unset profile API_KEY in some failure modes.
Files:
-
Modify:
clients/wearos/local.properties(this file is gitignored, so the change is local-only — the commit only touches build.gradle.kts in Step 3) -
Modify:
clients/wearos/app/build.gradle.ktsto drop thelocal.propertiesfallback fordoctate.apiKeyanddoctate.serverUrl -
Step 1: Inspect the current local.properties
cat clients/wearos/local.properties
If it contains doctate.apiKey=... and nothing else, delete the whole file. If it contains other keys (e.g. sdk.dir=...), edit it and delete only the doctate.apiKey and any doctate.serverUrl lines, keeping the rest.
- Step 2: Update build.gradle.kts to drop the local.properties fallback for doctate. keys*
Find this block in clients/wearos/app/build.gradle.kts:
// Build-time config resolved in this order:
// 1. Gradle CLI property (-Pdoctate.serverUrl=...) — used by run.sh
// to switch between emulator and watch builds without touching
// local.properties.
// 2. local.properties (gitignored) — sticky per-developer default.
// 3. Hardcoded fallback — emulator loopback + sentinel key.
val localProps = Properties().apply {
rootProject.file("local.properties").takeIf { it.exists() }
?.inputStream()?.use { load(it) }
}
fun resolveProp(key: String, default: String): String =
(project.findProperty(key) as? String)
?: localProps.getProperty(key)
?: default
buildConfigField(
"String", "SERVER_URL",
"\"${resolveProp("doctate.serverUrl", "http://10.0.2.2:3000")}\""
)
buildConfigField(
"String", "API_KEY",
"\"${resolveProp("doctate.apiKey", "MISSING_API_KEY")}\""
)
Replace it with:
// Build-time config resolved in this order:
// 1. Gradle CLI property (-Pdoctate.serverUrl=...) — set by run.sh
// from the active profile (profiles.d/<name>.sh).
// 2. Hardcoded fallback — emulator loopback + sentinel key. This
// path is only taken for the `emulator` target, which has no
// profile concept.
fun resolveProp(key: String, default: String): String =
(project.findProperty(key) as? String) ?: default
buildConfigField(
"String", "SERVER_URL",
"\"${resolveProp("doctate.serverUrl", "http://10.0.2.2:3000")}\""
)
buildConfigField(
"String", "API_KEY",
"\"${resolveProp("doctate.apiKey", "MISSING_API_KEY")}\""
)
Also remove the now-unused import at the top of the file:
import java.util.Properties
- Step 3: Verify the watch build still configures
From clients/wearos/:
./gradlew :app:tasks --quiet | head -5
Expected: no Kotlin compile errors, task list prints. (We don't run a full build yet — that comes in Task 6.)
- Step 4: Commit
git add clients/wearos/app/build.gradle.kts
git commit -m "refactor(wear): drop local.properties fallback for doctate.* keys
Profile file (via -Pdoctate.* injected by run.sh) is now the only source.
local.properties stays gitignored but is no longer consulted by the build.
refs #3"
Task 6: Mid-implementation smoke-test — install brummel-dev on the watch
Before adding the other two profiles, prove the brummel-dev path works end-to-end against a real watch. This is the canary build.
Files: none modified — verification only.
Precondition: Brummel's watch is connected via ADB-WiFi (run ./run.sh connect from clients/wearos/ if not already), and the laptop dev server is running on 192.168.178.27:3000.
- Step 1: Verify the watch is attached
From clients/wearos/:
./run.sh devices
Expected: at least Brummel's watch serial in the listing.
- Step 2: Install with brummel-dev
./run.sh watch brummel-dev install
Expected: Target: watch (profile=brummel-dev, serial=adb-XXX..., server=http://192.168.178.27:3000) log line, Gradle build completes, install succeeds. If verify_serial_allowed dies at this step, the watch serial in profiles.d/brummel-dev.sh does not match — fix the profile file and retry.
- Step 3: Start the app and confirm it can reach the dev server
./run.sh watch brummel-dev start
Then on the watch: open the app, observe the case list loads (oneliners come back from the server). On the laptop:
tail -n 50 ~/.local/share/doctate/logs/server.log
# or wherever the dev-server logs go — adjust to your setup
Expected: a 200 response for a GET /api/oneliners from the watch's IP within a few seconds of opening the app.
- Step 4: Verify the cross-watch refusal (if you have access to a second watch right now)
If a second watch (or any other ADB-WiFi device) is also connected, run:
ADB_SERIAL=<other-device-serial> ./run.sh watch brummel-dev install
Expected: hard refusal with the verify_serial_allowed error message naming both the attached serial and the allow-list. If you do not have a second watch attached, skip this step — Task 11 will cover it as part of the full matrix.
- Step 5: No commit (verification only)
If the smoke-test passes, proceed to Task 7. If it fails, debug the failure before continuing — fixes to run.sh get folded into Task 2 or Task 3 retroactively (squash via interactive rebase or amend the most recent commit if not yet pushed).
Task 7: Add the remaining profiles (brummel-minerva, krey-minerva)
Files:
-
Create:
clients/wearos/profiles.d/brummel-minerva.sh(gitignored) -
Create:
clients/wearos/profiles.d/krey-minerva.sh(gitignored) -
Step 1: Create brummel-minerva.sh
cat > clients/wearos/profiles.d/brummel-minerva.sh <<'EOF'
SERVER_URL="http://minerva.lan:3000"
API_KEY="<plaintext from Prereq 1 — minerva's users.toml under slug 'brummel'>"
ALLOWED_SERIALS=(
"<Brummel watch serial from Prereq 3 — same as in brummel-dev.sh>"
)
EOF
Edit the file to replace both <...> placeholders with the real values from Prereq 1 and Prereq 3.
- Step 2: Create krey-minerva.sh
cat > clients/wearos/profiles.d/krey-minerva.sh <<'EOF'
SERVER_URL="http://minerva.lan:3000"
API_KEY="<plaintext from Prereq 2 — minerva's users.toml under slug 'krey'>"
ALLOWED_SERIALS=(
"<Krey watch serial from Prereq 3>"
)
EOF
Edit the file to replace both <...> placeholders.
- Step 3: Verify both files are gitignored
git status clients/wearos/profiles.d/
Expected: neither brummel-minerva.sh nor krey-minerva.sh shows up as untracked.
- Step 4: Verify load_profile sees both new profiles
./run.sh watch bogus install 2>&1 | head -3
Expected: the Available: line in the error now lists brummel-dev brummel-minerva krey-minerva.
- Step 5: No commit (profile files are local-only)
Task 8: Add PROFILE_NAME, IS_DEV_PROFILE, and appLabel placeholder
Inject the profile name into the build and derive a IS_DEV_PROFILE boolean and the launcher label placeholder.
Files:
-
Modify:
clients/wearos/app/build.gradle.kts -
Step 1: Resolve profileName and derive isDevProfile
In clients/wearos/app/build.gradle.kts, find the block from Task 5 Step 2 (the one with fun resolveProp and the two existing buildConfigField calls). Immediately above the first buildConfigField call, add:
val profileName = resolveProp("doctate.profileName", "unknown")
val isDevProfile = profileName.endsWith("-dev")
- Step 2: Add the two new BuildConfig fields
Immediately after the existing buildConfigField("String", "API_KEY", ...) call, add:
buildConfigField(
"String", "PROFILE_NAME",
"\"$profileName\""
)
buildConfigField(
"boolean", "IS_DEV_PROFILE",
"$isDevProfile"
)
- Step 3: Set the manifest placeholder for the app label
Still inside defaultConfig { ... }, immediately after the four buildConfigField calls, add:
manifestPlaceholders["appLabel"] = if (isDevProfile) "Doctate (Dev)" else "Doctate"
- Step 4: Verify the configuration compiles
./run.sh watch brummel-dev build
Expected: build succeeds. The generated BuildConfig.java (under clients/wearos/app/build/generated/source/buildConfig/...) should contain PROFILE_NAME = "brummel-dev" and IS_DEV_PROFILE = true.
- Step 5: Verify BuildConfig values for a non-dev profile
./run.sh watch brummel-minerva build
grep -nE 'PROFILE_NAME|IS_DEV_PROFILE' \
clients/wearos/app/build/generated/source/buildConfig/debug/com/doctate/watch/BuildConfig.java
Expected: public static final String PROFILE_NAME = "brummel-minerva"; and public static final boolean IS_DEV_PROFILE = false;.
- Step 6: Commit
git add clients/wearos/app/build.gradle.kts
git commit -m "feat(wear): expose PROFILE_NAME and IS_DEV_PROFILE to the app
BuildConfig now carries the active profile name plus an isDev boolean
(profile name ends in '-dev'). The manifestPlaceholder 'appLabel'
switches between 'Doctate' and 'Doctate (Dev)' for the launcher.
refs #3"
Task 9: Wire appLabel placeholder into AndroidManifest.xml
Files:
-
Modify:
clients/wearos/app/src/main/AndroidManifest.xml -
Step 1: Replace the hardcoded app label
In clients/wearos/app/src/main/AndroidManifest.xml, find:
android:label="@string/app_name"
(This is in the <application> tag, around line 18.)
Replace it with:
android:label="${appLabel}"
Leave the android:label="@string/tile_label" on the tile service and android:label="@string/app_name" on the complication service unchanged — those identify the tile/complication choices in the system UI, not the launcher icon.
- Step 2: Verify the build produces the right manifest for brummel-dev
./run.sh watch brummel-dev build
unzip -p clients/wearos/app/build/outputs/apk/debug/app-debug.apk AndroidManifest.xml \
| aapt2 dump xmltree --file - 2>/dev/null \
| grep -A1 'application' | head -20
If aapt2 is not on PATH, fall back to:
"$HOME/Android/Sdk/build-tools/"*/aapt2 dump xmltree clients/wearos/app/build/outputs/apk/debug/app-debug.apk --file AndroidManifest.xml \
| grep -A1 'application' | head -20
Expected: a label attribute with the value Doctate (Dev) in the application element.
- Step 3: Verify for brummel-minerva
./run.sh watch brummel-minerva build
"$HOME/Android/Sdk/build-tools/"*/aapt2 dump xmltree clients/wearos/app/build/outputs/apk/debug/app-debug.apk --file AndroidManifest.xml \
| grep -A1 'application' | head -20
Expected: label attribute is Doctate (no (Dev) suffix).
- Step 4: Install on the watch and confirm the launcher label visually
./run.sh watch brummel-dev install
Then on the watch: open the app drawer, look for the Doctate icon — its label should read Doctate (Dev).
Reinstall with the other profile:
./run.sh watch brummel-minerva install
App drawer should now show Doctate without the suffix.
- Step 5: Commit
git add clients/wearos/app/src/main/AndroidManifest.xml
git commit -m "feat(wear): use appLabel placeholder for launcher label
Dev profiles (name ends in -dev) get 'Doctate (Dev)' in the launcher;
production profiles keep the plain 'Doctate'. The string resource
@string/app_name is no longer the application-level label, only used
for the complication entry where the launcher distinction is irrelevant.
refs #3"
Task 10 (OPTIONAL): DevBadge composable and integration
This task is the visual-badge layer from the spec. It is classified as nice-to-have — the hard-lock in run.sh already prevents the worst-case misinstall. The badge protects against the softer case of Brummel forgetting which profile is currently active. Skip the whole task if you want to ship faster.
Files:
-
Create:
clients/wearos/app/src/main/java/com/doctate/watch/presentation/components/DevBadge.kt -
Modify:
clients/wearos/app/src/main/java/com/doctate/watch/presentation/CaseListScreen.kt -
Modify:
clients/wearos/app/src/main/java/com/doctate/watch/presentation/RecordingScreen.kt -
Step 1: Create the DevBadge composable
Create clients/wearos/app/src/main/java/com/doctate/watch/presentation/components/DevBadge.kt:
package com.doctate.watch.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.Text
import com.doctate.watch.BuildConfig
@Composable
fun DevBadge(modifier: Modifier = Modifier) {
if (!BuildConfig.IS_DEV_PROFILE) return
Text(
text = "DEV",
color = Color(0xFFFF5252),
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold),
modifier = modifier
.background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(4.dp))
.padding(horizontal = 4.dp, vertical = 1.dp),
)
}
- Step 2: Mount in CaseListScreen
In clients/wearos/app/src/main/java/com/doctate/watch/presentation/CaseListScreen.kt, add this import alongside the existing imports (alphabetical order — between com.doctate.watch.DoctateApp and com.doctate.watch.domain.CaseEntry):
import com.doctate.watch.presentation.components.DevBadge
The ScreenScaffold's content slot is a BoxScope (verifiable: the existing code in RecordingScreen.kt uses Modifier.align(Alignment.BottomCenter) on the EdgeButton inside ScreenScaffold). So Alignment.TopEnd works directly.
Find the line ) { contentPadding -> (around line 63). Insert the badge as the FIRST composable inside that lambda, immediately after the { contentPadding -> opening:
) { contentPadding ->
DevBadge(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(end = 4.dp, top = 2.dp),
)
if (displayed.isEmpty()) {
This places the badge at the top-right corner of the screen, layered on top of the list content. In a non-dev build the composable returns immediately and renders nothing.
- Step 3: Mount in RecordingScreen
Open clients/wearos/app/src/main/java/com/doctate/watch/presentation/RecordingScreen.kt. Add the import alongside the existing imports (alphabetical order — after androidx.wear.compose.material3.Text):
import com.doctate.watch.presentation.components.DevBadge
Find the line ScreenScaffold { contentPadding -> (around line 81). Insert the badge as the FIRST composable inside that lambda, immediately after the { contentPadding -> opening, before the existing Column(...):
ScreenScaffold { contentPadding ->
DevBadge(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(end = 4.dp, top = 2.dp),
)
Column(
modifier = Modifier
.fillMaxSize()
Same alignment idiom as CaseListScreen — top-right corner, layered above the recording counter and the EdgeButton. No-op in production builds.
- Step 4: Build + visually verify both profiles
./run.sh watch brummel-dev install
Open the app on the watch. Expected: red "DEV" badge visible in the top-right of both the case list and the recording screen.
./run.sh watch brummel-minerva install
Reopen the app. Expected: no badge visible anywhere — UI looks production-clean.
- Step 5: Commit
git add clients/wearos/app/src/main/java/com/doctate/watch/presentation/components/DevBadge.kt \
clients/wearos/app/src/main/java/com/doctate/watch/presentation/CaseListScreen.kt \
clients/wearos/app/src/main/java/com/doctate/watch/presentation/RecordingScreen.kt
git commit -m "feat(wear): add DevBadge visible only in dev-profile builds
Small red 'DEV' tag in the top-right of CaseListScreen and RecordingScreen
when BuildConfig.IS_DEV_PROFILE is true. Production profiles render
nothing — Krey's UI stays clean.
refs #3"
Task 11: Full smoke-test matrix
Run the full spec matrix end-to-end. Prereq 1 and Prereq 2 must be complete before any minerva-server row in this matrix can pass.
Files: none modified — verification only.
- Step 1: Row 1 — brummel-dev on Brummel's watch (happy path)
Watch attached: Brummel's.
./run.sh watch brummel-dev install && ./run.sh watch brummel-dev start
Expected:
-
Install succeeds.
-
App reaches the laptop dev server at
192.168.178.27:3000. -
Server log shows 200 on
/api/onelinersfrom the watch. -
Launcher label is
Doctate (Dev). -
(Optional)
DEVbadge visible in the UI. -
Step 2: Row 2 — brummel-dev on Krey's watch (hard refusal)
Watch attached: Krey's (or any device whose serial is NOT in profiles.d/brummel-dev.sh::ALLOWED_SERIALS).
./run.sh watch brummel-dev install 2>&1 | tail -10
Expected: hard refusal with the verify_serial_allowed error, listing both the attached serial and the allow-list, no Gradle invocation.
- Step 3: Row 3 — brummel-minerva on Brummel's watch
Watch attached: Brummel's.
./run.sh watch brummel-minerva install && ./run.sh watch brummel-minerva start
Expected:
-
Install succeeds.
-
App reaches
minerva.lan:3000(verify via the minerva server's container log). -
Launcher label is plain
Doctate(no(Dev)suffix). -
No DEV badge.
-
Step 4: Row 4 — krey-minerva on Krey's watch
Watch attached: Krey's.
./run.sh watch krey-minerva install && ./run.sh watch krey-minerva start
Expected:
-
Install succeeds.
-
App reaches
minerva.lan:3000and authenticates as slugkrey(verify in the minerva server log that the X-API-Key resolves tokrey). -
Launcher label is plain
Doctate. -
Step 5: Row 5 — krey-minerva on Brummel's watch (hard refusal)
Watch attached: Brummel's.
./run.sh watch krey-minerva install 2>&1 | tail -10
Expected: hard refusal — Brummel's serial is not in krey-minerva.sh::ALLOWED_SERIALS.
- Step 6: Row 6 — unknown profile
./run.sh watch nonexistent install 2>&1 | tail -5
Expected: error listing available profiles (brummel-dev brummel-minerva krey-minerva).
- Step 7: Row 7 — missing subcommand after profile
./run.sh watch brummel-dev 2>&1 | tail -5
Expected: the default no-subcommand path runs cmd_install; cmd_start. (This is by design — the bare-watch path was "install + start" before, the profile positional doesn't change that. If you'd rather see an error here, that's a separate design tweak.)
- Step 8: Row 8 — emulator path unchanged
./run.sh emulator install
(Requires a Wear OS AVD; pick one from the menu when prompted.)
Expected: build + install succeeds, the emulator path is unaffected, no profile required.
- Step 9: Wrap-up
If all rows pass: this plan is done. Close Issue #3 with a comment linking the merged commit range (git log --oneline <first-task-1-commit>..HEAD -- clients/wearos).
If any row fails: open a new commit fixing the regression, retry the affected row, and re-run the full matrix on green.
- Step 10: Push
git push origin main
(Or whichever branch this work lives on if not main — adjust accordingly.)