Skip to content

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 TripEngine or TrackQueue from another thread.
  • Lifetime: the TripEngine lives in SailEverApplication.engine (StateFlow), not in the activity. TrackingService keeps the process alive while a track records (not while paused) and calls engine.onTick() every second.
  • UI refresh: engine.ticks (a StateFlow bumped on every change) drives the canvases; the overlay is Compose state (engine.overlay). A screen that reads engine fields must read ticks in the same composable scope, or pass it as the canvas key.

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 from TripStore.startNewTrip.
  • Pause: PausedTrack in 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 keys cfg_* (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.state and open.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

  1. Find the Monkey C change (../../apps/garmin-watchapp/source/…) and its Kotlin counterpart in the table in §3.
  2. Pure logic goes to :core with a unit test. Drawing goes to the view object, line by line: same fractions of width/height, same colours (Palette holds the Connect IQ named colours), same fonts (dc.fonts.sog / numerals / header / xtiny / tiny / small), same Justify flags. Dc keeps Connect IQ semantics (arc angles counter-clockwise from 3 o'clock, drawRectangle inside the box, VCENTER on the font's line box).
  3. Strings stay English literals as on Garmin (the UI is not localised yet).
  4. 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's bin/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: Http calls ConnectivityManager.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) adds ACCESS_LOCAL_NETWORK and a cleartext network config for 10.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 play sends geo fix without a heading; TripEngine.withDerivedMotion derives bearing (and speed if missing) from consecutive fixes.
  • At --speed 10 the boat moves 10× faster than the watch clock, so SOG and the uploaded track's speeds read 10× too high. Use --speed 1 for 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 prefer adb 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 button KEY_POWER, the OnePlus "multifunction" key. Neither reaches apps. The bottom button can launch an app (settings get system multifunction_single_shortcut_component, factory value com.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.Global ambient_enabled here (SecurityException).
  • Over Wi-Fi debugging the watch drops off adb when the screen goes off; svc power stayon only works while charging.
  • adb shell getevent -lt shows 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):

  1. ./gradlew :core:test passes; new logic in :core has a test.
  2. ./gradlew :wear:assembleDebug lintDebug with 0 lint errors. Do not add lint suppressions or baseline entries without the user's approval.
  3. UI changes are checked on the emulator with screenshots (all three instrument pages if a shared piece changed), for Garmin parity.
  4. 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.
  5. Docs: the spec (/spec-update) when behaviour changes, this file when build steps or code layout change, and play/ (data safety, listing) when permissions or uploaded data change. The Garmin app gets the same change (/port-change) or a row in parity.md.