AGENTS.md — SailEver for Wear OS (apps/android)¶
Guide for agents working on the Wear OS app: how to build, test, run and find
code. What the app does is specified in
../../docs/specs/watchapp/, the same spec the
Garmin app implements. Read the workspace ../../AGENTS.md
(Definition of Done, glossary, spec rules) first. Open release work:
../../docs/plans/wear_os_release.md. Store
release: play/README.md.
The app is a port of the Garmin watch app (../../apps/garmin-watchapp, Monkey C,
guide: ../../apps/garmin-watchapp/AGENTS.md). Its
three instrument screens are drawn line by line like the Garmin views. The
reference for both is the spec; where the two apps differ without a platform
reason, the gap is listed in
parity.md. A change here that isn't
Wear OS-specific goes to Garmin too (/port-change).
1. Commands¶
mise android:watchapp [avd] # emulator (started if needed) + installDebug + launch; never the real watch
mise android:watchapp-device # release build (debug-signed) onto the OnePlus Watch 2; refuses while it records
mise android:check # the Definition of Done gates below
By hand (what the tasks run):
cd apps/android
./gradlew :core:test # JVM unit tests: SLTB golden vectors, session, COG, sun/moon, settings
./gradlew :wear:assembleDebug # debug APK (API http://10.0.2.2:3000/api/v1)
./gradlew :wear:installDebug # onto the running emulator / watch (kills a running recording!)
./gradlew lintDebug # must report 0 errors
./gradlew :wear:bundleRelease # release .aab (https://api.sailever.com/api/v1), see play/README.md
Toolchain: Gradle 9.8 wrapper, AGP 9.4, Kotlin 2.4, compile/target SDK 37,
minSdk 30 (Wear OS 3). local.properties points to the Android SDK.
2. Architecture¶
:core (plain Kotlin/JVM, no Android) :app (Android, Compose for Wear OS)
VesselState, Palette SailEverApplication singletons, owns the TripEngine
SltbEncoder, Sltb (crc16, semicircles) recording/TripEngine 1 Hz engine = Garmin TripContext + DataModel
TrackSession, TrackQueue, Totals recording/TrackingService foreground service: GPS, barometer, tick
CogCalculator, BaroIndicator, SpeedChart recording/Feedback vibration + tones for overlays
SunCalc, MoonCalc, Settings data/TripStore SharedPreferences (= Garmin TripStore)
AlertManager, AutoStatsTracker, AutoStats data/TokenCipher Keystore AES-GCM for the device token
sync/SyncEngine, Http, DeviceApi uploads, pairing, registration
presentation/… screens, navigation, input
tile/, complication/ live surfaces
- Threading: everything runs on the main thread (sensor callbacks, the 1 Hz
tick, UI reads of engine fields) except the HTTP call itself, which runs on a
single worker thread and posts its result back. Do not touch
TripEngineorTrackQueuefrom another thread. - Lifetime: the
TripEnginelives inSailEverApplication.engine(StateFlow), not in the activity.TrackingServicekeeps the process alive while a track records (not while paused) and callsengine.onTick()every second. - UI refresh:
engine.ticks(aStateFlowbumped on every change) drives the canvases; the overlay is Compose state (engine.overlay). A screen that reads engine fields must readticksin the same composable scope, or pass it as the canvaskey.
3. Source map¶
Path (wear/src/main/java/com/sailever/watchapp/…) |
Garmin counterpart | Notes |
|---|---|---|
presentation/MainActivity.kt |
SailEverApp.mc |
permissions, ambient observer, stem keys, crown routing |
presentation/SailEverApp.kt |
StartMenu.mc, delegates |
navigation stack (Screen), all flows: start / resume / pause / stop / discard / sync / pairing / settings |
presentation/Nav.kt |
— | Screen sealed class, Stem |
presentation/TrackingScreen.kt |
TripDelegate.mc, ActionMenu.mc |
Controls page + vertical instrument pager, crown, taps, stems, touch lock, overlays, ambient |
presentation/Menus.kt |
Menu2 |
MenuScreen: Wear list; with focusIndex the crown moves a highlight and a stem confirms |
presentation/GarminCanvas.kt |
View.onUpdate |
full-screen canvas giving a Dc |
presentation/Gestures.kt |
onBack |
in-app swipe-right-to-go-back |
presentation/gfx/Dc.kt |
Graphics.Dc, fonts |
Connect IQ drawing API on android.graphics.Canvas; Fonts = sog / numerals / header sheets + system font sizes |
presentation/views/DashboardView.kt |
views/DashboardView.mc + Chart.mc |
screen 1 |
presentation/views/TrackView.kt |
views/TrackView.mc |
screen 2, zoom steps, tap zones |
presentation/views/EnvironmentView.kt |
views/EnvironmentView.mc |
screen 3, measured layout with degradation order |
presentation/views/OtherViews.kt |
StatsSummaryView, AlertView, AutoStatsView, SyncView, PairingView, SplashView |
plus AmbientView (the low-power layout, Wear only); no PauseView (Wear OS pauses into the background) |
recording/TripEngine.kt |
TripContext.mc, DataModel.mc |
totals, state periods, ring buffer (600 × 5 s), SLTB feed, overlays |
recording/TrackingService.kt |
Position, Activity |
FusedLocation 1 Hz, TYPE_PRESSURE, battery, wake lock, Ongoing Activity |
data/TripStore.kt |
TripStore.mc, SettingsStore.mc storage |
trip, paused track, last state, day totals, device uid / token, settings |
sync/SyncEngine.kt |
sync/SyncEngine.mc |
serial, one attempt per file, first failure ends the job, 401 clears the token, tides |
sync/Http.kt |
Communications, sync/Net.mc |
every request goes through here; blocked without consent; requests an internet network per call (see §8) |
core/…/Consent.kt |
consent functions in TripStore.mc, sync/Net.mc |
privacy consent: version, upgrade rule, unpair, the request gate |
sync/DeviceApi.kt |
DeviceRegistration.mc, PairingView.mc |
register, pairing code, poll |
core/…/TrackSession.kt, TrackQueue.kt |
sync/TrackSession.mc, TrackQueue.mc |
files in filesDir/session, filesDir/sltb |
4. Screens and input¶
Behaviour and the full Garmin → Wear OS control mapping:
input.md. Where it lives in code:
| Behaviour | Code |
|---|---|
| Crown / vertical swipe pages the instruments | TrackingScreen rotary handler, pageBy; crown read in MainActivity.dispatchGenericMotionEvent (§10) |
| Swipe right: Controls page | ControlsPage, controlsVisit |
| Tap the state header: picker | DashboardView.headerBand, Screen.StatePicker |
| Track view zoom taps / crown zoom mode | TrackView.zoomForTap |
| Stem buttons | MainActivity.onKeyDown |
| Swipe left: vessel state picker page | StatePickerPage (PAGE_PICKER) |
| Screen kept on while recording, low-power layout after 10 s idle | TrackingScreen (keepScreenOn, dark), MainActivity.keepScreenOn |
| Overlay swallows all input | swallowAll |
| Touch lock | TrackingUiState.touchLocked |
| In-app swipe back | Modifier.swipeBack (window swipe-to-dismiss is off in MainActivityTheme) |
5. Recording lifecycle¶
Behaviour: lifecycle.md and
recording.md. Implementation notes:
- Start:
app.newEngine(token, state),engine.startRecording(resume),TrackingService.start; the trip token comes fromTripStore.startNewTrip. - Pause:
PausedTrackin prefs, open SLTB segment closed, service stopped. "Resume track" rebuilds the engine from it. - Process death while recording: on the next launch without an engine,
recoverIfCrashed()ends the stored session with END reason=2. Installing a new APK kills a running recording this way.
6. Storage¶
- SharedPreferences
sailever(TripStore):trip_token,trip_track_count,trip_*_nm,last_vessel_state,paused_track(PausedTrack.serialize),day_stats(_date),device_uid,device_token(encrypted),reg_app_version,consent_version(privacy consent, never sent), settings keyscfg_*(same names as Garmin). - Files:
filesDir/sltb/<trackId>_<seq>.sltb+queue.idx(one line per entry:trackId,seq,final,tripToken,size,discard),filesDir/session/track.stateandopen.state(5 min checkpoint). Directories are recreated on every write. - Backup and device transfer exclude everything (
xml/data_extraction_rules.xml).
7. Porting a change from the Garmin app¶
- Find the Monkey C change (
../../apps/garmin-watchapp/source/…) and its Kotlin counterpart in the table in §3. - Pure logic goes to
:corewith a unit test. Drawing goes to the view object, line by line: same fractions of width/height, same colours (Paletteholds the Connect IQ named colours), same fonts (dc.fonts.sog / numerals / header / xtiny / tiny / small), sameJustifyflags.Dckeeps Connect IQ semantics (arc angles counter-clockwise from 3 o'clock,drawRectangleinside the box,VCENTERon the font's line box). - Strings stay English literals as on Garmin (the UI is not localised yet).
- Compare a screenshot on the emulator with the Garmin simulator for the same data.
8. Network, pairing and sync¶
- Debug:
http://10.0.2.2:3000/api/v1(the host'sbin/rails server); release:https://api.sailever.com/api/v1(BuildConfig.API_BASE_URL). - Wear OS gives an app Wi-Fi / LTE only on request when no phone is connected
over Bluetooth:
HttpcallsConnectivityManager.requestNetwork(INTERNET)before every request and releases it after. Without this, sockets time out. - Android 17 local network permission: the debug manifest
(
src/debug/AndroidManifest.xml) addsACCESS_LOCAL_NETWORKand a cleartext network config for10.0.2.2/127.0.0.1. Release has neither. - Platform sent at registration / pairing:
wear_os(Device::PLATFORMS). - Uploads: one silent attempt per closed segment (15 s in, hourly, 4 KB cap),
the save view on Stop, "Upload tracks" for leftovers. Tides come back in the
upload response (
SyncEngine.onTides→TripEngine.applyTides).
9. Running it on the emulator¶
SDK=~/Library/Android/sdk; A=$SDK/platform-tools/adb
$A emu power ac on; $A shell svc power stayon true # otherwise it dozes into ambient and taps are lost
./gradlew :wear:installDebug && $A shell am start -n com.sailever/com.sailever.watchapp.presentation.MainActivity
# Pair: open "Pair device", read the code, then claim it as the web would (dev DB!):
# cd ../apps/server && bin/rails runner 'd = Device.where("pairing_expires_at > ?", Time.current)
# .find_by(pairing_code_hash: Digest::SHA256.hexdigest("123456")); d.update!(user: User.first, paired_at: Time.current,
# token_hash: nil, pairing_code_hash: nil, revoked_at: nil)'
# Sail: replay a simulated track (see ../../tools/tracksim/README.md); adb must be on PATH
PATH="$SDK/platform-tools:$PATH" mise tracksim play 2 --target android --speed 10
# Input
$A shell input rotaryencoder scroll --axis SCROLL,-1 # crown: next page / down the list
$A shell input keyevent KEYCODE_STEM_1 # stem button
$A shell input swipe 60 190 340 190 250 # swipe right (Controls)
$A exec-out screencap -p > shot.png
$A logcat -s SailEver # HTTP and segment log
tracksim playsendsgeo fixwithout a heading;TripEngine.withDerivedMotionderives bearing (and speed if missing) from consecutive fixes.- At
--speed 10the boat moves 10× faster than the watch clock, so SOG and the uploaded track's speeds read 10× too high. Use--speed 1for screenshots. - The emulator clock can lag after a snapshot restore; timestamps follow it.
- Clearing
files/under the running app was the cause of a past bug; the queue now recreates its directory, but preferadb shell pm clear com.sailever(then re-grant permissions) for a clean slate.
Physical test watch: OnePlus Watch 2¶
Wear OS 4 (API 34), 466 × 466 px, model OPWWE231, developer options on; it
shows up in adb devices as H631105000004E4C001516. With the emulator also
running, pass the serial: adb -s <serial> … and
ANDROID_SERIAL=<serial> ./gradlew :wear:installDebug.
- No usable buttons, no rotating crown: there is no rotary input device. The
crown press is
KEY_VOLUMEDOWN(system navigation), the bottom buttonKEY_POWER, the OnePlus "multifunction" key. Neither reaches apps. The bottom button can launch an app (settings get system multifunction_single_shortcut_component, factory valuecom.heytap.wearable.sports/com.heytap.wearable.sports.MainActivity), but the system cancels the shortcut while that app is in front (ShortcutKeyCtrl: cancelPendingShortcutKeyAction), so it can't act as a SailEver button. - No ambient for apps: even with Always-on screen on, the display switches
fully off at the timeout (
mWakefulness=Asleep,display is off) and the lock screen comes up off-wrist. Hence the app keeps the screen on while recording. - Apps may not read
Settings.Globalambient_enabledhere (SecurityException). - Over Wi-Fi debugging the watch drops off adb when the screen goes off;
svc power stayononly works while charging. adb shell getevent -ltshows raw key presses;adb logcat | grep -E "ShortcutKeyCtrl|NaviKeyCtrl"shows what the system did with them.
10. Pitfalls already paid for¶
| Symptom | Cause | Fix in place |
|---|---|---|
| Instrument pages render black | androidx.compose.foundation VerticalPager nested in a HorizontalPager |
Use the Wear pagers (androidx.wear.compose.foundation.pager) |
| Crown stops paging after the first page change | Pager pages take Compose focus, rotary events go elsewhere | Crown read in MainActivity.dispatchGenericMotionEvent, routed via RotarySlot |
| Overlay never closes | Plain field read in a scope that never recomposes | overlay is Compose state |
| Second clock over the ambient screen | A ScreenScaffold inside the pager registered TimeText with AppScaffold |
MenuScreen(showTime = false) on Controls; AppScaffold(timeText = {}) |
| Uploads time out on the emulator | No requested network; Android 17 LAN permission | §8 |
| Segment "entry null", nothing uploaded | Queue directory deleted under the app, storage marked full | mkdirs() before writes (test recordingSurvivesAWipedDirectory) |
| Ambient after a minute, taps ignored | Emulator not "powered" | adb emu power ac on + svc power stayon true |
11. Definition of Done (Android)¶
In addition to the workspace rules (../../AGENTS.md §0):
./gradlew :core:testpasses; new logic in:corehas a test../gradlew :wear:assembleDebug lintDebugwith 0 lint errors. Do not add lint suppressions or baseline entries without the user's approval.- UI changes are checked on the emulator with screenshots (all three instrument pages if a shared piece changed), for Garmin parity.
- Flows touching recording or sync are run end to end against local Rails
(pair, record with
tracksim play, stop, check the segments in the dev DB), and the side effects on the dev DB are reported. - Docs: the spec (
/spec-update) when behaviour changes, this file when build steps or code layout change, andplay/(data safety, listing) when permissions or uploaded data change. The Garmin app gets the same change (/port-change) or a row inparity.md.