Two copies for two jobs. rippr-src/ is a browsable git archive export of the tracked tree at 2f76983 - no build outputs, no local.properties, no nested .git - which is convenient to read in gitea but carries no history and will drift. rippr-full-history.bundle is the real backup: all 18 commits, verified as "records a complete history" and test-cloned before committing. This matters because ~/dojo/rippr has no git remote and otherwise exists only on one machine. rippr-src/SNAPSHOT.md explains the difference and how to restore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
147 lines
6.7 KiB
Markdown
147 lines
6.7 KiB
Markdown
# T06 — Trip lifecycle in TrackingService
|
|
|
|
**Phase** 3 · **Depends on** T03 · **Status** Done
|
|
|
|
## Goal
|
|
|
|
Teach the foreground service the full ride lifecycle — start, pause, resume, stop,
|
|
discard — with segments opening and closing correctly and the notification reflecting
|
|
actual state. Done when a paused ride keeps its notification, stops consuming GPS, and
|
|
resumes into a *new* segment.
|
|
|
|
## Context
|
|
|
|
`app/src/main/java/com/rippr/TrackingService.kt` (265 lines) already gets the hard parts
|
|
right, and **none of this architecture should be disturbed**:
|
|
|
|
- Location callbacks `trySend` into an **unbounded `Channel`**; a single writer coroutine
|
|
drains it in batches of 25 every 2 s. This is why slow disk I/O can never block the GPS
|
|
thread, and why no fix is dropped under back pressure.
|
|
- `onDestroy` drains whatever remains on a scope that *outlives* `serviceScope`, so the
|
|
final points still land. Verified on the emulator: 81 points, ids 1..81 contiguous.
|
|
- A `PARTIAL_WAKE_LOCK` keeps the writer running with the screen off — a foreground
|
|
service alone does not guarantee this on all OEMs.
|
|
- `startForegroundCompat()` runs before anything else, because Android allows roughly
|
|
five seconds from `startForegroundService()` before killing the process.
|
|
|
|
What changes is lifecycle, not plumbing.
|
|
|
|
### State after T03
|
|
|
|
The repository is already wired in: the service holds `TripRepository`, start calls
|
|
`trips.startTrip()`, and `stopRecording()` calls `trips.completeTrip()` on a survivor
|
|
scope. `@Volatile currentTripId`/`currentSegmentId` remain (the location callback runs on
|
|
another thread) and are now populated from the returned `TripHandle`.
|
|
|
|
Still to do here:
|
|
- The five actions; today there is only start and `ACTION_STOP`
|
|
- `drainChannel()` and drain-before-close ordering
|
|
- Pause/resume, wake-lock release on pause, per-state notification
|
|
- The null-intent restart path (`trips.activeTrip()` already provides what it needs)
|
|
- The `tripId == 0L` guard in the location callback — **keep it**
|
|
|
|
**Note on force-stop.** A user-initiated `am force-stop` is more aggressive than an OS
|
|
kill: Android will not honour START_STICKY afterwards. The database state stays correct
|
|
and the UI reads it correctly, but recording does not auto-resume. The restart path here
|
|
covers OS-initiated kills, which is the case that matters on a long ride.
|
|
|
|
## Design
|
|
|
|
### Actions
|
|
|
|
| Action | Behaviour |
|
|
|---|---|
|
|
| `ACTION_START` | `repo.startTrip()`, acquire wake lock, request location updates |
|
|
| `ACTION_PAUSE` | **Drain channel first**, close segment, `removeLocationUpdates`, release wake lock, keep service alive |
|
|
| `ACTION_RESUME` | `repo.resumeTrip()` (new segment), re-acquire wake lock, re-request updates |
|
|
| `ACTION_STOP` | Drain, close segment, `completeTrip()`, recompute aggregates (T07), stop service |
|
|
| `ACTION_DISCARD` | Drain and discard buffered points, `discardTrip()` (CASCADE), stop service |
|
|
|
|
**Correction to the original reasoning.** This doc previously claimed that closing a
|
|
segment before draining would write points into the *wrong* segment. That is wrong:
|
|
`segmentId` is stamped onto each `TrackPoint` at creation in the location callback, so a
|
|
fix already queued always lands in the segment it was recorded during, whatever order the
|
|
writes happen in.
|
|
|
|
Draining before closing still matters, just for different reasons: points should not sit
|
|
unwritten while a ride idles at a pause, and stop/discard must not lose the tail. The
|
|
order used is stop-the-source → drain → close.
|
|
|
|
### Pause keeps the service, drops the wake lock
|
|
|
|
The foreground service stays alive so the notification persists and resuming is instant.
|
|
But with no location updates arriving there is nothing for the writer to do, so the wake
|
|
lock is released — a paused ride at a long lunch stop should not hold the CPU awake.
|
|
|
|
### Notification states
|
|
|
|
| State | Title | Text | Actions |
|
|
|---|---|---|---|
|
|
| Recording | Rippr Active | Recording ride telemetry… | Pause, Stop |
|
|
| Paused | Rippr Paused | Ride paused — not recording | Resume, Stop |
|
|
|
|
`NotificationCompat.Builder` is rebuilt and re-notified on transition; the channel and id
|
|
(101) stay as they are. The small icon stays `R.drawable.ic_stat_rippr`.
|
|
|
|
### Restart after process death
|
|
|
|
`START_STICKY` redelivers a null intent. On a null intent the service asks the repository
|
|
for the active trip:
|
|
|
|
- **RECORDING** → resume recording into a new segment (the gap is real; the phone was off)
|
|
- **PAUSED** → restore the paused notification, do not request location
|
|
- **none** → `stopSelf()`
|
|
|
|
This is only possible because T03 moved state into the database.
|
|
|
|
## Implementation
|
|
|
|
1. Replace the single `ACTION_STOP` constant with the five actions.
|
|
2. Inject `TripRepository`; drop all `RecordingState` references.
|
|
3. Add `drainChannel()` — a suspend function that empties the channel into the DB and
|
|
returns once flushed; reuse it from pause, stop, discard, and `onDestroy`.
|
|
4. Split wake lock acquire/release out of `onStartCommand` so pause/resume can call them.
|
|
5. Rebuild the notification per state; add `buildNotification(state: TripState)`.
|
|
6. Handle the null-intent restart path.
|
|
7. `stopWithTask="false"` in the manifest already survives task swipe — keep it.
|
|
|
|
## Acceptance criteria
|
|
|
|
- [x] Start → pause → resume → stop produces exactly two segments
|
|
- [x] No point is written into a closed segment
|
|
- [x] Paused: notification persists, no location updates, wake lock released
|
|
- [x] Discard deletes the trip and every one of its points
|
|
- [x] Force-stop then restart with an active RECORDING trip resumes into a new segment
|
|
- [x] Contiguous point ids across a pause (no gaps, no duplicates)
|
|
|
|
## Tests
|
|
|
|
Instrumented plus emulator end-to-end:
|
|
- Feed fixes, pause, feed more, resume, feed more, stop → assert two segments with the
|
|
right point counts either side
|
|
- Assert no point has a `segmentId` whose segment closed before its timestamp
|
|
- Discard leaves zero rows
|
|
|
|
Emulator harness (proven in v1):
|
|
```
|
|
adb shell am start-foreground-service -n com.rippr/.TrackingService # blocked: not exported
|
|
```
|
|
Drive through the UI instead — `adb shell input tap`, after waiting for the UI to
|
|
actually exist. A fixed `sleep` is not enough; the activity took 8.7 s to display on a
|
|
cold start during v1 testing and a premature tap silently does nothing.
|
|
|
|
## Risks / gotchas
|
|
|
|
- **Do not replace the Channel with a direct write.** It is the reason the GPS callback
|
|
never blocks.
|
|
- **Drain ordering** as described above; get this wrong and points land in the wrong
|
|
segment, which only shows up later as a rendering artefact.
|
|
- **Wake lock double-release** throws. `setReferenceCounted(false)` is already set; still
|
|
guard with `isHeld`.
|
|
- **`onDestroy` already spawns a survivor scope** for the final flush — do not remove it
|
|
while refactoring.
|
|
|
|
## Out of scope
|
|
|
|
Aggregate maths (T07) and any UI (T09).
|