Skip to content

Register devices for OBACloud service-alert push notifications (#1957) - #1958

Merged
bmander merged 27 commits into
mainfrom
feature/1957-push-registrations
Jul 22, 2026
Merged

Register devices for OBACloud service-alert push notifications (#1957)#1958
bmander merged 27 commits into
mainfrom
feature/1957-push-registrations

Conversation

@bmander

@bmander bmander commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Registers devices proactively with OBACloud's push_registrations API (#1957) so service-alert push notifications reach every rider who has notifications enabled — not just those who happen to create a trip alarm.

Today OBACloud only learns a device's FCM token as a side effect of creating a trip alarm, which means:

  • Riders who enable notifications but never set an alarm are never registered.
  • Device locale is never captured, so alerts default to English.
  • Tokens age out after 180 days without re-registration.

What's included

  • PushRegistrationWebService — form POST register (token, locale as-is, test_device, description, operating_system=android) and DELETE unregister, on the region's sidecar host via @Url, mirroring ReminderWebService. Provided through NetworkModule.plainRetrofit.
  • PushRegistrationDecision (decidePushRegistration) — a pure, Android-free function that reconciles the desired registration against the last-sent one → register / re-register / unregister / no-op, including the 24h keep-alive. JVM-unit-tested.
  • PushRegistrationManager — derives the desired registration from OS notifications-enabled + region + FCM token + locale + the test-device settings; persists the last registration and when it was sent (so opt-out DELETEs the right token, redundant POSTs are skipped given the 30 req/min/IP limit, and the server's last_seen_at stays fresh). Driven reactively off the region flow and the preferences it reads, plus a ProcessLifecycleOwner ON_START resync to catch OS-permission changes made in system settings.
  • "Register as test device" toggle + "Test device name" field on the Advanced settings screen → OBACloud's "Test users only" preview audience. The name is capped at the server's 255-character limit.

Opt-in is the OS notification permission — there is no separate in-app toggle.

Triggers covered

App launch, app foreground, FCM token rotation, region change, the test-device settings, and notification opt-in/opt-out.

Two bugs found during device testing

Both were found by exercising the real endpoint against the live sidecar, and both are fixed here.

1. Test-device registration was rejected outright. A test_device=true POST returns 422 {"error":"Unable to register device","messages":["Description can't be blank"]} — the server requires a description field that the contract table in #1957 never sent, so the toggle could not work at all. The requirement landed server-side after that table was written, which is why it wasn't implemented initially. It is now specified in OBACloud's published documentation: free text ≤255 chars identifying the device to admins, required only when test_device=true, and cleared server-side when a device is demoted.

2. HTTP error responses were silently swallowed. runCatching { … }.onFailure only fires for thrown exceptions, but a non-2xx is a successful call returning an unsuccessful response — so the 422 above produced no log output whatsoever. Worse, because a failed call persists nothing, the same doomed request repeated on every single app foreground, invisibly, against a rate-limited endpoint. Failures are now branched on explicitly and logged with the status code and the server's error body (never the token, and never the URL — the unregister URL carries the token as a query param).

Conformance with the published OBACloud documentation

OBACloud's push-notifications documentation was published after this PR was opened. Checking the implementation against it found it already conformant on every point below — notably the dedupe rule, which was derived independently from the iOS client's source and matches the documented behaviour exactly.

Documented requirement This PR
POST /api/v2/regions/{id}/push_registrations registrationUrl() builds exactly this
DELETE …?token={token} token as a query param
404 on DELETE = treat as success explicit HTTP_NOT_FOUND check
operating_system must be ios/android "android"
locale BCP-47, do not normalize Locale.getDefault().toLanguageTag(), sent as-is
test_device sent on every call (omission resets it to false) non-null Boolean, always sent
description required iff test_device=true testDevice = description != null — holds by construction
description ≤255 chars capped at the input field and before the wire
Dedupe on (token, region, locale, test_device, timestamp); re-POST on change or >24h PUSH_REFRESH_INTERVAL = 24.hours, identical semantics
Register on launch/foreground, token rotation, region change all three wired

The one gap this surfaced was the 255-character cap, now fixed. The rider-entered name was unbounded, so a long one would 422 — and since a failed registration persists nothing, that doomed POST would have repeated on every foreground. It is bounded in two places: at the input field, so the limit is felt while typing, and in buildTarget(), so a value stored before that bound existed cannot reach the wire.

Two deliberate divergences, both worth a maintainer's eye:

  • No API key. The doc says non-OBAKit clients "should send an API key the same way" OBAKit appends key/app_uid/app_ver/version=2. This client sends none, matching the sibling ReminderWebService on the same sidecar, which already ships that way. Device testing returns 204 without it and the rate limit is per-IP rather than per-key, so there is no functional impact today — but if OBACloud intends to enforce or attribute by key, both sidecar services should change together.
  • No 429 backoff. The doc's guidance on 429 is diagnostic ("your dedupe is broken"), iOS implements none, and the 24h dedupe keeps a healthy device at roughly one POST per day.

One documentation ambiguity worth flagging upstream: token is described as "APNs device token, lowercase hex", which is iOS-specific — FCM tokens are neither. Since operating_system accepts android and the live server returns 204, this is evidently phrasing rather than a constraint.

Parity with the iOS client

Comparing against OneBusAway/onebusaway-ios (whose docs/push-notifications.md §5.2 is a conformance checklist for non-OBAKit clients) surfaced two more gaps and improved a third:

  • 24h keep-alive. Previously we re-posted only when an input changed, so a device whose token, region and locale never change would go untouched until the server's 180-day last_seen_at prune dropped it and alerts silently stopped — defeating the "freshness" goal of Register for OBACloud service-alert push notifications (new push_registrations API) #1957 that this PR's own KDoc claimed to address. decidePushRegistration now takes elapsed-time and re-registers after PUSH_REFRESH_INTERVAL (24h, matching iOS). An unknown timestamp counts as stale, so existing records refresh once on upgrade. No WorkManager job: every foreground already runs the reconcile, and a rider who hasn't opened the app in 180 days has no reach to preserve.
  • An unresolved region is no longer mistaken for an opt-out. RegionRepository seeds its region flow to null and fills it asynchronously, so a cold-start reconcile could read "no region" as "the rider opted out", DELETE the registration, and re-POST it seconds later. Notifications being off is now the only definitive opt-out; unresolved inputs bail and wait for the collector to re-fire. (The underlying RegionState limitation affects other consumers too — filed as RegionState cannot express "not resolved yet", so a transient null region is indistinguishable from "no region" #1969.)
  • The test-device description is now rider-entered rather than derived from Build.MODEL, which could not distinguish two identical handsets in the admin UI. An unnamed device registers as an ordinary device rather than sending a request the server is guaranteed to reject — the same gating iOS uses.
  • Server rejections are reported to Crashlytics. Registrations are the server's only audience source for service alerts, so a systematic failure must not be visible only in one developer's logcat — which is exactly how bug 1 above stayed hidden. Transport failures stay local; reporting every offline moment would drown the signal.

Device verification

On a Pixel 7 Pro (API 36) against the live sidecar.onebusaway.org, region 1:

Scenario Result
Launch with notifications denied No traffic — correct no-op
Opt-in via system settings, then foreground POST204
Opt-in via the in-app permission dialog POST204, fired from the result callback
Repeat foregrounds Zero extra POSTs — no-op path holds
Opt-out (permission revoked) DELETE …?token=…204, record cleared
Test device with a description POST204
Upgrade from a build without the timestamp One refresh POST204, then quiet

Two details worth recording. The in-app dialog case is the one the shared permission helper exists for: the system dialog launches as a transparent activity in the app's own task, so the host activity is only paused and ProcessLifecycleOwner's ON_START never fires — logs confirm no stop/start cycle, so without the result-callback resync the device would have stayed unregistered until the next cold launch. And the DELETE contract question raised in an earlier revision of this description is settled: the token as a query param is both accepted (204) and now the documented form.

Not yet device-exercised (unit-tested only): the Reregister path via a region change, the unnamed-test-device downgrade, renaming a test device, and the 255-character truncation.

Notes

  • main has been merged in. The notification-permission conflict resolved toward this branch's Compose rememberNotificationPermissionRequest() helper, which requests POST_NOTIFICATIONS behind the same API-33 guard as the request-code flow it replaces and resyncs the registration from the result callback. The now-unreferenced PermissionUtils.NOTIFICATION_PERMISSION_REQUEST constant is removed. The branch's files are also reformatted for the Spotless gate (Automate formatting with Spotless; retire AndroidStyle.xml (wiring) #1970Turn on the Spotless formatting gate #1972) that landed on main while this was open.
  • No heuristics remain in this change. The description semantics are documented upstream and the value is rider-supplied rather than inferred.
  • The repo's CLAUDE.md documents a OneSignal push setup that doesn't exist in code (the app is FCM-only). Left untouched here; worth a separate cleanup.

Testing

  • ./gradlew :onebusaway-android:compileObaGoogleDebugKotlin -PwarningsAsErrors=true — clean.
  • ./gradlew spotlessCheck — clean.
  • ./gradlew :onebusaway-android:testObaGoogleDebugUnitTest — 29 push-registration tests pass (11 decision + 18 manager), covering the decision matrix, the persist/retry semantics across all four re-register failure quadrants, the keep-alive window, the region guard, the test-device gating, the description truncation, and the failure-reporting split.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added automatic push registration synchronization tied to app start/foreground, region availability, notification enablement, and FCM token changes.
    • Added Advanced Settings for configuring test-device service alerts, including a capped optional device name.
    • Added Android 13+ notification permission prompting that triggers an immediate push re-registration after approval.
  • Bug Fixes
    • Improved push registration reconciliation for safer idempotency, refresh timing, and resilient handling of transient/server failures.
  • Tests
    • Added/expanded unit and integration tests covering push registration decisioning, sync/persistence, and test-device name normalization.

OBACloud can deliver service-alert push notifications, but today it only
learns a device's FCM token as a side effect of the rider creating a trip
alarm. Riders who enable notifications but never set an alarm go
unregistered, device locale is never captured (alerts default to English),
and tokens age out after 180 days without re-registration.

Register the device proactively against the new push_registrations API:

- PushRegistrationWebService: form POST register (token, locale as-is,
  test_device, operating_system=android) and DELETE unregister, on the
  region sidecar host via @url, mirroring ReminderWebService.
- PushRegistrationManager: derives the desired registration from OS
  notifications-enabled + region + FCM token + locale + a hidden
  test-device flag, reconciles it against the last-sent registration via a
  pure decide() (register / re-register / unregister / no-op), and persists
  the last registration so opt-out can DELETE the right token and redundant
  POSTs are skipped (30 req/min/IP limit). Driven reactively off the region
  flow + the token/test-device prefs, plus a ProcessLifecycleOwner ON_START
  resync to catch OS-permission changes made in system settings.
- Hidden "Register as test device" toggle on the Advanced settings screen
  (OBACloud "Test users only" preview audience).

Opt-in is the OS notification permission; there is no separate in-app
toggle. decide() is a pure, JVM-tested function (PushRegistrationDecisionTest).

Note: the exact way the token is conveyed to the DELETE (form body vs query
param) is not pinned down by the issue; it is sent as a form field
mirroring register, flagged at the call site to confirm against the
OBACloud spec before release.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 583300af-50e1-4b21-8f5f-2d7930f9e645

📥 Commits

Reviewing files that changed from the base of the PR and between 7efe9f5 and d2e0e24.

📒 Files selected for processing (1)
  • onebusaway-android/src/main/java/org/onebusaway/android/preferences/PreferencesRepository.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • onebusaway-android/src/main/java/org/onebusaway/android/preferences/PreferencesRepository.kt

📝 Walkthrough

Walkthrough

Adds OBACloud push registration with Retrofit, persisted reconciliation state, lifecycle-triggered synchronization, notification-permission resync, test-device settings, batched preference persistence, and comprehensive tests.

Changes

Push registration

Layer / File(s) Summary
Registration contract and network wiring
gradle/libs.versions.toml, onebusaway-android/build.gradle.kts, onebusaway-android/src/main/java/org/onebusaway/android/api/contract/*, onebusaway-android/src/main/java/org/onebusaway/android/app/di/*, onebusaway-android/src/main/java/org/onebusaway/android/ui/tripinfo/TripInfoRepository.kt
Adds the lifecycle dependency, Retrofit registration endpoints, Hilt bindings, application entry-point access, and shared sidecar URL construction.
Registration state and server client
onebusaway-android/src/main/java/org/onebusaway/android/push/*
Defines registration models, reconciliation actions, durable state, refresh timing, endpoint construction, HTTP handling, retries, and error reporting.
Lifecycle reconciliation
onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt, onebusaway-android/src/main/java/org/onebusaway/android/app/Application.kt
Synchronizes registration from startup, token and region changes, preferences, notification status, and foreground events.
Test-device settings and permission flow
onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/*, onebusaway-android/src/main/java/org/onebusaway/android/ui/compose/*, onebusaway-android/src/main/java/org/onebusaway/android/ui/tripdetails/*, onebusaway-android/src/main/res/values/*, onebusaway-android/src/main/java/org/onebusaway/android/util/PermissionUtils.java
Adds test-device preferences and UI, normalizes names, and replaces inline notification-permission requests with a reusable Compose helper.
Preference batching and validation
onebusaway-android/src/main/java/org/onebusaway/android/preferences/PreferencesRepository.kt, onebusaway-android/src/test/..., onebusaway-android/src/androidTest/...
Adds staged atomic preference edits and tests reconciliation outcomes, persistence edge cases, payload shaping, HTTP failures, refresh behavior, settings normalization, and editor compatibility.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
  participant Application
  participant PushRegistrationManager
  participant PushRegistrationStore
  participant PushRegistrationClient
  participant PushRegistrationWebService
  Application->>PushRegistrationManager: start()
  PushRegistrationManager->>PushRegistrationStore: read registration state
  PushRegistrationManager->>PushRegistrationClient: register or unregister
  PushRegistrationClient->>PushRegistrationWebService: POST or DELETE registration
  PushRegistrationClient-->>PushRegistrationManager: return success or failure
  PushRegistrationManager->>PushRegistrationStore: record or clear successful state
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 29.17% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding OBACloud service-alert push notification registration for devices.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/1957-push-registrations

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@bmander

bmander commented Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Code review by Claude (Fable 5)

Automated review generated with Claude Code.

Overview

Adds proactive device registration against OBACloud's new push_registrations API: a Retrofit contract (PushRegistrationWebService), a pure reconciliation function (decide() in PushRegistrationDecision.kt), a reactive PushRegistrationManager singleton kicked from Application.onCreate, and a hidden "Register as test device" toggle in Advanced settings. +514/−0 across 15 files.

I verified the integration points against the codebase: arrivals_reminders_api_endpoint is /api/v2/regions/, so the URL build exactly mirrors the alarms path in TripInfoRepository.requestAlarm; PreferencesRepository has every overload used (observeString/observeBoolean/getLong, res-id keyed); region.sidecarBaseUrl and FirebaseMessagingManager.userPushId() exist as used. The plainRetrofit provider mirrors the five existing sidecar clients.

Strengths

  • The decision extraction is exactly the house style — pure, Android-free, exhaustively JVM-tested (all 4 action types, 8 transitions including locale/test-flag refresh vs. token/region re-register). This matches the project's "extract branchy logic and unit-test it" standard.
  • The commit-sentinel persistence design is sound. I checked DefaultPreferencesRepository.put: cache updates are synchronous in call order, disk persists async per-key. Torn writes (crash between persist's writes) read back as "no record" because the token is written last, and re-registration is idempotent. The design comment matches the actual prefs semantics.
  • Rate-limit awareness: decide()'s no-op path + persisted last-registration correctly avoid redundant POSTs against the 30 req/min/IP limit; DELETE treating 404 as success is the right call.
  • Coroutine hygiene: runCatchingCancellable used in both network paths, per the Focused-trip data layer: stops-for-route fetched via two paths, shapes double-cached, focusedTrips in three copies #1908 rule. Mutex-serialized sync() prevents interleaved reconciliations.
  • The pre-existing-heuristic and DELETE-shape ambiguities are honestly flagged rather than silently guessed — consistent with the "no unsanctioned heuristics" policy.

Issues

1. In-app notification-permission grant doesn't trigger registration (PushRegistrationManager.start). The app requests POST_NOTIFICATIONS at runtime in the trip-details flow (DestinationReminder.kt:177, TripDestinations.kt:179). The system permission dialog pauses but does not stop the activity, so no process-lifecycle ON_STOPON_START cycle occurs and the grant isn't picked up until the next app foreground/launch. That's the highest-value opt-in moment of the ones this feature exists for. Fix is small: call resync() from the permission-result callbacks (the manager is injectable, or via the entry point).

2. Reregister can orphan the previous registration (PushRegistrationManager.sync). In the Reregister arm, unregister(previous)'s result is discarded; if both the DELETE and the subsequent POST fail (e.g. offline during a region switch), clearLast() forgets previous. The next sync sees last == null → plain Register, and the old region's registration is never DELETEd — it keeps pushing that region's alerts to this still-valid token until the 180-day age-out. Suggested shape:

is PushRegistrationAction.Reregister -> {
    val cleaned = unregister(action.previous)
    if (register(action.target)) persist(action.target)
    else if (cleaned) clearLast()   // else keep `previous` so the next sync retries the full Reregister
}

3. DELETE token conveyance — already flagged in the PR body and at the call site; agreeing this is the one hard pre-merge blocker. While verifying against the OBACloud spec, also confirm the boolean form encoding: @Field("test_device") testDevice: Boolean serializes as the literal strings "true"/"false" — fine for a Rails-style parser, but worth confirming the server doesn't expect 1/0. Same verification session, zero extra cost.

Minor / nits

  • decide is a very generic name for a package-level public function; call sites outside the file lose context. decidePushRegistration(...), or hanging it off a PushRegistrationDecision object, would read better — but it's package-local in practice, so take or leave.
  • No retry/backoff on transient failure: a failed register waits for the next trigger (foreground/pref change). Given the triggers fire on every app foreground, that's an acceptable eventual-consistency story — just noting it's deliberate-looking and fine.
  • loadLast() defaults regionId to -1 and locale to "" for missing slots; both are unreachable given the sentinel ordering, and harmlessly self-heal (inequality → refresh) if somehow hit. OK as is.
  • sync() is suspend + public while resync() is the fire-and-forget wrapper — the split is clean, but nothing outside the class calls sync() directly; it could be private with resync() as the only public trigger. (start() uses it from the collector, so private works.)

Test coverage

The pure decision core is fully covered, and SettingsUiStateTest was updated for the new snapshot field. PushRegistrationManager itself is untested — the blocker is NotificationManagerCompat.from(context) buried in currentTarget(); every other dependency is already constructor-injected. If you want sync()'s apply/persist semantics under JVM test (issue 2 above is exactly the kind of path that would have been caught), injecting a notificationsEnabled: () -> Boolean supplier would get you there cheaply. Not a merge blocker given the extraction that was done.

Security

Nothing concerning client-side: the token travels over TLS to the region's sidecar, and "possession of token = ownership" on an unauthenticated endpoint is the server's documented model. The token was already persisted in prefs pre-PR. The push_reg_* bookkeeping adds no new sensitive data beyond the token slot that already existed.

Verdict

Well-built and convention-faithful; the decision/manager split is the right architecture. Before merge: confirm the DELETE shape (author-flagged), and I'd recommend fixing #1 (in-app permission grant is a missed registration trigger for the feature's primary audience) and #2 (orphaned registration on double failure) — both are small, contained changes. On-device verification of the actual request shapes, as the PR body notes, remains pending.

bmander and others added 9 commits July 19, 2026 16:55
The two in-app POST_NOTIFICATIONS requests (destination-reminder and
trip-info save) called ActivityCompat.requestPermissions() fire-and-forget
— there was no result handler at all. The system permission dialog only
pauses the activity, so no ON_STOP->ON_START cycle occurs and
PushRegistrationManager's ProcessLifecycleOwner resync never fires. An
in-flow opt-in — the highest-value moment for this feature — wasn't picked
up until the next app foreground/launch.

Convert both sites to a Compose RequestPermission launcher and call
PushRegistrationManager.resync() (via the entry point) from the result
callback. resync() is a no-op when nothing changed and currentTarget()
re-checks areNotificationsEnabled(), so firing on both grant and deny is
safe. Drop the now-unused ActivityCompat / NOTIFICATION_PERMISSION_REQUEST
/ PermissionUtils imports.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The resync-on-grant fix had duplicated the RequestPermission launcher, its
resync callback, the explanatory comment, and the API-33 gate across both
opt-in sites (DestinationReminder, TripDestinations) — copy-paste that would
silently reintroduce the fire-and-forget bug at any future opt-in site.

Factor it into rememberNotificationPermissionRequest() in ui.compose (beside
findActivity), returning a () -> Unit that requests POST_NOTIFICATIONS, gates
on API 33 internally, and resyncs the push registration on the result. Both
sites now call it unconditionally. Also delete the now-orphaned
PermissionUtils.NOTIFICATION_PERMISSION_REQUEST constant and prune the
imports the extraction freed up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In the Reregister arm of sync(), unregister(previous)'s result was discarded
and clearLast() ran whenever the POST failed. If both the DELETE and the POST
failed (e.g. offline during a region switch), the still-registered `previous`
was forgotten: the next sync saw last == null, did a plain Register, and the
old region/token was never DELETEd — it keeps receiving that region's alerts
until the 180-day age-out.

Capture the DELETE result and only clearLast() when the DELETE actually
removed `previous`; otherwise keep `previous` on record so the next sync
retries the full Reregister (DELETE + POST). Persisting the new record still
happens only once the POST lands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The originating issue (#1957) is the authoritative OBACloud contract (its
author is the obacloud maintainer), and it resolves both open questions the
PR flagged:

- test_device is documented as the literal true/false, upserted on every call
  (an omitted value resets it to false) — so the existing Boolean @field is
  correct as-is; no change needed there.
- unregister is DELETE …/push_registrations with a `token` param. The contract
  doesn't pin body-vs-query, but a query param avoids sending a body on a
  DELETE (which some HTTP stacks/proxies drop) and a Rails `params` backend
  reads it either way. Switch @Field-in-body to @query("token") and drop the
  now-needless @FormUrlEncoded / hasBody on that call.

Also replace the "verify before release" doc comments with the confirmed
contract details.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Rename the package-level decide() to decidePushRegistration() so call sites
  outside PushRegistrationDecision.kt (the manager, the tests) carry context;
  the bare name read as too generic. Updated the one main call site, the eight
  test call sites, and the KDoc references.
- Make PushRegistrationManager.sync() private — nothing outside the class calls
  it (start()'s collector and resync() are the only callers), so resync() is
  the sole public trigger.

Left as-is per review: no retry/backoff (foreground/pref-change triggers give
an acceptable eventual-consistency story) and loadLast()'s unreachable
regionId=-1 / locale="" defaults (they self-heal via inequality → refresh).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…M test

The pure decision (decidePushRegistration) was well covered, but the manager
that applies it — which endpoint calls fire and, crucially, what the persisted
"last registration" record becomes after each success/failure — had no test.
That's the layer where the Reregister-orphan bug lived.

Add a small test seam so sync() is JVM-testable without Robolectric: the two
Android-only reads (OS notifications-enabled + the endpoint-path string) move
behind a delegating @Inject constructor that resolves them from Context, while
a package-visible primary constructor takes them as a `() -> Boolean` supplier
and a plain String. Every other dependency was already injectable (and
FirebaseMessagingManager.userPushId() only touches prefs, so the real object
works over a fake).

PushRegistrationManagerTest drives sync() through the public resync() trigger
over hand fakes (reusing FakePreferencesRepository / FakeRegionRepository),
covering: register+persist then no-op, disabled-notifications no-op, opt-out
unregister+clear, failed-register no-persist+retry, token-rotation reregister,
delete-succeeds/post-fails clears, 404-as-already-gone, and the headline case —
a fully-failed Reregister must keep `previous` so the next sync retries the
DELETE rather than orphaning the old token. Mutation-checked: reverting the arm
to the old `clearLast()` fails exactly that test (expected 2 DELETEs, got 1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Collapse the ~20 repeated `resync() + advanceUntilIdle()` pairs into a single
Fixture.sync() (Fixture now holds the TestScope), and give the five tests that
open from an already-registered device a Fixture.registered("T1") baseline
instead of copy-pasting the setToken+sync preamble. Also trim the @Inject
constructor's KDoc, which restated the seam rationale already on the primary
constructor's params.

Behaviour-identical; the 8 tests still pass. From /simplify (the reuse,
efficiency, and altitude passes found nothing to change — the two-constructor
seam matches the FocusedTripRepository/StopsForRouteRepository house style).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address PR review on the Reregister quadrant where the DELETE of the stale
registration fails but the POST of the new one succeeds. Previously persist()
overwrote the record and the owed DELETE was never retried, so on a region
change the old region kept pushing to a still-valid token until the 180-day
server prune.

- Add a single push_pending_del_* prefs slot: on the DELETE-fail/POST-ok
  quadrant, persist(target) AND queue `previous`, then drainPendingDelete()
  retries the owed DELETE at the top of every sync. persist() drops a queued
  delete whose endpoint matches the just-committed live record, keeping the
  single slot safe against a return-to-the-old-endpoint. The sync() comment now
  enumerates all four failure quadrants.
- Surface register/unregister failures via a Log.w instead of swallowing them,
  logging the region + host (never the token). Injected as a logWarning seam so
  the failure paths stay exercisable from the plain-JVM unit test.
- Hoist the endpoint comparison into a shared PushRegistration.sameEndpoint in
  the pure-data decision file, used by both decidePushRegistration and persist().
- Cover the new behavior with tests: the DELETE-fail/POST-ok quadrant queues and
  later drains the DELETE, and a return-to-old-token never deletes the live row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…esh registrations

Device testing against the live OBACloud sidecar turned up two bugs, and a
comparison with the iOS client (OneBusAway/onebusaway-ios) turned up two more
gaps. All four are fixed here.

Bugs found on device:

- A test-device registration was rejected with 422 "Description can't be
  blank". The server requires a `description` field when `test_device=true`,
  which the contract never sent, so the test-device toggle could not work at
  all. The requirement landed server-side on 2026-07-19 and postdates the
  contract table in #1957; it is documented in the iOS client's
  docs/push-notifications.md.

- HTTP error responses were silently swallowed. `runCatching { ... }.onFailure`
  only fires for thrown exceptions, but a non-2xx is a successful call
  returning an unsuccessful response, so the 422 produced no log output at all
  — and because a failed call persists nothing, the same doomed request
  repeated on every foreground, invisibly. Failures are now branched on
  explicitly and logged with the status and server error body (never the token,
  and never the URL: the unregister URL carries the token as a query param).

Parity with the iOS client:

- Re-register an otherwise-unchanged registration every 24h. Previously we
  re-posted only when an input changed, so a device whose token, region and
  locale never change would go untouched until the server's 180-day last_seen_at
  prune dropped it and alerts silently stopped — defeating the "freshness" goal
  of #1957. An unknown timestamp counts as stale, so existing records refresh
  once on upgrade.

- Stop mistaking an unresolved region for an opt-out. RegionRepository seeds its
  region flow to null and fills it asynchronously, so a cold-start sync could
  DELETE the registration and re-POST it seconds later. Notifications being off
  is now the only definitive opt-out; unresolved inputs bail and wait.

- Take the test-device description from a new "Test device name" setting rather
  than deriving it from Build.MODEL, which could not distinguish two identical
  handsets in the admin UI. An unnamed device registers as an ordinary device
  instead of sending a request the server is guaranteed to reject.

- Report server rejections to Crashlytics. Registrations are the server's only
  audience source for service alerts, so a systematic failure must not be
  visible only in one developer's logcat. Transport failures stay local.

Verified on a Pixel 7 Pro against the live sidecar: first registration, opt-in
via both the in-app permission dialog and system settings, opt-out, the
test-device round trip with a description (204), the no-op path, and the
upgrade refresh.
bmander and others added 6 commits July 19, 2026 23:54
…sk-best-effort

Each prefs slot persists as its own async DataStore edit, so the "token
written last" ordering is not guaranteed across a process death mid-persist.
The recovery paths don't depend on it — a torn record re-registers via the
idempotent upsert, and a stale one DELETEs into a tolerated 404 — but the
comments previously implied a durability the writes don't have.
PushRegistrationManager was ~65% plumbing: of its 227 code lines, roughly 100
were prefs slots and commit-sentinel bookkeeping and 65 were HTTP calls and
failure classification, leaving only ~90 for the reconciliation it is named
for. Two mechanisms move out:

- PushRegistrationStore owns the durable record — every push_reg_*/
  push_pending_del_* slot, the commit-sentinel semantics and their torn-write
  reasoning, the refresh timestamp, and the pending-delete queue.
- PushRegistrationClient owns talking to the endpoint and making failures
  visible — URL assembly, which non-2xx codes still count as success (DELETE
  404), the redaction rule, and the local-vs-remote reporting split.

The manager keeps what is actually policy: the triggers, deriving the desired
registration, and the rule for what to record given which of a Reregister's two
calls succeeded. That four-quadrant logic deliberately stays inline and is now
its own named function — it is the least obvious part of the feature and hiding
it behind an abstraction would be worse than the plumbing was.

Behaviour is unchanged; the 28 existing tests pass untouched, exercising the
real store and client over the same fakes. The manager drops from 227 to 114
code lines and from 9 constructor parameters to 7, with one Android seam left
instead of four.
…istrations

# Conflicts:
#	onebusaway-android/src/main/java/org/onebusaway/android/ui/tripdetails/DestinationReminder.kt
#	onebusaway-android/src/main/java/org/onebusaway/android/util/PermissionUtils.java
The merge conflict resolution kept this request-code constant, but the PR
deliberately removed it when it replaced the request-code notification-permission
flow with the Compose rememberNotificationPermissionRequest() helper. Both former
call sites (DestinationReminder, TripDestinations) now use the helper, so the
constant is referenced nowhere and is dead code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
OBACloud's newly published push-notifications documentation
(https://developer.onebusaway.org/projects/obacloud) specifies the test-device
description as free text of at most 255 characters, server-enforced when
test_device=true. The rider-entered name was unbounded, so a long one would 422 —
and because a failed registration persists nothing, the doomed POST would repeat
on every app foreground against a rate-limited endpoint.

Bound it in two places: at the input field (so the limit is felt while typing) and
in buildTarget() (so a value stored before that bound existed, or arriving from
anywhere else, still cannot reach the wire). EditTextPreferenceItem gains an
optional maxLength that defaults to the previous unbounded behaviour.

Also refresh two KDoc blocks that described the contract as unresolved. The
description requirement was originally established by probing the deployed server
and noted as awaiting maintainer confirmation; the DELETE body-vs-query question
was noted as unstated. Both are now specified upstream, so cite the documentation
instead of the probe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s write

Quality cleanup from a four-angle review of the branch. No behaviour change.

Derive PushRegistration.testDevice from description instead of storing it. Every
construction already set testDevice == (description != null) — the server requires
a description exactly when test_device=true — so the separate field bought a
redundant preference slot, an extra write and read, and a representable state
(flag set, no name) that can only ever be rejected. A computed property makes the
rule hold at the type level.

Move the 255-char description cap to the settings write boundary, the one place
the value enters persistence, per CLAUDE.md's normalize-at-the-boundary rule. It
was previously clamped in the shared EditTextPreferenceItem, which gave that
component a second validation mechanism competing with its existing
reject-on-invalid contract, leaked an OBACloud constant into UI code, and let the
stored name differ from the one sent. Extracted as applyPushTestDeviceName
alongside applyMapStopCacheSize so it is unit-tested directly; the clamp in
buildTarget stays as a cheap guard on the wire boundary itself.

Remember the lambda returned by rememberNotificationPermissionRequest. It captured
launcher and was reallocated on every recomposition despite the remember* name, so
the onSave lambda wrapping it at TripDestinations changed identity each time and
TripInfoRoute could never skip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…gs legible

Review follow-ups on #1958:

- sinceLastSent() treats a negative elapsed time (device wall clock set
  backwards past the record's stamp) as unknown, i.e. stale: the next sync
  re-POSTs once and restamps at the new clock, instead of the keep-alive
  staying silent until the clock catches back up — within reach of the
  server's 180-day prune for a large jump.
- clear() drops the sent-at stamp with the record it stamped, so the store
  can no longer report an elapsed-since-sent for a registration last() says
  doesn't exist (previously harmless only because the decision layer ignores
  the stamp when there is no record).
- Advanced settings: the test-device name field is enabled only while the
  toggle is on, and the toggle's summary calls out the one silent state —
  on but unnamed, where the device keeps registering normally until a name
  is set.
@bmander
bmander marked this pull request as ready for review July 21, 2026 05:46

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
onebusaway-android/src/main/java/org/onebusaway/android/api/contract/PushRegistrationWebService.kt (1)

80-81: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Prefer @DELETE over @HTTP(method = "DELETE").

Since this request does not include a body, you can use the more concise and idiomatic @DELETE annotation from Retrofit instead of the generic @HTTP annotation.

♻️ Proposed refactor

Make sure to add import retrofit2.http.DELETE if you apply this change:

-    `@HTTP`(method = "DELETE")
+    `@DELETE`
     suspend fun unregister(`@Url` url: String, `@Query`("token") token: String): Response<Unit>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@onebusaway-android/src/main/java/org/onebusaway/android/api/contract/PushRegistrationWebService.kt`
around lines 80 - 81, Update the unregister method annotation to use Retrofit’s
`@DELETE` instead of `@HTTP`(method = "DELETE"), and add the retrofit2.http.DELETE
import while removing the no-longer-needed generic annotation import.
onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/SettingsUiStateTest.kt (1)

126-135: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the new pushTestDevice/pushTestDeviceName pass-through.

The baseline snapshot now carries these two new fields, but no test in this file asserts how they map into AdvancedSettingsUiState (e.g., row visibility or a summary string), unlike the pattern used for other fields (see the "values pass through" test for SettingsPrefSnapshot). Worth a small assertion to lock down the wiring for this new setting.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/SettingsUiStateTest.kt`
around lines 126 - 135, Extend the settings UI state tests using the existing
advPrefs fixture to cover pushTestDevice and pushTestDeviceName propagation into
AdvancedSettingsUiState. Add assertions for the resulting setting row visibility
and summary, following the established “values pass through” test pattern used
for SettingsPrefSnapshot.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In
`@onebusaway-android/src/main/java/org/onebusaway/android/api/contract/PushRegistrationWebService.kt`:
- Around line 80-81: Update the unregister method annotation to use Retrofit’s
`@DELETE` instead of `@HTTP`(method = "DELETE"), and add the retrofit2.http.DELETE
import while removing the no-longer-needed generic annotation import.

In
`@onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/SettingsUiStateTest.kt`:
- Around line 126-135: Extend the settings UI state tests using the existing
advPrefs fixture to cover pushTestDevice and pushTestDeviceName propagation into
AdvancedSettingsUiState. Add assertions for the resulting setting row visibility
and summary, following the established “values pass through” test pattern used
for SettingsPrefSnapshot.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 73359b46-ff48-4d2c-95f2-b2d41ba76970

📥 Commits

Reviewing files that changed from the base of the PR and between 2faa7b8 and 8400733.

📒 Files selected for processing (24)
  • gradle/libs.versions.toml
  • onebusaway-android/build.gradle.kts
  • onebusaway-android/src/main/java/org/onebusaway/android/api/contract/PushRegistrationWebService.kt
  • onebusaway-android/src/main/java/org/onebusaway/android/app/Application.kt
  • onebusaway-android/src/main/java/org/onebusaway/android/app/di/NetworkModule.kt
  • onebusaway-android/src/main/java/org/onebusaway/android/app/di/PushRegistrationEntryPoint.kt
  • onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationClient.kt
  • onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt
  • onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt
  • onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationStore.kt
  • onebusaway-android/src/main/java/org/onebusaway/android/ui/compose/NotificationPermissionRequest.kt
  • onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsScreen.kt
  • onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModel.kt
  • onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/SettingsUiState.kt
  • onebusaway-android/src/main/java/org/onebusaway/android/ui/tripdetails/DestinationReminder.kt
  • onebusaway-android/src/main/java/org/onebusaway/android/ui/tripdetails/TripDestinations.kt
  • onebusaway-android/src/main/java/org/onebusaway/android/util/PermissionUtils.java
  • onebusaway-android/src/main/res/values/donottranslate.xml
  • onebusaway-android/src/main/res/values/strings.xml
  • onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationDecisionTest.kt
  • onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt
  • onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationStoreTest.kt
  • onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModelTest.kt
  • onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/SettingsUiStateTest.kt
💤 Files with no reviewable changes (1)
  • onebusaway-android/src/main/java/org/onebusaway/android/util/PermissionUtils.java

// two requests against a rate-limited endpoint and leaving a window with no registration at all.
val target = if (notificationsEnabled()) {
// Inputs still settling — bail rather than decide; a later trigger re-runs us.
buildTarget() ?: return@withLock

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Confirmed correctness bug — stale registration leaks when the new region has no sidecar host

When notifications are enabled but buildTarget() returns null because the current region's sidecarBaseUrl is blank, sync() bails via ?: return@withLock here — before decidePushRegistration runs. A null target on this path is treated as "inputs not resolved yet", not opt-out, so a registration stored for a different region is never Unregistered.

Scenario: rider registers in region A (has a sidecar), then switches to region B whose Region.sidecarBaseUrl is blank (the field defaults to ""). On next foreground, notificationsEnabled() is true, buildTarget() returns null at the takeIf { it.isNotBlank() } guard (~line 210), and sync() returns. The region-A registration is never deleted — the device keeps getting region A's alerts and no region-B alerts until the server's 180-day prune.

Consider distinguishing "no sidecar for this region" (which should still reconcile away a stale registration) from "inputs genuinely not ready yet."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 20a051b, and refined in 57c5d39. The null-target bail is gone: deriveDesiredRegistration now returns a sealed DesiredRegistration in which "the region hasn't loaded yet" (Unresolved — freezes reconciliation) and "this region has no sidecar" (NoSidecar — a resolved fact) are distinct types, so a blank sidecarBaseUrl can no longer masquerade as not-ready. decidePushRegistration only accepts resolved states, and NoSidecar with a record on file yields Unregister — your exact scenario (region A → sidecar-less region B) is pinned by the switching to a region without a sidecar unregisters the stale registration test, which asserts the DELETE targets region A's host.

val base = prefs.getString(KEY_PENDING_DEL_BASE, null) ?: return null
val token = prefs.getString(KEY_PENDING_DEL_TOKEN, null) ?: return null
return PushRegistration(
regionId = prefs.getLong(KEY_PENDING_DEL_REGION_ID, -1L),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Confirmed correctness bug — torn pending-delete write DELETEs against region -1 and treats the 404 as success

pendingDelete() reconstructs regionId with a default of -1L. queuePendingDelete issues three independent async DataStore writes, with the token written last as the presence sentinel — but the sentinel guards record presence, not regionId correctness. If the process is killed after KEY_PENDING_DEL_TOKEN flushes but before KEY_PENDING_DEL_REGION_ID, the next launch reads regionId = -1L, drainPendingDelete issues DELETE …/regions/-1/push_registrations?token=…, the server returns 404, the client counts 404 as success, and clearPendingDelete() runs.

Net effect: the genuinely-stale registration is never deleted and keeps pushing to the still-valid token until the 180-day prune. Consider persisting the pending-delete record atomically (single serialized value) rather than as three separate keys, and/or refusing to drain when regionId < 0.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed at the root in 8a8f67a by implementing #1978's option 1: PreferencesRepository gained an atomic batch edit {} (one cache swap + one dataStore.edit, so a multi-slot record persists whole or not at all across process death), and every multi-slot store write goes through it. A record whose presence marker exists but whose regionId didn't flush can no longer exist, so no regionId < 0 drain guard was needed — nothing shipped the old layout, so no legacy torn state exists in the wild. A bonus fix fell out: record() folds the same-endpoint pending-delete drop into the same commit, closing the sibling crash window where a fresh registration and a stale queued DELETE for the same endpoint could both survive and delete the row just POSTed.

(Since then, the pending-delete record itself was removed entirely per the over-engineering thread below — but the atomicity fix stands for the six-slot live record, and closes #1978.)

description = target.description
)
val registered = response.isSuccessful
if (!registered) reportHttpFailure("register", target, response)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Confirmed correctness concern — Crashlytics retry storm on rate-limited (429) responses

reportHttpFailure records every non-2xx register response to Crashlytics, gating only on response.isSuccessful with no status-code carve-out — contrast unregister() (line ~90), which does carve out 404. The unauthenticated endpoint is rate-limited (30 req/min/IP). On a shared-NAT network (public wifi, carrier CGNAT) a 429 gets reported like a systematic 422. Because a failed register persists nothing, the next foreground retries and reports again, flooding the error channel with transient throttling noise and drowning the genuine systematic-rejection signal the reporting was meant to surface.

Consider treating 429 (and other transient statuses) as non-reportable, mirroring the 404 carve-out already in unregister().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2812ca2. reportHttpFailure still logs every non-2xx locally, but a new isTransient() (429, plus the 5xx range — a server outage has the same fleet-wide flood shape) skips the Crashlytics report, mirroring the transport-failure split that already existed. Contract-shaped 4xx like the 422 still report. The boolean return is untouched, so the retry-on-next-foreground behavior is identical — tests pin that a 429 logs without reporting and succeeds on the next sync once the throttle lifts.

One deliberate trade worth your eyes: a chronic 5xx (server-side register handler broken indefinitely) is now invisible to Crashlytics too. We judged that acceptable since a fleet-wide 5xx shows up in server-side monitoring, but if you'd rather catch it client-side, a report-after-N-consecutive-failures counter would cover it.

PushRegistrationAction.NoOp -> Unit
is PushRegistrationAction.Register ->
if (client.register(action.target)) store.record(action.target)
is PushRegistrationAction.Unregister ->

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Correctness / contract divergence — Android DELETEs on OS-level opt-out; iOS never does

When notifications are disabled at the OS level, target becomes null, decidePushRegistration returns Unregister(last), and the client DELETEs …/push_registrations?token={token} (plus store.clear()). Issue #1957 explicitly says an OS-level disable doesn't need a DELETE — "FCM bounces feed back to the server and it cleans up unregistered tokens itself" — and the iOS PushRegistrationManager only ever POSTs, never DELETEs.

Beyond the divergence: if the server couples trip-alarm delivery to the push_registration row for that token, this Android-only DELETE can silently drop the delivery target of an already-scheduled alarm when a rider toggles notifications off. Recommend matching the iOS contract — don't DELETE on opt-out; let FCM-bounce/server prune handle it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 57c5d39, verified against all three sources: #1957's "OS-level disable doesn't need a DELETE", the iOS manager (no DELETE call exists at all), and the iOS docs ("OS-level permission revocation does not require a DELETE"). One nuance: the old DesiredRegistration.None conflated two cases with opposite correct behaviors, so it's split rather than removed — OptedOut (notifications off) is now a pure NoOp that keeps both the server row and the local record (so re-enabling inside the refresh window reads as "unchanged", with no spurious re-POST), while NoSidecar still Unregisters, since your docs sanction exactly that old-row DELETE on a region change. The trip-alarm delivery-target risk you raised is recorded in OptedOut's KDoc as part of the rationale.

val token = firebaseMessagingManager.userPushId().takeIf { it.isNotEmpty() } ?: return null
val description = prefs.getString(R.string.preference_key_push_test_device_name, null)
?.trim()
?.take(PUSH_DESCRIPTION_MAX_LENGTH)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Plausible correctness — take(255) counts UTF-16 code units and can split a surrogate pair

The test-device name cap uses String.take(PUSH_DESCRIPTION_MAX_LENGTH), which counts UTF-16 code units. A description near the boundary containing emoji / astral characters can be truncated mid-surrogate-pair, leaving a lone surrogate (mangling the last char), and the code-unit count may not match the server's char/byte limit.

If the server then 422-rejects, nothing is persisted, so buildTarget rebuilds the identical doomed request and re-POSTs on every foreground — the exact repeat loop the cap was meant to prevent, plus a Crashlytics report each time. Consider capping on Unicode code points / grapheme boundaries, and confirming whether the server limit is chars or bytes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6381afa. Both cap sites (the settings persistence boundary and the wire-boundary clamp) now share truncatedToDescriptionCap(), which keeps the 255-UTF-16-unit budget but drops a straddling pair whole — a lone surrogate can no longer be stored or sent. Tests cover the straddling and exact-fit boundary cases.

On your chars-vs-bytes question: we couldn't settle it at the source — the OBACloud repo is private — so the cap deliberately stays at 255 UTF-16 units as the strictest plausible reading of the documented "≤255 chars" (≤N units implies ≤N code points, so it's legal under either counting; only a byte-count limit could differ, and nothing in the docs suggests bytes). Since you have OBACloud access: if the Rails validation counts characters/code points, this could safely loosen to 255 code points later — purely a permissiveness change. Happy to file that as a follow-up if you can confirm.

}.onFailure { logTransportFailure("unregister", previous, it) }.getOrDefault(false)

/** `{sidecarBaseUrl}/api/v2/regions/{regionId}/push_registrations` — mirrors the alarms URL build. */
private fun registrationUrl(registration: PushRegistration): String = registration.sidecarBaseUrl +

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Cleanup (DRY) — sidecar registration-URL assembly duplicates TripInfoRepository.requestAlarm

registrationUrl() hand-concatenates sidecarBaseUrl + arrivals_reminders_api_endpoint + regionId + "/push_registrations", which is the same sidecar-path shape TripInfoRepository.requestAlarm (~line 263) builds for "/alarms". Both independently re-derive the /api/v2/regions/{id} path, so a future change to the sidecar path scheme must be found and edited in two places. Consider extracting one helper (e.g. Region.sidecarUrl(endpoint, suffix)) as the single source of truth for the sidecar URL shape.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9b6c973 (polished in 7efe9f5): both sites now call sidecarRegionUrl() in api/contract, next to the two sidecar Retrofit interfaces it serves. Output is byte-identical (the test pinning the exact registration URL passes untouched). Two deviations from the Region.sidecarUrl() sketch, both forced: it's not a Region method because the push client addresses DELETEs from its recorded registration, whose region may no longer be current; and the /api/v2/regions/ segment stays a parameter because it lives in the arrivals_reminders_api_endpoint string resource. Open question for you: no flavor overrides that resource today — if the override affordance isn't wanted, the segment could be hardcoded in the helper and the resource deleted, which would also let the client drop its constructor param. (Also noted in the helper's KDoc: the v1 sidecar endpoints — weather, surveys — use a separate regionID-placeholder idiom; this helper owns only the v2 shape.)

* dropping it — otherwise the old region keeps pushing to a still-valid token until the server's
* 180-day prune.
*/
private suspend fun applyReregister(action: PushRegistrationAction.Reregister) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Cleanup / over-engineering vs. the ticket + iOS — the whole Reregister + persisted pending-delete machinery

Issue #1957 marks deleting the old region's registration as optional ("optionally DELETE from the old one"), and iOS PushRegistrationManager never DELETEs at all — it re-POSTs and relies on the server's 180-day prune + FCM-bounce cleanup. This branch instead adds a full Reregister action, applyReregister's 4-quadrant handling, three persisted pref slots (KEY_PENDING_DEL_*), the queuePendingDelete/pendingDelete/clearPendingDelete/drainPendingDelete cycle drained on every sync, and a large slice of the manager test — all to robustly recover the one DELETE-fail/POST-ok quadrant of a behavior the sister platform chose not to implement.

That's permanent maintenance + test surface for an optional cleanup the server already performs, and it silently diverges from iOS. Worth confirming with a human whether the DELETE-on-region-change path is wanted at all, or whether matching iOS (POST-only) is the simpler, intended contract.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed with a human as requested, and resolved down the middle in 6d8e8e0 (+7efe9f5b): the Reregister DELETE stays, but it's now best-effort — the persisted pending-delete queue (three pref slots, queue/drain on every sync, and its test surface, net −110 lines) is gone. The reasoning: on a region change the old row holds a live token, so nothing bounces and the rider who moved keeps getting the old region's alerts until the 180-day prune — your own iOS docs flag that consequence and sanction the DELETE, so we kept it. The queue, though, only insured the DELETE-fails/POST-lands corner, and its failure mode without the queue (old row lingers until prune) is precisely iOS's baseline for every region change — disproportionate machinery for that corner, exactly as you argued. applyReregister is now the plain two-step composition of the Unregister/Register rules, with the best-effort choice documented on the Reregister action. If you'd rather go full POST-only (drop the region-change DELETE too), that's now a five-line change — but we'd argue the wrong-region-alerts window makes the best-effort DELETE worth keeping.

bmander and others added 9 commits July 22, 2026 05:31
…ime property

Review of #1958 found a stale-registration leak: with notifications enabled,
a region whose sidecarBaseUrl is blank (the default) made buildTarget() return
null, which sync() read as "inputs not resolved yet" and bailed — so a
registration recorded for a previous sidecar region was never unregistered,
and the device kept receiving the old region's alerts until the server's
180-day prune.

Rather than patching the guard, remove the representation that allowed the
bug: buildTarget(): PushRegistration? collapsed three answers ("inputs still
settling", "definitively no registration should exist", "this registration
should exist") into two values. Desired state is now the sealed
DesiredRegistration type — Unresolved, plus Resolved { None, Wanted } — with
a pure deriveDesiredRegistration() in which every early exit must name its
classification: a null region or missing FCM token is Unresolved (freeze
reconciliation; a later trigger re-runs it), while notifications-off or a
sidecar-less region is None (reconcile the stale record away).

decidePushRegistration() accepts only DesiredRegistration.Resolved, so an
unresolved state cannot reach the decision and be mistaken for an opt-out —
and None cannot skip it. The original bug now fails to compile in every form.

Covered by new derivation unit tests (the None-vs-Unresolved classification,
test-device description gating) and a manager regression test proving a
switch to a sidecar-less region DELETEs the old registration against the old
region's host, then settles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…y batch edit

Fixes the confirmed review finding on #1958: queuePendingDelete() persisted
its three slots as three independent async DataStore edits, with the token
written last as the presence sentinel. The sentinel guarded record presence,
not field completeness — a process death after the token flushed but before
the region id left a durable record whose regionId read back as the -1
default. The next launch's drain then issued DELETE …/regions/-1/… , counted
the resulting 404 as success (the documented already-gone contract), and
cleared the queue — permanently leaking the genuinely-stale registration
until the server's 180-day prune. record() had the same torn-write exposure.

Fixed at the root by implementing #1978 (option 1): PreferencesRepository
gains edit {}, which stages writes through a PreferencesEditor receiver and
commits them as ONE cache swap + ONE dataStore.edit — atomic on disk, and
one file rewrite/fsync/emission instead of one per key. PreferencesEditor
holds the String-keyed setters and PreferencesRepository extends it, so the
batch receiver and the repository's own one-shot setters are the same five
methods rather than parallel declarations. edit has deliberately NO default
body — the atomicity is the contract, so each implementation must decide how
it honors it; the in-memory fakes (which have no commit to tear) implement
it honestly as block(this), while DefaultPreferencesRepository stages ops
and funnels both paths through a shared single-op commit() (put() is now
just the one-op case).

PushRegistrationStore now writes each record as a single edit {}. That also
lets record() fold the same-endpoint pending-delete drop into the same
commit, closing the sibling crash window where a recorded registration and
a not-yet-cleared pending DELETE for the same endpoint could survive
together and delete the row just POSTed; the drop shares one staging helper
with clearPendingDelete() so the clear semantics live in one place. The
store KDoc's "best-effort on disk" caveat is gone — a torn record can no
longer exist — so no drain guard against regionId < 0 is needed (nothing
shipped the old layout, so no legacy torn state exists in the wild).

The read-your-writes instrumented test grows a case pinning the contract:
one updateData commit carrying every staged key (including null-removes),
with synchronous read-your-writes across the batch.

Closes #1978.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes the confirmed review finding on #1958: reportHttpFailure() sent every
non-2xx response to Crashlytics. The push_registrations endpoint is
unauthenticated and rate-limited at 30 req/min/IP, so on a shared-NAT
network (public wifi, carrier CGNAT) other clients spend this device's
budget and a 429 says nothing about this client. Because a failed register
persists nothing, every app foreground retried and re-reported it —
flooding the error channel with throttling noise and drowning the
systematic-rejection signal (a 422 contract failure) the reporting exists
to carry.

Transient statuses — 429 and 5xx server-side trouble — are now local-log
only, the same treatment transport failures already get: they heal on a
later reconcile pass, which retries by design. Only statuses that say
*this request* can never succeed (contract-shaped 4xx like the 422) still
report remotely. The return value is unchanged, so retry behavior is
untouched.

Tests: a 429 register logs but does not report and succeeds on the next
sync once the throttle lifts; the 500-unregister case now also pins the
not-reported half.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…1957)

Fixes the confirmed review finding on #1958: disabling notifications at the
OS level produced Unregister(last), DELETEing the server row and clearing
the record. Issue #1957 explicitly reserves DELETE for an *in-app* opt-out
(which this app doesn't have): "Notifications disabled at the OS level
doesn't need a DELETE — FCM bounces feed back to the server and it cleans
up unregistered tokens itself." The iOS PushRegistrationManager confirms
the contract — it never DELETEs at all; on a denied permission it simply
no-ops. Beyond the divergence, an Android-only DELETE here could silently
drop the delivery target of an already-scheduled trip alarm if the server
couples alarm delivery to the registration row.

DesiredRegistration.None conflated two cases with opposite consequences,
so it is split rather than removed:

- OptedOut (notifications off) → NoOp. The record is kept alongside the
  server row, so re-enabling within the refresh window reads as
  "unchanged" — no spurious re-POST — and a stale record refreshes via the
  ordinary keep-alive on re-enable.
- NoSidecar (the region has no sidecar host) → still Unregister. The iOS
  docs sanction exactly this old-row DELETE on a region change: a rider
  who moves to a sidecar-less region must stop receiving the old region's
  alerts rather than waiting out the 180-day prune.

The Reregister DELETE (token/region changed while enabled) is untouched —
also doc-sanctioned cleanup of a stale row.

Tests: the decision matrix covers both new states (including no keep-alive
POST while opted out); the manager test asserts opt-out issues no traffic
and re-enable is a NoOp; the unregister 404/500 cases now drive the DELETE
via a region switch, the path that still owns it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes the plausible review finding on #1958: both description cap sites
(applyPushTestDeviceName at the persistence boundary, and the wire-boundary
clamp in deriveDesiredRegistration) used String.take(255), which counts
UTF-16 code units and can cut between the halves of a surrogate pair. A
name with emoji near the boundary would end in a lone surrogate — mangled
to '?'/U+FFFD by the form encoding — as its final character.

Both sites now share truncatedToDescriptionCap(), which keeps the 255-unit
budget but drops a straddling pair whole instead of splitting it.

On the counting-semantics half of the finding: the server's published
contract says "Free text ≤255 chars" with no byte qualifier, the iOS client
does not truncate at all, and the OBACloud source is private, so whether
"chars" means code points or something else cannot be read at the source.
The cap therefore deliberately stays in UTF-16 units, the strictest of the
plausible readings: a string of at most N UTF-16 units also has at most N
code points, so the result is within a 255-char limit under either
interpretation — no new assumption is introduced, and the choice plus its
rationale are documented on the helper. Grapheme clusters (ZWJ sequences,
combining marks) can still be cut between code points; that renders
imperfectly but is valid Unicode and within the server limit.

Tests: the derive-level matrix gains a boundary-straddling emoji case (pair
dropped whole) and an exact-fit case (pair kept); the settings-boundary
test now pins the same behavior at the persistence site.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ighten the cap helper

Cleanup pass over the transient-status carve-out, the OptedOut/NoSidecar
split, and the surrogate-safe cap:

- PushRegistrationManager's overview KDoc still described the derivation as
  "wanted / none / not-yet-resolved"; now names the four states. Its
  Unresolved comment (and the missing-region tests) said misreading it as
  an "opt-out" fires spurious DELETEs — stale since an opt-out no longer
  DELETEs; the dangerous misreading is no-sidecar.

- The OptedOut rationale (FCM bounces / iOS parity / trip-alarm risk) was
  copied near-verbatim into three test comments; they now carry one line
  plus a pointer to DesiredRegistration.OptedOut, the canonical home. Same
  for the 429 shared-NAT rationale, consolidated into isTransient's KDoc
  with reportHttpFailure and the manager test pointing at it.

- truncatedToDescriptionCap: take(n).dropLastWhile { isHighSurrogate } in
  place of the index-arithmetic substring form. (On already-malformed input
  with consecutive trailing lone high surrogates it drops them all rather
  than one — at least as correct.)

- Dropped the duplicate surrogate-boundary assertion from
  AdvancedSettingsViewModelTest: the branch behavior of the one shared
  helper is pinned in DeriveDesiredRegistrationTest, and the settings-side
  test's 300-char case already proves that site applies the cap.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes the DRY review finding on #1958: PushRegistrationClient's
registrationUrl() and TripInfoRepository.requestAlarm() each hand-
concatenated {sidecarBaseUrl}{/api/v2/regions/}{regionId}/{resource}, so a
change to the sidecar path scheme had to be found and edited in two places.

Both now call sidecarRegionUrl() in api/contract — a pure function, so
each output is unchanged (the manager test pinning the exact registration
URL passes untouched).

Two deliberate deviations from the review's Region.sidecarUrl() sketch,
both forced by existing constraints: the helper is not a Region method
because the push client addresses URLs from its *recorded* registration
(whose region may no longer be current), not from a live Region; and the
/api/v2/regions/ segment stays a parameter rather than a constant because
it is a string resource (a brand override point) that the push client
already injects for JVM testability.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t-effort

Resolves the last review item on #1958 (over-engineering vs. the ticket and
iOS), per maintainer decision: keep the old-row DELETE on a token/region
change, but stop persisting a missed one for retry.

The split follows what the DELETE is actually worth in each case. On a
region change the old row holds a live token — nothing bounces, so without
a DELETE the rider who moved keeps receiving the old region's alerts until
the 180-day prune; the iOS docs sanction exactly this cleanup, so the
Reregister DELETE stays. The persisted queue, however, existed only for
the corner where that DELETE fails while the POST succeeds (old host
unreachable at the moment of a region switch, new host reachable). Its
failure mode without the queue — the old row lingers until the prune — is
precisely the iOS client's baseline for every region change, and #1957
marks the old-row DELETE as optional. Three pref slots, a queue/drain
cycle on every sync, and a slice of test surface were disproportionate
insurance for that corner.

Removed: queuePendingDelete/pendingDelete/clearPendingDelete and the
KEY_PENDING_DEL_* slots from the store (record() no longer needs the
same-endpoint drop — with no queue there is nothing to resurrect a stale
DELETE from), drainPendingDelete and the sync-top drain from the manager,
and the queued-delete manager tests. applyReregister's fourth quadrant
(DELETE fail + POST ok) now just records the target, with the best-effort
rationale documented at the site; the other three quadrants are unchanged,
so a full Reregister failure still retries via the kept record.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ls KDoc

Cleanup pass over the last two commits:

- applyReregister: with the pending-delete queue gone, the cleaned-flag
  quadrant logic was provably equivalent to composing the standalone
  Unregister/Register apply rules — DELETE settles and persists, then POST
  settles and persists (record() overwrites the intermediate clear() in the
  both-ok case; every end state matches, pinned by the existing quadrant
  tests). The four-row truth-table KDoc shrinks accordingly, and the
  best-effort rationale moves to PushRegistrationAction.Reregister's KDoc
  in the decision layer, where the sibling policy notes (OptedOut,
  NoSidecar) already live.

- SidecarUrls: scope the "single source" claim to the v2 URL shape and
  name the pre-existing v1 idiom (weather/surveys embed the region id in
  their own string resources via a regionID placeholder) instead of
  implying this helper covers all sidecar URLs; stop asserting the
  /api/v2/regions/ resource IS a brand override point — no flavor
  overrides it today — and say only that it stays overridable. Rename the
  client's registrationsEndpointPath param to regionsPath: it carries the
  shared regions segment, not anything registrations-specific.

- The best-effort reregister test no longer resets onUnregister to success
  before the settled-state sync — leaving it throwing pins that no DELETE
  is even attempted, a strictly stronger assertion.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@onebusaway-android/src/main/java/org/onebusaway/android/preferences/PreferencesRepository.kt`:
- Around line 228-262: Serialize the read-modify-write section of
PreferencesRepository.commit so concurrent callers cannot overwrite each other’s
cache updates before launching the asynchronous DataStore edit. Protect the
cache snapshot update using the repository’s existing synchronization mechanism
or add a suitable lock, while preserving the single async edit for each
operation and keeping the edit method’s batching behavior unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 29ec51d8-6b0e-46f2-938e-e6576ecc2d7c

📥 Commits

Reviewing files that changed from the base of the PR and between 20a051b and 7efe9f5.

📒 Files selected for processing (14)
  • onebusaway-android/src/androidTest/java/org/onebusaway/android/database/oba/LegacyDataImporterTest.kt
  • onebusaway-android/src/androidTest/java/org/onebusaway/android/preferences/PreferencesRepositoryReadYourWritesTest.kt
  • onebusaway-android/src/main/java/org/onebusaway/android/api/contract/SidecarUrls.kt
  • onebusaway-android/src/main/java/org/onebusaway/android/preferences/PreferencesRepository.kt
  • onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationClient.kt
  • onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt
  • onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt
  • onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationStore.kt
  • onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModel.kt
  • onebusaway-android/src/main/java/org/onebusaway/android/ui/tripinfo/TripInfoRepository.kt
  • onebusaway-android/src/test/java/org/onebusaway/android/push/DeriveDesiredRegistrationTest.kt
  • onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationDecisionTest.kt
  • onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt
  • onebusaway-android/src/test/java/org/onebusaway/android/testing/FakePreferencesRepository.kt
🚧 Files skipped from review as they are similar to previous changes (3)
  • onebusaway-android/src/test/java/org/onebusaway/android/push/DeriveDesiredRegistrationTest.kt
  • onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationClient.kt
  • onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModel.kt

Resolves the CodeRabbit finding on #1958: commit()'s optimistic cache
update (cache = cache.toMutablePreferences().also(op).toPreferences()) was
an unsynchronized read-modify-write on the @volatile cache — two threads
committing concurrently could snapshot the same cache, and the later swap
would silently drop the earlier writer's keys from every subsequent
synchronous read for the life of the process (the cache is deliberately
never re-mirrored from DataStore, so the loss never heals). @volatile
provides visibility, not atomicity. The race predates this branch (the
pre-refactor put() had the same shape); the shared commit() is now where
it is fixed.

The swap is now under a private lock. Reads stay lock-free (volatile read
of an immutable Preferences), and the async persist stays outside the
lock — DataStore already serializes its own edits, and each op is
self-contained, so only the in-memory side needed guarding. No
deterministic test: the interleaving cannot be forced without hooks into
the critical section, and a stress test would be flaky-by-design; the
lock's rationale is documented at the site instead.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bmander
bmander merged commit f7311e5 into main Jul 22, 2026
3 checks passed
@bmander
bmander deleted the feature/1957-push-registrations branch July 22, 2026 07:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants