Files
samplez/rippr-src/docs/v2/06-service-lifecycle.md
uhryniuk 280fd7f988 Add Rippr source snapshot and full-history bundle
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>
2026-08-11 08:30:25 -05:00

6.7 KiB

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

  • Start → pause → resume → stop produces exactly two segments
  • No point is written into a closed segment
  • Paused: notification persists, no location updates, wake lock released
  • Discard deletes the trip and every one of its points
  • Force-stop then restart with an active RECORDING trip resumes into a new segment
  • 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).