# Code Map & Context Tips **Purpose:** Bootstrap context for any new agent session. Read this before touching code. **Last updated:** 2026-06-30 --- ## 1. Session Start Checklist Run these four commands before anything else: ```bash git branch --show-current # must be main (or a feature branch off main) git checkout main # if not already there git config core.hooksPath scripts/git-hooks # activate pre-commit hook git pull origin main # sync latest ``` Then read `.agent/worklog.md` for current focus and recently completed work. **Merge policy (non-negotiable):** merge to `main` after every complete unit of work, not at session end. --- ## 2. Module Topology ``` nav/ ├── android-app/ Android application (API 36, Kotlin) │ └── app/src/main/kotlin/org/terst/nav/ ← all app source ├── test-runner/ JVM-only test module (no Android SDK) │ └── src/main/kotlin/org/terst/nav/ ← shared portable code ├── .agent/ Agent config, worklog, design, standards ├── scripts/ │ ├── git-hooks/pre-commit blocks pushes to master │ └── .claude/ change templates (ct-*.yaml) └── .github/workflows/android.yml CI; fires on main pushes only ``` **Build note:** `./gradlew assembleDebug` requires network access to Google plugin repo — unavailable in the cloud sandbox. Use `test-runner` for logic tests: ```bash cd test-runner && ./gradlew test ``` --- ## 3. Source Package Map All source lives under `org.terst.nav`. ### Root-level (android-app only) | File | Role | |------|------| | `MainActivity.kt` | Single-activity host; nav graph, SAF picker launcher | | `NavApplication.kt` | Application class; DI/singleton initialization | | `MainViewModel.kt` → `ui/MainViewModel.kt` | Top-level state (GPS, NMEA, mode) | | `LocationService.kt` | Foreground service; provides GPS to background | | `AnchorAlarmManager.kt` | Anchor drag detection; survives screen lock | | `PerformanceViewModel.kt` | Real-time sailing metrics (VMG, polar %, wind triangle) | | `PerformanceViewModelFactory.kt` | Factory for PerformanceViewModel | | `UnitPrefs.kt` | Unit system prefs (knots/m/s, m/ft, °C/°F) | | `NmeaConnectionPrefs.kt` | TCP/UDP NMEA connection settings | | `PolarData.kt` | Polar table data class + interpolation | | `PolarDiagramView.kt` | Custom View: polar diagram canvas renderer | | `BarometerSensorManager.kt` | Device barometer reader | | `BarometerTrendView.kt` | Pressure trend sparkline view | | `BarometerData.kt` | Barometer data class | | `MobData.kt` | MOB event data (position, timestamp) | | `TidalCurrentData.kt` | Tidal current data model | | `MockTidalCurrentGenerator.kt` | Test-only tidal current stub | | `PowerMode.kt` | Enum: FULL / ECONOMY / ANCHOR_WATCH | | `FeatureFlags.kt` → `settings/FeatureFlags.kt` | Runtime feature toggles | ### `ais/` | File | Role | |------|------| | `AisRepository.kt` | In-memory AIS target store (MMSI → AisVessel) | | `AisVessel.kt` | AIS target data class | | `AisVdmParser.kt` → `nmea/` | Parses !AIVDM sentences | | `CpaCalculator.kt` | CPA / TCPA computation | | `CpaAlert.kt` | Alert data class for CPA threshold breach | | `AisHubApiService.kt` | AISHub internet feed Retrofit service | | `AisHubSource.kt` | Polls AISHub; feeds AisRepository | ### `data/` | Sub-package | Files | Role | |-------------|-------|------| | `api/` | `ApiClient.kt`, `WeatherApiService.kt`, `MarineApiService.kt` | Retrofit/OkHttp setup; weather + marine endpoints | | `model/` | `BoatPolars`, `GribFile`, `GribRegion`, `GribParameter`, `WeatherResponse`, `MarineResponse`, `MarineConditions`, `WindArrow`, `WindForecast`, `ForecastItem`, `TideStation`, `TideConstituent`, `TidePrediction`, `LogbookEntry`, `SensorData`, `SatelliteDownloadRequest` | Pure data classes, no Android deps | | `repository/` | `WeatherRepository.kt` | Weather fetch + cache coordination | | `storage/` | `GribFileManager.kt` | GRIB2 file lifecycle (download, store, delete) | | `weather/` | `GribStalenessChecker.kt`, `SatelliteGribDownloader.kt` | GRIB freshness logic; satellite GRIB fetch | ### `db/` | File | Role | |------|------| | `NavDatabase.kt` | Room database; holds `TrackDao` | ### `gps/` | File | Role | |------|------| | `GpsProvider.kt` | Interface; abstracts device GPS vs NMEA GPS | | `GpsPosition.kt` | Data class: lat, lon, sog, cog, accuracy, timestamp | | `DeviceGpsProvider.kt` | FusedLocationProvider implementation | ### `logbook/` | File | Role | |------|------| | `LogbookRepository.kt` | Interface | | `InMemoryLogbookRepository.kt` | Thread-safe impl (`@Synchronized` on save/getAll/reload) | | `LogbookStorage.kt` | JSON persistence to SAF directory | | `LogEntry.kt` | Log entry data class (type enum, text, photo, position, optional `trackId: Long?`) | | `LogbookFormatter.kt` | Formats log for display | | `LogbookPdfExporter.kt` | PDF export | | `VoiceLogViewModel.kt` | ViewModel for logbook tab — filters to standalone entries (`trackId == null`) only; `save()` accepts optional `trackId` | | `VoiceLogState.kt` | Sealed state for voice log UI | ### `nmea/` | File | Role | |------|------| | `NmeaParser.kt` | Parses all NMEA 0183 sentences (GGA, RMC, MWV, HDG, DBT, VHW, XTE…) | | `AisVdmParser.kt` | Parses !AIVDM / !AIVDO AIS sentences | | `NmeaStreamManager.kt` | TCP/UDP connection manager; streams sentences to NmeaParser | ### `routing/` | File | Role | |------|------| | `IsochroneRouter.kt` | Isochrone weather routing engine | | `IsochroneResult.kt` | Result data class with optimal route + isochrones | | `RoutePoint.kt` | Waypoint data class for routing | ### `safety/` | File | Role | |------|------| | `AnchorWatchState.kt` | State sealed class for anchor alarm UI | ### `sensors/` Data-only classes (no logic): `WindData`, `HeadingData`, `BoatSpeedData`, `DepthData`, `AttitudeData`, `NmeaBaroData` ### `settings/` | File | Role | |------|------| | `FeatureFlags.kt` | Runtime boolean flags (e.g. night mode, race mode) | ### `thermal/` | File | Role | |------|------| | `ThermalMonitor.kt` | Monitors battery temp via `PowerManager`; fires alert if overheating | ### `tide/` | File | Role | |------|------| | `HarmonicTideCalculator.kt` | Offline tidal harmonic prediction; no network required | ### `track/` | File | Role | |------|------| | `TrackRepository.kt` | Coordinates GPX + Room storage; source of truth = GPX/SAF; **sorted descending by `startMs`** | | `TrackStorage.kt` | SAF primary + MediaStore fallback for GPX files | | `TrackEntity.kt` | Room entity; `toShallowSavedTrack()` for fast list load | | `TrackDao.kt` | Room DAO | | `TackDetector.kt` | Time-based tack/jibe detection (CRUISING + RACE modes) | | `TackEvent.kt` | Detected tack data class | | `GpxParser.kt` | GPX file → list of TrackPoints | | `GpxSerializer.kt` | TrackPoints → GPX file | | `SavedTrack.kt` | In-memory track with points + metadata | | `TrackSummary.kt` | Lightweight summary (no points) for list display | | `TrackStats.kt` | Computed stats: distance, max speed, duration, tacks | | `TrackColors.kt` | Speed-to-color mapping for track rendering | | `SavedTracksFragment.kt` | List of saved tracks; SAF setup button | | `TrackDetailSheet.kt` | Bottom sheet: track stats, add note/photo | | `TrackSummarySheet.kt` | Mini summary bottom sheet | ### `tripreport/` | File | Role | |------|------| | `TripReportFragment.kt` | Full trip report view (map + stats + log entries) | | `TripReportGenerator.kt` | Generates report from track + log data | | `TripReportViewModel.kt` | ViewModel for trip report | | `PreTripReportFragment.kt` | Pre-departure briefing; auto-generates on open if forecast available; `newAutoInstance()` triggers generation immediately (called when recording starts) | | `PreTripReportGenerator.kt` | Generates pre-trip summary (weather, similar trips, watch items) | | `PreTripReportViewModel.kt` | ViewModel for pre-trip report | | `PreTripModels.kt` | Data classes for pre-trip report | | `BoatProfileRepository.kt` | Stores boat profile (name, dimensions, polars) | ### `vessel/` | File | Role | |------|------| | `VesselRepository.kt` | Crew + vessel data; in-memory + persistence | ### `ui/fishing/` | File | Role | |------|------| | `FishingTarget.kt` | `FishingSpecies` enum, `RigRecommendation`, `FishingTarget` data classes | | `RigAdvisor.kt` | Pure function: `recommend(tempC, currentKt, windKt) → RigRecommendation` — Ono for warm+fast current, Mahi for moderate, BOTH otherwise | | `FishingModeManager.kt` | Persists `isActive` pref; `computeTarget()` returns cross-current bearing toward estimated temp break | | `FishingOverlayView.kt` | Custom `FrameLayout` inflating `view_fishing_overlay.xml`; `bind(FishingTarget)` updates all text views | ### `ui/` (top level) | File | Role | |------|------| | `MainViewModel.kt` | Top-level app state | | `MobHandler.kt` | MOB activation logic; always-on-top button handler | | `InstrumentHandler.kt` | Routes NMEA/GPS data to instrument display widgets | | `MapHandler.kt` | MapLibre map setup, overlay rendering, track display, fishing bearing line | | `MapLayerManager.kt` | Layer visibility: satellite, charts, wind, SST, depth (GEBCO WMS), seamarks, FADs toggle | | `LayerPickerSheet.kt` | Bottom sheet: layer toggle UI; constructor takes `onSstChanged` in addition to existing callbacks | | `FadData.kt` | 18 Hawaii Island DLNR FAD positions in decimal degrees; `toFeatures()` returns GeoJSON for MapHandler | | `NavLogger.kt` | In-app debug log (primary debugging tool — prefer over ADB) | | `LocationPermissionHandler.kt` | Runtime location permission request flow | | `DevLogSheet.kt` | Bottom sheet: shows NavLogger output | | `DirectionArrowView.kt` | Custom View: compass/bearing arrow | | `InclinometerView.kt` | Custom View: heel angle indicator | | `WaveView.kt` | Custom View: wave height visualization | | `WeatherActivity.kt` | Standalone weather details screen | ### `ui/map/` | File | Role | |------|------| | `MapFragment.kt` | Main chart fragment; hosts MapLibre GL map | | `ParticleWindView.kt` | Animated wind particle overlay on chart | ### `ui/safety/` | File | Role | |------|------| | `SafetyFragment.kt` | Safety tab: MOB, CPA alerts, anchor alarm (collapsible), hardware sources (collapsible); Quit button at top of title row | ### `ui/voicelog/` | File | Role | |------|------| | `VoiceLogFragment.kt` | Log tab: logbook entries + trip report; passes both `onSelect` and `onReport` to `SavedTrackAdapter` | ### `ui/forecast/` | File | Role | |------|------| | `ForecastFragment.kt` | Weather forecast tab | | `ForecastAdapter.kt` | RecyclerView adapter for forecast items | ### `ui/vessel/` | File | Role | |------|------| | `VesselRegistryFragment.kt` | Crew registry UI | ### `ui/doc/` | File | Role | |------|------| | `DocFragment.kt` | In-app documentation viewer | ### `ui/learn/` | File | Role | |------|------| | `LearnFragment.kt` | Sailing knowledge / learning content | --- ## 4. test-runner Module JVM-only; mirrors a subset of android-app source with no Android SDK dependencies. Run with `cd test-runner && ./gradlew test`. **Shared sources** (canonical copy in android-app, symlinked or mirrored in test-runner): | Package | Files | |---------|-------| | `ais/` | `AisVessel`, `CpaCalculator`, `CpaAlert` | | `data/model/` | `GribFile`, `GribRegion` | | `data/storage/` | `GribFileManager` | | `gps/` | `GpsPosition`, `GpsProvider` | | `nmea/` | `NmeaParser`, `AisVdmParser` | | `settings/` | `HardwareSource` | | `track/` | `TackDetector` ← **symlink** to android-app canonical source | | `vessel/` | `Vessel`, `VesselRepository`, `CrewMember` | **Important:** `TackDetector.kt` in test-runner is a filesystem symlink (`../../../../../../../../android-app/.../TackDetector.kt`). Edit the android-app copy only — test-runner picks it up automatically. --- ## 5. Key Architectural Invariants 1. **Track storage source-of-truth = GPX files in `Documents/Nav/` via SAF.** Room (`NavDatabase`) is a read-cache for fast list loading. GPX/SAF is always authoritative. 2. **Offline-first.** Every core feature (navigation, tides, safety) works without connectivity. Never gate core functionality on network. 3. **`getExternalFilesDir()` is never acceptable** for user data. User data must survive uninstall. Use SAF (`Documents/Nav/`) or MediaStore. 4. **`NavLogger` is the primary debug tool.** Log to `NavLogger`, not just Android `Log`, so in-app debug sheet shows it. 5. **MOB button is always visible and non-dismissible.** Never place anything above it in the Z-order. 6. **Logbook `InMemoryLogbookRepository`** has `@Synchronized` on `save()`, `getAll()`, `reload()`. Don't remove synchronization. 7. **`VoiceLogFragment`** must pass both `onSelect` and `onReport` to `SavedTrackAdapter` — omitting either causes a CI failure. 8. **MapLibre Android 11.x**: `Expression.get("color")` silently fails for `lineColor` on `LineLayer`. Use `Expression.step(Expression.get("speed"), ...)` to map speed → color literal. 9. **Speed/temp/depth in UI** must use `UnitPrefs` conversions — never raw SI values. 10. **NMEA sentences from instruments are magnetic.** 11. **Night vision vs system dark mode are separate.** `NavApplication` does NOT set `MODE_NIGHT_NO` — system dark mode is respected. `MainActivity` has a `fab_night_mode` FAB that toggles `Theme.Nav.NightVision` (red-on-black, 0.3× brightness) for dark adaptation at sea. These are independent; do not confuse them. 12. **`LogEntry.trackId`**: entries saved while recording carry `trackId = mainViewModel.trackStartMs`. Ship's log (`VoiceLogFragment`) filters to `trackId == null` only — in-track notes/photos are associated with the track, not the standalone log. 13. **Idle exit timer**: `MainActivity.resetIdleTimer()` schedules a 3-hour coroutine to `exitProcess(0)` if no user interaction. Cancelled on any `onUserInteraction()` call. Skipped entirely while recording (`isRecording == true`). Convert to true using WMM variation from `HarmonicTideCalculator`'s companion magnetic model — not a hardcoded offset. --- ## 6. Testing Guide ```bash # JVM unit tests (no Android SDK required, works in cloud sandbox) cd test-runner && ./gradlew test # Android unit tests (requires local dev env with Android SDK) cd android-app && ./gradlew test # Android instrumented tests (requires device/emulator) cd android-app && ./gradlew connectedAndroidTest ``` Key test files by domain: | Domain | Test file | |--------|-----------| | Tack detection | `test-runner/.../track/TackDetectorTest.kt` (16 tests CRUISING + 2 RACE) | | Pre-trip report | `android-app/.../tripreport/PreTripReportGeneratorTest.kt` (18 JVM tests) | | Tide calculator | `android-app/.../tide/HarmonicTideCalculatorTest.kt` | | AIS / CPA | `test-runner/.../ais/CpaCalculatorTest.kt` | | NMEA parsing | `test-runner/.../nmea/NmeaParserTest.kt` | | GRIB storage | `test-runner/.../data/storage/GribFileManagerTest.kt` | | Fishing / rig advisor | `android-app/.../ui/fishing/RigAdvisorTest.kt` (2 tests) | | Fishing / mode manager | `android-app/.../ui/fishing/FishingModeManagerTest.kt` (1 test) | --- ## 7. CI / Merge Workflow ```bash # 1. Work on feature branch git checkout -b feature/my-thing # 2. Commit incrementally git add && git commit -m "..." # 3. Push branch git push -u origin feature/my-thing # 4. Merge to main IMMEDIATELY when done (not at session end) git checkout main git merge --no-ff feature/my-thing git push -u origin main # 5. Delete feature branch (optional cleanup) git branch -d feature/my-thing ``` CI (`android.yml`) triggers only on pushes to `main`. Firebase App Distribution fires on `main` pushes only. --- ## 8. Context Tips & Gotchas ### Navigation / GPS - `LocationService` (foreground service) uses `FusedLocationProviderClient` for Android GPS. External GPS comes via NMEA TCP/UDP through `NmeaStreamManager` → `NmeaParser`, emitted on `LocationService.nmeaGpsPositionFlow`. - `LocationService.recomputeBestPosition()` fuses NMEA + Android GPS: prefers NMEA if < 5 s old, falls back to Android. Result is `bestPosition: StateFlow`. - `locationFlow: SharedFlow(replay=1)` carries raw Android fixes. **`replay=1` means new subscribers immediately receive the last cached position regardless of age — there is no staleness expiry.** Consumers must check `gpsData.timestampMs` if they care about age. - `DeviceGpsProvider` implements `GpsProvider` and has a `fixLostRunnable` (10 s timeout), but is **dead code** — it is never instantiated. `LocationService` does not use the `GpsProvider` interface. There is no "No GPS" banner. - Background location: `throttleLocationIfIdle()` (called on `onPause`) sends `ACTION_STOP_UPDATES` to stop `fusedLocationClient` when neither recording nor anchor-watch is active. `restoreLocationMode()` (called on `onResume`) restarts appropriate mode. ### NMEA - `NmeaStreamManager` handles connection lifecycle (reconnect on failure). Don't manage socket lifecycle elsewhere. - All wind data from NMEA is apparent (`AWS`, `AWA`). True wind is computed in `PerformanceViewModel` using the vector triangle. ### Tack Detection - `TackDetector` uses time-based windows (not point-count). `T_SETTLE=30s` before/after apex. - `circularMAD` stability check handles anchor-swing noise without an SOG gate. - RACE mode: `T_SETTLE=12s`, `MIN_GAP=20s`, `STAB_MAX=30°`. ### Anchor Alarm - Alarm must survive: app background, screen lock, low battery, OS memory pressure. - `AnchorAlarmManager` runs in `LocationService` (foreground service), not in an Activity. - UI inputs (depth, rode) embedded in `card_anchor` in `fragment_safety.xml` — there is no separate anchor config screen. ### Trip Report - Report auto-generates on tab open; RETRY button appears only on error. - Track map uses a full-bleed 260dp MapLibre instance embedded in the report fragment. - Pirate mode Easter egg: long-press title in `TripReportFragment`. - **Pre-trip report** auto-generates when recording starts: `MainActivity` calls `PreTripReportFragment.newAutoInstance()` and shows it as an overlay. Use `newAutoInstance()` for immediate generation; otherwise the fragment waits for user tap. ### Fishing Mode - `fab_fishing` (bottom-start mini FAB) toggles `FishingModeManager.isActive`; shows/hides `FishingOverlayView` and SST layer in sync. - `updateFishingOverlay()` in `MainActivity` is called from `marineConditions.collect` — updates whenever conditions refresh. - `lastCogDeg: Double` is updated from `gpsData.cog` inside `observeDataSources()` GPS collect. - FADs (18 Hawaii DLNR buoys) are loaded once at style setup via `FadData.toFeatures()`. They default **visible** (`fadsEnabled = true`). Layer is in MapHandler; visibility pref is in MapLayerManager. - Depth tiles: GEBCO-equivalent from NOAA CoastWatch ETOPO180 WMS — has full Pacific coverage. OpenSeaMap depth tiles were dropped (essentially empty for Hawaii). ### LocationService notifications - While idle: non-persistent notification titled "Sailing Companion". - While recording: persistent (`setOngoing(true)`) notification titled "Recording track". - `MainActivity` sends `ACTION_SET_RECORDING` intent to update the notification when `isRecording` state changes. ### GRIB / Weather - GRIB files stored via `GribFileManager`; staleness checked by `GribStalenessChecker`. - `addGpsPoint()` matches current UTC hour prefix against `timeIso` for forecast slot lookup. - Particle wind animation: `ParticleWindView` is a GL overlay on the map. ### Room / Track Storage - Room is a read-cache only. On cold start, if Room is empty, GPX files are backfilled. - `TrackEntity.toShallowSavedTrack()` serves the list view without loading track points. - `TrackStorage` has SAF primary path with MediaStore fallback; SAF state is a `Flow` observed by `SavedTracksFragment`. ### Logbook - `onSafDirectorySelected` must call `logbookRepository.reload()` so entries from the newly-granted directory appear immediately. ### Units - `UnitPrefs` is the single source of truth for display units. Always convert through it. - Distance: nm / km. Speed: knots / m/s. Depth: m / ft. Temperature: °C / °F.