From 8534def086194ee5bfc805e0dc7e34a4f75db2ab Mon Sep 17 00:00:00 2001 From: Brandon Martin-Anderson Date: Sun, 19 Jul 2026 06:57:28 +0000 Subject: [PATCH 01/26] Register devices for OBACloud service-alert push (#1957) 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 --- gradle/libs.versions.toml | 1 + onebusaway-android/build.gradle.kts | 2 + .../contract/PushRegistrationWebService.kt | 66 ++++++ .../org/onebusaway/android/app/Application.kt | 5 + .../android/app/di/NetworkModule.kt | 10 + .../app/di/PushRegistrationEntryPoint.kt | 46 ++++ .../android/push/PushRegistrationDecision.kt | 75 +++++++ .../android/push/PushRegistrationManager.kt | 199 ++++++++++++++++++ .../ui/settings/AdvancedSettingsScreen.kt | 8 + .../ui/settings/AdvancedSettingsViewModel.kt | 9 + .../android/ui/settings/SettingsUiState.kt | 4 + .../src/main/res/values/donottranslate.xml | 1 + .../src/main/res/values/strings.xml | 2 + .../push/PushRegistrationDecisionTest.kt | 85 ++++++++ .../ui/settings/SettingsUiStateTest.kt | 1 + 15 files changed, 514 insertions(+) create mode 100644 onebusaway-android/src/main/java/org/onebusaway/android/api/contract/PushRegistrationWebService.kt create mode 100644 onebusaway-android/src/main/java/org/onebusaway/android/app/di/PushRegistrationEntryPoint.kt create mode 100644 onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt create mode 100644 onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt create mode 100644 onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationDecisionTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 855bb41f2a..d66ef8924c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -116,6 +116,7 @@ androidx-room-testing = { module = "androidx.room:room-testing", version.ref = " androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } +androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" } diff --git a/onebusaway-android/build.gradle.kts b/onebusaway-android/build.gradle.kts index 9f773dcfd0..c2dd17c9ba 100644 --- a/onebusaway-android/build.gradle.kts +++ b/onebusaway-android/build.gradle.kts @@ -450,6 +450,8 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.runtime.compose) + // ProcessLifecycleOwner: app-foreground (ON_START) trigger for push re-registration (#1957). + implementation(libs.androidx.lifecycle.process) // Navigation-Compose backbone, on the 2.9.x line (minSdk 23). // hilt-navigation-compose bridges hiltViewModel() to @HiltViewModel + SavedStateHandle nav-args. implementation(libs.androidx.navigation.compose) diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/api/contract/PushRegistrationWebService.kt b/onebusaway-android/src/main/java/org/onebusaway/android/api/contract/PushRegistrationWebService.kt new file mode 100644 index 0000000000..2ae60daab9 --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/api/contract/PushRegistrationWebService.kt @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.api.contract + +import retrofit2.Response +import retrofit2.http.Field +import retrofit2.http.FormUrlEncoded +import retrofit2.http.HTTP +import retrofit2.http.POST +import retrofit2.http.Url + +/** + * The OBACloud push-registration client (issue #1957) — like [ReminderWebService], it is served from + * the region's sidecar host, not the OBA `where` host, so both calls pass the resolved URL via [Url] + * and this service runs WITHOUT `ApiParamsInterceptor` (the Retrofit base URL is a throwaway). + * + * Registering the device's FCM token proactively (rather than only as a side effect of creating a trip + * alarm) is what lets OBACloud deliver service-alert notifications to riders who enable notifications + * but never set an alarm, capture the device locale, and keep tokens from ageing out at 180 days. + * + * Both calls return `204 No Content` on success, so the body is unused — [Response] carries only the + * status. The registration endpoint is unauthenticated (ownership is possession of the token) and + * rate-limited to 30 requests/minute/IP, so callers must avoid redundant registrations. + */ +interface PushRegistrationWebService { + + /** + * Registers (or refreshes) this device's push token with the region. Form-urlencoded POST to + * `…/regions/{id}/push_registrations`. [testDevice] must be sent on every call so a device is not + * accidentally reset out of the test audience; [locale] is the device's BCP-47 tag sent as-is. + */ + @FormUrlEncoded + @POST + suspend fun register( + @Url url: String, + @Field("token") token: String, + @Field("locale") locale: String, + @Field("test_device") testDevice: Boolean, + @Field("operating_system") operatingSystem: String = "android", + ): Response + + /** + * Unregisters this device's push [token] from the region (when the rider opts out of + * notifications). DELETE to `…/regions/{id}/push_registrations`. + * + * The issue does not pin down how the token is conveyed to the DELETE; mirroring [register], it is + * sent as a form-encoded body field. If the OBACloud contract turns out to expect a query param + * instead, switch this to `@Query("token")`. Verify against the server spec before release. + */ + @FormUrlEncoded + @HTTP(method = "DELETE", hasBody = true) + suspend fun unregister(@Url url: String, @Field("token") token: String): Response +} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/app/Application.kt b/onebusaway-android/src/main/java/org/onebusaway/android/app/Application.kt index 4910f4c0d1..6ebc4bd1f5 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/app/Application.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/app/Application.kt @@ -24,6 +24,7 @@ import org.onebusaway.android.app.di.AnalyticsEntryPoint import org.onebusaway.android.app.di.DatabaseEntryPoint import org.onebusaway.android.app.di.FirebaseMessagingEntryPoint import org.onebusaway.android.app.di.PreferencesEntryPoint +import org.onebusaway.android.app.di.PushRegistrationEntryPoint import org.onebusaway.android.app.di.RegionEntryPoint import org.onebusaway.android.notifications.NotificationChannels import org.onebusaway.android.region.RegionSubsystems @@ -73,6 +74,10 @@ class Application : android.app.Application() { PreferencesEntryPoint.get(this).incrementAppLaunchCount() FirebaseMessagingEntryPoint.get(this).fetchAndStoreToken() + + // Keep this device's OBACloud push registration in sync (register on launch / token rotation / + // opt-in, unregister on opt-out) so service alerts reach riders who never set a trip alarm (#1957). + PushRegistrationEntryPoint.get(this).start() } /** diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/app/di/NetworkModule.kt b/onebusaway-android/src/main/java/org/onebusaway/android/app/di/NetworkModule.kt index e7c9ad9d9f..018956543d 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/app/di/NetworkModule.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/app/di/NetworkModule.kt @@ -31,6 +31,7 @@ import okhttp3.logging.HttpLoggingInterceptor import org.onebusaway.android.BuildConfig import org.onebusaway.android.api.contract.BikeWebService import org.onebusaway.android.api.contract.OtpWebService +import org.onebusaway.android.api.contract.PushRegistrationWebService import org.onebusaway.android.api.contract.RegionsWebService import org.onebusaway.android.api.contract.ReminderWebService import org.onebusaway.android.api.contract.SurveyWebService @@ -119,6 +120,15 @@ object NetworkModule { fun provideReminderWebService(json: Json): ReminderWebService = plainRetrofit(json).create(ReminderWebService::class.java) + /** + * The OBACloud push-registration client (#1957). Targets the region's sidecar host via `@Url`, so + * like the reminders client it uses a plain client without [ApiParamsInterceptor]. + */ + @Provides + @Singleton + fun providePushRegistrationWebService(json: Json): PushRegistrationWebService = + plainRetrofit(json).create(PushRegistrationWebService::class.java) + /** * The OpenTripPlanner trip-planner client. Like [provideBikeWebService] it targets the region's OTP * host via absolute `@Url`, so it uses a plain client without [ApiParamsInterceptor] — but with the diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/app/di/PushRegistrationEntryPoint.kt b/onebusaway-android/src/main/java/org/onebusaway/android/app/di/PushRegistrationEntryPoint.kt new file mode 100644 index 0000000000..38a3e9c864 --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/app/di/PushRegistrationEntryPoint.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.app.di + +import android.content.Context +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import org.onebusaway.android.push.PushRegistrationManager + +/** + * A Hilt [EntryPoint] that lets code which can't be constructor-injected — `Application.onCreate`, an + * `Activity` lifecycle callback — reach the injected [PushRegistrationManager] singleton (mirrors + * [FirebaseMessagingEntryPoint]). It needs a [Context] only to resolve the singleton graph. + * + * Use it only where injection genuinely isn't available — Hilt-reachable classes should inject + * [PushRegistrationManager] directly. + */ +@EntryPoint +@InstallIn(SingletonComponent::class) +interface PushRegistrationEntryPoint { + + fun pushRegistrationManager(): PushRegistrationManager + + companion object { + /** Resolves the [PushRegistrationManager] singleton from any [context] (its application is used). */ + @JvmStatic + fun get(context: Context): PushRegistrationManager = + EntryPointAccessors.fromApplication(context, PushRegistrationEntryPoint::class.java) + .pushRegistrationManager() + } +} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt new file mode 100644 index 0000000000..6a7301cc3b --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.push + +/** + * A single push registration on record with OBACloud: the region it targets, the FCM token, and the + * metadata sent alongside it. All fields participate in equality, so a change to *any* of them (a + * rotated token, a moved region, a new device locale, a flipped test flag) is a change that must be + * pushed to the server. Pure data — no Android dependencies — so [decide] is JVM-unit-testable. + */ +data class PushRegistration( + val regionId: Long, + val sidecarBaseUrl: String, + val token: String, + val locale: String, + val testDevice: Boolean, +) + +/** + * What the registrar should do to reconcile the [PushRegistration] currently on record with the one the + * device now wants (issue #1957). + */ +sealed interface PushRegistrationAction { + + /** On record and desired already match (or neither exists) — no network call. */ + object NoOp : PushRegistrationAction + + /** POST [target]: either the first registration, or a metadata refresh on the same token+region. */ + data class Register(val target: PushRegistration) : PushRegistrationAction + + /** DELETE [previous]: the rider opted out (or the token/region became unavailable). */ + data class Unregister(val previous: PushRegistration) : PushRegistrationAction + + /** + * The token or the region changed while still enabled: DELETE the stale [previous] registration + * (so the old region/token stops receiving) and POST the new [target]. + */ + data class Reregister( + val previous: PushRegistration, + val target: PushRegistration, + ) : PushRegistrationAction +} + +/** + * The pure reconciliation decision. [target] is the registration the device wants right now — non-null + * only when notifications are enabled and a region, sidecar URL, and FCM token are all available; + * null otherwise. [last] is the registration last successfully sent to the server, or null if none. + * + * Keeping this a pure function (no clock, no I/O, no Android) is deliberate: it is the one place the + * register/refresh/unregister rules live, and it is exhaustively unit-tested. + */ +fun decide(target: PushRegistration?, last: PushRegistration?): PushRegistrationAction = when { + target == null -> if (last == null) PushRegistrationAction.NoOp else PushRegistrationAction.Unregister(last) + last == null -> PushRegistrationAction.Register(target) + target == last -> PushRegistrationAction.NoOp + // Same endpoint + token, only locale/test flag changed → a plain re-POST upserts it. + target.token == last.token && + target.regionId == last.regionId && + target.sidecarBaseUrl == last.sidecarBaseUrl -> PushRegistrationAction.Register(target) + // Token or region changed → clean up the stale registration, then register the new one. + else -> PushRegistrationAction.Reregister(previous = last, target = target) +} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt new file mode 100644 index 0000000000..4f8d1d2679 --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt @@ -0,0 +1,199 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.push + +import android.content.Context +import androidx.core.app.NotificationManagerCompat +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import dagger.hilt.android.qualifiers.ApplicationContext +import java.net.HttpURLConnection +import java.util.Locale +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.onebusaway.android.R +import org.onebusaway.android.api.contract.PushRegistrationWebService +import org.onebusaway.android.app.di.AppScope +import org.onebusaway.android.preferences.PreferencesRepository +import org.onebusaway.android.region.RegionRepository +import org.onebusaway.android.util.runCatchingCancellable + +/** + * Keeps this device's OBACloud push registration (issue #1957) in sync with the app's current state so + * that service-alert notifications reach every rider who has notifications enabled — not only those who + * happen to create a trip alarm. + * + * The desired state is derived from three inputs: whether notifications are enabled at the OS level + * (the opt-in signal — there is no separate in-app toggle), the current region (its id + sidecar host), + * and the FCM token. Whenever any of them changes we [sync]: [decide] compares the desired + * [PushRegistration] against the one last sent to the server and yields a register / re-register / + * unregister / no-op action, which [apply] performs against [PushRegistrationWebService]. + * + * The last successful registration is persisted (the `push_reg_*` prefs slots) so we know exactly what + * to DELETE on opt-out and can skip redundant POSTs — important given the endpoint's 30 req/min/IP rate + * limit (see [PushRegistrationWebService]). + */ +@Singleton +class PushRegistrationManager @Inject constructor( + @param:ApplicationContext private val context: Context, + private val service: PushRegistrationWebService, + private val regionRepository: RegionRepository, + private val firebaseMessagingManager: FirebaseMessagingManager, + private val prefs: PreferencesRepository, + @param:AppScope private val scope: CoroutineScope, +) { + + private val started = AtomicBoolean(false) + + // Serializes reconciliation so overlapping triggers (region change + a token write arriving together) + // can't interleave two syncs and race on the persisted last-registration record. + private val mutex = Mutex() + + /** + * Begins reconciling in the background, idempotently (safe to call more than once). Kicked from + * `Application.onCreate` (on the main thread). Two triggers, covering every input that can change: + * + * - A collector over the region flow and the two preferences this feature reads (the FCM token and + * the test-device flag), running one [sync] per change. All three replay on launch, and each + * later change (token rotation, region switch, toggling the test flag) re-emits. Observing those + * keys specifically — rather than every app-wide preference write — avoids a needless + * `areNotificationsEnabled()` IPC on unrelated writes. + * - A process-lifecycle `ON_START` observer that [resync]s whenever the app comes to the foreground, + * catching an OS notification-permission change made in system settings (which touches neither a + * preference nor the region, so the collector can't see it) regardless of which activity resumes. + */ + fun start() { + if (started.getAndSet(true)) return + scope.launch { + combine( + regionRepository.region, + prefs.observeString(R.string.firebase_messaging_token, null), + prefs.observeBoolean(R.string.preference_key_push_test_device, false), + ) { _, _, _ -> }.collect { sync() } + } + ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) = resync() + }) + } + + /** Re-runs reconciliation once, off the main thread; a no-op when nothing changed. */ + fun resync() { + scope.launch { sync() } + } + + /** Reconciles the on-record registration with the currently desired one. */ + suspend fun sync() = mutex.withLock { + when (val action = decide(currentTarget(), loadLast())) { + PushRegistrationAction.NoOp -> Unit + is PushRegistrationAction.Register -> + if (register(action.target)) persist(action.target) + is PushRegistrationAction.Unregister -> + if (unregister(action.previous)) clearLast() + is PushRegistrationAction.Reregister -> { + unregister(action.previous) // best-effort cleanup of the stale token/region + if (register(action.target)) persist(action.target) else clearLast() + } + } + } + + /** + * The registration the device wants right now, or null when it can't/shouldn't be registered + * (notifications disabled, no resolved region + sidecar host, or no FCM token yet). The locale is + * the device's BCP-47 tag, sent as-is with no normalization. + */ + private fun currentTarget(): PushRegistration? { + if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) return null + val region = regionRepository.region.value ?: return null + val base = region.sidecarBaseUrl?.takeIf { it.isNotBlank() } ?: return null + val token = firebaseMessagingManager.userPushId().takeIf { it.isNotEmpty() } ?: return null + return PushRegistration( + regionId = region.id, + sidecarBaseUrl = base, + token = token, + locale = Locale.getDefault().toLanguageTag(), + testDevice = prefs.getBoolean(R.string.preference_key_push_test_device, false), + ) + } + + /** POSTs [target]; true only on a 2xx (204) success. */ + private suspend fun register(target: PushRegistration): Boolean = runCatchingCancellable { + service.register( + url = registrationUrl(target), + token = target.token, + locale = target.locale, + testDevice = target.testDevice, + ).isSuccessful + }.getOrDefault(false) + + /** DELETEs [previous]; true on 204 or 404 (already gone), so a stale record is cleared either way. */ + private suspend fun unregister(previous: PushRegistration): Boolean = runCatchingCancellable { + val response = service.unregister(url = registrationUrl(previous), token = previous.token) + response.isSuccessful || response.code() == HttpURLConnection.HTTP_NOT_FOUND + }.getOrDefault(false) + + /** `{sidecarBaseUrl}/api/v2/regions/{regionId}/push_registrations` — mirrors the alarms URL build. */ + private fun registrationUrl(registration: PushRegistration): String = + registration.sidecarBaseUrl + + context.getString(R.string.arrivals_reminders_api_endpoint) + + registration.regionId + "/push_registrations" + + /** + * The registration last successfully sent to the server, or null if none is on record. The token + * slot doubles as the commit sentinel: [persist] writes it last and [clearLast] nulls it, so a + * torn/absent write reads back here as "no record" and re-registration (safe, idempotent) follows. + */ + private fun loadLast(): PushRegistration? { + val base = prefs.getString(KEY_BASE, null) ?: return null + val token = prefs.getString(KEY_TOKEN, null) ?: return null + return PushRegistration( + regionId = prefs.getLong(KEY_REGION_ID, -1L), + sidecarBaseUrl = base, + token = token, + locale = prefs.getString(KEY_LOCALE, null) ?: "", + testDevice = prefs.getBoolean(KEY_TEST_DEVICE, false), + ) + } + + private fun persist(registration: PushRegistration) { + prefs.setLong(KEY_REGION_ID, registration.regionId) + prefs.setString(KEY_BASE, registration.sidecarBaseUrl) + prefs.setString(KEY_LOCALE, registration.locale) + prefs.setBoolean(KEY_TEST_DEVICE, registration.testDevice) + // Written last: its presence is what [loadLast] treats as "a full record is committed". + prefs.setString(KEY_TOKEN, registration.token) + } + + /** Nulls the commit-sentinel token slot so [loadLast] reports no record. */ + private fun clearLast() { + prefs.setString(KEY_TOKEN, null) + } + + private companion object { + // Internal bookkeeping slots (not user-facing) for the last successful registration. + const val KEY_REGION_ID = "push_reg_region_id" + const val KEY_BASE = "push_reg_base" + const val KEY_TOKEN = "push_reg_token" + const val KEY_LOCALE = "push_reg_locale" + const val KEY_TEST_DEVICE = "push_reg_test_device" + } +} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsScreen.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsScreen.kt index df23ff9775..2b0aa0e945 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsScreen.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsScreen.kt @@ -96,6 +96,7 @@ fun AdvancedSettingsRoute( onBack = onBack, onExperimentalToggle = onExperimentalToggle, onDisplayTestAlerts = viewModel::onDisplayTestAlertsChanged, + onPushTestDevice = viewModel::onPushTestDeviceChanged, onObaUrlChange = { url -> when (viewModel.onCustomObaApiUrlChanged(url)) { UrlChangeResult.InvalidObaUrl -> { @@ -150,6 +151,7 @@ fun AdvancedSettingsScreen( onBack: () -> Unit, onExperimentalToggle: (Boolean) -> Unit, onDisplayTestAlerts: (Boolean) -> Unit, + onPushTestDevice: (Boolean) -> Unit, onObaUrlChange: (String) -> Boolean, onOtpUrlChange: (String) -> Boolean, onOtpUrlUsesGraphQlChange: (Boolean) -> Unit, @@ -186,6 +188,12 @@ fun AdvancedSettingsScreen( checked = state.displayTestAlerts, onCheckedChange = onDisplayTestAlerts, ) + SwitchPreferenceItem( + title = stringResource(R.string.preferences_push_test_device_title), + summary = stringResource(R.string.preferences_push_test_device_summary), + checked = state.pushTestDevice, + onCheckedChange = onPushTestDevice, + ) EditTextPreferenceItem( title = stringResource(R.string.preferences_map_stop_cache_size_title), summary = stringResource( diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModel.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModel.kt index c243ead776..45b035b3f0 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModel.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModel.kt @@ -81,6 +81,7 @@ class AdvancedSettingsViewModel @Inject constructor( prefs = AdvancedPrefSnapshot( experimentalRegionsEnabled = prefs.getBoolean(R.string.preference_key_experimental_regions, false), displayTestAlerts = prefs.getBoolean(R.string.preferences_display_test_alerts, false), + pushTestDevice = prefs.getBoolean(R.string.preference_key_push_test_device, false), customObaApiUrl = prefs.getString(R.string.preference_key_oba_api_url, null), customOtpApiUrl = prefs.getString(R.string.preference_key_otp_api_url, null), customOtpApiUrlUsesGraphQl = prefs.getBoolean( @@ -104,6 +105,14 @@ class AdvancedSettingsViewModel @Inject constructor( fun onDisplayTestAlertsChanged(value: Boolean) = prefs.setBoolean(R.string.preferences_display_test_alerts, value) + /** + * Toggle whether this device registers into OBACloud's test-only push audience (#1957). The write + * lands in the `preference_key_push_test_device` slot that `PushRegistrationManager` reads; its + * reactive collector observes preference changes and re-registers with the flipped `test_device` flag. + */ + fun onPushTestDeviceChanged(value: Boolean) = + prefs.setBoolean(R.string.preference_key_push_test_device, value) + /** * Apply an edit to the map stop LRU cache size. Returns true if the input was a valid in-range * number (and was persisted); false otherwise so the dialog stays open (reject-on-invalid). diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/SettingsUiState.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/SettingsUiState.kt index 9263a9a9f9..b39f294cce 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/SettingsUiState.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/SettingsUiState.kt @@ -135,6 +135,8 @@ fun buildSettingsUiState( data class AdvancedPrefSnapshot( val experimentalRegionsEnabled: Boolean, val displayTestAlerts: Boolean, + // Registers this device into OBACloud's test-only push audience for previewing service alerts (#1957). + val pushTestDevice: Boolean, val customObaApiUrl: String?, val customOtpApiUrl: String?, // Manual OTP protocol override for the custom URL above (#1780) — the custom-server counterpart @@ -163,6 +165,7 @@ data class AdvancedSettingsUiState( val showExperimentalRegions: Boolean, val experimentalRegionsEnabled: Boolean, val displayTestAlerts: Boolean, + val pushTestDevice: Boolean, val customObaApiUrl: String?, val customOtpApiUrl: String?, val customOtpApiUrlUsesGraphQl: Boolean, @@ -194,6 +197,7 @@ fun buildAdvancedSettingsUiState( showExperimentalRegions = !useFixedRegion, experimentalRegionsEnabled = prefs.experimentalRegionsEnabled, displayTestAlerts = prefs.displayTestAlerts, + pushTestDevice = prefs.pushTestDevice, customObaApiUrl = prefs.customObaApiUrl, customOtpApiUrl = prefs.customOtpApiUrl, customOtpApiUrlUsesGraphQl = prefs.customOtpApiUrlUsesGraphQl, diff --git a/onebusaway-android/src/main/res/values/donottranslate.xml b/onebusaway-android/src/main/res/values/donottranslate.xml index 36d29b37aa..6ee6608dcd 100644 --- a/onebusaway-android/src/main/res/values/donottranslate.xml +++ b/onebusaway-android/src/main/res/values/donottranslate.xml @@ -63,6 +63,7 @@ preference_never_show_destination_reminder_beta_dialog preferences_key_user_denied_location_permissions preferences_display_test_alerts + preference_push_test_device https://regions.onebusaway.org/regions-v3.json diff --git a/onebusaway-android/src/main/res/values/strings.xml b/onebusaway-android/src/main/res/values/strings.xml index 2806b27c16..8a320ed9f9 100644 --- a/onebusaway-android/src/main/res/values/strings.xml +++ b/onebusaway-android/src/main/res/values/strings.xml @@ -1187,6 +1187,8 @@ More Info Display test alerts Display test-wide alerts for regions + Register as test device + Register this device in the test-only audience for previewing service-alert push notifications No description available Close donation card Zoom out diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationDecisionTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationDecisionTest.kt new file mode 100644 index 0000000000..91b61a31d2 --- /dev/null +++ b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationDecisionTest.kt @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.push + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Unit tests for [decide] — the pure register/refresh/unregister reconciliation for OBACloud push + * registration (#1957). Covers each transition between the desired registration ([target]) and the one + * last sent to the server ([last]). + */ +class PushRegistrationDecisionTest { + + private val base = PushRegistration( + regionId = 1L, + sidecarBaseUrl = "https://sidecar.example.org", + token = "token-a", + locale = "en-US", + testDevice = false, + ) + + @Test + fun `nothing desired and nothing on record is a no-op`() { + assertEquals(PushRegistrationAction.NoOp, decide(target = null, last = null)) + } + + @Test + fun `first registration when nothing is on record`() { + assertEquals(PushRegistrationAction.Register(base), decide(target = base, last = null)) + } + + @Test + fun `opting out unregisters the recorded registration`() { + assertEquals(PushRegistrationAction.Unregister(base), decide(target = null, last = base)) + } + + @Test + fun `unchanged registration is a no-op`() { + assertEquals(PushRegistrationAction.NoOp, decide(target = base, last = base)) + } + + @Test + fun `a locale change re-posts on the same token and region`() { + val target = base.copy(locale = "es-MX") + assertEquals(PushRegistrationAction.Register(target), decide(target = target, last = base)) + } + + @Test + fun `a test-device flag change re-posts on the same token and region`() { + val target = base.copy(testDevice = true) + assertEquals(PushRegistrationAction.Register(target), decide(target = target, last = base)) + } + + @Test + fun `a token rotation unregisters the old token then registers the new one`() { + val target = base.copy(token = "token-b") + assertEquals( + PushRegistrationAction.Reregister(previous = base, target = target), + decide(target = target, last = base), + ) + } + + @Test + fun `a region change unregisters the old region then registers the new one`() { + val target = base.copy(regionId = 2L, sidecarBaseUrl = "https://other.example.org") + assertEquals( + PushRegistrationAction.Reregister(previous = base, target = target), + decide(target = target, last = base), + ) + } +} diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/SettingsUiStateTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/SettingsUiStateTest.kt index d379c2ae50..39e9b91aa0 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/SettingsUiStateTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/SettingsUiStateTest.kt @@ -127,6 +127,7 @@ class SettingsUiStateTest { private val advPrefs = AdvancedPrefSnapshot( experimentalRegionsEnabled = false, displayTestAlerts = false, + pushTestDevice = false, customObaApiUrl = null, customOtpApiUrl = null, customOtpApiUrlUsesGraphQl = false, From 0d32d80fe93304d4629245c60e70ac3a6c23c4d2 Mon Sep 17 00:00:00 2001 From: Brandon Martin-Anderson Date: Sun, 19 Jul 2026 16:55:40 +0000 Subject: [PATCH 02/26] Resync push registration when notification permission granted in-flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../ui/tripdetails/DestinationReminder.kt | 15 ++++++++++----- .../android/ui/tripdetails/TripDestinations.kt | 18 +++++++++++------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripdetails/DestinationReminder.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripdetails/DestinationReminder.kt index 9a8a6ccab1..c31468b6c1 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripdetails/DestinationReminder.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripdetails/DestinationReminder.kt @@ -40,7 +40,6 @@ import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import com.google.android.gms.common.api.ApiException import com.google.android.gms.common.api.ResolvableApiException @@ -51,13 +50,13 @@ import com.google.android.gms.location.LocationSettingsStatusCodes import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.onebusaway.android.R import org.onebusaway.android.app.di.AnalyticsEntryPoint +import org.onebusaway.android.app.di.PushRegistrationEntryPoint import org.onebusaway.android.analytics.PlausibleAnalytics import org.onebusaway.android.nav.NavigationService import org.onebusaway.android.preferences.PreferencesRepository import org.onebusaway.android.ui.compose.components.OptOutInfoDialog import org.onebusaway.android.ui.compose.findActivity import org.onebusaway.android.location.isLocationEnabled -import org.onebusaway.android.util.PermissionUtils.NOTIFICATION_PERMISSION_REQUEST /** * The destination-reminder flow (set a reminder to alight at a chosen stop), as a reusable Compose @@ -105,6 +104,14 @@ internal fun rememberDestinationReminderAction( } } + // The system notification-permission dialog only pauses (never stops) the activity, so the + // ProcessLifecycleOwner ON_START resync in PushRegistrationManager never fires for it. Resync + // straight from the result callback so an in-flow opt-in registers this device immediately — + // the highest-value moment for the push feature — instead of waiting for the next app launch. + val notificationPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { PushRegistrationEntryPoint.get(context).resync() } + fun askUserToTurnLocationOn() { @Suppress("DEPRECATION") val locationRequest = LocationRequest.create().apply { @@ -173,9 +180,7 @@ internal fun rememberDestinationReminderAction( ) // POST_NOTIFICATIONS is a runtime permission only on API 33+; older versions grant it implicitly. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - ActivityCompat.requestPermissions( - activity, arrayOf(Manifest.permission.POST_NOTIFICATIONS), NOTIFICATION_PERMISSION_REQUEST - ) + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } val serviceIntent = setUpNavigationService(position) ?: return startNavigationService(serviceIntent) diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripdetails/TripDestinations.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripdetails/TripDestinations.kt index 8ef94089a7..43d8431375 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripdetails/TripDestinations.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripdetails/TripDestinations.kt @@ -20,9 +20,10 @@ package org.onebusaway.android.ui.tripdetails import android.Manifest import android.os.Build import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.platform.LocalContext -import androidx.core.app.ActivityCompat import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController @@ -32,6 +33,7 @@ import androidx.navigation.navArgument import org.onebusaway.android.R import org.onebusaway.android.app.di.DatabaseEntryPoint import org.onebusaway.android.app.di.PreferencesEntryPoint +import org.onebusaway.android.app.di.PushRegistrationEntryPoint import org.onebusaway.android.ui.compose.findActivity import org.onebusaway.android.ui.compose.theme.ObaTheme import org.onebusaway.android.ui.dataview.TripTrajectoryRoute @@ -41,7 +43,6 @@ import org.onebusaway.android.ui.tripinfo.TripInfoEvent import org.onebusaway.android.ui.tripinfo.TripInfoRoute import org.onebusaway.android.ui.tripinfo.TripInfoViewModel import org.onebusaway.android.ui.tripinfo.confirmDeleteReminder -import org.onebusaway.android.util.PermissionUtils /** * The trip navigation cluster: a trip's stops + live vehicle ([NavRoutes.TRIP_DETAILS]) and the @@ -152,6 +153,13 @@ fun NavGraphBuilder.tripGraph(navController: NavHostController) { val infoStopId = backStackEntry.arguments?.getString(NavRoutes.ARG_STOP_ID).orEmpty() val infoVm: TripInfoViewModel = hiltViewModel() + // The system notification-permission dialog only pauses (never stops) the activity, so the + // ProcessLifecycleOwner ON_START resync in PushRegistrationManager never fires for it. Resync + // straight from the result callback so an in-flow opt-in registers this device immediately + // instead of waiting for the next app launch. + val notificationPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { PushRegistrationEntryPoint.get(activity).resync() } LaunchedEffect(infoVm) { infoVm.events.collect { event -> when (event) { @@ -174,11 +182,7 @@ fun NavGraphBuilder.tripGraph(navController: NavHostController) { onSave = { // POST_NOTIFICATIONS is a runtime permission only on API 33+; older versions grant it implicitly. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - ActivityCompat.requestPermissions( - activity, - arrayOf(Manifest.permission.POST_NOTIFICATIONS), - PermissionUtils.NOTIFICATION_PERMISSION_REQUEST - ) + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } infoVm.save() }, From 080a9fc3c045be91050dfb28fba0633747c7edcc Mon Sep 17 00:00:00 2001 From: Brandon Martin-Anderson Date: Sun, 19 Jul 2026 17:03:22 +0000 Subject: [PATCH 03/26] Extract shared notification-permission-request Compose helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../compose/NotificationPermissionRequest.kt | 51 +++++++++++++++++++ .../ui/tripdetails/DestinationReminder.kt | 17 ++----- .../ui/tripdetails/TripDestinations.kt | 20 ++------ .../android/util/PermissionUtils.java | 2 - 4 files changed, 59 insertions(+), 31 deletions(-) create mode 100644 onebusaway-android/src/main/java/org/onebusaway/android/ui/compose/NotificationPermissionRequest.kt diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/compose/NotificationPermissionRequest.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/compose/NotificationPermissionRequest.kt new file mode 100644 index 0000000000..074aeba998 --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/compose/NotificationPermissionRequest.kt @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.ui.compose + +import android.Manifest +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import org.onebusaway.android.app.di.PushRegistrationEntryPoint + +/** + * Remembers the in-app POST_NOTIFICATIONS opt-in, returning a `() -> Unit` that requests the + * permission and, once the user responds, resyncs this device's push registration. + * + * The system permission dialog only pauses (never stops) the activity, so PushRegistrationManager's + * `ProcessLifecycleOwner` `ON_START` resync never fires for an in-flow grant. Resyncing straight from + * the result callback registers the device the moment the user opts in — the highest-value moment for + * the push feature — instead of waiting for the next app launch. Wiring this into one shared helper + * means every opt-in site inherits the resync by construction rather than re-deriving it (and risking + * the old fire-and-forget bug). + * + * The returned action is a no-op below API 33, where POST_NOTIFICATIONS is granted implicitly at + * install, so callers can invoke it unconditionally. + */ +@Composable +internal fun rememberNotificationPermissionRequest(): () -> Unit { + val context = LocalContext.current + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { PushRegistrationEntryPoint.get(context).resync() } + return { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + launcher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } +} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripdetails/DestinationReminder.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripdetails/DestinationReminder.kt index c31468b6c1..d4487768d8 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripdetails/DestinationReminder.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripdetails/DestinationReminder.kt @@ -16,7 +16,6 @@ */ package org.onebusaway.android.ui.tripdetails -import android.Manifest import android.app.Activity import android.content.BroadcastReceiver import android.content.Context @@ -50,12 +49,12 @@ import com.google.android.gms.location.LocationSettingsStatusCodes import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.onebusaway.android.R import org.onebusaway.android.app.di.AnalyticsEntryPoint -import org.onebusaway.android.app.di.PushRegistrationEntryPoint import org.onebusaway.android.analytics.PlausibleAnalytics import org.onebusaway.android.nav.NavigationService import org.onebusaway.android.preferences.PreferencesRepository import org.onebusaway.android.ui.compose.components.OptOutInfoDialog import org.onebusaway.android.ui.compose.findActivity +import org.onebusaway.android.ui.compose.rememberNotificationPermissionRequest import org.onebusaway.android.location.isLocationEnabled /** @@ -104,13 +103,8 @@ internal fun rememberDestinationReminderAction( } } - // The system notification-permission dialog only pauses (never stops) the activity, so the - // ProcessLifecycleOwner ON_START resync in PushRegistrationManager never fires for it. Resync - // straight from the result callback so an in-flow opt-in registers this device immediately — - // the highest-value moment for the push feature — instead of waiting for the next app launch. - val notificationPermissionLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { PushRegistrationEntryPoint.get(context).resync() } + // Requests POST_NOTIFICATIONS and resyncs the push registration on the result (see the helper). + val requestNotificationPermission = rememberNotificationPermissionRequest() fun askUserToTurnLocationOn() { @Suppress("DEPRECATION") @@ -178,10 +172,7 @@ internal fun rememberDestinationReminderAction( resources.getString(R.string.analytics_label_destination_reminder), resources.getString(R.string.analytics_label_destination_reminder_variant_started) ) - // POST_NOTIFICATIONS is a runtime permission only on API 33+; older versions grant it implicitly. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - } + requestNotificationPermission() val serviceIntent = setUpNavigationService(position) ?: return startNavigationService(serviceIntent) Toast.makeText( diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripdetails/TripDestinations.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripdetails/TripDestinations.kt index 43d8431375..939fba0fd7 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripdetails/TripDestinations.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripdetails/TripDestinations.kt @@ -17,11 +17,7 @@ */ package org.onebusaway.android.ui.tripdetails -import android.Manifest -import android.os.Build import android.widget.Toast -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.platform.LocalContext import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -33,8 +29,8 @@ import androidx.navigation.navArgument import org.onebusaway.android.R import org.onebusaway.android.app.di.DatabaseEntryPoint import org.onebusaway.android.app.di.PreferencesEntryPoint -import org.onebusaway.android.app.di.PushRegistrationEntryPoint import org.onebusaway.android.ui.compose.findActivity +import org.onebusaway.android.ui.compose.rememberNotificationPermissionRequest import org.onebusaway.android.ui.compose.theme.ObaTheme import org.onebusaway.android.ui.dataview.TripTrajectoryRoute import org.onebusaway.android.ui.dataview.TripTrajectoryViewModel @@ -153,13 +149,8 @@ fun NavGraphBuilder.tripGraph(navController: NavHostController) { val infoStopId = backStackEntry.arguments?.getString(NavRoutes.ARG_STOP_ID).orEmpty() val infoVm: TripInfoViewModel = hiltViewModel() - // The system notification-permission dialog only pauses (never stops) the activity, so the - // ProcessLifecycleOwner ON_START resync in PushRegistrationManager never fires for it. Resync - // straight from the result callback so an in-flow opt-in registers this device immediately - // instead of waiting for the next app launch. - val notificationPermissionLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { PushRegistrationEntryPoint.get(activity).resync() } + // Requests POST_NOTIFICATIONS and resyncs the push registration on the result (see the helper). + val requestNotificationPermission = rememberNotificationPermissionRequest() LaunchedEffect(infoVm) { infoVm.events.collect { event -> when (event) { @@ -180,10 +171,7 @@ fun NavGraphBuilder.tripGraph(navController: NavHostController) { viewModel = infoVm, onBack = { navController.popBackStack() }, onSave = { - // POST_NOTIFICATIONS is a runtime permission only on API 33+; older versions grant it implicitly. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - } + requestNotificationPermission() infoVm.save() }, onDelete = { diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/util/PermissionUtils.java b/onebusaway-android/src/main/java/org/onebusaway/android/util/PermissionUtils.java index e548cabc30..5d23b25bd7 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/util/PermissionUtils.java +++ b/onebusaway-android/src/main/java/org/onebusaway/android/util/PermissionUtils.java @@ -25,8 +25,6 @@ public class PermissionUtils { - public static final int NOTIFICATION_PERMISSION_REQUEST = 5; - public static final String[] LOCATION_PERMISSIONS = { Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION From 6944d68a59836308bd86fe458f736ee9e5be7f1a Mon Sep 17 00:00:00 2001 From: Brandon Martin-Anderson Date: Sun, 19 Jul 2026 17:05:39 +0000 Subject: [PATCH 04/26] Don't orphan the previous registration when a Reregister fully fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../onebusaway/android/push/PushRegistrationManager.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt index 4f8d1d2679..9b8ac0d8c6 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt @@ -110,8 +110,13 @@ class PushRegistrationManager @Inject constructor( is PushRegistrationAction.Unregister -> if (unregister(action.previous)) clearLast() is PushRegistrationAction.Reregister -> { - unregister(action.previous) // best-effort cleanup of the stale token/region - if (register(action.target)) persist(action.target) else clearLast() + // DELETE the stale registration, then POST the new one, persisting only once the POST + // lands. On failure keep `previous` on record UNLESS the DELETE already removed it: + // clearing it while it's still registered would make the next sync a plain Register, + // orphaning the old region/token (it keeps receiving alerts until the 180-day age-out). + val cleaned = unregister(action.previous) + if (register(action.target)) persist(action.target) + else if (cleaned) clearLast() } } } From 669f523c2ffd513de9e54eedd69f8cb1eb120d32 Mon Sep 17 00:00:00 2001 From: Brandon Martin-Anderson Date: Sun, 19 Jul 2026 17:24:18 +0000 Subject: [PATCH 05/26] Send push-unregister token as a query param; document the #1957 contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../contract/PushRegistrationWebService.kt | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/api/contract/PushRegistrationWebService.kt b/onebusaway-android/src/main/java/org/onebusaway/android/api/contract/PushRegistrationWebService.kt index 2ae60daab9..3a16afe0e3 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/api/contract/PushRegistrationWebService.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/api/contract/PushRegistrationWebService.kt @@ -20,6 +20,7 @@ import retrofit2.http.Field import retrofit2.http.FormUrlEncoded import retrofit2.http.HTTP import retrofit2.http.POST +import retrofit2.http.Query import retrofit2.http.Url /** @@ -39,8 +40,11 @@ interface PushRegistrationWebService { /** * Registers (or refreshes) this device's push token with the region. Form-urlencoded POST to - * `…/regions/{id}/push_registrations`. [testDevice] must be sent on every call so a device is not - * accidentally reset out of the test audience; [locale] is the device's BCP-47 tag sent as-is. + * `…/regions/{id}/push_registrations` (issue #1957). The server upserts on `(region, token)` and + * overwrites the stored value from each call, so [testDevice] must be sent every time (an omitted + * `test_device` resets it to `false`) — the wire value is the literal `true`/`false` the contract + * documents. [locale] is the device's BCP-47 tag sent as-is; the server maps it to its translation + * catalog itself. */ @FormUrlEncoded @POST @@ -54,13 +58,10 @@ interface PushRegistrationWebService { /** * Unregisters this device's push [token] from the region (when the rider opts out of - * notifications). DELETE to `…/regions/{id}/push_registrations`. - * - * The issue does not pin down how the token is conveyed to the DELETE; mirroring [register], it is - * sent as a form-encoded body field. If the OBACloud contract turns out to expect a query param - * instead, switch this to `@Query("token")`. Verify against the server spec before release. + * notifications). DELETE to `…/regions/{id}/push_registrations` with the token as a query param + * (issue #1957). The contract leaves body-vs-query unstated; 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. */ - @FormUrlEncoded - @HTTP(method = "DELETE", hasBody = true) - suspend fun unregister(@Url url: String, @Field("token") token: String): Response + @HTTP(method = "DELETE") + suspend fun unregister(@Url url: String, @Query("token") token: String): Response } From 72b618ba3b2e61b2ab61586783be8634726432f3 Mon Sep 17 00:00:00 2001 From: Brandon Martin-Anderson Date: Sun, 19 Jul 2026 17:27:38 +0000 Subject: [PATCH 06/26] Push registration nits: rename decide(), make sync() private MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../android/push/PushRegistrationDecision.kt | 8 ++++++-- .../android/push/PushRegistrationManager.kt | 6 +++--- .../push/PushRegistrationDecisionTest.kt | 18 +++++++++--------- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt index 6a7301cc3b..1602cbc0c7 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt @@ -19,7 +19,8 @@ package org.onebusaway.android.push * A single push registration on record with OBACloud: the region it targets, the FCM token, and the * metadata sent alongside it. All fields participate in equality, so a change to *any* of them (a * rotated token, a moved region, a new device locale, a flipped test flag) is a change that must be - * pushed to the server. Pure data — no Android dependencies — so [decide] is JVM-unit-testable. + * pushed to the server. Pure data — no Android dependencies — so [decidePushRegistration] is + * JVM-unit-testable. */ data class PushRegistration( val regionId: Long, @@ -62,7 +63,10 @@ sealed interface PushRegistrationAction { * Keeping this a pure function (no clock, no I/O, no Android) is deliberate: it is the one place the * register/refresh/unregister rules live, and it is exhaustively unit-tested. */ -fun decide(target: PushRegistration?, last: PushRegistration?): PushRegistrationAction = when { +fun decidePushRegistration( + target: PushRegistration?, + last: PushRegistration?, +): PushRegistrationAction = when { target == null -> if (last == null) PushRegistrationAction.NoOp else PushRegistrationAction.Unregister(last) last == null -> PushRegistrationAction.Register(target) target == last -> PushRegistrationAction.NoOp diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt index 9b8ac0d8c6..98ae99c5dd 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt @@ -45,7 +45,7 @@ import org.onebusaway.android.util.runCatchingCancellable * * The desired state is derived from three inputs: whether notifications are enabled at the OS level * (the opt-in signal — there is no separate in-app toggle), the current region (its id + sidecar host), - * and the FCM token. Whenever any of them changes we [sync]: [decide] compares the desired + * and the FCM token. Whenever any of them changes we [sync]: [decidePushRegistration] compares the desired * [PushRegistration] against the one last sent to the server and yields a register / re-register / * unregister / no-op action, which [apply] performs against [PushRegistrationWebService]. * @@ -102,8 +102,8 @@ class PushRegistrationManager @Inject constructor( } /** Reconciles the on-record registration with the currently desired one. */ - suspend fun sync() = mutex.withLock { - when (val action = decide(currentTarget(), loadLast())) { + private suspend fun sync() = mutex.withLock { + when (val action = decidePushRegistration(currentTarget(), loadLast())) { PushRegistrationAction.NoOp -> Unit is PushRegistrationAction.Register -> if (register(action.target)) persist(action.target) diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationDecisionTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationDecisionTest.kt index 91b61a31d2..2bab13aaff 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationDecisionTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationDecisionTest.kt @@ -19,7 +19,7 @@ import org.junit.Assert.assertEquals import org.junit.Test /** - * Unit tests for [decide] — the pure register/refresh/unregister reconciliation for OBACloud push + * Unit tests for [decidePushRegistration] — the pure register/refresh/unregister reconciliation for OBACloud push * registration (#1957). Covers each transition between the desired registration ([target]) and the one * last sent to the server ([last]). */ @@ -35,34 +35,34 @@ class PushRegistrationDecisionTest { @Test fun `nothing desired and nothing on record is a no-op`() { - assertEquals(PushRegistrationAction.NoOp, decide(target = null, last = null)) + assertEquals(PushRegistrationAction.NoOp, decidePushRegistration(target = null, last = null)) } @Test fun `first registration when nothing is on record`() { - assertEquals(PushRegistrationAction.Register(base), decide(target = base, last = null)) + assertEquals(PushRegistrationAction.Register(base), decidePushRegistration(target = base, last = null)) } @Test fun `opting out unregisters the recorded registration`() { - assertEquals(PushRegistrationAction.Unregister(base), decide(target = null, last = base)) + assertEquals(PushRegistrationAction.Unregister(base), decidePushRegistration(target = null, last = base)) } @Test fun `unchanged registration is a no-op`() { - assertEquals(PushRegistrationAction.NoOp, decide(target = base, last = base)) + assertEquals(PushRegistrationAction.NoOp, decidePushRegistration(target = base, last = base)) } @Test fun `a locale change re-posts on the same token and region`() { val target = base.copy(locale = "es-MX") - assertEquals(PushRegistrationAction.Register(target), decide(target = target, last = base)) + assertEquals(PushRegistrationAction.Register(target), decidePushRegistration(target = target, last = base)) } @Test fun `a test-device flag change re-posts on the same token and region`() { val target = base.copy(testDevice = true) - assertEquals(PushRegistrationAction.Register(target), decide(target = target, last = base)) + assertEquals(PushRegistrationAction.Register(target), decidePushRegistration(target = target, last = base)) } @Test @@ -70,7 +70,7 @@ class PushRegistrationDecisionTest { val target = base.copy(token = "token-b") assertEquals( PushRegistrationAction.Reregister(previous = base, target = target), - decide(target = target, last = base), + decidePushRegistration(target = target, last = base), ) } @@ -79,7 +79,7 @@ class PushRegistrationDecisionTest { val target = base.copy(regionId = 2L, sidecarBaseUrl = "https://other.example.org") assertEquals( PushRegistrationAction.Reregister(previous = base, target = target), - decide(target = target, last = base), + decidePushRegistration(target = target, last = base), ) } } From 33f3088ab5ad4270bfbe9c66c7d6fc04b01221f6 Mon Sep 17 00:00:00 2001 From: Brandon Martin-Anderson Date: Sun, 19 Jul 2026 17:47:21 +0000 Subject: [PATCH 07/26] Cover PushRegistrationManager.sync() apply/persist semantics under JVM test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../android/push/PushRegistrationManager.kt | 37 ++- .../push/PushRegistrationManagerTest.kt | 294 ++++++++++++++++++ 2 files changed, 326 insertions(+), 5 deletions(-) create mode 100644 onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt index 98ae99c5dd..96e08569b2 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt @@ -54,15 +54,42 @@ import org.onebusaway.android.util.runCatchingCancellable * limit (see [PushRegistrationWebService]). */ @Singleton -class PushRegistrationManager @Inject constructor( - @param:ApplicationContext private val context: Context, +class PushRegistrationManager internal constructor( private val service: PushRegistrationWebService, private val regionRepository: RegionRepository, private val firebaseMessagingManager: FirebaseMessagingManager, private val prefs: PreferencesRepository, - @param:AppScope private val scope: CoroutineScope, + private val scope: CoroutineScope, + // Test seams for the two Android-only reads (see the @Inject constructor). Kept off the DI path so + // sync()'s reconcile/persist semantics — including the Reregister-failure record handling — are + // exercisable from a plain JVM test, which is where the "orphan on double failure" class of bug lives. + private val notificationsEnabled: () -> Boolean, + private val registrationsEndpointPath: String, ) { + /** + * The production constructor Hilt builds from. It resolves the two seams the JVM can't — the OS + * notifications-enabled check and the registrations endpoint-path string — from the application + * [context], then delegates to the primary constructor. + */ + @Inject + constructor( + @ApplicationContext context: Context, + service: PushRegistrationWebService, + regionRepository: RegionRepository, + firebaseMessagingManager: FirebaseMessagingManager, + prefs: PreferencesRepository, + @AppScope scope: CoroutineScope, + ) : this( + service = service, + regionRepository = regionRepository, + firebaseMessagingManager = firebaseMessagingManager, + prefs = prefs, + scope = scope, + notificationsEnabled = { NotificationManagerCompat.from(context).areNotificationsEnabled() }, + registrationsEndpointPath = context.getString(R.string.arrivals_reminders_api_endpoint), + ) + private val started = AtomicBoolean(false) // Serializes reconciliation so overlapping triggers (region change + a token write arriving together) @@ -127,7 +154,7 @@ class PushRegistrationManager @Inject constructor( * the device's BCP-47 tag, sent as-is with no normalization. */ private fun currentTarget(): PushRegistration? { - if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) return null + if (!notificationsEnabled()) return null val region = regionRepository.region.value ?: return null val base = region.sidecarBaseUrl?.takeIf { it.isNotBlank() } ?: return null val token = firebaseMessagingManager.userPushId().takeIf { it.isNotEmpty() } ?: return null @@ -159,7 +186,7 @@ class PushRegistrationManager @Inject constructor( /** `{sidecarBaseUrl}/api/v2/regions/{regionId}/push_registrations` — mirrors the alarms URL build. */ private fun registrationUrl(registration: PushRegistration): String = registration.sidecarBaseUrl + - context.getString(R.string.arrivals_reminders_api_endpoint) + + registrationsEndpointPath + registration.regionId + "/push_registrations" /** diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt new file mode 100644 index 0000000000..f0a1b8ddfd --- /dev/null +++ b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt @@ -0,0 +1,294 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.push + +import java.io.IOException +import java.util.Locale +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.onebusaway.android.R +import org.onebusaway.android.api.contract.PushRegistrationWebService +import org.onebusaway.android.region.FakeRegionRepository +import org.onebusaway.android.region.region +import org.onebusaway.android.testing.FakePreferencesRepository +import retrofit2.Response + +/** + * Drives [PushRegistrationManager.sync] (through the public [PushRegistrationManager.resync] trigger) + * over hand fakes to lock down the reconcile → apply → persist semantics [decidePushRegistration] can't + * see: which endpoint calls fire and, crucially, what the persisted "last registration" record becomes + * after each success/failure. The manager's two Android-only reads (OS notifications-enabled, the + * endpoint-path string) are injected as test seams, so this is a plain JVM test with no Robolectric. + * + * The headline case is [reregister that fully fails keeps previous so the next sync retries the delete]: + * a Reregister whose DELETE and POST both fail must NOT forget `previous`, or the stale region/token is + * orphaned (still pushing) until the 180-day server prune. That path is invisible to the pure decision + * test and is exactly the kind of bug a persist-semantics test catches. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class PushRegistrationManagerTest { + + private var previousLocale: Locale? = null + + @Before + fun pinLocale() { + // currentTarget() sends Locale.getDefault().toLanguageTag(); pin it so the assertion is stable. + previousLocale = Locale.getDefault() + Locale.setDefault(Locale.US) + } + + @After + fun restoreLocale() { + previousLocale?.let { Locale.setDefault(it) } + } + + @Test + fun `registers and persists, then a no-op when nothing changed`() = runTest { + val f = Fixture(this) + f.setToken("T1") + + f.manager.resync() + advanceUntilIdle() + + val call = f.service.registerCalls.single() + assertEquals("https://sidecar.test/api/v2/regions/1/push_registrations", call.url) + assertEquals("T1", call.token) + assertEquals("en-US", call.locale) + assertEquals(true, call.testDevice) + assertEquals("android", call.operatingSystem) + + // Same inputs → decide() yields NoOp; no second POST despite the trigger firing again. + f.manager.resync() + advanceUntilIdle() + assertEquals(1, f.service.registerCalls.size) + } + + @Test + fun `does nothing when notifications are disabled and nothing is on record`() = runTest { + val f = Fixture(this) + f.setToken("T1") + f.notificationsEnabled = false + + f.manager.resync() + advanceUntilIdle() + + assertTrue(f.service.registerCalls.isEmpty()) + assertTrue(f.service.unregisterCalls.isEmpty()) + } + + @Test + fun `opt-out unregisters the recorded token and clears the record`() = runTest { + val f = Fixture(this) + f.setToken("T1") + f.manager.resync() + advanceUntilIdle() + + // Rider turns notifications off in system settings; the ON_START resync reconciles. + f.notificationsEnabled = false + f.manager.resync() + advanceUntilIdle() + + assertEquals("T1", f.service.unregisterCalls.single().token) + + // Record cleared → a further resync is a NoOp (no duplicate DELETE). + f.manager.resync() + advanceUntilIdle() + assertEquals(1, f.service.unregisterCalls.size) + } + + @Test + fun `a failed register persists nothing and the next sync retries`() = runTest { + val f = Fixture(this) + f.setToken("T1") + f.service.onRegister = { throw IOException("offline") } + + f.manager.resync() + advanceUntilIdle() + assertEquals(1, f.service.registerCalls.size) + + // Nothing was persisted, so the retry is a full Register again (not a NoOp). + f.service.onRegister = { Response.success(Unit) } + f.manager.resync() + advanceUntilIdle() + assertEquals(2, f.service.registerCalls.size) + + // Now it's on record → the third sync is a NoOp. + f.manager.resync() + advanceUntilIdle() + assertEquals(2, f.service.registerCalls.size) + } + + @Test + fun `token rotation reregisters, deleting the old token and posting the new`() = runTest { + val f = Fixture(this) + f.setToken("T1") + f.manager.resync() + advanceUntilIdle() + + f.setToken("T2") + f.manager.resync() + advanceUntilIdle() + + assertEquals("T1", f.service.unregisterCalls.single().token) + assertEquals(listOf("T1", "T2"), f.service.registerCalls.map { it.token }) + + // The new token is on record now → NoOp. + f.manager.resync() + advanceUntilIdle() + assertEquals(2, f.service.registerCalls.size) + } + + @Test + fun `reregister that fully fails keeps previous so the next sync retries the delete`() = runTest { + val f = Fixture(this) + f.setToken("T1") + f.manager.resync() + advanceUntilIdle() + + // Region switch mid-offline: both the DELETE of T1 and the POST of T2 fail. + f.setToken("T2") + f.service.onUnregister = { throw IOException("offline") } + f.service.onRegister = { throw IOException("offline") } + f.manager.resync() + advanceUntilIdle() + + // Back online: the record must still point at T1 so this sync re-runs the full Reregister + // (DELETE T1 + POST T2). If `previous` had been forgotten, this would be a plain Register(T2) + // and T1 would never be deleted — the orphan bug. + f.service.onUnregister = { Response.success(Unit) } + f.service.onRegister = { Response.success(Unit) } + f.manager.resync() + advanceUntilIdle() + + assertEquals(2, f.service.unregisterCalls.count { it.token == "T1" }) + + // T2 is finally on record → NoOp, no further POSTs. + val postsSoFar = f.service.registerCalls.size + f.manager.resync() + advanceUntilIdle() + assertEquals(postsSoFar, f.service.registerCalls.size) + } + + @Test + fun `reregister clears the record when the delete succeeds but the post fails`() = runTest { + val f = Fixture(this) + f.setToken("T1") + f.manager.resync() + advanceUntilIdle() + + // DELETE of T1 lands, POST of T2 fails → the server holds nothing, so the record must clear. + f.setToken("T2") + f.service.onRegister = { throw IOException("offline") } + f.manager.resync() + advanceUntilIdle() + assertEquals(1, f.service.unregisterCalls.size) + + // Record cleared → the retry is a plain Register(T2), with no further DELETE. + f.service.onRegister = { Response.success(Unit) } + f.manager.resync() + advanceUntilIdle() + assertEquals(1, f.service.unregisterCalls.size) + assertEquals("T2", f.service.registerCalls.last().token) + } + + @Test + fun `unregister treats a 404 as already-gone and clears the record`() = runTest { + val f = Fixture(this) + f.setToken("T1") + f.manager.resync() + advanceUntilIdle() + + f.notificationsEnabled = false + f.service.onUnregister = { Response.error(404, "".toResponseBody(null)) } + f.manager.resync() + advanceUntilIdle() + assertEquals(1, f.service.unregisterCalls.size) + + // 404 counts as removed → the record is cleared, so a further resync is a NoOp. + f.manager.resync() + advanceUntilIdle() + assertEquals(1, f.service.unregisterCalls.size) + } + + /** + * A ready-to-drive manager over fakes: notifications on, one region (id 1 + sidecar), the + * test-device flag on, and no token until a test sets one. [notificationsEnabled] is a mutable flag + * so a test can flip the OS opt-in between syncs. + */ + private class Fixture(scope: CoroutineScope) { + val service = FakePushRegistrationWebService() + val prefs = FakePreferencesRepository().apply { + setBoolean(R.string.preference_key_push_test_device, true) + } + val regions = FakeRegionRepository(region(1).copy(sidecarBaseUrl = "https://sidecar.test")) + var notificationsEnabled = true + + val manager = PushRegistrationManager( + service = service, + regionRepository = regions, + firebaseMessagingManager = FirebaseMessagingManager(prefs), + prefs = prefs, + scope = scope, + notificationsEnabled = { notificationsEnabled }, + registrationsEndpointPath = "/api/v2/regions/", + ) + + fun setToken(token: String?) = prefs.setString(R.string.firebase_messaging_token, token) + } + + /** Records every call and returns programmable outcomes (default: success). */ + private class FakePushRegistrationWebService : PushRegistrationWebService { + data class RegisterCall( + val url: String, + val token: String, + val locale: String, + val testDevice: Boolean, + val operatingSystem: String, + ) + + data class UnregisterCall(val url: String, val token: String) + + val registerCalls = mutableListOf() + val unregisterCalls = mutableListOf() + + var onRegister: () -> Response = { Response.success(Unit) } + var onUnregister: () -> Response = { Response.success(Unit) } + + override suspend fun register( + url: String, + token: String, + locale: String, + testDevice: Boolean, + operatingSystem: String, + ): Response { + registerCalls += RegisterCall(url, token, locale, testDevice, operatingSystem) + return onRegister() + } + + override suspend fun unregister(url: String, token: String): Response { + unregisterCalls += UnregisterCall(url, token) + return onUnregister() + } + } +} From 12f131889c355e8f2ee899bd4d3bf877f8febadb Mon Sep 17 00:00:00 2001 From: Brandon Martin-Anderson Date: Sun, 19 Jul 2026 17:53:06 +0000 Subject: [PATCH 08/26] Tidy push-registration test: Fixture.sync()/registered() helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../android/push/PushRegistrationManager.kt | 6 +- .../push/PushRegistrationManagerTest.kt | 90 +++++++------------ 2 files changed, 34 insertions(+), 62 deletions(-) diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt index 96e08569b2..45ce7c5977 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt @@ -67,11 +67,7 @@ class PushRegistrationManager internal constructor( private val registrationsEndpointPath: String, ) { - /** - * The production constructor Hilt builds from. It resolves the two seams the JVM can't — the OS - * notifications-enabled check and the registrations endpoint-path string — from the application - * [context], then delegates to the primary constructor. - */ + /** Production constructor Hilt builds from: resolves the two seams from [context] and delegates. */ @Inject constructor( @ApplicationContext context: Context, diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt index f0a1b8ddfd..32d1d7e3c6 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt @@ -17,8 +17,8 @@ package org.onebusaway.android.push import java.io.IOException import java.util.Locale -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import okhttp3.ResponseBody.Companion.toResponseBody @@ -67,9 +67,7 @@ class PushRegistrationManagerTest { fun `registers and persists, then a no-op when nothing changed`() = runTest { val f = Fixture(this) f.setToken("T1") - - f.manager.resync() - advanceUntilIdle() + f.sync() val call = f.service.registerCalls.single() assertEquals("https://sidecar.test/api/v2/regions/1/push_registrations", call.url) @@ -79,8 +77,7 @@ class PushRegistrationManagerTest { assertEquals("android", call.operatingSystem) // Same inputs → decide() yields NoOp; no second POST despite the trigger firing again. - f.manager.resync() - advanceUntilIdle() + f.sync() assertEquals(1, f.service.registerCalls.size) } @@ -90,8 +87,7 @@ class PushRegistrationManagerTest { f.setToken("T1") f.notificationsEnabled = false - f.manager.resync() - advanceUntilIdle() + f.sync() assertTrue(f.service.registerCalls.isEmpty()) assertTrue(f.service.unregisterCalls.isEmpty()) @@ -99,21 +95,16 @@ class PushRegistrationManagerTest { @Test fun `opt-out unregisters the recorded token and clears the record`() = runTest { - val f = Fixture(this) - f.setToken("T1") - f.manager.resync() - advanceUntilIdle() + val f = Fixture(this).registered("T1") // Rider turns notifications off in system settings; the ON_START resync reconciles. f.notificationsEnabled = false - f.manager.resync() - advanceUntilIdle() + f.sync() assertEquals("T1", f.service.unregisterCalls.single().token) // Record cleared → a further resync is a NoOp (no duplicate DELETE). - f.manager.resync() - advanceUntilIdle() + f.sync() assertEquals(1, f.service.unregisterCalls.size) } @@ -123,111 +114,87 @@ class PushRegistrationManagerTest { f.setToken("T1") f.service.onRegister = { throw IOException("offline") } - f.manager.resync() - advanceUntilIdle() + f.sync() assertEquals(1, f.service.registerCalls.size) // Nothing was persisted, so the retry is a full Register again (not a NoOp). f.service.onRegister = { Response.success(Unit) } - f.manager.resync() - advanceUntilIdle() + f.sync() assertEquals(2, f.service.registerCalls.size) // Now it's on record → the third sync is a NoOp. - f.manager.resync() - advanceUntilIdle() + f.sync() assertEquals(2, f.service.registerCalls.size) } @Test fun `token rotation reregisters, deleting the old token and posting the new`() = runTest { - val f = Fixture(this) - f.setToken("T1") - f.manager.resync() - advanceUntilIdle() + val f = Fixture(this).registered("T1") f.setToken("T2") - f.manager.resync() - advanceUntilIdle() + f.sync() assertEquals("T1", f.service.unregisterCalls.single().token) assertEquals(listOf("T1", "T2"), f.service.registerCalls.map { it.token }) // The new token is on record now → NoOp. - f.manager.resync() - advanceUntilIdle() + f.sync() assertEquals(2, f.service.registerCalls.size) } @Test fun `reregister that fully fails keeps previous so the next sync retries the delete`() = runTest { - val f = Fixture(this) - f.setToken("T1") - f.manager.resync() - advanceUntilIdle() + val f = Fixture(this).registered("T1") // Region switch mid-offline: both the DELETE of T1 and the POST of T2 fail. f.setToken("T2") f.service.onUnregister = { throw IOException("offline") } f.service.onRegister = { throw IOException("offline") } - f.manager.resync() - advanceUntilIdle() + f.sync() // Back online: the record must still point at T1 so this sync re-runs the full Reregister // (DELETE T1 + POST T2). If `previous` had been forgotten, this would be a plain Register(T2) // and T1 would never be deleted — the orphan bug. f.service.onUnregister = { Response.success(Unit) } f.service.onRegister = { Response.success(Unit) } - f.manager.resync() - advanceUntilIdle() + f.sync() assertEquals(2, f.service.unregisterCalls.count { it.token == "T1" }) // T2 is finally on record → NoOp, no further POSTs. val postsSoFar = f.service.registerCalls.size - f.manager.resync() - advanceUntilIdle() + f.sync() assertEquals(postsSoFar, f.service.registerCalls.size) } @Test fun `reregister clears the record when the delete succeeds but the post fails`() = runTest { - val f = Fixture(this) - f.setToken("T1") - f.manager.resync() - advanceUntilIdle() + val f = Fixture(this).registered("T1") // DELETE of T1 lands, POST of T2 fails → the server holds nothing, so the record must clear. f.setToken("T2") f.service.onRegister = { throw IOException("offline") } - f.manager.resync() - advanceUntilIdle() + f.sync() assertEquals(1, f.service.unregisterCalls.size) // Record cleared → the retry is a plain Register(T2), with no further DELETE. f.service.onRegister = { Response.success(Unit) } - f.manager.resync() - advanceUntilIdle() + f.sync() assertEquals(1, f.service.unregisterCalls.size) assertEquals("T2", f.service.registerCalls.last().token) } @Test fun `unregister treats a 404 as already-gone and clears the record`() = runTest { - val f = Fixture(this) - f.setToken("T1") - f.manager.resync() - advanceUntilIdle() + val f = Fixture(this).registered("T1") f.notificationsEnabled = false f.service.onUnregister = { Response.error(404, "".toResponseBody(null)) } - f.manager.resync() - advanceUntilIdle() + f.sync() assertEquals(1, f.service.unregisterCalls.size) // 404 counts as removed → the record is cleared, so a further resync is a NoOp. - f.manager.resync() - advanceUntilIdle() + f.sync() assertEquals(1, f.service.unregisterCalls.size) } @@ -236,7 +203,7 @@ class PushRegistrationManagerTest { * test-device flag on, and no token until a test sets one. [notificationsEnabled] is a mutable flag * so a test can flip the OS opt-in between syncs. */ - private class Fixture(scope: CoroutineScope) { + private class Fixture(private val scope: TestScope) { val service = FakePushRegistrationWebService() val prefs = FakePreferencesRepository().apply { setBoolean(R.string.preference_key_push_test_device, true) @@ -255,6 +222,15 @@ class PushRegistrationManagerTest { ) fun setToken(token: String?) = prefs.setString(R.string.firebase_messaging_token, token) + + /** Fires the reconcile trigger and drains it — the manager runs sync() off resync()'s launch. */ + fun sync() { + manager.resync() + scope.advanceUntilIdle() + } + + /** Establishes a "[token] already registered and on record" baseline before the scenario. */ + fun registered(token: String): Fixture = apply { setToken(token); sync() } } /** Records every call and returns programmable outcomes (default: success). */ From a38db10a122346680725b6e7d67db873a2f3bfe7 Mon Sep 17 00:00:00 2001 From: Brandon Martin-Anderson Date: Sun, 19 Jul 2026 19:16:18 +0000 Subject: [PATCH 09/26] Retry orphaned push DELETE and log registration failures 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 --- .../android/push/PushRegistrationDecision.kt | 15 ++- .../android/push/PushRegistrationManager.kt | 97 +++++++++++++++++-- .../push/PushRegistrationManagerTest.kt | 71 ++++++++++++++ 3 files changed, 170 insertions(+), 13 deletions(-) diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt index 1602cbc0c7..64a15d66ef 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt @@ -30,6 +30,15 @@ data class PushRegistration( val testDevice: Boolean, ) +/** + * True when [this] and [other] address the same server row — the region, host, and token a DELETE (or an + * upsert POST) targets. Locale and test-device aren't part of the row's identity, so they don't count. + */ +fun PushRegistration.sameEndpoint(other: PushRegistration): Boolean = + regionId == other.regionId && + sidecarBaseUrl == other.sidecarBaseUrl && + token == other.token + /** * What the registrar should do to reconcile the [PushRegistration] currently on record with the one the * device now wants (issue #1957). @@ -70,10 +79,8 @@ fun decidePushRegistration( target == null -> if (last == null) PushRegistrationAction.NoOp else PushRegistrationAction.Unregister(last) last == null -> PushRegistrationAction.Register(target) target == last -> PushRegistrationAction.NoOp - // Same endpoint + token, only locale/test flag changed → a plain re-POST upserts it. - target.token == last.token && - target.regionId == last.regionId && - target.sidecarBaseUrl == last.sidecarBaseUrl -> PushRegistrationAction.Register(target) + // Same endpoint, only locale/test flag changed → a plain re-POST upserts it. + target.sameEndpoint(last) -> PushRegistrationAction.Register(target) // Token or region changed → clean up the stale registration, then register the new one. else -> PushRegistrationAction.Reregister(previous = last, target = target) } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt index 45ce7c5977..002c2249e5 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt @@ -16,6 +16,7 @@ package org.onebusaway.android.push import android.content.Context +import android.util.Log import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner @@ -51,7 +52,9 @@ import org.onebusaway.android.util.runCatchingCancellable * * The last successful registration is persisted (the `push_reg_*` prefs slots) so we know exactly what * to DELETE on opt-out and can skip redundant POSTs — important given the endpoint's 30 req/min/IP rate - * limit (see [PushRegistrationWebService]). + * limit (see [PushRegistrationWebService]). A separate `push_pending_del_*` slot holds a registration + * whose DELETE we still owe when a re-registration's POST landed but its DELETE didn't, so the stale one + * is retried on a later [sync] rather than orphaned (still pushing until the 180-day server prune). */ @Singleton class PushRegistrationManager internal constructor( @@ -60,14 +63,17 @@ class PushRegistrationManager internal constructor( private val firebaseMessagingManager: FirebaseMessagingManager, private val prefs: PreferencesRepository, private val scope: CoroutineScope, - // Test seams for the two Android-only reads (see the @Inject constructor). Kept off the DI path so + // Test seams for the Android-only bits (see the @Inject constructor). Kept off the DI path so // sync()'s reconcile/persist semantics — including the Reregister-failure record handling — are // exercisable from a plain JVM test, which is where the "orphan on double failure" class of bug lives. + // logWarning is a seam too: the failure paths call it, and android.util.Log isn't mocked under a + // plain JVM test, so the production default routes to Log.w while tests capture the message instead. private val notificationsEnabled: () -> Boolean, private val registrationsEndpointPath: String, + private val logWarning: (String, Throwable) -> Unit, ) { - /** Production constructor Hilt builds from: resolves the two seams from [context] and delegates. */ + /** Production constructor Hilt builds from: resolves the seams from [context] and delegates. */ @Inject constructor( @ApplicationContext context: Context, @@ -84,6 +90,7 @@ class PushRegistrationManager internal constructor( scope = scope, notificationsEnabled = { NotificationManagerCompat.from(context).areNotificationsEnabled() }, registrationsEndpointPath = context.getString(R.string.arrivals_reminders_api_endpoint), + logWarning = { message, cause -> Log.w(TAG, message, cause) }, ) private val started = AtomicBoolean(false) @@ -126,6 +133,9 @@ class PushRegistrationManager internal constructor( /** Reconciles the on-record registration with the currently desired one. */ private suspend fun sync() = mutex.withLock { + // Settle any DELETE we still owe from an earlier Reregister whose POST landed but whose DELETE + // didn't, before reconciling, so the stale registration can't linger past this pass. + drainPendingDelete() when (val action = decidePushRegistration(currentTarget(), loadLast())) { PushRegistrationAction.NoOp -> Unit is PushRegistrationAction.Register -> @@ -134,12 +144,20 @@ class PushRegistrationManager internal constructor( if (unregister(action.previous)) clearLast() is PushRegistrationAction.Reregister -> { // DELETE the stale registration, then POST the new one, persisting only once the POST - // lands. On failure keep `previous` on record UNLESS the DELETE already removed it: - // clearing it while it's still registered would make the next sync a plain Register, - // orphaning the old region/token (it keeps receiving alerts until the 180-day age-out). + // lands. All four failure quadrants are handled so nothing is orphaned: + // - DELETE ok + POST ok → persist(target): old gone, new on record. + // - DELETE ok + POST fail → clearLast(): server holds nothing, next sync re-Registers. + // - DELETE fail + POST fail → keep `previous`: next sync retries the whole Reregister. + // - DELETE fail + POST ok → persist(target) AND queue `previous` for a retried DELETE + // (drained at the top of a later sync) rather than dropping it — otherwise the old + // region keeps pushing to a still-valid token until the 180-day server age-out. val cleaned = unregister(action.previous) - if (register(action.target)) persist(action.target) - else if (cleaned) clearLast() + if (register(action.target)) { + persist(action.target) + if (!cleaned) persistPendingDelete(action.previous) + } else if (cleaned) { + clearLast() + } } } } @@ -163,7 +181,7 @@ class PushRegistrationManager internal constructor( ) } - /** POSTs [target]; true only on a 2xx (204) success. */ + /** POSTs [target]; true only on a 2xx (204) success. Retried on the next sync, so a failure isn't fatal. */ private suspend fun register(target: PushRegistration): Boolean = runCatchingCancellable { service.register( url = registrationUrl(target), @@ -171,14 +189,31 @@ class PushRegistrationManager internal constructor( locale = target.locale, testDevice = target.testDevice, ).isSuccessful + }.onFailure { + // Surface the cause (without the token) so a "why isn't this device registered" report isn't + // silent; the retry happens on the next trigger regardless. + logWarning("push register failed for region ${target.regionId} at ${target.sidecarBaseUrl}", it) }.getOrDefault(false) /** DELETEs [previous]; true on 204 or 404 (already gone), so a stale record is cleared either way. */ private suspend fun unregister(previous: PushRegistration): Boolean = runCatchingCancellable { val response = service.unregister(url = registrationUrl(previous), token = previous.token) response.isSuccessful || response.code() == HttpURLConnection.HTTP_NOT_FOUND + }.onFailure { + // Same rationale as register(): log the cause without the token; the DELETE is retried later. + logWarning("push unregister failed for region ${previous.regionId} at ${previous.sidecarBaseUrl}", it) }.getOrDefault(false) + /** + * Retries the DELETE queued by an earlier Reregister (the DELETE-fail/POST-ok quadrant in [sync]). + * Runs at the top of every sync; a no-op (one prefs read, no network) when the slot is empty. Cleared + * once the DELETE lands (204 or 404), else left to retry on the next sync/foreground. + */ + private suspend fun drainPendingDelete() { + val pending = loadPendingDelete() ?: return + if (unregister(pending)) clearPendingDelete() + } + /** `{sidecarBaseUrl}/api/v2/regions/{regionId}/push_registrations` — mirrors the alarms URL build. */ private fun registrationUrl(registration: PushRegistration): String = registration.sidecarBaseUrl + @@ -209,6 +244,10 @@ class PushRegistrationManager internal constructor( prefs.setBoolean(KEY_TEST_DEVICE, registration.testDevice) // Written last: its presence is what [loadLast] treats as "a full record is committed". prefs.setString(KEY_TOKEN, registration.token) + // This endpoint is now the live registration, so drop any owed DELETE for it — otherwise a + // return-to-an-old-endpoint (region/token switched away, then back) would DELETE the row we just + // POSTed. This is why the single pending-delete slot is safe. + if (loadPendingDelete()?.sameEndpoint(registration) == true) clearPendingDelete() } /** Nulls the commit-sentinel token slot so [loadLast] reports no record. */ @@ -216,12 +255,52 @@ class PushRegistrationManager internal constructor( prefs.setString(KEY_TOKEN, null) } + /** + * Persists the registration whose DELETE is still outstanding (the DELETE-fail/POST-ok Reregister + * quadrant) so [drainPendingDelete] can retry it later. Only the DELETE-addressing fields + * (region + host + token) are stored; locale/test-device don't affect a DELETE. A single slot: a + * second orphan before the first drains overwrites it — rare (two region changes with both old hosts + * unreachable back-to-back), and the loser still falls back to the 180-day server prune. + */ + private fun persistPendingDelete(registration: PushRegistration) { + prefs.setLong(KEY_PENDING_DEL_REGION_ID, registration.regionId) + prefs.setString(KEY_PENDING_DEL_BASE, registration.sidecarBaseUrl) + // Written last: the commit sentinel, mirroring [persist]. + prefs.setString(KEY_PENDING_DEL_TOKEN, registration.token) + } + + /** The registration awaiting a retried DELETE, or null if none is queued. Metadata is placeholder. */ + private fun loadPendingDelete(): PushRegistration? { + 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), + sidecarBaseUrl = base, + token = token, + // Unused by [unregister] — a DELETE is addressed by region + host + token only. + locale = "", + testDevice = false, + ) + } + + /** Nulls the pending-delete sentinel token slot so [loadPendingDelete] reports nothing queued. */ + private fun clearPendingDelete() { + prefs.setString(KEY_PENDING_DEL_TOKEN, null) + } + private companion object { + const val TAG = "PushRegistration" + // Internal bookkeeping slots (not user-facing) for the last successful registration. const val KEY_REGION_ID = "push_reg_region_id" const val KEY_BASE = "push_reg_base" const val KEY_TOKEN = "push_reg_token" const val KEY_LOCALE = "push_reg_locale" const val KEY_TEST_DEVICE = "push_reg_test_device" + + // Slots for a registration whose DELETE is owed but not yet confirmed (see [persistPendingDelete]). + const val KEY_PENDING_DEL_REGION_ID = "push_pending_del_region_id" + const val KEY_PENDING_DEL_BASE = "push_pending_del_base" + const val KEY_PENDING_DEL_TOKEN = "push_pending_del_token" } } diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt index 32d1d7e3c6..6462c977fa 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt @@ -117,6 +117,9 @@ class PushRegistrationManagerTest { f.sync() assertEquals(1, f.service.registerCalls.size) + // The failure is surfaced (not swallowed silently) so an on-device report has the cause. + assertTrue(f.loggedWarnings.any { it.startsWith("push register failed") }) + // Nothing was persisted, so the retry is a full Register again (not a NoOp). f.service.onRegister = { Response.success(Unit) } f.sync() @@ -184,6 +187,70 @@ class PushRegistrationManagerTest { assertEquals("T2", f.service.registerCalls.last().token) } + @Test + fun `reregister with a failed delete but successful post queues the delete for a later retry`() = + runTest { + val f = Fixture(this).registered("T1") + + // Region-change-style Reregister where DELETE(T1) fails but POST(T2) lands: T2 is live, yet + // the old T1 registration is still on the server. It must be queued, not dropped. + f.setToken("T2") + f.service.onUnregister = { throw IOException("offline") } + f.sync() + + assertEquals(listOf("T1", "T2"), f.service.registerCalls.map { it.token }) + assertEquals(1, f.service.unregisterCalls.count { it.token == "T1" }) + + // Next sync, old host reachable again: the queued DELETE(T1) is retried and clears, while the + // live T2 registration is left untouched (no re-POST). + f.service.onUnregister = { Response.success(Unit) } + val postsBefore = f.service.registerCalls.size + f.sync() + assertEquals(2, f.service.unregisterCalls.count { it.token == "T1" }) + assertEquals(postsBefore, f.service.registerCalls.size) + + // Drained → a further sync neither DELETEs nor POSTs. + f.sync() + assertEquals(2, f.service.unregisterCalls.count { it.token == "T1" }) + assertEquals(postsBefore, f.service.registerCalls.size) + } + + @Test + fun `a queued delete never removes the live registration after returning to the old token`() = + runTest { + val f = Fixture(this).registered("T1") + + // T1 -> T2 with DELETE(T1) failing: T2 live, T1 queued for a retried DELETE. + f.setToken("T2") + f.service.onUnregister = { throw IOException("offline") } + f.sync() + + // Return to T1 while the old host is still unreachable: the reconcile re-POSTs T1 (now live), + // and committing T1 must drop the still-queued DELETE(T1) so it can't later kill the live row + // (only the newly-stale T2 stays queued). + f.setToken("T1") + f.sync() + + // Old host reachable again. Draining must NOT delete T1 (it's live); only DELETE(T2) fires. + f.service.onUnregister = { Response.success(Unit) } + val deletesT1Before = f.service.unregisterCalls.count { it.token == "T1" } + f.sync() + + assertEquals( + "the live T1 registration must not be deleted", + deletesT1Before, + f.service.unregisterCalls.count { it.token == "T1" }, + ) + assertTrue("the stale T2 must be drained", f.service.unregisterCalls.any { it.token == "T2" }) + + // T1 remains the live, on-record registration → a further sync is a pure NoOp. + val posts = f.service.registerCalls.size + val deletes = f.service.unregisterCalls.size + f.sync() + assertEquals(posts, f.service.registerCalls.size) + assertEquals(deletes, f.service.unregisterCalls.size) + } + @Test fun `unregister treats a 404 as already-gone and clears the record`() = runTest { val f = Fixture(this).registered("T1") @@ -211,6 +278,9 @@ class PushRegistrationManagerTest { val regions = FakeRegionRepository(region(1).copy(sidecarBaseUrl = "https://sidecar.test")) var notificationsEnabled = true + /** Captures every warning the manager logs (android.util.Log isn't mocked under a JVM test). */ + val loggedWarnings = mutableListOf() + val manager = PushRegistrationManager( service = service, regionRepository = regions, @@ -219,6 +289,7 @@ class PushRegistrationManagerTest { scope = scope, notificationsEnabled = { notificationsEnabled }, registrationsEndpointPath = "/api/v2/regions/", + logWarning = { message, _ -> loggedWarnings += message }, ) fun setToken(token: String?) = prefs.setString(R.string.firebase_messaging_token, token) From d30b4315150a6e664c47e11fbef6fbc4a9038264 Mon Sep 17 00:00:00 2001 From: Brandon Martin-Anderson Date: Sun, 19 Jul 2026 23:35:54 -0700 Subject: [PATCH 10/26] Send the required test-device description, surface failures, and refresh registrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../contract/PushRegistrationWebService.kt | 12 ++ .../android/push/PushRegistrationDecision.kt | 44 ++++- .../android/push/PushRegistrationManager.kt | 140 +++++++++++++--- .../ui/settings/AdvancedSettingsScreen.kt | 11 ++ .../ui/settings/AdvancedSettingsViewModel.kt | 12 ++ .../android/ui/settings/SettingsUiState.kt | 5 + .../src/main/res/values/donottranslate.xml | 1 + .../src/main/res/values/strings.xml | 4 +- .../push/PushRegistrationDecisionTest.kt | 63 ++++++- .../push/PushRegistrationManagerTest.kt | 156 +++++++++++++++++- .../ui/settings/SettingsUiStateTest.kt | 1 + 11 files changed, 409 insertions(+), 40 deletions(-) diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/api/contract/PushRegistrationWebService.kt b/onebusaway-android/src/main/java/org/onebusaway/android/api/contract/PushRegistrationWebService.kt index 3a16afe0e3..11d739f4f6 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/api/contract/PushRegistrationWebService.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/api/contract/PushRegistrationWebService.kt @@ -45,6 +45,17 @@ interface PushRegistrationWebService { * `test_device` resets it to `false`) — the wire value is the literal `true`/`false` the contract * documents. [locale] is the device's BCP-47 tag sent as-is; the server maps it to its translation * catalog itself. + * + * [description] is a human-readable device label that the server requires **only** when + * [testDevice] is true, rejecting the call otherwise with + * `422 {"error":"Unable to register device","messages":["Description can't be blank"]}`. Pass null + * for an ordinary registration and Retrofit omits the field entirely — deliberate, so an ordinary + * rider's device model is never sent. NOTE: this requirement is absent from the #1957 contract + * table; it was established against the deployed server (a `test_device=true` POST without it 422s, + * with it 204s, while `test_device=false` 204s either way). The field's *purpose* — labelling rows + * in the "Test users only" audience so a human can tell devices apart — is inferred from its name + * and that gate, so the exact value we send is a judgement call awaiting confirmation from the + * OBACloud maintainers. */ @FormUrlEncoded @POST @@ -53,6 +64,7 @@ interface PushRegistrationWebService { @Field("token") token: String, @Field("locale") locale: String, @Field("test_device") testDevice: Boolean, + @Field("description") description: String?, @Field("operating_system") operatingSystem: String = "android", ): Response diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt index 64a15d66ef..ee4d1e7162 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt @@ -15,6 +15,9 @@ */ package org.onebusaway.android.push +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours + /** * A single push registration on record with OBACloud: the region it targets, the FCM token, and the * metadata sent alongside it. All fields participate in equality, so a change to *any* of them (a @@ -28,8 +31,22 @@ data class PushRegistration( val token: String, val locale: String, val testDevice: Boolean, + /** + * The admin-facing label sent alongside a test-device registration (null for an ordinary one). It + * participates in equality so that renaming the device re-POSTs — and because it can only change + * when the rider edits the setting, it never churns on its own. + */ + val description: String?, ) +/** + * Re-POST an otherwise-unchanged registration this often, so the server's `last_seen_at` is refreshed + * and its 180-day prune never silently drops this device (issue #1957's "freshness" goal). Matches the + * iOS client's `PushRegistrationManager.refreshInterval`, which keeps a healthy device at roughly one + * POST per day — comfortably inside the endpoint's 30 req/min/IP budget. + */ +val PUSH_REFRESH_INTERVAL: Duration = 24.hours + /** * True when [this] and [other] address the same server row — the region, host, and token a DELETE (or an * upsert POST) targets. Locale and test-device aren't part of the row's identity, so they don't count. @@ -65,21 +82,34 @@ sealed interface PushRegistrationAction { } /** - * The pure reconciliation decision. [target] is the registration the device wants right now — non-null - * only when notifications are enabled and a region, sidecar URL, and FCM token are all available; - * null otherwise. [last] is the registration last successfully sent to the server, or null if none. + * The pure reconciliation decision. [target] is the registration the device wants right now, or null + * when the rider has opted out. [last] is the registration last successfully sent to the server, or + * null if none. [sinceLastSent] is how long ago [last] was sent, or null when that is unknown (no + * record, or a record written by a build that predates the timestamp) — unknown counts as stale, since + * a redundant upsert is harmless and losing the device to the 180-day prune is not. + * + * A null [target] means the rider opted out, never "the inputs haven't resolved yet" — see the guard + * in `PushRegistrationManager.sync`. * - * Keeping this a pure function (no clock, no I/O, no Android) is deliberate: it is the one place the - * register/refresh/unregister rules live, and it is exhaustively unit-tested. + * Keeping this a pure function (no clock, no I/O, no Android — the caller supplies the elapsed time) + * is deliberate: it is the one place the register/refresh/unregister rules live, and it is + * exhaustively unit-tested. */ fun decidePushRegistration( target: PushRegistration?, last: PushRegistration?, + sinceLastSent: Duration?, ): PushRegistrationAction = when { target == null -> if (last == null) PushRegistrationAction.NoOp else PushRegistrationAction.Unregister(last) last == null -> PushRegistrationAction.Register(target) - target == last -> PushRegistrationAction.NoOp - // Same endpoint, only locale/test flag changed → a plain re-POST upserts it. + // Unchanged, but the server's last_seen_at needs refreshing before the 180-day prune reaches it. + target == last -> + if (sinceLastSent == null || sinceLastSent >= PUSH_REFRESH_INTERVAL) { + PushRegistrationAction.Register(target) + } else { + PushRegistrationAction.NoOp + } + // Same endpoint, only metadata (locale / test flag / description) changed → a plain re-POST upserts it. target.sameEndpoint(last) -> PushRegistrationAction.Register(target) // Token or region changed → clean up the stale registration, then register the new one. else -> PushRegistrationAction.Reregister(previous = last, target = target) diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt index 002c2249e5..3ea47d3fe9 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt @@ -21,12 +21,14 @@ import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner +import com.google.firebase.crashlytics.FirebaseCrashlytics import dagger.hilt.android.qualifiers.ApplicationContext import java.net.HttpURLConnection import java.util.Locale import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Duration import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch @@ -37,7 +39,15 @@ import org.onebusaway.android.api.contract.PushRegistrationWebService import org.onebusaway.android.app.di.AppScope import org.onebusaway.android.preferences.PreferencesRepository import org.onebusaway.android.region.RegionRepository +import org.onebusaway.android.time.WallTime import org.onebusaway.android.util.runCatchingCancellable +import retrofit2.Response + +/** + * Carries a push-registration server rejection to the remote error channel. A dedicated type so these + * group as their own Crashlytics issue rather than mixing into generic [IllegalStateException]s. + */ +class PushRegistrationException(message: String) : Exception(message) /** * Keeps this device's OBACloud push registration (issue #1957) in sync with the app's current state so @@ -70,7 +80,10 @@ class PushRegistrationManager internal constructor( // plain JVM test, so the production default routes to Log.w while tests capture the message instead. private val notificationsEnabled: () -> Boolean, private val registrationsEndpointPath: String, - private val logWarning: (String, Throwable) -> Unit, + // Remote error channel for server rejections (see [logHttpFailure]); Crashlytics in production. + private val reportError: (Throwable) -> Unit, + private val logWarning: (String, Throwable?) -> Unit, + private val now: () -> WallTime = WallTime::now, ) { /** Production constructor Hilt builds from: resolves the seams from [context] and delegates. */ @@ -90,6 +103,7 @@ class PushRegistrationManager internal constructor( scope = scope, notificationsEnabled = { NotificationManagerCompat.from(context).areNotificationsEnabled() }, registrationsEndpointPath = context.getString(R.string.arrivals_reminders_api_endpoint), + reportError = { FirebaseCrashlytics.getInstance().recordException(it) }, logWarning = { message, cause -> Log.w(TAG, message, cause) }, ) @@ -103,14 +117,19 @@ class PushRegistrationManager internal constructor( * Begins reconciling in the background, idempotently (safe to call more than once). Kicked from * `Application.onCreate` (on the main thread). Two triggers, covering every input that can change: * - * - A collector over the region flow and the two preferences this feature reads (the FCM token and - * the test-device flag), running one [sync] per change. All three replay on launch, and each - * later change (token rotation, region switch, toggling the test flag) re-emits. Observing those - * keys specifically — rather than every app-wide preference write — avoids a needless - * `areNotificationsEnabled()` IPC on unrelated writes. + * - A collector over the region flow and the preferences this feature reads (the FCM token, the + * test-device flag, and the test-device name), running one [sync] per change. All of them replay + * on launch, and each later change (token rotation, region switch, toggling or naming the test + * device) re-emits. Observing those keys specifically — rather than every app-wide preference + * write — avoids a needless `areNotificationsEnabled()` IPC on unrelated writes. * - A process-lifecycle `ON_START` observer that [resync]s whenever the app comes to the foreground, * catching an OS notification-permission change made in system settings (which touches neither a * preference nor the region, so the collector can't see it) regardless of which activity resumes. + * + * Between them these also drive the [PUSH_REFRESH_INTERVAL] keep-alive: every foreground re-runs + * [sync], which re-POSTs an unchanged registration once its 24h window has elapsed. No separate + * timer or `WorkManager` job is needed — a rider who never opens the app for 180 days has no + * service-alert reach to preserve anyway, and the server prunes exactly that population. */ fun start() { if (started.getAndSet(true)) return @@ -119,7 +138,8 @@ class PushRegistrationManager internal constructor( regionRepository.region, prefs.observeString(R.string.firebase_messaging_token, null), prefs.observeBoolean(R.string.preference_key_push_test_device, false), - ) { _, _, _ -> }.collect { sync() } + prefs.observeString(R.string.preference_key_push_test_device_name, null), + ) { _, _, _, _ -> }.collect { sync() } } ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver { override fun onStart(owner: LifecycleOwner) = resync() @@ -136,7 +156,22 @@ class PushRegistrationManager internal constructor( // Settle any DELETE we still owe from an earlier Reregister whose POST landed but whose DELETE // didn't, before reconciling, so the stale registration can't linger past this pass. drainPendingDelete() - when (val action = decidePushRegistration(currentTarget(), loadLast())) { + + // Notifications being off is the ONLY definitive "this device should not be registered" signal. + // A missing region or FCM token just means we can't tell yet — both resolve asynchronously, and + // the region flow is explicitly "briefly null at cold start" (see RegionRepository) — so treating + // that as an opt-out would fire a spurious DELETE on launch and re-POST seconds later, burning + // two requests against a 30 req/min/IP endpoint and leaving a window with no registration at all. + // Bail instead and let the collector re-run us when the inputs land; iOS no-ops the same way. + val target = if (notificationsEnabled()) { + // Inputs still settling (the region seeds asynchronously, the FCM token arrives late) — + // bail rather than decide; the collector re-runs us once they land. + buildTarget() ?: return@withLock + } else { + null + } + + when (val action = decidePushRegistration(target, loadLast(), sinceLastSent())) { PushRegistrationAction.NoOp -> Unit is PushRegistrationAction.Register -> if (register(action.target)) persist(action.target) @@ -163,47 +198,96 @@ class PushRegistrationManager internal constructor( } /** - * The registration the device wants right now, or null when it can't/shouldn't be registered - * (notifications disabled, no resolved region + sidecar host, or no FCM token yet). The locale is - * the device's BCP-47 tag, sent as-is with no normalization. + * The registration this device wants, or null when the inputs aren't all resolved yet (no region, + * no sidecar host, or no FCM token) — never an opt-out; see [sync]. The locale is the device's + * BCP-47 tag, sent as-is with no normalization. + * + * The test-device flag is honoured only once the rider has named the device: the server rejects a + * `test_device=true` registration with a blank `description` (422), so an unnamed device registers + * as an ordinary one rather than POSTing a request guaranteed to fail. The iOS client gates its + * "Test Device Name" the same way. Deriving `testDevice` from the name rather than tracking it + * separately makes that rule hold by construction. */ - private fun currentTarget(): PushRegistration? { - if (!notificationsEnabled()) return null + private fun buildTarget(): PushRegistration? { val region = regionRepository.region.value ?: return null val base = region.sidecarBaseUrl?.takeIf { it.isNotBlank() } ?: return null val token = firebaseMessagingManager.userPushId().takeIf { it.isNotEmpty() } ?: return null + val description = prefs.getString(R.string.preference_key_push_test_device_name, null) + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.takeIf { prefs.getBoolean(R.string.preference_key_push_test_device, false) } return PushRegistration( regionId = region.id, sidecarBaseUrl = base, token = token, locale = Locale.getDefault().toLanguageTag(), - testDevice = prefs.getBoolean(R.string.preference_key_push_test_device, false), + testDevice = description != null, + description = description, ) } /** POSTs [target]; true only on a 2xx (204) success. Retried on the next sync, so a failure isn't fatal. */ private suspend fun register(target: PushRegistration): Boolean = runCatchingCancellable { - service.register( + val response = service.register( url = registrationUrl(target), token = target.token, locale = target.locale, testDevice = target.testDevice, - ).isSuccessful + // Required by the server for a test device, omitted otherwise (see the service KDoc). + description = target.description, + ) + val registered = response.isSuccessful + if (!registered) logHttpFailure("register", target, response) + registered }.onFailure { // Surface the cause (without the token) so a "why isn't this device registered" report isn't // silent; the retry happens on the next trigger regardless. - logWarning("push register failed for region ${target.regionId} at ${target.sidecarBaseUrl}", it) + logWarning(failurePrefix("register", target), it) }.getOrDefault(false) /** DELETEs [previous]; true on 204 or 404 (already gone), so a stale record is cleared either way. */ private suspend fun unregister(previous: PushRegistration): Boolean = runCatchingCancellable { val response = service.unregister(url = registrationUrl(previous), token = previous.token) - response.isSuccessful || response.code() == HttpURLConnection.HTTP_NOT_FOUND + val removed = response.isSuccessful || response.code() == HttpURLConnection.HTTP_NOT_FOUND + if (!removed) logHttpFailure("unregister", previous, response) + removed }.onFailure { // Same rationale as register(): log the cause without the token; the DELETE is retried later. - logWarning("push unregister failed for region ${previous.regionId} at ${previous.sidecarBaseUrl}", it) + logWarning(failurePrefix("unregister", previous), it) }.getOrDefault(false) + /** + * The shared opening of every failure message. Identifies the endpoint by region + host — never the + * token, and never the URL, since the unregister URL carries the token as a query param. + */ + private fun failurePrefix(operation: String, registration: PushRegistration): String = + "push $operation failed for region ${registration.regionId} at ${registration.sidecarBaseUrl}" + + /** + * Logs a non-2xx response. Without this an HTTP error is *invisible*: a failed status is a + * perfectly successful call as far as [runCatchingCancellable] is concerned, so the `onFailure` + * handlers above only ever fire for transport exceptions. Since a failed call also persists + * nothing, the same doomed request then repeats on every foreground — silently, and against a + * 30 req/min/IP endpoint — which is exactly how the missing-`description` 422 went unnoticed + * until a packet capture. + */ + private fun logHttpFailure( + operation: String, + registration: PushRegistration, + response: Response, + ) { + // The server's error body carries the actionable message (e.g. "Description can't be blank"). + val body = response.errorBody()?.string()?.trim().orEmpty() + val detail = if (body.isEmpty()) "" else " $body" + val message = "${failurePrefix(operation, registration)}: HTTP ${response.code()}$detail" + logWarning(message, null) + // Registrations are the server's ONLY audience source for service alerts, so a systematic + // rejection — a required field added server-side, a contract change, a throttle — must not be + // visible only in one developer's logcat. Report server rejections remotely; transport failures + // (handled by the onFailure paths) are ordinary offline noise and stay local. + reportError(PushRegistrationException(message)) + } + /** * Retries the DELETE queued by an earlier Reregister (the DELETE-fail/POST-ok quadrant in [sync]). * Runs at the top of every sync; a no-op (one prefs read, no network) when the slot is empty. Cleared @@ -234,14 +318,29 @@ class PushRegistrationManager internal constructor( token = token, locale = prefs.getString(KEY_LOCALE, null) ?: "", testDevice = prefs.getBoolean(KEY_TEST_DEVICE, false), + description = prefs.getString(KEY_DESCRIPTION, null), ) } + /** + * How long ago the on-record registration was sent, or null if that is unknown — no record, or one + * written before this slot existed. Both the write and this read are device-clock ([WallTime]): it + * is a purely local elapsed-time question about our own write, never compared against a server + * timestamp, so no server-clock crossing is involved. + */ + private fun sinceLastSent(): Duration? { + val sentAtMs = prefs.getLong(KEY_SENT_AT, 0L).takeIf { it > 0L } ?: return null + return now() - WallTime(sentAtMs) + } + private fun persist(registration: PushRegistration) { prefs.setLong(KEY_REGION_ID, registration.regionId) prefs.setString(KEY_BASE, registration.sidecarBaseUrl) prefs.setString(KEY_LOCALE, registration.locale) prefs.setBoolean(KEY_TEST_DEVICE, registration.testDevice) + prefs.setString(KEY_DESCRIPTION, registration.description) + // Stamps the refresh clock: [sinceLastSent] measures the 24h re-POST window from here. + prefs.setLong(KEY_SENT_AT, now().epochMs) // Written last: its presence is what [loadLast] treats as "a full record is committed". prefs.setString(KEY_TOKEN, registration.token) // This endpoint is now the live registration, so drop any owed DELETE for it — otherwise a @@ -280,6 +379,7 @@ class PushRegistrationManager internal constructor( // Unused by [unregister] — a DELETE is addressed by region + host + token only. locale = "", testDevice = false, + description = null, ) } @@ -297,6 +397,8 @@ class PushRegistrationManager internal constructor( const val KEY_TOKEN = "push_reg_token" const val KEY_LOCALE = "push_reg_locale" const val KEY_TEST_DEVICE = "push_reg_test_device" + const val KEY_DESCRIPTION = "push_reg_description" + const val KEY_SENT_AT = "push_reg_sent_at" // Slots for a registration whose DELETE is owed but not yet confirmed (see [persistPendingDelete]). const val KEY_PENDING_DEL_REGION_ID = "push_pending_del_region_id" diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsScreen.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsScreen.kt index 2b0aa0e945..f25b06f496 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsScreen.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsScreen.kt @@ -97,6 +97,7 @@ fun AdvancedSettingsRoute( onExperimentalToggle = onExperimentalToggle, onDisplayTestAlerts = viewModel::onDisplayTestAlertsChanged, onPushTestDevice = viewModel::onPushTestDeviceChanged, + onPushTestDeviceName = viewModel::onPushTestDeviceNameChanged, onObaUrlChange = { url -> when (viewModel.onCustomObaApiUrlChanged(url)) { UrlChangeResult.InvalidObaUrl -> { @@ -152,6 +153,7 @@ fun AdvancedSettingsScreen( onExperimentalToggle: (Boolean) -> Unit, onDisplayTestAlerts: (Boolean) -> Unit, onPushTestDevice: (Boolean) -> Unit, + onPushTestDeviceName: (String) -> Boolean, onObaUrlChange: (String) -> Boolean, onOtpUrlChange: (String) -> Boolean, onOtpUrlUsesGraphQlChange: (Boolean) -> Unit, @@ -194,6 +196,15 @@ fun AdvancedSettingsScreen( checked = state.pushTestDevice, onCheckedChange = onPushTestDevice, ) + EditTextPreferenceItem( + title = stringResource(R.string.preferences_push_test_device_name_title), + summary = state.pushTestDeviceName + ?: stringResource(R.string.preferences_push_test_device_name_summary), + currentValue = state.pushTestDeviceName, + hint = stringResource(R.string.preferences_push_test_device_name_title), + onValueChange = onPushTestDeviceName, + keyboardType = KeyboardType.Text, + ) EditTextPreferenceItem( title = stringResource(R.string.preferences_map_stop_cache_size_title), summary = stringResource( diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModel.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModel.kt index 45b035b3f0..519853077b 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModel.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModel.kt @@ -82,6 +82,7 @@ class AdvancedSettingsViewModel @Inject constructor( experimentalRegionsEnabled = prefs.getBoolean(R.string.preference_key_experimental_regions, false), displayTestAlerts = prefs.getBoolean(R.string.preferences_display_test_alerts, false), pushTestDevice = prefs.getBoolean(R.string.preference_key_push_test_device, false), + pushTestDeviceName = prefs.getString(R.string.preference_key_push_test_device_name, null), customObaApiUrl = prefs.getString(R.string.preference_key_oba_api_url, null), customOtpApiUrl = prefs.getString(R.string.preference_key_otp_api_url, null), customOtpApiUrlUsesGraphQl = prefs.getBoolean( @@ -113,6 +114,17 @@ class AdvancedSettingsViewModel @Inject constructor( fun onPushTestDeviceChanged(value: Boolean) = prefs.setBoolean(R.string.preference_key_push_test_device, value) + /** + * Set the admin-facing name for this test device (#1957). Blank clears it, which downgrades the + * device to an ordinary registration — the server rejects a test-device registration without a + * name, so `PushRegistrationManager` declines to send one rather than POST a guaranteed failure. + * Always accepted (returns true): any non-blank text is a valid name. + */ + fun onPushTestDeviceNameChanged(value: String): Boolean { + prefs.setString(R.string.preference_key_push_test_device_name, value.trim().ifEmpty { null }) + return true + } + /** * Apply an edit to the map stop LRU cache size. Returns true if the input was a valid in-range * number (and was persisted); false otherwise so the dialog stays open (reject-on-invalid). diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/SettingsUiState.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/SettingsUiState.kt index b39f294cce..19017c7123 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/SettingsUiState.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/SettingsUiState.kt @@ -137,6 +137,9 @@ data class AdvancedPrefSnapshot( val displayTestAlerts: Boolean, // Registers this device into OBACloud's test-only push audience for previewing service alerts (#1957). val pushTestDevice: Boolean, + // Admin-facing label for this test device. The server rejects a test-device registration without + // one, so an unnamed device registers as an ordinary device instead (see PushRegistrationManager). + val pushTestDeviceName: String?, val customObaApiUrl: String?, val customOtpApiUrl: String?, // Manual OTP protocol override for the custom URL above (#1780) — the custom-server counterpart @@ -166,6 +169,7 @@ data class AdvancedSettingsUiState( val experimentalRegionsEnabled: Boolean, val displayTestAlerts: Boolean, val pushTestDevice: Boolean, + val pushTestDeviceName: String?, val customObaApiUrl: String?, val customOtpApiUrl: String?, val customOtpApiUrlUsesGraphQl: Boolean, @@ -198,6 +202,7 @@ fun buildAdvancedSettingsUiState( experimentalRegionsEnabled = prefs.experimentalRegionsEnabled, displayTestAlerts = prefs.displayTestAlerts, pushTestDevice = prefs.pushTestDevice, + pushTestDeviceName = prefs.pushTestDeviceName, customObaApiUrl = prefs.customObaApiUrl, customOtpApiUrl = prefs.customOtpApiUrl, customOtpApiUrlUsesGraphQl = prefs.customOtpApiUrlUsesGraphQl, diff --git a/onebusaway-android/src/main/res/values/donottranslate.xml b/onebusaway-android/src/main/res/values/donottranslate.xml index 6ee6608dcd..215cecca1b 100644 --- a/onebusaway-android/src/main/res/values/donottranslate.xml +++ b/onebusaway-android/src/main/res/values/donottranslate.xml @@ -64,6 +64,7 @@ preferences_key_user_denied_location_permissions preferences_display_test_alerts preference_push_test_device + preference_push_test_device_name https://regions.onebusaway.org/regions-v3.json diff --git a/onebusaway-android/src/main/res/values/strings.xml b/onebusaway-android/src/main/res/values/strings.xml index 8a320ed9f9..742ec6fed7 100644 --- a/onebusaway-android/src/main/res/values/strings.xml +++ b/onebusaway-android/src/main/res/values/strings.xml @@ -1188,7 +1188,9 @@ Display test alerts Display test-wide alerts for regions Register as test device - Register this device in the test-only audience for previewing service-alert push notifications + Register this device in the test-only audience for previewing service-alert push notifications. Requires a test device name. + Test device name + Identifies this device to transit agency admins, for example “Sam’s Pixel 9”. Until this is set, the device registers normally instead of as a test device. No description available Close donation card Zoom out diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationDecisionTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationDecisionTest.kt index 2bab13aaff..4c30bd2779 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationDecisionTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationDecisionTest.kt @@ -15,13 +15,16 @@ */ package org.onebusaway.android.push +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours import org.junit.Assert.assertEquals import org.junit.Test /** * Unit tests for [decidePushRegistration] — the pure register/refresh/unregister reconciliation for OBACloud push * registration (#1957). Covers each transition between the desired registration ([target]) and the one - * last sent to the server ([last]). + * last sent to the server ([last]), plus the [PUSH_REFRESH_INTERVAL] keep-alive. */ class PushRegistrationDecisionTest { @@ -31,38 +34,80 @@ class PushRegistrationDecisionTest { token = "token-a", locale = "en-US", testDevice = false, + description = null, ) + /** Comfortably inside [PUSH_REFRESH_INTERVAL], so the keep-alive never confounds these cases. */ + private val fresh: Duration = 1.hours + + private fun decide( + target: PushRegistration?, + last: PushRegistration?, + sinceLastSent: Duration? = fresh, + ) = decidePushRegistration(target, last, sinceLastSent) + @Test fun `nothing desired and nothing on record is a no-op`() { - assertEquals(PushRegistrationAction.NoOp, decidePushRegistration(target = null, last = null)) + assertEquals(PushRegistrationAction.NoOp, decide(target = null, last = null)) } @Test fun `first registration when nothing is on record`() { - assertEquals(PushRegistrationAction.Register(base), decidePushRegistration(target = base, last = null)) + assertEquals(PushRegistrationAction.Register(base), decide(target = base, last = null)) } @Test fun `opting out unregisters the recorded registration`() { - assertEquals(PushRegistrationAction.Unregister(base), decidePushRegistration(target = null, last = base)) + assertEquals(PushRegistrationAction.Unregister(base), decide(target = null, last = base)) } @Test fun `unchanged registration is a no-op`() { - assertEquals(PushRegistrationAction.NoOp, decidePushRegistration(target = base, last = base)) + assertEquals(PushRegistrationAction.NoOp, decide(target = base, last = base)) } @Test fun `a locale change re-posts on the same token and region`() { val target = base.copy(locale = "es-MX") - assertEquals(PushRegistrationAction.Register(target), decidePushRegistration(target = target, last = base)) + assertEquals(PushRegistrationAction.Register(target), decide(target = target, last = base)) } @Test fun `a test-device flag change re-posts on the same token and region`() { val target = base.copy(testDevice = true) - assertEquals(PushRegistrationAction.Register(target), decidePushRegistration(target = target, last = base)) + assertEquals(PushRegistrationAction.Register(target), decide(target = target, last = base)) + } + + @Test + fun `an unchanged registration is re-posted once the refresh interval elapses`() { + // The keep-alive: without this the server's last_seen_at is never refreshed and the 180-day + // prune silently drops a device whose token, region, locale and flags simply never change. + assertEquals( + PushRegistrationAction.Register(base), + decide(target = base, last = base, sinceLastSent = PUSH_REFRESH_INTERVAL), + ) + assertEquals( + PushRegistrationAction.Register(base), + decide(target = base, last = base, sinceLastSent = 30.days), + ) + } + + @Test + fun `an unchanged registration is left alone just inside the refresh interval`() { + assertEquals( + PushRegistrationAction.NoOp, + decide(target = base, last = base, sinceLastSent = PUSH_REFRESH_INTERVAL - 1.hours), + ) + } + + @Test + fun `an unknown last-sent time counts as stale`() { + // A record written before the timestamp slot existed. A redundant upsert is harmless; losing + // the device to the prune is not, so unknown must re-post rather than no-op. + assertEquals( + PushRegistrationAction.Register(base), + decide(target = base, last = base, sinceLastSent = null), + ) } @Test @@ -70,7 +115,7 @@ class PushRegistrationDecisionTest { val target = base.copy(token = "token-b") assertEquals( PushRegistrationAction.Reregister(previous = base, target = target), - decidePushRegistration(target = target, last = base), + decide(target = target, last = base), ) } @@ -79,7 +124,7 @@ class PushRegistrationDecisionTest { val target = base.copy(regionId = 2L, sidecarBaseUrl = "https://other.example.org") assertEquals( PushRegistrationAction.Reregister(previous = base, target = target), - decidePushRegistration(target = target, last = base), + decide(target = target, last = base), ) } } diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt index 6462c977fa..9b2fac7a1b 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt @@ -17,6 +17,7 @@ package org.onebusaway.android.push import java.io.IOException import java.util.Locale +import kotlin.time.Duration.Companion.hours import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle @@ -32,6 +33,7 @@ import org.onebusaway.android.api.contract.PushRegistrationWebService import org.onebusaway.android.region.FakeRegionRepository import org.onebusaway.android.region.region import org.onebusaway.android.testing.FakePreferencesRepository +import org.onebusaway.android.time.WallTime import retrofit2.Response /** @@ -53,7 +55,7 @@ class PushRegistrationManagerTest { @Before fun pinLocale() { - // currentTarget() sends Locale.getDefault().toLanguageTag(); pin it so the assertion is stable. + // buildTarget() sends Locale.getDefault().toLanguageTag(); pin it so the assertion is stable. previousLocale = Locale.getDefault() Locale.setDefault(Locale.US) } @@ -74,6 +76,8 @@ class PushRegistrationManagerTest { assertEquals("T1", call.token) assertEquals("en-US", call.locale) assertEquals(true, call.testDevice) + // The server 422s a test_device=true POST without a description. + assertEquals(DEVICE_DESCRIPTION, call.description) assertEquals("android", call.operatingSystem) // Same inputs → decide() yields NoOp; no second POST despite the trigger firing again. @@ -97,7 +101,8 @@ class PushRegistrationManagerTest { fun `opt-out unregisters the recorded token and clears the record`() = runTest { val f = Fixture(this).registered("T1") - // Rider turns notifications off in system settings; the ON_START resync reconciles. + // Rider turns notifications off in system settings; the ON_START resync reconciles. This is the + // ONLY definitive opt-out — contrast the missing-region case below, which must not DELETE. f.notificationsEnabled = false f.sync() @@ -117,8 +122,11 @@ class PushRegistrationManagerTest { f.sync() assertEquals(1, f.service.registerCalls.size) - // The failure is surfaced (not swallowed silently) so an on-device report has the cause. + // The failure is surfaced (not swallowed silently) so an on-device report has the cause — but + // only locally: transport failures are ordinary and self-healing, and reporting every offline + // moment remotely would drown the systematic-rejection signal. assertTrue(f.loggedWarnings.any { it.startsWith("push register failed") }) + assertTrue(f.reportedErrors.isEmpty()) // Nothing was persisted, so the retry is a full Register again (not a NoOp). f.service.onRegister = { Response.success(Unit) } @@ -265,6 +273,129 @@ class PushRegistrationManagerTest { assertEquals(1, f.service.unregisterCalls.size) } + @Test + fun `an ordinary registration omits the description`() = runTest { + val f = Fixture(this) + f.prefs.setBoolean(R.string.preference_key_push_test_device, false) + f.setToken("T1") + f.sync() + + // Null means Retrofit drops the field: the server doesn't require it here, and an ordinary + // rider's device name has no business being sent. + val call = f.service.registerCalls.single() + assertEquals(false, call.testDevice) + assertEquals(null, call.description) + } + + @Test + fun `an unnamed test device registers as an ordinary device instead of failing`() = runTest { + val f = Fixture(this) + // Flag on, but no name: the server would 422 a test_device=true POST with a blank description, + // so we must downgrade rather than send a request guaranteed to fail (matching iOS). + f.prefs.setString(R.string.preference_key_push_test_device_name, null) + f.setToken("T1") + f.sync() + + val call = f.service.registerCalls.single() + assertEquals(false, call.testDevice) + assertEquals(null, call.description) + assertTrue("a downgraded registration must still succeed", f.loggedWarnings.isEmpty()) + } + + @Test + fun `renaming the test device re-posts the new name without a delete`() = runTest { + val f = Fixture(this).registered("T1") + assertEquals(DEVICE_DESCRIPTION, f.service.registerCalls.single().description) + + f.prefs.setString(R.string.preference_key_push_test_device_name, "Renamed Device") + f.sync() + + // Same endpoint (region + host + token), so this upserts in place — no DELETE. + assertEquals("Renamed Device", f.service.registerCalls.last().description) + assertTrue(f.service.unregisterCalls.isEmpty()) + + // Now on record → settled. + f.sync() + assertEquals(2, f.service.registerCalls.size) + } + + @Test + fun `an unchanged registration is re-posted once a day to defeat the 180-day prune`() = runTest { + val f = Fixture(this).registered("T1") + assertEquals(1, f.service.registerCalls.size) + + // Same inputs, an hour later: still a no-op. + f.now += 1.hours + f.sync() + assertEquals(1, f.service.registerCalls.size) + + // Past the refresh window: re-POST so the server's last_seen_at is refreshed. + f.now += PUSH_REFRESH_INTERVAL + f.sync() + assertEquals(2, f.service.registerCalls.size) + assertTrue("the keep-alive must not DELETE anything", f.service.unregisterCalls.isEmpty()) + + // The refresh restamps the clock, so the very next sync is quiet again. + f.sync() + assertEquals(2, f.service.registerCalls.size) + } + + @Test + fun `a missing region is treated as not-yet-known rather than an opt-out`() = runTest { + val f = Fixture(this).registered("T1") + + // The region flow is briefly null at cold start (RegionRepository seeds it asynchronously). + // Treating that as an opt-out would DELETE the registration and re-POST seconds later. + f.regions.emit(null) + f.sync() + + assertTrue("a null region must not trigger a DELETE", f.service.unregisterCalls.isEmpty()) + assertEquals(1, f.service.registerCalls.size) + + // Once the region resolves, the unchanged registration is still on record → no re-POST. + f.regions.emit(region(1).copy(sidecarBaseUrl = "https://sidecar.test")) + f.sync() + assertEquals(1, f.service.registerCalls.size) + } + + + @Test + fun `an HTTP error response is logged and reported, not silently swallowed`() = runTest { + val f = Fixture(this) + f.setToken("T1") + // The real regression: a 422 is a *successful call* returning an unsuccessful response, so it + // never reaches runCatching's onFailure. It used to vanish entirely. + f.service.onRegister = { + Response.error(422, """{"error":"Unable to register device"}""".toResponseBody(null)) + } + + f.sync() + + val warning = f.loggedWarnings.single() + assertTrue("status code must be logged: $warning", warning.contains("422")) + assertTrue("server error body must be logged: $warning", warning.contains("Unable to register device")) + // The token must never reach the log. + assertTrue("token must not be logged: $warning", !warning.contains("T1")) + + // Registrations are the server's only audience source, so a systematic rejection must also be + // visible remotely (Crashlytics), not just in one developer's logcat. + val reported = f.reportedErrors.single() + assertTrue("$reported", reported is PushRegistrationException) + assertTrue("$reported", reported.message.orEmpty().contains("422")) + } + + @Test + fun `a failed unregister response is logged too`() = runTest { + val f = Fixture(this).registered("T1") + + f.notificationsEnabled = false + f.service.onUnregister = { Response.error(500, "boom".toResponseBody(null)) } + f.sync() + + val warning = f.loggedWarnings.single() + assertTrue("status code must be logged: $warning", warning.contains("500")) + } + /** * A ready-to-drive manager over fakes: notifications on, one region (id 1 + sidecar), the * test-device flag on, and no token until a test sets one. [notificationsEnabled] is a mutable flag @@ -274,10 +405,18 @@ class PushRegistrationManagerTest { val service = FakePushRegistrationWebService() val prefs = FakePreferencesRepository().apply { setBoolean(R.string.preference_key_push_test_device, true) + // Named, so the test-device flag is honoured rather than downgraded (see buildTarget). + setString(R.string.preference_key_push_test_device_name, DEVICE_DESCRIPTION) } val regions = FakeRegionRepository(region(1).copy(sidecarBaseUrl = "https://sidecar.test")) var notificationsEnabled = true + /** Mutable device clock, so a test can jump past [PUSH_REFRESH_INTERVAL]. */ + var now = WallTime(1_000_000_000L) + + /** Server rejections routed to the remote error channel (Crashlytics in production). */ + val reportedErrors = mutableListOf() + /** Captures every warning the manager logs (android.util.Log isn't mocked under a JVM test). */ val loggedWarnings = mutableListOf() @@ -289,7 +428,9 @@ class PushRegistrationManagerTest { scope = scope, notificationsEnabled = { notificationsEnabled }, registrationsEndpointPath = "/api/v2/regions/", + reportError = { reportedErrors += it }, logWarning = { message, _ -> loggedWarnings += message }, + now = { now }, ) fun setToken(token: String?) = prefs.setString(R.string.firebase_messaging_token, token) @@ -311,6 +452,7 @@ class PushRegistrationManagerTest { val token: String, val locale: String, val testDevice: Boolean, + val description: String?, val operatingSystem: String, ) @@ -327,9 +469,10 @@ class PushRegistrationManagerTest { token: String, locale: String, testDevice: Boolean, + description: String?, operatingSystem: String, ): Response { - registerCalls += RegisterCall(url, token, locale, testDevice, operatingSystem) + registerCalls += RegisterCall(url, token, locale, testDevice, description, operatingSystem) return onRegister() } @@ -338,4 +481,9 @@ class PushRegistrationManagerTest { return onUnregister() } } + + private companion object { + /** Stands in for the name the rider types into the "Test device name" setting. */ + const val DEVICE_DESCRIPTION = "Sam's Test Pixel" + } } diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/SettingsUiStateTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/SettingsUiStateTest.kt index 39e9b91aa0..836f71e005 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/SettingsUiStateTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/SettingsUiStateTest.kt @@ -128,6 +128,7 @@ class SettingsUiStateTest { experimentalRegionsEnabled = false, displayTestAlerts = false, pushTestDevice = false, + pushTestDeviceName = null, customObaApiUrl = null, customOtpApiUrl = null, customOtpApiUrlUsesGraphQl = false, From 9a99bf7c4eecf0872fdc3ee7c89c061af485fdc8 Mon Sep 17 00:00:00 2001 From: Brandon Martin-Anderson Date: Sun, 19 Jul 2026 23:54:30 -0700 Subject: [PATCH 11/26] Document that the commit-sentinel write order is in-process-exact, disk-best-effort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../android/push/PushRegistrationManager.kt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt index 3ea47d3fe9..b674095151 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt @@ -306,8 +306,13 @@ class PushRegistrationManager internal constructor( /** * The registration last successfully sent to the server, or null if none is on record. The token - * slot doubles as the commit sentinel: [persist] writes it last and [clearLast] nulls it, so a - * torn/absent write reads back here as "no record" and re-registration (safe, idempotent) follows. + * slot doubles as the commit sentinel: [persist] writes it last and [clearLast] nulls it. That + * ordering is exact for in-process reads (the prefs cache updates synchronously, in call order) but + * each slot persists to disk as its own async DataStore edit, so it is NOT guaranteed across a + * process death mid-persist. That's acceptable by design: any torn disk state either reads back as + * "no record" (missing base/token) → re-registration, which is a safe idempotent upsert, or as a + * stale record whose DELETE the server answers with a tolerated 404. No recovery path depends on + * the sentinel being durably last. */ private fun loadLast(): PushRegistration? { val base = prefs.getString(KEY_BASE, null) ?: return null @@ -341,7 +346,8 @@ class PushRegistrationManager internal constructor( prefs.setString(KEY_DESCRIPTION, registration.description) // Stamps the refresh clock: [sinceLastSent] measures the 24h re-POST window from here. prefs.setLong(KEY_SENT_AT, now().epochMs) - // Written last: its presence is what [loadLast] treats as "a full record is committed". + // Written last: its presence is what [loadLast] treats as "a full record is committed". Exact + // in-process; only best-effort on disk (see [loadLast] for why a torn persist is still safe). prefs.setString(KEY_TOKEN, registration.token) // This endpoint is now the live registration, so drop any owed DELETE for it — otherwise a // return-to-an-old-endpoint (region/token switched away, then back) would DELETE the row we just From a2bff192faa74c57708efed129170a977429d6f4 Mon Sep 17 00:00:00 2001 From: Brandon Martin-Anderson Date: Mon, 20 Jul 2026 00:07:09 -0700 Subject: [PATCH 12/26] Split the push registration mechanisms out of the reconciler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../android/push/PushRegistrationClient.kt | 145 ++++++++ .../android/push/PushRegistrationManager.kt | 316 ++++-------------- .../android/push/PushRegistrationStore.kt | 152 +++++++++ .../push/PushRegistrationManagerTest.kt | 15 +- 4 files changed, 368 insertions(+), 260 deletions(-) create mode 100644 onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationClient.kt create mode 100644 onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationStore.kt diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationClient.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationClient.kt new file mode 100644 index 0000000000..68e37393ad --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationClient.kt @@ -0,0 +1,145 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.push + +import android.content.Context +import android.util.Log +import com.google.firebase.crashlytics.FirebaseCrashlytics +import dagger.hilt.android.qualifiers.ApplicationContext +import java.net.HttpURLConnection +import javax.inject.Inject +import javax.inject.Singleton +import org.onebusaway.android.R +import org.onebusaway.android.api.contract.PushRegistrationWebService +import org.onebusaway.android.util.runCatchingCancellable +import retrofit2.Response + +/** + * Carries a push-registration server rejection to the remote error channel. A dedicated type so these + * group as their own Crashlytics issue rather than mixing into generic [IllegalStateException]s. + */ +class PushRegistrationException(message: String) : Exception(message) + +/** + * Performs the two push_registrations calls (issue #1957) and answers a single question about each: + * **did the server end up in the state we wanted?** Everything a caller would otherwise have to get + * right about an unreliable network lives here — URL assembly, which non-2xx codes still count as + * success, and making failures visible — so [PushRegistrationManager] can reason about reconciliation + * rather than about HTTP. + * + * Nothing here retries. A false return means "not achieved this time"; the manager's next reconcile + * pass tries again, which is the only retry policy the feature needs given it already runs on every + * app foreground. + */ +@Singleton +class PushRegistrationClient internal constructor( + private val service: PushRegistrationWebService, + private val registrationsEndpointPath: String, + // Seams for the Android-only reporting sinks, so failure handling is assertable from a plain JVM + // test: android.util.Log and Crashlytics are both unavailable there. + private val logWarning: (String, Throwable?) -> Unit, + private val reportError: (Throwable) -> Unit, +) { + + /** Production constructor Hilt builds from: resolves the seams from [context] and delegates. */ + @Inject + constructor( + @ApplicationContext context: Context, + service: PushRegistrationWebService, + ) : this( + service = service, + registrationsEndpointPath = context.getString(R.string.arrivals_reminders_api_endpoint), + logWarning = { message, cause -> Log.w(TAG, message, cause) }, + reportError = { FirebaseCrashlytics.getInstance().recordException(it) }, + ) + + /** POSTs [target]. True only on a 2xx (204) success. */ + suspend fun register(target: PushRegistration): Boolean = runCatchingCancellable { + val response = service.register( + url = registrationUrl(target), + token = target.token, + locale = target.locale, + testDevice = target.testDevice, + // Required by the server for a test device, omitted otherwise (see the service KDoc). + description = target.description, + ) + val registered = response.isSuccessful + if (!registered) reportHttpFailure("register", target, response) + registered + }.onFailure { logTransportFailure("register", target, it) }.getOrDefault(false) + + /** + * DELETEs [previous]. True on 204 **or 404** — the row already being gone is the state we wanted, so + * the caller can clear its record either way. + */ + suspend fun unregister(previous: PushRegistration): Boolean = runCatchingCancellable { + val response = service.unregister(url = registrationUrl(previous), token = previous.token) + val removed = response.isSuccessful || response.code() == HttpURLConnection.HTTP_NOT_FOUND + if (!removed) reportHttpFailure("unregister", previous, response) + removed + }.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 + + registrationsEndpointPath + + registration.regionId + "/push_registrations" + + /** + * Reports a non-2xx response. Without this an HTTP error would be *invisible*: a failed status is a + * perfectly successful call as far as [runCatchingCancellable] is concerned, so the `onFailure` + * handlers above only ever see transport exceptions. Since a failed call also persists nothing, the + * same doomed request then repeats on every foreground — which is exactly how the missing- + * `description` 422 went unnoticed until a packet capture. + * + * Server rejections also go to the remote channel: registrations are the server's ONLY audience + * source for service alerts, so a systematic failure — a field added server-side, a contract change, + * a throttle — must not be visible solely in one developer's logcat. + */ + private fun reportHttpFailure( + operation: String, + registration: PushRegistration, + response: Response, + ) { + // The server's error body carries the actionable message (e.g. "Description can't be blank"). + val body = response.errorBody()?.string()?.trim().orEmpty() + val detail = if (body.isEmpty()) "" else " $body" + val message = "${failurePrefix(operation, registration)}: HTTP ${response.code()}$detail" + logWarning(message, null) + reportError(PushRegistrationException(message)) + } + + /** + * Logs a transport-level failure. Local only — being offline is ordinary and self-healing, and + * reporting every such moment remotely would drown the systematic-rejection signal above. + */ + private fun logTransportFailure( + operation: String, + registration: PushRegistration, + cause: Throwable, + ) = logWarning(failurePrefix(operation, registration), cause) + + /** + * The shared opening of every failure message. Identifies the endpoint by region + host — never the + * token, and never the URL, since the unregister URL carries the token as a query param. + */ + private fun failurePrefix(operation: String, registration: PushRegistration): String = + "push $operation failed for region ${registration.regionId} at ${registration.sidecarBaseUrl}" + + private companion object { + const val TAG = "PushRegistration" + } +} diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt index b674095151..d14753f961 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt @@ -16,101 +16,81 @@ package org.onebusaway.android.push import android.content.Context -import android.util.Log import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner -import com.google.firebase.crashlytics.FirebaseCrashlytics import dagger.hilt.android.qualifiers.ApplicationContext -import java.net.HttpURLConnection import java.util.Locale import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject import javax.inject.Singleton -import kotlin.time.Duration import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.onebusaway.android.R -import org.onebusaway.android.api.contract.PushRegistrationWebService import org.onebusaway.android.app.di.AppScope import org.onebusaway.android.preferences.PreferencesRepository import org.onebusaway.android.region.RegionRepository -import org.onebusaway.android.time.WallTime -import org.onebusaway.android.util.runCatchingCancellable -import retrofit2.Response /** - * Carries a push-registration server rejection to the remote error channel. A dedicated type so these - * group as their own Crashlytics issue rather than mixing into generic [IllegalStateException]s. - */ -class PushRegistrationException(message: String) : Exception(message) - -/** - * Keeps this device's OBACloud push registration (issue #1957) in sync with the app's current state so + * Keeps this device's OBACloud push registration (issue #1957) in sync with the app's current state, so * that service-alert notifications reach every rider who has notifications enabled — not only those who * happen to create a trip alarm. * - * The desired state is derived from three inputs: whether notifications are enabled at the OS level - * (the opt-in signal — there is no separate in-app toggle), the current region (its id + sidecar host), - * and the FCM token. Whenever any of them changes we [sync]: [decidePushRegistration] compares the desired - * [PushRegistration] against the one last sent to the server and yields a register / re-register / - * unregister / no-op action, which [apply] performs against [PushRegistrationWebService]. + * A reconciler, in three parts: + * + * 1. **Desired state** ([buildTarget]) — derived from whether notifications are enabled at the OS level + * (the opt-in signal; there is no separate in-app toggle), the current region (its id + sidecar + * host), the FCM token, the device locale, and the test-device settings. + * 2. **The decision** ([decidePushRegistration]) — a pure function comparing desired against the record + * of what was last sent, yielding register / re-register / unregister / no-op. + * 3. **Applying it** — this class, which owns only the rule for *what to record given which calls + * succeeded* (the quadrants in [sync]). * - * The last successful registration is persisted (the `push_reg_*` prefs slots) so we know exactly what - * to DELETE on opt-out and can skip redundant POSTs — important given the endpoint's 30 req/min/IP rate - * limit (see [PushRegistrationWebService]). A separate `push_pending_del_*` slot holds a registration - * whose DELETE we still owe when a re-registration's POST landed but its DELETE didn't, so the stale one - * is retried on a later [sync] rather than orphaned (still pushing until the 180-day server prune). + * The mechanisms underneath are deliberately elsewhere: [PushRegistrationClient] owns talking to the + * endpoint and making failures visible, and [PushRegistrationStore] owns the durable record and its + * commit semantics. Neither retries — every trigger below re-runs [sync], and that is the retry policy. */ @Singleton class PushRegistrationManager internal constructor( - private val service: PushRegistrationWebService, + private val client: PushRegistrationClient, + private val store: PushRegistrationStore, private val regionRepository: RegionRepository, private val firebaseMessagingManager: FirebaseMessagingManager, private val prefs: PreferencesRepository, private val scope: CoroutineScope, - // Test seams for the Android-only bits (see the @Inject constructor). Kept off the DI path so - // sync()'s reconcile/persist semantics — including the Reregister-failure record handling — are - // exercisable from a plain JVM test, which is where the "orphan on double failure" class of bug lives. - // logWarning is a seam too: the failure paths call it, and android.util.Log isn't mocked under a - // plain JVM test, so the production default routes to Log.w while tests capture the message instead. + // The one Android-only read left here: NotificationManagerCompat isn't available to a plain JVM + // test, and the opt-in signal is what the whole reconcile hinges on. private val notificationsEnabled: () -> Boolean, - private val registrationsEndpointPath: String, - // Remote error channel for server rejections (see [logHttpFailure]); Crashlytics in production. - private val reportError: (Throwable) -> Unit, - private val logWarning: (String, Throwable?) -> Unit, - private val now: () -> WallTime = WallTime::now, ) { - /** Production constructor Hilt builds from: resolves the seams from [context] and delegates. */ + /** Production constructor Hilt builds from: resolves the seam from [context] and delegates. */ @Inject constructor( @ApplicationContext context: Context, - service: PushRegistrationWebService, + client: PushRegistrationClient, + store: PushRegistrationStore, regionRepository: RegionRepository, firebaseMessagingManager: FirebaseMessagingManager, prefs: PreferencesRepository, @AppScope scope: CoroutineScope, ) : this( - service = service, + client = client, + store = store, regionRepository = regionRepository, firebaseMessagingManager = firebaseMessagingManager, prefs = prefs, scope = scope, notificationsEnabled = { NotificationManagerCompat.from(context).areNotificationsEnabled() }, - registrationsEndpointPath = context.getString(R.string.arrivals_reminders_api_endpoint), - reportError = { FirebaseCrashlytics.getInstance().recordException(it) }, - logWarning = { message, cause -> Log.w(TAG, message, cause) }, ) private val started = AtomicBoolean(false) // Serializes reconciliation so overlapping triggers (region change + a token write arriving together) - // can't interleave two syncs and race on the persisted last-registration record. + // can't interleave two syncs and race on the stored record. private val mutex = Mutex() /** @@ -151,9 +131,9 @@ class PushRegistrationManager internal constructor( scope.launch { sync() } } - /** Reconciles the on-record registration with the currently desired one. */ + /** Reconciles the recorded registration with the currently desired one. */ private suspend fun sync() = mutex.withLock { - // Settle any DELETE we still owe from an earlier Reregister whose POST landed but whose DELETE + // Settle any DELETE still owed from an earlier Reregister whose POST landed but whose DELETE // didn't, before reconciling, so the stale registration can't linger past this pass. drainPendingDelete() @@ -161,42 +141,54 @@ class PushRegistrationManager internal constructor( // A missing region or FCM token just means we can't tell yet — both resolve asynchronously, and // the region flow is explicitly "briefly null at cold start" (see RegionRepository) — so treating // that as an opt-out would fire a spurious DELETE on launch and re-POST seconds later, burning - // two requests against a 30 req/min/IP endpoint and leaving a window with no registration at all. - // Bail instead and let the collector re-run us when the inputs land; iOS no-ops the same way. + // two requests against a rate-limited endpoint and leaving a window with no registration at all. val target = if (notificationsEnabled()) { - // Inputs still settling (the region seeds asynchronously, the FCM token arrives late) — - // bail rather than decide; the collector re-runs us once they land. + // Inputs still settling — bail rather than decide; a later trigger re-runs us. buildTarget() ?: return@withLock } else { null } - when (val action = decidePushRegistration(target, loadLast(), sinceLastSent())) { + when (val action = decidePushRegistration(target, store.last(), store.sinceLastSent())) { PushRegistrationAction.NoOp -> Unit is PushRegistrationAction.Register -> - if (register(action.target)) persist(action.target) + if (client.register(action.target)) store.record(action.target) is PushRegistrationAction.Unregister -> - if (unregister(action.previous)) clearLast() - is PushRegistrationAction.Reregister -> { - // DELETE the stale registration, then POST the new one, persisting only once the POST - // lands. All four failure quadrants are handled so nothing is orphaned: - // - DELETE ok + POST ok → persist(target): old gone, new on record. - // - DELETE ok + POST fail → clearLast(): server holds nothing, next sync re-Registers. - // - DELETE fail + POST fail → keep `previous`: next sync retries the whole Reregister. - // - DELETE fail + POST ok → persist(target) AND queue `previous` for a retried DELETE - // (drained at the top of a later sync) rather than dropping it — otherwise the old - // region keeps pushing to a still-valid token until the 180-day server age-out. - val cleaned = unregister(action.previous) - if (register(action.target)) { - persist(action.target) - if (!cleaned) persistPendingDelete(action.previous) - } else if (cleaned) { - clearLast() - } - } + if (client.unregister(action.previous)) store.clear() + is PushRegistrationAction.Reregister -> applyReregister(action) + } + } + + /** + * DELETEs the stale registration, then POSTs the new one, recording only once the POST lands. Both + * calls can fail independently, and all four quadrants have to leave the device recoverable: + * + * - DELETE ok + POST ok → record(target): old gone, new on record. + * - DELETE ok + POST fail → clear(): the server holds nothing, so the next sync re-Registers. + * - DELETE fail + POST fail → keep `previous`: the next sync retries the whole Reregister. + * - DELETE fail + POST ok → record(target) AND queue `previous` for a retried DELETE, rather than + * 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) { + val cleaned = client.unregister(action.previous) + if (client.register(action.target)) { + store.record(action.target) + if (!cleaned) store.queuePendingDelete(action.previous) + } else if (cleaned) { + store.clear() } } + /** + * Retries the DELETE queued by an earlier Reregister. Runs at the top of every sync; a no-op (one + * prefs read, no network) when nothing is queued. + */ + private suspend fun drainPendingDelete() { + val pending = store.pendingDelete() ?: return + if (client.unregister(pending)) store.clearPendingDelete() + } + /** * The registration this device wants, or null when the inputs aren't all resolved yet (no region, * no sidecar host, or no FCM token) — never an opt-out; see [sync]. The locale is the device's @@ -225,190 +217,4 @@ class PushRegistrationManager internal constructor( description = description, ) } - - /** POSTs [target]; true only on a 2xx (204) success. Retried on the next sync, so a failure isn't fatal. */ - private suspend fun register(target: PushRegistration): Boolean = runCatchingCancellable { - val response = service.register( - url = registrationUrl(target), - token = target.token, - locale = target.locale, - testDevice = target.testDevice, - // Required by the server for a test device, omitted otherwise (see the service KDoc). - description = target.description, - ) - val registered = response.isSuccessful - if (!registered) logHttpFailure("register", target, response) - registered - }.onFailure { - // Surface the cause (without the token) so a "why isn't this device registered" report isn't - // silent; the retry happens on the next trigger regardless. - logWarning(failurePrefix("register", target), it) - }.getOrDefault(false) - - /** DELETEs [previous]; true on 204 or 404 (already gone), so a stale record is cleared either way. */ - private suspend fun unregister(previous: PushRegistration): Boolean = runCatchingCancellable { - val response = service.unregister(url = registrationUrl(previous), token = previous.token) - val removed = response.isSuccessful || response.code() == HttpURLConnection.HTTP_NOT_FOUND - if (!removed) logHttpFailure("unregister", previous, response) - removed - }.onFailure { - // Same rationale as register(): log the cause without the token; the DELETE is retried later. - logWarning(failurePrefix("unregister", previous), it) - }.getOrDefault(false) - - /** - * The shared opening of every failure message. Identifies the endpoint by region + host — never the - * token, and never the URL, since the unregister URL carries the token as a query param. - */ - private fun failurePrefix(operation: String, registration: PushRegistration): String = - "push $operation failed for region ${registration.regionId} at ${registration.sidecarBaseUrl}" - - /** - * Logs a non-2xx response. Without this an HTTP error is *invisible*: a failed status is a - * perfectly successful call as far as [runCatchingCancellable] is concerned, so the `onFailure` - * handlers above only ever fire for transport exceptions. Since a failed call also persists - * nothing, the same doomed request then repeats on every foreground — silently, and against a - * 30 req/min/IP endpoint — which is exactly how the missing-`description` 422 went unnoticed - * until a packet capture. - */ - private fun logHttpFailure( - operation: String, - registration: PushRegistration, - response: Response, - ) { - // The server's error body carries the actionable message (e.g. "Description can't be blank"). - val body = response.errorBody()?.string()?.trim().orEmpty() - val detail = if (body.isEmpty()) "" else " $body" - val message = "${failurePrefix(operation, registration)}: HTTP ${response.code()}$detail" - logWarning(message, null) - // Registrations are the server's ONLY audience source for service alerts, so a systematic - // rejection — a required field added server-side, a contract change, a throttle — must not be - // visible only in one developer's logcat. Report server rejections remotely; transport failures - // (handled by the onFailure paths) are ordinary offline noise and stay local. - reportError(PushRegistrationException(message)) - } - - /** - * Retries the DELETE queued by an earlier Reregister (the DELETE-fail/POST-ok quadrant in [sync]). - * Runs at the top of every sync; a no-op (one prefs read, no network) when the slot is empty. Cleared - * once the DELETE lands (204 or 404), else left to retry on the next sync/foreground. - */ - private suspend fun drainPendingDelete() { - val pending = loadPendingDelete() ?: return - if (unregister(pending)) clearPendingDelete() - } - - /** `{sidecarBaseUrl}/api/v2/regions/{regionId}/push_registrations` — mirrors the alarms URL build. */ - private fun registrationUrl(registration: PushRegistration): String = - registration.sidecarBaseUrl + - registrationsEndpointPath + - registration.regionId + "/push_registrations" - - /** - * The registration last successfully sent to the server, or null if none is on record. The token - * slot doubles as the commit sentinel: [persist] writes it last and [clearLast] nulls it. That - * ordering is exact for in-process reads (the prefs cache updates synchronously, in call order) but - * each slot persists to disk as its own async DataStore edit, so it is NOT guaranteed across a - * process death mid-persist. That's acceptable by design: any torn disk state either reads back as - * "no record" (missing base/token) → re-registration, which is a safe idempotent upsert, or as a - * stale record whose DELETE the server answers with a tolerated 404. No recovery path depends on - * the sentinel being durably last. - */ - private fun loadLast(): PushRegistration? { - val base = prefs.getString(KEY_BASE, null) ?: return null - val token = prefs.getString(KEY_TOKEN, null) ?: return null - return PushRegistration( - regionId = prefs.getLong(KEY_REGION_ID, -1L), - sidecarBaseUrl = base, - token = token, - locale = prefs.getString(KEY_LOCALE, null) ?: "", - testDevice = prefs.getBoolean(KEY_TEST_DEVICE, false), - description = prefs.getString(KEY_DESCRIPTION, null), - ) - } - - /** - * How long ago the on-record registration was sent, or null if that is unknown — no record, or one - * written before this slot existed. Both the write and this read are device-clock ([WallTime]): it - * is a purely local elapsed-time question about our own write, never compared against a server - * timestamp, so no server-clock crossing is involved. - */ - private fun sinceLastSent(): Duration? { - val sentAtMs = prefs.getLong(KEY_SENT_AT, 0L).takeIf { it > 0L } ?: return null - return now() - WallTime(sentAtMs) - } - - private fun persist(registration: PushRegistration) { - prefs.setLong(KEY_REGION_ID, registration.regionId) - prefs.setString(KEY_BASE, registration.sidecarBaseUrl) - prefs.setString(KEY_LOCALE, registration.locale) - prefs.setBoolean(KEY_TEST_DEVICE, registration.testDevice) - prefs.setString(KEY_DESCRIPTION, registration.description) - // Stamps the refresh clock: [sinceLastSent] measures the 24h re-POST window from here. - prefs.setLong(KEY_SENT_AT, now().epochMs) - // Written last: its presence is what [loadLast] treats as "a full record is committed". Exact - // in-process; only best-effort on disk (see [loadLast] for why a torn persist is still safe). - prefs.setString(KEY_TOKEN, registration.token) - // This endpoint is now the live registration, so drop any owed DELETE for it — otherwise a - // return-to-an-old-endpoint (region/token switched away, then back) would DELETE the row we just - // POSTed. This is why the single pending-delete slot is safe. - if (loadPendingDelete()?.sameEndpoint(registration) == true) clearPendingDelete() - } - - /** Nulls the commit-sentinel token slot so [loadLast] reports no record. */ - private fun clearLast() { - prefs.setString(KEY_TOKEN, null) - } - - /** - * Persists the registration whose DELETE is still outstanding (the DELETE-fail/POST-ok Reregister - * quadrant) so [drainPendingDelete] can retry it later. Only the DELETE-addressing fields - * (region + host + token) are stored; locale/test-device don't affect a DELETE. A single slot: a - * second orphan before the first drains overwrites it — rare (two region changes with both old hosts - * unreachable back-to-back), and the loser still falls back to the 180-day server prune. - */ - private fun persistPendingDelete(registration: PushRegistration) { - prefs.setLong(KEY_PENDING_DEL_REGION_ID, registration.regionId) - prefs.setString(KEY_PENDING_DEL_BASE, registration.sidecarBaseUrl) - // Written last: the commit sentinel, mirroring [persist]. - prefs.setString(KEY_PENDING_DEL_TOKEN, registration.token) - } - - /** The registration awaiting a retried DELETE, or null if none is queued. Metadata is placeholder. */ - private fun loadPendingDelete(): PushRegistration? { - 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), - sidecarBaseUrl = base, - token = token, - // Unused by [unregister] — a DELETE is addressed by region + host + token only. - locale = "", - testDevice = false, - description = null, - ) - } - - /** Nulls the pending-delete sentinel token slot so [loadPendingDelete] reports nothing queued. */ - private fun clearPendingDelete() { - prefs.setString(KEY_PENDING_DEL_TOKEN, null) - } - - private companion object { - const val TAG = "PushRegistration" - - // Internal bookkeeping slots (not user-facing) for the last successful registration. - const val KEY_REGION_ID = "push_reg_region_id" - const val KEY_BASE = "push_reg_base" - const val KEY_TOKEN = "push_reg_token" - const val KEY_LOCALE = "push_reg_locale" - const val KEY_TEST_DEVICE = "push_reg_test_device" - const val KEY_DESCRIPTION = "push_reg_description" - const val KEY_SENT_AT = "push_reg_sent_at" - - // Slots for a registration whose DELETE is owed but not yet confirmed (see [persistPendingDelete]). - const val KEY_PENDING_DEL_REGION_ID = "push_pending_del_region_id" - const val KEY_PENDING_DEL_BASE = "push_pending_del_base" - const val KEY_PENDING_DEL_TOKEN = "push_pending_del_token" - } } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationStore.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationStore.kt new file mode 100644 index 0000000000..e7872698cb --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationStore.kt @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.push + +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration +import org.onebusaway.android.preferences.PreferencesRepository +import org.onebusaway.android.time.WallTime + +/** + * The durable memory of what this device has told OBACloud (issue #1957) — the only record of it, + * since the push_registrations API offers no GET. Two records live here: + * + * - **The live registration** ([last]) plus when it was sent ([sinceLastSent]). Needed to address the + * DELETE on opt-out (by which point the current token/region may already have changed), to skip + * redundant POSTs against a 30 req/min/IP endpoint, and to drive the [PUSH_REFRESH_INTERVAL] + * keep-alive. + * - **A pending delete** ([pendingDelete]) — a registration whose DELETE is still owed. See + * [queuePendingDelete]. + * + * Each record spans several preference slots, so one of them — the token — acts as a **commit + * sentinel**: writes store it last and clears null it, and a read returns nothing unless it is present. + * A half-written record therefore reads back as "no record" rather than as a corrupt one. + * + * That ordering is exact for in-process reads (the prefs cache updates synchronously, in call order) + * but each slot persists to disk as its own async DataStore edit, so it is NOT guaranteed across a + * process death mid-persist. Acceptable by design: every torn state lands on a safe path — a missing + * sentinel reads as "no record" and re-registration is an idempotent upsert, while a stale record's + * DELETE is answered with a tolerated 404. No recovery path depends on the sentinel being durably last. + */ +@Singleton +class PushRegistrationStore internal constructor( + private val prefs: PreferencesRepository, + // Device clock, injectable so the refresh window is drivable from a JVM test. + private val now: () -> WallTime, +) { + + @Inject + constructor(prefs: PreferencesRepository) : this(prefs, WallTime::now) + + /** The registration last successfully sent to the server, or null if none is on record. */ + fun last(): PushRegistration? { + val base = prefs.getString(KEY_BASE, null) ?: return null + val token = prefs.getString(KEY_TOKEN, null) ?: return null + return PushRegistration( + regionId = prefs.getLong(KEY_REGION_ID, -1L), + sidecarBaseUrl = base, + token = token, + locale = prefs.getString(KEY_LOCALE, null) ?: "", + testDevice = prefs.getBoolean(KEY_TEST_DEVICE, false), + description = prefs.getString(KEY_DESCRIPTION, null), + ) + } + + /** + * How long ago [last] was sent, or null if unknown — no record, or one written before this slot + * existed. Purely local elapsed time measured against our own write ([WallTime] on both ends), never + * against a server timestamp, so no server-clock crossing is involved. + */ + fun sinceLastSent(): Duration? { + val sentAtMs = prefs.getLong(KEY_SENT_AT, 0L).takeIf { it > 0L } ?: return null + return now() - WallTime(sentAtMs) + } + + /** Commits [registration] as the live record and stamps the refresh clock. */ + fun record(registration: PushRegistration) { + prefs.setLong(KEY_REGION_ID, registration.regionId) + prefs.setString(KEY_BASE, registration.sidecarBaseUrl) + prefs.setString(KEY_LOCALE, registration.locale) + prefs.setBoolean(KEY_TEST_DEVICE, registration.testDevice) + prefs.setString(KEY_DESCRIPTION, registration.description) + prefs.setLong(KEY_SENT_AT, now().epochMs) + // The commit sentinel, written last. + prefs.setString(KEY_TOKEN, registration.token) + // This endpoint is live again, so drop any DELETE still owed for it — otherwise returning to an + // old endpoint (region/token switched away, then back) would delete the row we just POSTed. + // This is what makes a single pending-delete slot safe. + if (pendingDelete()?.sameEndpoint(registration) == true) clearPendingDelete() + } + + /** Forgets the live record, so [last] reports nothing on record. */ + fun clear() { + prefs.setString(KEY_TOKEN, null) + } + + /** + * Remembers that [registration] still needs deleting server-side. Written when a re-registration's + * POST lands but its DELETE doesn't: the new registration must be recorded, yet the old row is still + * on the server pointing at a token this device still holds, so without this it would keep pushing + * until the 180-day prune. + * + * Only the DELETE-addressing fields (region + host + token) are kept; locale and the test-device + * fields don't affect a DELETE. There is deliberately one slot — a second orphan before the first + * drains overwrites it, which takes two region changes with both old hosts unreachable back to back, + * and the loser still falls back to the server prune. + */ + fun queuePendingDelete(registration: PushRegistration) { + prefs.setLong(KEY_PENDING_DEL_REGION_ID, registration.regionId) + prefs.setString(KEY_PENDING_DEL_BASE, registration.sidecarBaseUrl) + // The commit sentinel, written last — mirroring [record]. + prefs.setString(KEY_PENDING_DEL_TOKEN, registration.token) + } + + /** The registration awaiting a retried DELETE, or null if none is queued. */ + fun pendingDelete(): PushRegistration? { + 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), + sidecarBaseUrl = base, + token = token, + // Unused when deleting — a DELETE is addressed by region + host + token only. + locale = "", + testDevice = false, + description = null, + ) + } + + fun clearPendingDelete() { + prefs.setString(KEY_PENDING_DEL_TOKEN, null) + } + + private companion object { + // Internal bookkeeping slots (not user-facing) for the last successful registration. + const val KEY_REGION_ID = "push_reg_region_id" + const val KEY_BASE = "push_reg_base" + const val KEY_TOKEN = "push_reg_token" + const val KEY_LOCALE = "push_reg_locale" + const val KEY_TEST_DEVICE = "push_reg_test_device" + const val KEY_DESCRIPTION = "push_reg_description" + const val KEY_SENT_AT = "push_reg_sent_at" + + // Slots for a registration whose DELETE is owed but not yet confirmed (see [queuePendingDelete]). + const val KEY_PENDING_DEL_REGION_ID = "push_pending_del_region_id" + const val KEY_PENDING_DEL_BASE = "push_pending_del_base" + const val KEY_PENDING_DEL_TOKEN = "push_pending_del_token" + } +} diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt index 9b2fac7a1b..04b9b70cfd 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt @@ -420,17 +420,22 @@ class PushRegistrationManagerTest { /** Captures every warning the manager logs (android.util.Log isn't mocked under a JVM test). */ val loggedWarnings = mutableListOf() + // The real store and client over fakes: these tests are about the reconcile → apply → record + // loop as a whole, so exercising the actual persistence and failure-classification code (rather + // than fakes of it) is the point. val manager = PushRegistrationManager( - service = service, + client = PushRegistrationClient( + service = service, + registrationsEndpointPath = "/api/v2/regions/", + logWarning = { message, _ -> loggedWarnings += message }, + reportError = { reportedErrors += it }, + ), + store = PushRegistrationStore(prefs = prefs, now = { now }), regionRepository = regions, firebaseMessagingManager = FirebaseMessagingManager(prefs), prefs = prefs, scope = scope, notificationsEnabled = { notificationsEnabled }, - registrationsEndpointPath = "/api/v2/regions/", - reportError = { reportedErrors += it }, - logWarning = { message, _ -> loggedWarnings += message }, - now = { now }, ) fun setToken(token: String?) = prefs.setString(R.string.firebase_messaging_token, token) From 2cd7ecfa37d9cc7be2f2ac672edd4f0219dc4a42 Mon Sep 17 00:00:00 2001 From: Brandon Martin-Anderson Date: Tue, 21 Jul 2026 04:25:31 +0000 Subject: [PATCH 13/26] Drop now-unused NOTIFICATION_PERMISSION_REQUEST after merge 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 --- .../main/java/org/onebusaway/android/util/PermissionUtils.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/util/PermissionUtils.java b/onebusaway-android/src/main/java/org/onebusaway/android/util/PermissionUtils.java index 49bc944415..bf22d81ed2 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/util/PermissionUtils.java +++ b/onebusaway-android/src/main/java/org/onebusaway/android/util/PermissionUtils.java @@ -23,8 +23,6 @@ public class PermissionUtils { - public static final int NOTIFICATION_PERMISSION_REQUEST = 5; - public static final String[] LOCATION_PERMISSIONS = { Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION }; From 863064261f858cca00582ae414419d18ba2ffea7 Mon Sep 17 00:00:00 2001 From: Brandon Martin-Anderson Date: Tue, 21 Jul 2026 04:51:08 +0000 Subject: [PATCH 14/26] Cap the test-device description at the documented 255-char limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../contract/PushRegistrationWebService.kt | 19 +++++++++++-------- .../android/push/PushRegistrationDecision.kt | 8 ++++++++ .../android/push/PushRegistrationManager.kt | 5 +++++ .../ui/settings/AdvancedSettingsScreen.kt | 4 +++- .../ui/settings/components/PreferenceItems.kt | 8 ++++++-- .../push/PushRegistrationManagerTest.kt | 15 +++++++++++++++ 6 files changed, 48 insertions(+), 11 deletions(-) diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/api/contract/PushRegistrationWebService.kt b/onebusaway-android/src/main/java/org/onebusaway/android/api/contract/PushRegistrationWebService.kt index 220b8218ed..2f7fa0ac55 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/api/contract/PushRegistrationWebService.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/api/contract/PushRegistrationWebService.kt @@ -50,12 +50,14 @@ interface PushRegistrationWebService { * [testDevice] is true, rejecting the call otherwise with * `422 {"error":"Unable to register device","messages":["Description can't be blank"]}`. Pass null * for an ordinary registration and Retrofit omits the field entirely — deliberate, so an ordinary - * rider's device model is never sent. NOTE: this requirement is absent from the #1957 contract - * table; it was established against the deployed server (a `test_device=true` POST without it 422s, - * with it 204s, while `test_device=false` 204s either way). The field's *purpose* — labelling rows - * in the "Test users only" audience so a human can tell devices apart — is inferred from its name - * and that gate, so the exact value we send is a judgement call awaiting confirmation from the - * OBACloud maintainers. + * rider's device model is never sent. + * + * OBACloud's push-notifications documentation now specifies this field, confirming the behaviour + * this client was originally built against by probing the deployed server: free text "≤255 chars + * identifying the device to admins" (e.g. `"Aaron's iPhone 17"`), "server-enforced when + * `test_device=true` (422 if blank)", and cleared server-side when a device is demoted to + * `test_device=false`. The rider supplies the value, and it is capped at + * `PUSH_DESCRIPTION_MAX_LENGTH` before it reaches here. */ @FormUrlEncoded @POST @@ -71,8 +73,9 @@ interface PushRegistrationWebService { /** * Unregisters this device's push [token] from the region (when the rider opts out of * notifications). DELETE to `…/regions/{id}/push_registrations` with the token as a query param - * (issue #1957). The contract leaves body-vs-query unstated; 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. + * (issue #1957) — the form OBACloud's push-notifications documentation specifies: + * `DELETE /api/v2/regions/{region_id}/push_registrations?token={token}`. It also documents a `404` + * (token never registered) as equivalent to success, which [PushRegistrationClient] treats as such. */ @HTTP(method = "DELETE") suspend fun unregister(@Url url: String, @Query("token") token: String): Response diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt index 54a2150ad0..57b08b6c7b 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt @@ -47,6 +47,14 @@ data class PushRegistration( */ val PUSH_REFRESH_INTERVAL: Duration = 24.hours +/** + * The server's cap on a test device's `description` (OBACloud's push-notifications documentation: + * "Free text ≤255 chars identifying the device to admins"). Enforced client-side so a long name is + * truncated rather than POSTed and rejected — a `description` that violates this is a 422, and since a + * failed registration persists nothing, the doomed request would otherwise repeat on every foreground. + */ +const val PUSH_DESCRIPTION_MAX_LENGTH = 255 + /** * True when [this] and [other] address the same server row — the region, host, and token a DELETE (or an * upsert POST) targets. Locale and test-device aren't part of the row's identity, so they don't count. diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt index cb2fc30c03..fec73eacbf 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt @@ -199,6 +199,10 @@ class PushRegistrationManager internal constructor( * as an ordinary one rather than POSTing a request guaranteed to fail. The iOS client gates its * "Test Device Name" the same way. Deriving `testDevice` from the name rather than tracking it * separately makes that rule hold by construction. + * + * The name is truncated to [PUSH_DESCRIPTION_MAX_LENGTH] here as well as being bounded at the input + * field, so a value that predates that bound (or arrives from anywhere else) still can't produce a + * request the server is guaranteed to reject. */ private fun buildTarget(): PushRegistration? { val region = regionRepository.region.value ?: return null @@ -206,6 +210,7 @@ class PushRegistrationManager internal constructor( 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) ?.takeIf { it.isNotEmpty() } ?.takeIf { prefs.getBoolean(R.string.preference_key_push_test_device, false) } return PushRegistration( diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsScreen.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsScreen.kt index 19a2553375..785bdb6500 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsScreen.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsScreen.kt @@ -35,6 +35,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.onebusaway.android.R import org.onebusaway.android.map.StopsMapController +import org.onebusaway.android.push.PUSH_DESCRIPTION_MAX_LENGTH import org.onebusaway.android.ui.compose.components.ObaTopAppBar import org.onebusaway.android.ui.compose.findActivity import org.onebusaway.android.ui.settings.components.ClickPreferenceItem @@ -203,7 +204,8 @@ fun AdvancedSettingsScreen( currentValue = state.pushTestDeviceName, hint = stringResource(R.string.preferences_push_test_device_name_title), onValueChange = onPushTestDeviceName, - keyboardType = KeyboardType.Text + keyboardType = KeyboardType.Text, + maxLength = PUSH_DESCRIPTION_MAX_LENGTH ) EditTextPreferenceItem( title = stringResource(R.string.preferences_map_stop_cache_size_title), diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/components/PreferenceItems.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/components/PreferenceItems.kt index ac78c63223..99df23490f 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/components/PreferenceItems.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/components/PreferenceItems.kt @@ -235,6 +235,9 @@ fun ListPreferenceItem( * A free-text row (replaces `EditTextPreference`). Tapping opens a dialog seeded with [currentValue]; * confirming calls [onValueChange] with the trimmed text and keeps the dialog open if it returns * `false` (preserving the legacy `onPreferenceChange` reject-on-invalid contract, e.g. URL validation). + * + * [maxLength], when non-null, caps what the field will accept — for values a server bounds, so the + * limit is felt while typing rather than surfacing later as a rejected request. */ @Composable fun EditTextPreferenceItem( @@ -245,7 +248,8 @@ fun EditTextPreferenceItem( onValueChange: (String) -> Boolean, modifier: Modifier = Modifier, enabled: Boolean = true, - keyboardType: KeyboardType = KeyboardType.Uri + keyboardType: KeyboardType = KeyboardType.Uri, + maxLength: Int? = null ) { var showDialog by remember { mutableStateOf(false) } ClickPreferenceItem( @@ -263,7 +267,7 @@ fun EditTextPreferenceItem( text = { OutlinedTextField( value = text, - onValueChange = { text = it }, + onValueChange = { text = if (maxLength == null) it else it.take(maxLength) }, singleLine = true, placeholder = { Text(hint) }, keyboardOptions = KeyboardOptions(keyboardType = keyboardType) diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt index ca1d066318..e8326f74ec 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt @@ -300,6 +300,21 @@ class PushRegistrationManagerTest { assertTrue("a downgraded registration must still succeed", f.loggedWarnings.isEmpty()) } + @Test + fun `an over-long test device name is truncated to the server's limit`() = runTest { + val f = Fixture(this) + // The input field bounds this too, but a value stored before that bound existed must still not + // reach the server: OBACloud caps the description at 255 chars and 422s anything longer, and a + // rejected registration persists nothing, so the doomed POST would repeat on every foreground. + f.prefs.setString(R.string.preference_key_push_test_device_name, "N".repeat(300)) + f.setToken("T1") + f.sync() + + val call = f.service.registerCalls.single() + assertEquals(true, call.testDevice) + assertEquals("N".repeat(PUSH_DESCRIPTION_MAX_LENGTH), call.description) + } + @Test fun `renaming the test device re-posts the new name without a delete`() = runTest { val f = Fixture(this).registered("T1") From 43a34ebd6ec350aa519754514d7f393ae5e8eb4a Mon Sep 17 00:00:00 2001 From: Brandon Martin-Anderson Date: Tue, 21 Jul 2026 05:05:13 +0000 Subject: [PATCH 15/26] Simplify push registration: derive testDevice, own the name cap at its write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../android/push/PushRegistrationDecision.kt | 20 +++++++++----- .../android/push/PushRegistrationManager.kt | 12 ++++----- .../android/push/PushRegistrationStore.kt | 4 --- .../compose/NotificationPermissionRequest.kt | 12 ++++++--- .../ui/settings/AdvancedSettingsScreen.kt | 4 +-- .../ui/settings/AdvancedSettingsViewModel.kt | 27 ++++++++++++++++--- .../ui/settings/components/PreferenceItems.kt | 8 ++---- .../push/PushRegistrationDecisionTest.kt | 6 +++-- .../push/PushRegistrationManagerTest.kt | 9 ++++--- .../settings/AdvancedSettingsViewModelTest.kt | 25 +++++++++++++++++ 10 files changed, 89 insertions(+), 38 deletions(-) diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt index 57b08b6c7b..d5cd80af14 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt @@ -20,24 +20,32 @@ import kotlin.time.Duration.Companion.hours /** * A single push registration on record with OBACloud: the region it targets, the FCM token, and the - * metadata sent alongside it. All fields participate in equality, so a change to *any* of them (a - * rotated token, a moved region, a new device locale, a flipped test flag) is a change that must be - * pushed to the server. Pure data — no Android dependencies — so [decidePushRegistration] is - * JVM-unit-testable. + * metadata sent alongside it. All properties participate in equality, so a change to *any* of them (a + * rotated token, a moved region, a new device locale, a renamed or cleared test-device label) is a + * change that must be pushed to the server. Pure data — no Android dependencies — so + * [decidePushRegistration] is JVM-unit-testable. */ data class PushRegistration( val regionId: Long, val sidecarBaseUrl: String, val token: String, val locale: String, - val testDevice: Boolean, /** * The admin-facing label sent alongside a test-device registration (null for an ordinary one). It * participates in equality so that renaming the device re-POSTs — and because it can only change * when the rider edits the setting, it never churns on its own. */ val description: String? -) +) { + + /** + * Whether this registers as a test device. Derived rather than stored: the server requires a + * `description` exactly when `test_device=true` (422 otherwise), so carrying the flag separately + * would admit a pair — flag set, no name — that can only ever be rejected. Deriving it makes that + * rule hold by construction, at the type level, instead of by agreement between call sites. + */ + val testDevice: Boolean get() = description != null +} /** * Re-POST an otherwise-unchanged registration this often, so the server's `last_seen_at` is refreshed diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt index fec73eacbf..c49a3911cd 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt @@ -197,12 +197,13 @@ class PushRegistrationManager internal constructor( * The test-device flag is honoured only once the rider has named the device: the server rejects a * `test_device=true` registration with a blank `description` (422), so an unnamed device registers * as an ordinary one rather than POSTing a request guaranteed to fail. The iOS client gates its - * "Test Device Name" the same way. Deriving `testDevice` from the name rather than tracking it - * separately makes that rule hold by construction. + * "Test Device Name" the same way — and [PushRegistration.testDevice] derives the flag from the + * name, so that rule holds by construction. * - * The name is truncated to [PUSH_DESCRIPTION_MAX_LENGTH] here as well as being bounded at the input - * field, so a value that predates that bound (or arrives from anywhere else) still can't produce a - * request the server is guaranteed to reject. + * The name is capped when it is written (`AdvancedSettingsViewModel.onPushTestDeviceNameChanged`), + * which is what keeps the stored value legal; the [PUSH_DESCRIPTION_MAX_LENGTH] clamp here is a + * cheap guard on the wire boundary itself, so no path can put an over-long `description` on a + * request the server would reject outright. */ private fun buildTarget(): PushRegistration? { val region = regionRepository.region.value ?: return null @@ -218,7 +219,6 @@ class PushRegistrationManager internal constructor( sidecarBaseUrl = base, token = token, locale = Locale.getDefault().toLanguageTag(), - testDevice = description != null, description = description ) } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationStore.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationStore.kt index 800b682202..63f00bbd62 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationStore.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationStore.kt @@ -61,7 +61,6 @@ class PushRegistrationStore internal constructor( sidecarBaseUrl = base, token = token, locale = prefs.getString(KEY_LOCALE, null) ?: "", - testDevice = prefs.getBoolean(KEY_TEST_DEVICE, false), description = prefs.getString(KEY_DESCRIPTION, null) ) } @@ -81,7 +80,6 @@ class PushRegistrationStore internal constructor( prefs.setLong(KEY_REGION_ID, registration.regionId) prefs.setString(KEY_BASE, registration.sidecarBaseUrl) prefs.setString(KEY_LOCALE, registration.locale) - prefs.setBoolean(KEY_TEST_DEVICE, registration.testDevice) prefs.setString(KEY_DESCRIPTION, registration.description) prefs.setLong(KEY_SENT_AT, now().epochMs) // The commit sentinel, written last. @@ -125,7 +123,6 @@ class PushRegistrationStore internal constructor( token = token, // Unused when deleting — a DELETE is addressed by region + host + token only. locale = "", - testDevice = false, description = null ) } @@ -140,7 +137,6 @@ class PushRegistrationStore internal constructor( const val KEY_BASE = "push_reg_base" const val KEY_TOKEN = "push_reg_token" const val KEY_LOCALE = "push_reg_locale" - const val KEY_TEST_DEVICE = "push_reg_test_device" const val KEY_DESCRIPTION = "push_reg_description" const val KEY_SENT_AT = "push_reg_sent_at" diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/compose/NotificationPermissionRequest.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/compose/NotificationPermissionRequest.kt index 074aeba998..5e5c1c68d9 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/compose/NotificationPermissionRequest.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/compose/NotificationPermissionRequest.kt @@ -20,6 +20,7 @@ import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import org.onebusaway.android.app.di.PushRegistrationEntryPoint @@ -43,9 +44,14 @@ internal fun rememberNotificationPermissionRequest(): () -> Unit { val launcher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission() ) { PushRegistrationEntryPoint.get(context).resync() } - return { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - launcher.launch(Manifest.permission.POST_NOTIFICATIONS) + // Remembered so the returned action keeps a stable identity across recompositions — callers pass it + // into other composables (see TripDestinations), which could not skip if it were a fresh lambda + // every time. + return remember(launcher) { + { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + launcher.launch(Manifest.permission.POST_NOTIFICATIONS) + } } } } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsScreen.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsScreen.kt index 785bdb6500..19a2553375 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsScreen.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsScreen.kt @@ -35,7 +35,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.onebusaway.android.R import org.onebusaway.android.map.StopsMapController -import org.onebusaway.android.push.PUSH_DESCRIPTION_MAX_LENGTH import org.onebusaway.android.ui.compose.components.ObaTopAppBar import org.onebusaway.android.ui.compose.findActivity import org.onebusaway.android.ui.settings.components.ClickPreferenceItem @@ -204,8 +203,7 @@ fun AdvancedSettingsScreen( currentValue = state.pushTestDeviceName, hint = stringResource(R.string.preferences_push_test_device_name_title), onValueChange = onPushTestDeviceName, - keyboardType = KeyboardType.Text, - maxLength = PUSH_DESCRIPTION_MAX_LENGTH + keyboardType = KeyboardType.Text ) EditTextPreferenceItem( title = stringResource(R.string.preferences_map_stop_cache_size_title), diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModel.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModel.kt index 8026793f11..a776e68cd5 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModel.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModel.kt @@ -35,6 +35,7 @@ import org.onebusaway.android.R import org.onebusaway.android.donations.DonationsManager import org.onebusaway.android.map.StopsMapController import org.onebusaway.android.preferences.PreferencesRepository +import org.onebusaway.android.push.PUSH_DESCRIPTION_MAX_LENGTH import org.onebusaway.android.region.ApiUrlValidator import org.onebusaway.android.region.Region import org.onebusaway.android.region.RegionRepository @@ -117,11 +118,12 @@ class AdvancedSettingsViewModel @Inject constructor( * device to an ordinary registration — the server rejects a test-device registration without a * name, so `PushRegistrationManager` declines to send one rather than POST a guaranteed failure. * Always accepted (returns true): any non-blank text is a valid name. + * + * Normalized to the server's [PUSH_DESCRIPTION_MAX_LENGTH] cap here, at the one boundary where this + * value enters persistence, so what is stored is by construction a legal `description` and the name + * shown in settings is the name that goes on the wire. */ - fun onPushTestDeviceNameChanged(value: String): Boolean { - prefs.setString(R.string.preference_key_push_test_device_name, value.trim().ifEmpty { null }) - return true - } + fun onPushTestDeviceNameChanged(value: String): Boolean = applyPushTestDeviceName(value, prefs) /** * Apply an edit to the map stop LRU cache size. Returns true if the input was a valid in-range @@ -230,6 +232,23 @@ internal fun applyMapStopCacheSize(text: String, prefs: PreferencesRepository): return true } +/** + * The test-device-name edit's domain effect, split from + * [AdvancedSettingsViewModel.onPushTestDeviceNameChanged] so it's free of Android dependencies and + * unit-tested directly (see AdvancedSettingsViewModelTest). + * + * This is the one boundary where the name enters persistence, so it is where the value is normalized to + * OBACloud's [PUSH_DESCRIPTION_MAX_LENGTH] cap: what is stored is by construction a legal `description`, + * which keeps the name shown in settings identical to the one sent on the wire. Blank clears the slot, + * downgrading the device to an ordinary registration. Always accepted — any non-blank text is a valid + * name, so unlike [applyMapStopCacheSize] there is no reject-on-invalid case. + */ +internal fun applyPushTestDeviceName(value: String, prefs: PreferencesRepository): Boolean { + val name = value.trim().take(PUSH_DESCRIPTION_MAX_LENGTH).ifEmpty { null } + prefs.setString(R.string.preference_key_push_test_device_name, name) + return true +} + /** * The domain rule that a toggle-initiated experimental-region *change* resets the OTP API version to the * current default ([resetOtpVersion]). The repository's own [RegionRepository.applyRegion] reset covers diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/components/PreferenceItems.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/components/PreferenceItems.kt index 99df23490f..ac78c63223 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/components/PreferenceItems.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/components/PreferenceItems.kt @@ -235,9 +235,6 @@ fun ListPreferenceItem( * A free-text row (replaces `EditTextPreference`). Tapping opens a dialog seeded with [currentValue]; * confirming calls [onValueChange] with the trimmed text and keeps the dialog open if it returns * `false` (preserving the legacy `onPreferenceChange` reject-on-invalid contract, e.g. URL validation). - * - * [maxLength], when non-null, caps what the field will accept — for values a server bounds, so the - * limit is felt while typing rather than surfacing later as a rejected request. */ @Composable fun EditTextPreferenceItem( @@ -248,8 +245,7 @@ fun EditTextPreferenceItem( onValueChange: (String) -> Boolean, modifier: Modifier = Modifier, enabled: Boolean = true, - keyboardType: KeyboardType = KeyboardType.Uri, - maxLength: Int? = null + keyboardType: KeyboardType = KeyboardType.Uri ) { var showDialog by remember { mutableStateOf(false) } ClickPreferenceItem( @@ -267,7 +263,7 @@ fun EditTextPreferenceItem( text = { OutlinedTextField( value = text, - onValueChange = { text = if (maxLength == null) it else it.take(maxLength) }, + onValueChange = { text = it }, singleLine = true, placeholder = { Text(hint) }, keyboardOptions = KeyboardOptions(keyboardType = keyboardType) diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationDecisionTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationDecisionTest.kt index 77d78d35ee..f83d6d2664 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationDecisionTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationDecisionTest.kt @@ -19,6 +19,7 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.hours import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Test /** @@ -33,7 +34,6 @@ class PushRegistrationDecisionTest { sidecarBaseUrl = "https://sidecar.example.org", token = "token-a", locale = "en-US", - testDevice = false, description = null ) @@ -74,7 +74,9 @@ class PushRegistrationDecisionTest { @Test fun `a test-device flag change re-posts on the same token and region`() { - val target = base.copy(testDevice = true) + // Naming the device is what promotes it: testDevice derives from the description. + val target = base.copy(description = "Sam's Pixel") + assertTrue("naming the device must promote it to a test device", target.testDevice) assertEquals(PushRegistrationAction.Register(target), decide(target = target, last = base)) } diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt index e8326f74ec..5acbcf4579 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt @@ -303,9 +303,10 @@ class PushRegistrationManagerTest { @Test fun `an over-long test device name is truncated to the server's limit`() = runTest { val f = Fixture(this) - // The input field bounds this too, but a value stored before that bound existed must still not - // reach the server: OBACloud caps the description at 255 chars and 422s anything longer, and a - // rejected registration persists nothing, so the doomed POST would repeat on every foreground. + // The settings write boundary caps this, so an over-long value can only arrive by writing the + // pref directly (as here). Guard it at the wire anyway: OBACloud 422s a description over 255 + // chars, and a rejected registration persists nothing, so the doomed POST would repeat on + // every foreground. f.prefs.setString(R.string.preference_key_push_test_device_name, "N".repeat(300)) f.setToken("T1") f.sync() @@ -450,7 +451,7 @@ class PushRegistrationManagerTest { notificationsEnabled = { notificationsEnabled } ) - fun setToken(token: String?) = prefs.setString(R.string.firebase_messaging_token, token) + fun setToken(token: String) = prefs.setString(R.string.firebase_messaging_token, token) /** Fires the reconcile trigger and drains it — the manager runs sync() off resync()'s launch. */ fun sync() { diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModelTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModelTest.kt index 56fdd62777..451d2a51af 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModelTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModelTest.kt @@ -26,6 +26,7 @@ import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test import org.onebusaway.android.R +import org.onebusaway.android.push.PUSH_DESCRIPTION_MAX_LENGTH import org.onebusaway.android.region.FakeRegionRepository import org.onebusaway.android.region.RegionState import org.onebusaway.android.region.RegionStatus @@ -122,6 +123,30 @@ class AdvancedSettingsViewModelTest { assertEquals("nothing was persisted", -1, prefs.getInt(mapStopCacheSize, -1)) } + private val pushTestDeviceName = R.string.preference_key_push_test_device_name + + @Test + fun `a test device name is trimmed and stored, and blank clears it`() { + val prefs = FakePreferencesRepository() + + assertTrue("any non-blank name is accepted", applyPushTestDeviceName(" Sam's Pixel ", prefs)) + assertEquals("Sam's Pixel", prefs.getString(pushTestDeviceName, null)) + + assertTrue("blank is accepted too", applyPushTestDeviceName(" ", prefs)) + assertNull("blank clears the slot, downgrading to an ordinary device", prefs.getString(pushTestDeviceName, null)) + } + + @Test + fun `a test device name is capped at the server's limit before it is stored`() { + val prefs = FakePreferencesRepository() + + // Normalizing here — the only boundary where this value enters persistence — is what keeps the + // stored name a legal `description`, so settings shows exactly what goes on the wire. + applyPushTestDeviceName("N".repeat(300), prefs) + + assertEquals("N".repeat(PUSH_DESCRIPTION_MAX_LENGTH), prefs.getString(pushTestDeviceName, null)) + } + @Test fun `a re-resolve that needs a manual pick defers the OTP reset until the user chooses`() = runTest { val regions = listOf(region(1), region(2)) From 84007339a5dcef1b451768891cc0052f25fa5fb3 Mon Sep 17 00:00:00 2001 From: Brandon Martin-Anderson Date: Mon, 20 Jul 2026 22:30:50 -0700 Subject: [PATCH 16/26] Harden the push-registration clock edges; make the test-device settings legible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../android/push/PushRegistrationStore.kt | 17 ++++- .../ui/settings/AdvancedSettingsScreen.kt | 11 ++- .../src/main/res/values/strings.xml | 1 + .../push/PushRegistrationManagerTest.kt | 16 ++++ .../android/push/PushRegistrationStoreTest.kt | 73 +++++++++++++++++++ 5 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationStoreTest.kt diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationStore.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationStore.kt index 63f00bbd62..7351ec22cd 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationStore.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationStore.kt @@ -66,13 +66,18 @@ class PushRegistrationStore internal constructor( } /** - * How long ago [last] was sent, or null if unknown — no record, or one written before this slot - * existed. Purely local elapsed time measured against our own write ([WallTime] on both ends), never - * against a server timestamp, so no server-clock crossing is involved. + * How long ago [last] was sent, or null if unknown — no record, one written before this slot + * existed, or a device wall clock that has since been set backwards (the elapsed time would read + * negative, which would silence the [PUSH_REFRESH_INTERVAL] keep-alive until the clock caught back + * up — for a large jump, long enough for the server's 180-day prune to drop the device). Unknown + * counts as stale, so the caller re-POSTs and [record] restamps at the new clock, healing the jump. + * + * Purely local elapsed time measured against our own write ([WallTime] on both ends), never against + * a server timestamp, so no server-clock crossing is involved. */ fun sinceLastSent(): Duration? { val sentAtMs = prefs.getLong(KEY_SENT_AT, 0L).takeIf { it > 0L } ?: return null - return now() - WallTime(sentAtMs) + return (now() - WallTime(sentAtMs)).takeIf { !it.isNegative() } } /** Commits [registration] as the live record and stamps the refresh clock. */ @@ -93,6 +98,10 @@ class PushRegistrationStore internal constructor( /** Forgets the live record, so [last] reports nothing on record. */ fun clear() { prefs.setString(KEY_TOKEN, null) + // Dropped with the record it stamped (after the sentinel, so a torn clear still reads as "no + // record"): sinceLastSent() must not report an elapsed time for a registration last() says + // doesn't exist. + prefs.setLong(KEY_SENT_AT, 0L) } /** diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsScreen.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsScreen.kt index 19a2553375..e8b063d363 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsScreen.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsScreen.kt @@ -192,7 +192,14 @@ fun AdvancedSettingsScreen( ) SwitchPreferenceItem( title = stringResource(R.string.preferences_push_test_device_title), - summary = stringResource(R.string.preferences_push_test_device_summary), + // On-but-unnamed is the one state where the toggle silently has no effect (the + // device registers normally until a name is set — see PushRegistrationManager), + // so say exactly that instead of the generic description. + summary = if (state.pushTestDevice && state.pushTestDeviceName == null) { + stringResource(R.string.preferences_push_test_device_unnamed_summary) + } else { + stringResource(R.string.preferences_push_test_device_summary) + }, checked = state.pushTestDevice, onCheckedChange = onPushTestDevice ) @@ -203,6 +210,8 @@ fun AdvancedSettingsScreen( currentValue = state.pushTestDeviceName, hint = stringResource(R.string.preferences_push_test_device_name_title), onValueChange = onPushTestDeviceName, + // The name only matters while registering as a test device, so it follows the toggle. + enabled = state.pushTestDevice, keyboardType = KeyboardType.Text ) EditTextPreferenceItem( diff --git a/onebusaway-android/src/main/res/values/strings.xml b/onebusaway-android/src/main/res/values/strings.xml index 143547cc66..5d583400ee 100644 --- a/onebusaway-android/src/main/res/values/strings.xml +++ b/onebusaway-android/src/main/res/values/strings.xml @@ -1193,6 +1193,7 @@ Display test-wide alerts for regions Register as test device Register this device in the test-only audience for previewing service-alert push notifications. Requires a test device name. + Not in effect yet — set a test device name below. Until then, this device registers normally. Test device name Identifies this device to transit agency admins, for example “Sam’s Pixel 9”. Until this is set, the device registers normally instead of as a test device. No description available diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt index 5acbcf4579..4867721249 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt @@ -354,6 +354,22 @@ class PushRegistrationManagerTest { assertEquals(2, f.service.registerCalls.size) } + @Test + fun `a backwards device-clock jump refreshes once instead of starving the keep-alive`() = runTest { + val f = Fixture(this).registered("T1") + + // The clock is set back past the record's stamp: elapsed-since-sent would read negative, which + // must count as stale — otherwise the keep-alive stays silent until the clock catches back up, + // within reach of the 180-day prune for a large jump. + f.now -= 2.hours + f.sync() + assertEquals(2, f.service.registerCalls.size) + + // The refresh restamped at the new clock, so the next sync is quiet again. + f.sync() + assertEquals(2, f.service.registerCalls.size) + } + @Test fun `a missing region is treated as not-yet-known rather than an opt-out`() = runTest { val f = Fixture(this).registered("T1") diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationStoreTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationStoreTest.kt new file mode 100644 index 0000000000..a385dadca3 --- /dev/null +++ b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationStoreTest.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.push + +import kotlin.time.Duration.Companion.hours +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.onebusaway.android.testing.FakePreferencesRepository +import org.onebusaway.android.time.WallTime + +/** + * Unit tests for [PushRegistrationStore]'s clock edges — the [PushRegistrationStore.sinceLastSent] + * contract that [decidePushRegistration]'s keep-alive relies on. The reconcile → apply → persist loop + * as a whole is exercised in [PushRegistrationManagerTest]; these pin the store-level invariants that + * are invisible there because the decision layer happens to tolerate their violation today. + */ +class PushRegistrationStoreTest { + + private val registration = PushRegistration( + regionId = 1L, + sidecarBaseUrl = "https://sidecar.example.org", + token = "token-a", + locale = "en-US", + description = null + ) + + /** Mutable device clock, so a test can move it in either direction. */ + private var now = WallTime(1_000_000_000L) + + private val store = PushRegistrationStore(FakePreferencesRepository()) { now } + + @Test + fun `a backwards clock jump reads as unknown rather than a negative elapsed time`() { + store.record(registration) + + // The rider (or a bad network-time update) sets the device clock back: a negative elapsed time + // would keep the keep-alive comparison false until the clock caught back up — for a large jump, + // long enough for the server's 180-day prune to drop the device. + now -= 2.hours + assertNull("negative elapsed must read as unknown (= stale)", store.sinceLastSent()) + + // Once the clock is past the write again, elapsed time reads normally. + now += 3.hours + assertEquals(1.hours, store.sinceLastSent()) + } + + @Test + fun `clearing the record drops its sent-at timestamp with it`() { + store.record(registration) + now += 1.hours + + store.clear() + + assertNull(store.last()) + // The store must not report an elapsed-since-sent for a registration it says doesn't exist — + // otherwise a later record's freshness could be read against a dead record's stamp. + assertNull(store.sinceLastSent()) + } +} From 20a051bfa09f40413961b118293c165693cbec61 Mon Sep 17 00:00:00 2001 From: Brandon Martin-Anderson Date: Wed, 22 Jul 2026 05:31:17 +0000 Subject: [PATCH 17/26] Make the unresolved-vs-none push-registration distinction a compile-time property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../android/push/PushRegistrationDecision.kt | 132 +++++++++++++++--- .../android/push/PushRegistrationManager.kt | 65 +++------ .../push/DeriveDesiredRegistrationTest.kt | 125 +++++++++++++++++ .../push/PushRegistrationDecisionTest.kt | 34 ++--- .../push/PushRegistrationManagerTest.kt | 26 +++- 5 files changed, 293 insertions(+), 89 deletions(-) create mode 100644 onebusaway-android/src/test/java/org/onebusaway/android/push/DeriveDesiredRegistrationTest.kt diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt index d5cd80af14..1d01550ea0 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt @@ -17,6 +17,7 @@ package org.onebusaway.android.push import kotlin.time.Duration import kotlin.time.Duration.Companion.hours +import org.onebusaway.android.region.Region /** * A single push registration on record with OBACloud: the region it targets, the FCM token, and the @@ -71,6 +72,88 @@ fun PushRegistration.sameEndpoint(other: PushRegistration): Boolean = regionId = sidecarBaseUrl == other.sidecarBaseUrl && token == other.token +/** + * The registration this device should have, derived from the current inputs. Three-valued, not a + * nullable [PushRegistration], because "no registration" genuinely means two different things here and + * conflating them under one `null` is exactly how a stale registration leaks: an *unresolved* answer + * must freeze reconciliation (deciding on a half-read state fires spurious DELETEs), while a *resolved* + * "none" must drive it (a registration on record that shouldn't exist has to be unregistered). The + * split is enforced at the type level — [decidePushRegistration] accepts only [Resolved], so the only + * way to skip reconciling is an explicit match on [Unresolved]. + */ +sealed interface DesiredRegistration { + + /** + * An input that resolves asynchronously (the region flow, the FCM token) hasn't settled yet, so + * nothing can be decided — a later trigger re-runs the reconcile once it has. Never an opt-out: + * this state cannot reach [decidePushRegistration], so it can never DELETE anything. + */ + data object Unresolved : DesiredRegistration + + /** Every input has settled; only these states may be reconciled against the record. */ + sealed interface Resolved : DesiredRegistration + + /** + * Definitively, no registration should exist: the rider opted out, or the current region has no + * sidecar host to register with. Reconciling this *unregisters* whatever is on record — a rider who + * moves from a sidecar region to a sidecar-less one must stop receiving the old region's alerts. + */ + data object None : Resolved + + /** Definitively, [target] should be registered. */ + data class Wanted(val target: PushRegistration) : Resolved +} + +/** + * The pure derivation of [DesiredRegistration] from the raw inputs (issue #1957). Every early exit + * names which of the three answers it is — there is no `null` through which "this region has no + * sidecar" (a resolved fact, [DesiredRegistration.None]) can masquerade as "the region hasn't loaded + * yet" (an unresolved one) — see [DesiredRegistration] for why that distinction is load-bearing. + * Notifications off at the OS level is the **only opt-out signal** (there is no in-app toggle), and it + * is definitive regardless of every other input. + * + * The test-device [description][PushRegistration.description] is honoured only once the rider has + * named the device: the server rejects a `test_device=true` registration with a blank `description` + * (422), so an unnamed device registers as an ordinary one rather than POSTing a request guaranteed to + * fail. The iOS client gates its "Test Device Name" the same way — and [PushRegistration.testDevice] + * derives the flag from the name, so that rule holds by construction. The name is capped when it is + * written (`AdvancedSettingsViewModel.onPushTestDeviceNameChanged`); the [PUSH_DESCRIPTION_MAX_LENGTH] + * clamp here is a cheap guard on the wire boundary itself, so no path can put an over-long + * `description` on a request the server would reject outright. + * + * @param region null while the region flow hasn't resolved yet (it is briefly null at cold start — + * see `RegionRepository`). + * @param token the FCM token, or `""` while one hasn't been obtained yet (`userPushId()`'s contract). + * @param locale the device's BCP-47 tag, sent as-is with no normalization. + */ +fun deriveDesiredRegistration( + notificationsEnabled: Boolean, + region: Region?, + token: String, + locale: String, + testDeviceEnabled: Boolean, + testDeviceName: String? +): DesiredRegistration { + if (!notificationsEnabled) return DesiredRegistration.None + if (region == null) return DesiredRegistration.Unresolved + val base = region.sidecarBaseUrl?.takeIf { it.isNotBlank() } ?: return DesiredRegistration.None + if (token.isEmpty()) return DesiredRegistration.Unresolved + val description = testDeviceName + ?.takeIf { testDeviceEnabled } + ?.trim() + ?.take(PUSH_DESCRIPTION_MAX_LENGTH) + ?.takeIf { it.isNotEmpty() } + return DesiredRegistration.Wanted( + PushRegistration( + regionId = region.id, + sidecarBaseUrl = base, + token = token, + locale = locale, + description = description + ) + ) +} + /** * What the registrar should do to reconcile the [PushRegistration] currently on record with the one the * device now wants (issue #1957). @@ -97,35 +180,40 @@ sealed interface PushRegistrationAction { } /** - * The pure reconciliation decision. [target] is the registration the device wants right now, or null - * when the rider has opted out. [last] is the registration last successfully sent to the server, or - * null if none. [sinceLastSent] is how long ago [last] was sent, or null when that is unknown (no - * record, or a record written by a build that predates the timestamp) — unknown counts as stale, since - * a redundant upsert is harmless and losing the device to the 180-day prune is not. - * - * A null [target] means the rider opted out, never "the inputs haven't resolved yet" — see the guard - * in `PushRegistrationManager.sync`. + * The pure reconciliation decision. [desired] is the registration the device should have — typed as + * [DesiredRegistration.Resolved], so an unresolved input state cannot reach this function and be + * mistaken for either an opt-out or a wanted registration. [last] is the registration last + * successfully sent to the server, or null if none. [sinceLastSent] is how long ago [last] was sent, + * or null when that is unknown (no record, or a record written by a build that predates the + * timestamp) — unknown counts as stale, since a redundant upsert is harmless and losing the device to + * the 180-day prune is not. * * Keeping this a pure function (no clock, no I/O, no Android — the caller supplies the elapsed time) * is deliberate: it is the one place the register/refresh/unregister rules live, and it is * exhaustively unit-tested. */ fun decidePushRegistration( - target: PushRegistration?, + desired: DesiredRegistration.Resolved, last: PushRegistration?, sinceLastSent: Duration? -): PushRegistrationAction = when { - target == null -> if (last == null) PushRegistrationAction.NoOp else PushRegistrationAction.Unregister(last) - last == null -> PushRegistrationAction.Register(target) - // Unchanged, but the server's last_seen_at needs refreshing before the 180-day prune reaches it. - target == last -> - if (sinceLastSent == null || sinceLastSent >= PUSH_REFRESH_INTERVAL) { - PushRegistrationAction.Register(target) - } else { - PushRegistrationAction.NoOp +): PushRegistrationAction = when (desired) { + DesiredRegistration.None -> + if (last == null) PushRegistrationAction.NoOp else PushRegistrationAction.Unregister(last) + is DesiredRegistration.Wanted -> { + val target = desired.target + when { + last == null -> PushRegistrationAction.Register(target) + // Unchanged, but the server's last_seen_at needs refreshing before the 180-day prune reaches it. + target == last -> + if (sinceLastSent == null || sinceLastSent >= PUSH_REFRESH_INTERVAL) { + PushRegistrationAction.Register(target) + } else { + PushRegistrationAction.NoOp + } + // Same endpoint, only metadata (locale / test flag / description) changed → a plain re-POST upserts it. + target.sameEndpoint(last) -> PushRegistrationAction.Register(target) + // Token or region changed → clean up the stale registration, then register the new one. + else -> PushRegistrationAction.Reregister(previous = last, target = target) } - // Same endpoint, only metadata (locale / test flag / description) changed → a plain re-POST upserts it. - target.sameEndpoint(last) -> PushRegistrationAction.Register(target) - // Token or region changed → clean up the stale registration, then register the new one. - else -> PushRegistrationAction.Reregister(previous = last, target = target) + } } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt index c49a3911cd..f04e95ff0b 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt @@ -42,9 +42,10 @@ import org.onebusaway.android.region.RegionRepository * * A reconciler, in three parts: * - * 1. **Desired state** ([buildTarget]) — derived from whether notifications are enabled at the OS level - * (the opt-in signal; there is no separate in-app toggle), the current region (its id + sidecar - * host), the FCM token, the device locale, and the test-device settings. + * 1. **Desired state** ([deriveDesiredRegistration]) — a pure classification of the raw inputs (OS + * notifications-enabled — the opt-in signal; there is no separate in-app toggle — the current + * region, the FCM token, the device locale, and the test-device settings) into wanted / none / + * not-yet-resolved. This class only gathers the inputs. * 2. **The decision** ([decidePushRegistration]) — a pure function comparing desired against the record * of what was last sent, yielding register / re-register / unregister / no-op. * 3. **Applying it** — this class, which owns only the rule for *what to record given which calls @@ -137,19 +138,19 @@ class PushRegistrationManager internal constructor( // didn't, before reconciling, so the stale registration can't linger past this pass. drainPendingDelete() - // Notifications being off is the ONLY definitive "this device should not be registered" signal. - // A missing region or FCM token just means we can't tell yet — both resolve asynchronously, and - // the region flow is explicitly "briefly null at cold start" (see RegionRepository) — so treating - // that as an opt-out would fire a spurious DELETE on launch and re-POST seconds later, burning - // 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 - } else { - null - } + val desired = deriveDesiredRegistration( + notificationsEnabled = notificationsEnabled(), + region = regionRepository.region.value, + token = firebaseMessagingManager.userPushId(), + locale = Locale.getDefault().toLanguageTag(), + testDeviceEnabled = prefs.getBoolean(R.string.preference_key_push_test_device, false), + testDeviceName = prefs.getString(R.string.preference_key_push_test_device_name, null) + ) + // Unresolved (an async input hasn't settled) is the only state that may skip the decision — a + // later trigger re-runs us. See DesiredRegistration for why it must never be read as an opt-out. + if (desired !is DesiredRegistration.Resolved) return@withLock - when (val action = decidePushRegistration(target, store.last(), store.sinceLastSent())) { + when (val action = decidePushRegistration(desired, store.last(), store.sinceLastSent())) { PushRegistrationAction.NoOp -> Unit is PushRegistrationAction.Register -> if (client.register(action.target)) store.record(action.target) @@ -188,38 +189,4 @@ class PushRegistrationManager internal constructor( val pending = store.pendingDelete() ?: return if (client.unregister(pending)) store.clearPendingDelete() } - - /** - * The registration this device wants, or null when the inputs aren't all resolved yet (no region, - * no sidecar host, or no FCM token) — never an opt-out; see [sync]. The locale is the device's - * BCP-47 tag, sent as-is with no normalization. - * - * The test-device flag is honoured only once the rider has named the device: the server rejects a - * `test_device=true` registration with a blank `description` (422), so an unnamed device registers - * as an ordinary one rather than POSTing a request guaranteed to fail. The iOS client gates its - * "Test Device Name" the same way — and [PushRegistration.testDevice] derives the flag from the - * name, so that rule holds by construction. - * - * The name is capped when it is written (`AdvancedSettingsViewModel.onPushTestDeviceNameChanged`), - * which is what keeps the stored value legal; the [PUSH_DESCRIPTION_MAX_LENGTH] clamp here is a - * cheap guard on the wire boundary itself, so no path can put an over-long `description` on a - * request the server would reject outright. - */ - private fun buildTarget(): PushRegistration? { - val region = regionRepository.region.value ?: return null - val base = region.sidecarBaseUrl?.takeIf { it.isNotBlank() } ?: return null - 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) - ?.takeIf { it.isNotEmpty() } - ?.takeIf { prefs.getBoolean(R.string.preference_key_push_test_device, false) } - return PushRegistration( - regionId = region.id, - sidecarBaseUrl = base, - token = token, - locale = Locale.getDefault().toLanguageTag(), - description = description - ) - } } diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/push/DeriveDesiredRegistrationTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/push/DeriveDesiredRegistrationTest.kt new file mode 100644 index 0000000000..71f1587674 --- /dev/null +++ b/onebusaway-android/src/test/java/org/onebusaway/android/push/DeriveDesiredRegistrationTest.kt @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.push + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.onebusaway.android.region.Region +import org.onebusaway.android.region.region + +/** + * Unit tests for [deriveDesiredRegistration] — the pure classification of the raw inputs into + * wanted / none / not-yet-resolved. The load-bearing distinction is [DesiredRegistration.None] vs + * [DesiredRegistration.Unresolved]: `None` reconciles a stale registration away, `Unresolved` freezes + * reconciliation until the input settles, and collapsing the two (a nullable target) is exactly the + * bug that let a region-A registration outlive a switch to a sidecar-less region B. + */ +class DeriveDesiredRegistrationTest { + + private val sidecarRegion: Region = region(1).copy(sidecarBaseUrl = "https://sidecar.example.org") + + private fun derive( + notificationsEnabled: Boolean = true, + region: Region? = sidecarRegion, + token: String = "token-a", + locale: String = "en-US", + testDeviceEnabled: Boolean = false, + testDeviceName: String? = null + ) = deriveDesiredRegistration( + notificationsEnabled = notificationsEnabled, + region = region, + token = token, + locale = locale, + testDeviceEnabled = testDeviceEnabled, + testDeviceName = testDeviceName + ) + + @Test + fun `all inputs resolved yields the wanted registration`() { + assertEquals( + DesiredRegistration.Wanted( + PushRegistration( + regionId = 1L, + sidecarBaseUrl = "https://sidecar.example.org", + token = "token-a", + locale = "en-US", + description = null + ) + ), + derive() + ) + } + + @Test + fun `notifications off is a definitive opt-out regardless of the other inputs`() { + assertEquals(DesiredRegistration.None, derive(notificationsEnabled = false)) + // Definitive even while the async inputs are still settling: opting out never waits. + assertEquals(DesiredRegistration.None, derive(notificationsEnabled = false, region = null, token = "")) + } + + @Test + fun `a missing region is unresolved, not an opt-out`() { + // The region flow is briefly null at cold start (RegionRepository seeds it asynchronously); + // reading that as None would DELETE the registration and re-POST it seconds later. + assertEquals(DesiredRegistration.Unresolved, derive(region = null)) + } + + @Test + fun `a region without a sidecar host is definitively none, not unresolved`() { + // The regression this type exists to prevent: a resolved region whose sidecarBaseUrl is blank + // (the field defaults to "") is a fact — there is nowhere to be registered — so a registration + // left over from a sidecar region must be reconciled away, not frozen in place. + assertEquals(DesiredRegistration.None, derive(region = region(2).copy(sidecarBaseUrl = ""))) + assertEquals(DesiredRegistration.None, derive(region = region(2).copy(sidecarBaseUrl = null))) + } + + @Test + fun `a missing FCM token is unresolved`() { + assertEquals(DesiredRegistration.Unresolved, derive(token = "")) + } + + @Test + fun `a named test device carries its description`() { + val desired = derive(testDeviceEnabled = true, testDeviceName = "Sam's Pixel") + assertEquals("Sam's Pixel", (desired as DesiredRegistration.Wanted).target.description) + assertEquals(true, desired.target.testDevice) + } + + @Test + fun `an unnamed test device downgrades to an ordinary registration`() { + // The server 422s test_device=true with a blank description, so an unnamed device must + // register as ordinary rather than POST a request guaranteed to fail (matching iOS). + for (name in listOf(null, "", " ")) { + val desired = derive(testDeviceEnabled = true, testDeviceName = name) + assertEquals(null, (desired as DesiredRegistration.Wanted).target.description) + } + } + + @Test + fun `a device name without the test-device flag is not sent`() { + val desired = derive(testDeviceEnabled = false, testDeviceName = "Sam's Pixel") + assertEquals(null, (desired as DesiredRegistration.Wanted).target.description) + } + + @Test + fun `an over-long device name is truncated to the server's limit`() { + val desired = derive(testDeviceEnabled = true, testDeviceName = "N".repeat(300)) + assertEquals( + "N".repeat(PUSH_DESCRIPTION_MAX_LENGTH), + (desired as DesiredRegistration.Wanted).target.description + ) + } +} diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationDecisionTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationDecisionTest.kt index f83d6d2664..3cc80ec7b8 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationDecisionTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationDecisionTest.kt @@ -21,11 +21,13 @@ import kotlin.time.Duration.Companion.hours import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test +import org.onebusaway.android.push.DesiredRegistration.None +import org.onebusaway.android.push.DesiredRegistration.Wanted /** * Unit tests for [decidePushRegistration] — the pure register/refresh/unregister reconciliation for OBACloud push - * registration (#1957). Covers each transition between the desired registration ([target]) and the one - * last sent to the server ([last]), plus the [PUSH_REFRESH_INTERVAL] keep-alive. + * registration (#1957). Covers each transition between the resolved desired state ([DesiredRegistration.Resolved]) + * and the registration last sent to the server, plus the [PUSH_REFRESH_INTERVAL] keep-alive. */ class PushRegistrationDecisionTest { @@ -41,35 +43,35 @@ class PushRegistrationDecisionTest { private val fresh: Duration = 1.hours private fun decide( - target: PushRegistration?, + desired: DesiredRegistration.Resolved, last: PushRegistration?, sinceLastSent: Duration? = fresh - ) = decidePushRegistration(target, last, sinceLastSent) + ) = decidePushRegistration(desired, last, sinceLastSent) @Test fun `nothing desired and nothing on record is a no-op`() { - assertEquals(PushRegistrationAction.NoOp, decide(target = null, last = null)) + assertEquals(PushRegistrationAction.NoOp, decide(None, last = null)) } @Test fun `first registration when nothing is on record`() { - assertEquals(PushRegistrationAction.Register(base), decide(target = base, last = null)) + assertEquals(PushRegistrationAction.Register(base), decide(Wanted(base), last = null)) } @Test fun `opting out unregisters the recorded registration`() { - assertEquals(PushRegistrationAction.Unregister(base), decide(target = null, last = base)) + assertEquals(PushRegistrationAction.Unregister(base), decide(None, last = base)) } @Test fun `unchanged registration is a no-op`() { - assertEquals(PushRegistrationAction.NoOp, decide(target = base, last = base)) + assertEquals(PushRegistrationAction.NoOp, decide(Wanted(base), last = base)) } @Test fun `a locale change re-posts on the same token and region`() { val target = base.copy(locale = "es-MX") - assertEquals(PushRegistrationAction.Register(target), decide(target = target, last = base)) + assertEquals(PushRegistrationAction.Register(target), decide(Wanted(target), last = base)) } @Test @@ -77,7 +79,7 @@ class PushRegistrationDecisionTest { // Naming the device is what promotes it: testDevice derives from the description. val target = base.copy(description = "Sam's Pixel") assertTrue("naming the device must promote it to a test device", target.testDevice) - assertEquals(PushRegistrationAction.Register(target), decide(target = target, last = base)) + assertEquals(PushRegistrationAction.Register(target), decide(Wanted(target), last = base)) } @Test @@ -86,11 +88,11 @@ class PushRegistrationDecisionTest { // prune silently drops a device whose token, region, locale and flags simply never change. assertEquals( PushRegistrationAction.Register(base), - decide(target = base, last = base, sinceLastSent = PUSH_REFRESH_INTERVAL) + decide(Wanted(base), last = base, sinceLastSent = PUSH_REFRESH_INTERVAL) ) assertEquals( PushRegistrationAction.Register(base), - decide(target = base, last = base, sinceLastSent = 30.days) + decide(Wanted(base), last = base, sinceLastSent = 30.days) ) } @@ -98,7 +100,7 @@ class PushRegistrationDecisionTest { fun `an unchanged registration is left alone just inside the refresh interval`() { assertEquals( PushRegistrationAction.NoOp, - decide(target = base, last = base, sinceLastSent = PUSH_REFRESH_INTERVAL - 1.hours) + decide(Wanted(base), last = base, sinceLastSent = PUSH_REFRESH_INTERVAL - 1.hours) ) } @@ -108,7 +110,7 @@ class PushRegistrationDecisionTest { // the device to the prune is not, so unknown must re-post rather than no-op. assertEquals( PushRegistrationAction.Register(base), - decide(target = base, last = base, sinceLastSent = null) + decide(Wanted(base), last = base, sinceLastSent = null) ) } @@ -117,7 +119,7 @@ class PushRegistrationDecisionTest { val target = base.copy(token = "token-b") assertEquals( PushRegistrationAction.Reregister(previous = base, target = target), - decide(target = target, last = base) + decide(Wanted(target), last = base) ) } @@ -126,7 +128,7 @@ class PushRegistrationDecisionTest { val target = base.copy(regionId = 2L, sidecarBaseUrl = "https://other.example.org") assertEquals( PushRegistrationAction.Reregister(previous = base, target = target), - decide(target = target, last = base) + decide(Wanted(target), last = base) ) } } diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt index 4867721249..6328796303 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt @@ -55,7 +55,7 @@ class PushRegistrationManagerTest { @Before fun pinLocale() { - // buildTarget() sends Locale.getDefault().toLanguageTag(); pin it so the assertion is stable. + // sync() sends Locale.getDefault().toLanguageTag(); pin it so the assertion is stable. previousLocale = Locale.getDefault() Locale.setDefault(Locale.US) } @@ -388,6 +388,28 @@ class PushRegistrationManagerTest { assertEquals(1, f.service.registerCalls.size) } + @Test + fun `switching to a region without a sidecar unregisters the stale registration`() = runTest { + val f = Fixture(this).registered("T1") + + // Region 2 has no sidecar host (the field defaults to ""). Unlike the null region above, this + // is a *resolved* fact, not a still-settling input — so the region-1 registration must be + // reconciled away, or the device keeps receiving region 1's alerts (and no region-2 ones) + // until the server's 180-day prune. + f.regions.emit(region(2).copy(sidecarBaseUrl = "")) + f.sync() + + val delete = f.service.unregisterCalls.single() + assertEquals("T1", delete.token) + // The DELETE targets the *old* registration's host — the current region has none. + assertTrue(delete.url, delete.url.startsWith("https://sidecar.test")) + + // Record cleared and nothing to register → settled: no re-POST, no duplicate DELETE. + f.sync() + assertEquals(1, f.service.unregisterCalls.size) + assertEquals(1, f.service.registerCalls.size) + } + @Test fun `an HTTP error response is logged and reported, not silently swallowed`() = runTest { val f = Fixture(this) @@ -434,7 +456,7 @@ class PushRegistrationManagerTest { val service = FakePushRegistrationWebService() val prefs = FakePreferencesRepository().apply { setBoolean(R.string.preference_key_push_test_device, true) - // Named, so the test-device flag is honoured rather than downgraded (see buildTarget). + // Named, so the test-device flag is honoured rather than downgraded (see deriveDesiredRegistration). setString(R.string.preference_key_push_test_device_name, DEVICE_DESCRIPTION) } val regions = FakeRegionRepository(region(1).copy(sidecarBaseUrl = "https://sidecar.test")) From 8a8f67a11520475fb73c07a66112e060023ff931 Mon Sep 17 00:00:00 2001 From: Brandon Martin-Anderson Date: Wed, 22 Jul 2026 05:45:52 +0000 Subject: [PATCH 18/26] Persist multi-slot push records atomically via a PreferencesRepository batch edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../database/oba/LegacyDataImporterTest.kt | 2 + ...PreferencesRepositoryReadYourWritesTest.kt | 45 ++++++++++- .../preferences/PreferencesRepository.kt | 77 ++++++++++++++++--- .../android/push/PushRegistrationStore.kt | 67 ++++++++-------- .../testing/FakePreferencesRepository.kt | 5 ++ 5 files changed, 153 insertions(+), 43 deletions(-) diff --git a/onebusaway-android/src/androidTest/java/org/onebusaway/android/database/oba/LegacyDataImporterTest.kt b/onebusaway-android/src/androidTest/java/org/onebusaway/android/database/oba/LegacyDataImporterTest.kt index 09f26894a3..ca5f54a6c2 100644 --- a/onebusaway-android/src/androidTest/java/org/onebusaway/android/database/oba/LegacyDataImporterTest.kt +++ b/onebusaway-android/src/androidTest/java/org/onebusaway/android/database/oba/LegacyDataImporterTest.kt @@ -32,6 +32,7 @@ import org.onebusaway.android.database.AppDatabase import org.onebusaway.android.database.survey.entity.Study import org.onebusaway.android.database.survey.entity.Survey import org.onebusaway.android.database.widealerts.entity.AlertEntity +import org.onebusaway.android.preferences.PreferencesEditor import org.onebusaway.android.preferences.PreferencesRepository /** @@ -381,4 +382,5 @@ private object NoopPreferences : PreferencesRepository { override fun setLong(key: String, value: Long) = Unit override fun setFloat(keyRes: Int, value: Float) = Unit override fun setFloat(key: String, value: Float) = Unit + override fun edit(block: PreferencesEditor.() -> Unit) = block(this) } diff --git a/onebusaway-android/src/androidTest/java/org/onebusaway/android/preferences/PreferencesRepositoryReadYourWritesTest.kt b/onebusaway-android/src/androidTest/java/org/onebusaway/android/preferences/PreferencesRepositoryReadYourWritesTest.kt index e988f108b3..dc226ce786 100644 --- a/onebusaway-android/src/androidTest/java/org/onebusaway/android/preferences/PreferencesRepositoryReadYourWritesTest.kt +++ b/onebusaway-android/src/androidTest/java/org/onebusaway/android/preferences/PreferencesRepositoryReadYourWritesTest.kt @@ -18,6 +18,7 @@ package org.onebusaway.android.preferences import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.preferencesOf import androidx.datastore.preferences.core.stringPreferencesKey import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -39,6 +40,10 @@ import org.junit.runner.RunWith * the cache could revert a newer optimistic write when a *stale* emission of an earlier write arrived * after it — so `setX(B)` immediately followed by `getX()` could return an earlier value `A`. This is * what made the region/custom-URL instrumented tests flake (`expected api.tampa… but was api.pugetsound…`). + * + * Also pins the [PreferencesRepository.edit] batch contract (#1978): all staged keys land in a single + * DataStore commit — the atomicity that multi-slot records (e.g. `PushRegistrationStore`) rely on to + * never tear across a process death. */ @RunWith(AndroidJUnit4::class) class PreferencesRepositoryReadYourWritesTest { @@ -64,6 +69,36 @@ class PreferencesRepositoryReadYourWritesTest { assertEquals("second", repo.getString(key, null)) } + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun edit_commitsAllStagedKeysInOneDataStoreUpdate() = runTest { + val dataStore = FakeDataStore() + val repo = DefaultPreferencesRepository(InstrumentationRegistry.getInstrumentation().targetContext, dataStore, backgroundScope) + runCurrent() + repo.setString("preexisting", "value") + runCurrent() + val updatesBefore = dataStore.updateCount + + repo.edit { + setString("record_a", "a") + setLong("record_b", 7L) + setString("preexisting", null) // null removes, same as setString + } + + // Read-your-writes holds across the whole batch, synchronously. + assertEquals("a", repo.getString("record_a", null)) + assertEquals(7L, repo.getLong("record_b", 0L)) + assertEquals(null, repo.getString("preexisting", null)) + + // The batch persists as ONE commit — the atomicity multi-slot records depend on — and the + // committed state carries every staged key. + runCurrent() + assertEquals(1, dataStore.updateCount - updatesBefore) + assertEquals("a", dataStore.committed[stringPreferencesKey("record_a")]) + assertEquals(7L, dataStore.committed[longPreferencesKey("record_b")]) + assertEquals(null, dataStore.committed[stringPreferencesKey("preexisting")]) + } + /** * A [DataStore] test double whose committed-state emissions are driven by the test (via [emit]) * rather than emitted automatically on [updateData] — so a test can reproduce DataStore's real commit @@ -72,8 +107,15 @@ class PreferencesRepositoryReadYourWritesTest { private class FakeDataStore(initial: Preferences = emptyPreferences()) : DataStore { private val emissions = MutableSharedFlow(replay = 1, extraBufferCapacity = 64) + /** The state the last [updateData] committed. */ + @Volatile + var committed: Preferences = initial + private set + + /** Number of [updateData] commits so far — one per DataStore edit, however many keys it carries. */ @Volatile - private var committed = initial + var updateCount = 0 + private set init { emissions.tryEmit(initial) @@ -83,6 +125,7 @@ class PreferencesRepositoryReadYourWritesTest { override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences { committed = transform(committed) + updateCount++ return committed // deliberately does not emit; the test drives [emit] to model commit latency } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/preferences/PreferencesRepository.kt b/onebusaway-android/src/main/java/org/onebusaway/android/preferences/PreferencesRepository.kt index 5a68b678a0..2d3da633b9 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/preferences/PreferencesRepository.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/preferences/PreferencesRepository.kt @@ -18,6 +18,7 @@ package org.onebusaway.android.preferences import android.content.Context import androidx.annotation.StringRes import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.MutablePreferences import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit @@ -37,6 +38,22 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.onebusaway.android.app.di.AppScope +/** + * The String-keyed setters, split out as the receiver of [PreferencesRepository.edit] so a batch block + * is statically restricted to staging writes. [PreferencesRepository] extends this, so the same methods + * double as the repository's one-shot String-keyed setters. The `@StringRes` key form is deliberately + * omitted here until a batch caller needs it — batching so far only involves const-string record slots. + */ +interface PreferencesEditor { + fun setBoolean(key: String, value: Boolean) + + /** Null removes the key. */ + fun setString(key: String, value: String?) + fun setInt(key: String, value: Int) + fun setLong(key: String, value: Long) + fun setFloat(key: String, value: Float) +} + /** * Injected access to user preferences — the single seam in front of the persisted store (replacing * scattered `Application.getPrefs()` / `PreferenceUtils` reads). @@ -49,9 +66,10 @@ import org.onebusaway.android.app.di.AppScope * Every accessor comes in two key forms. The `@StringRes` overload lets a caller name a pref by its * resource id and stay Context-free / JVM-testable (the implementation resolves it); the `String` * overload handles keys that are const or runtime strings rather than resources (e.g. the - * region-version slots, a map layer's preference key). + * region-version slots, a map layer's preference key). The String-keyed setters are inherited from + * [PreferencesEditor], which also serves as [edit]'s batch receiver. */ -interface PreferencesRepository { +interface PreferencesRepository : PreferencesEditor { /** Emits the current value of the boolean pref [keyRes] and re-emits on every change. */ fun observeBoolean(@StringRes keyRes: Int, default: Boolean): Flow @@ -98,19 +116,28 @@ interface PreferencesRepository { } fun setBoolean(@StringRes keyRes: Int, value: Boolean) - fun setBoolean(key: String, value: Boolean) fun setString(@StringRes keyRes: Int, value: String?) - fun setString(key: String, value: String?) fun setInt(@StringRes keyRes: Int, value: Int) - fun setInt(key: String, value: Int) fun setLong(@StringRes keyRes: Int, value: Long) - fun setLong(key: String, value: Long) fun setFloat(@StringRes keyRes: Int, value: Float) - fun setFloat(key: String, value: Float) + + /** + * Applies every write staged in [block] as **one commit** — for [DefaultPreferencesRepository] a + * single cache swap and a single DataStore edit (one file rewrite + fsync, one `data` emission) + * instead of one per key (#1978). That makes the batch atomic: a multi-slot record written through + * here persists whole or not at all across a process death, where the same slots written via + * individual `setX` calls could tear. Use it whenever several slots form one logical record; + * independent single writes should keep using `setX`. + * + * Deliberately has no default body: the atomicity IS the contract, so each implementation must + * decide how it honors it rather than silently inheriting a per-key replay that can tear. An + * in-memory test fake, which has no commit to tear, honestly implements it as `block(this)`. + */ + fun edit(block: PreferencesEditor.() -> Unit) companion object { /** Preference key for the persisted app-launch counter. Preserves the original value so counts @@ -198,13 +225,39 @@ class DefaultPreferencesRepository @Inject constructor( override fun setFloat(keyRes: Int, value: Float) = setFloat(context.getString(keyRes), value) override fun setFloat(key: String, value: Float) = put(floatPreferencesKey(key), value) + override fun edit(block: PreferencesEditor.() -> Unit) { + val ops = mutableListOf<(MutablePreferences) -> Unit>() + object : PreferencesEditor { + override fun setBoolean(key: String, value: Boolean) { + ops += stage(booleanPreferencesKey(key), value) + } + override fun setString(key: String, value: String?) { + ops += stage(stringPreferencesKey(key), value) + } + override fun setInt(key: String, value: Int) { + ops += stage(intPreferencesKey(key), value) + } + override fun setLong(key: String, value: Long) { + ops += stage(longPreferencesKey(key), value) + } + override fun setFloat(key: String, value: Float) { + ops += stage(floatPreferencesKey(key), value) + } + }.block() + commit { prefs -> ops.forEach { it(prefs) } } + } + /** Apply [value] (or remove the key when null) to the cache immediately, then persist async. */ - private fun put(key: Preferences.Key, value: T?) { - cache = cache.toMutablePreferences().apply { - if (value == null) remove(key) else set(key, value) - }.toPreferences() + private fun put(key: Preferences.Key, value: T?) = commit(stage(key, value)) + + /** A staged write: sets [value] (null removes the key) on whatever preferences it is applied to. */ + private fun stage(key: Preferences.Key, value: T?): (MutablePreferences) -> Unit = { prefs -> if (value == null) prefs.remove(key) else prefs[key] = value } + + /** Apply [op] to the cache in one swap, then persist it as one async DataStore edit. */ + private fun commit(op: (MutablePreferences) -> Unit) { + cache = cache.toMutablePreferences().also(op).toPreferences() scope.launch { - dataStore.edit { prefs -> if (value == null) prefs.remove(key) else prefs[key] = value } + dataStore.edit { prefs -> op(prefs) } } } } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationStore.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationStore.kt index 7351ec22cd..0ecc0b7ed9 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationStore.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationStore.kt @@ -18,6 +18,7 @@ package org.onebusaway.android.push import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration +import org.onebusaway.android.preferences.PreferencesEditor import org.onebusaway.android.preferences.PreferencesRepository import org.onebusaway.android.time.WallTime @@ -32,15 +33,12 @@ import org.onebusaway.android.time.WallTime * - **A pending delete** ([pendingDelete]) — a registration whose DELETE is still owed. See * [queuePendingDelete]. * - * Each record spans several preference slots, so one of them — the token — acts as a **commit - * sentinel**: writes store it last and clears null it, and a read returns nothing unless it is present. - * A half-written record therefore reads back as "no record" rather than as a corrupt one. - * - * That ordering is exact for in-process reads (the prefs cache updates synchronously, in call order) - * but each slot persists to disk as its own async DataStore edit, so it is NOT guaranteed across a - * process death mid-persist. Acceptable by design: every torn state lands on a safe path — a missing - * sentinel reads as "no record" and re-registration is an idempotent upsert, while a stale record's - * DELETE is answered with a tolerated 404. No recovery path depends on the sentinel being durably last. + * Each record spans several preference slots, with the token as the **presence marker**: a read + * returns nothing unless it is present, and clearing a record nulls it. Every multi-slot write goes + * through [PreferencesRepository.edit], which commits all of a record's slots atomically — in process + * and on disk (#1978) — so a record persists whole or not at all across a process death. A record + * whose marker is present but whose addressing fields are missing (which for a pending delete would + * aim its DELETE at region -1, whose 404 reads as success) therefore cannot exist. */ @Singleton class PushRegistrationStore internal constructor( @@ -82,26 +80,29 @@ class PushRegistrationStore internal constructor( /** Commits [registration] as the live record and stamps the refresh clock. */ fun record(registration: PushRegistration) { - prefs.setLong(KEY_REGION_ID, registration.regionId) - prefs.setString(KEY_BASE, registration.sidecarBaseUrl) - prefs.setString(KEY_LOCALE, registration.locale) - prefs.setString(KEY_DESCRIPTION, registration.description) - prefs.setLong(KEY_SENT_AT, now().epochMs) - // The commit sentinel, written last. - prefs.setString(KEY_TOKEN, registration.token) - // This endpoint is live again, so drop any DELETE still owed for it — otherwise returning to an - // old endpoint (region/token switched away, then back) would delete the row we just POSTed. - // This is what makes a single pending-delete slot safe. - if (pendingDelete()?.sameEndpoint(registration) == true) clearPendingDelete() + // This endpoint is live again, so the same commit drops any DELETE still owed for it — otherwise + // returning to an old endpoint (region/token switched away, then back) would delete the row we + // just POSTed. This is what makes a single pending-delete slot safe. + val dropsPendingDelete = pendingDelete()?.sameEndpoint(registration) == true + prefs.edit { + setLong(KEY_REGION_ID, registration.regionId) + setString(KEY_BASE, registration.sidecarBaseUrl) + setString(KEY_LOCALE, registration.locale) + setString(KEY_DESCRIPTION, registration.description) + setLong(KEY_SENT_AT, now().epochMs) + setString(KEY_TOKEN, registration.token) + if (dropsPendingDelete) stagePendingDeleteClear() + } } /** Forgets the live record, so [last] reports nothing on record. */ fun clear() { - prefs.setString(KEY_TOKEN, null) - // Dropped with the record it stamped (after the sentinel, so a torn clear still reads as "no - // record"): sinceLastSent() must not report an elapsed time for a registration last() says - // doesn't exist. - prefs.setLong(KEY_SENT_AT, 0L) + prefs.edit { + setString(KEY_TOKEN, null) + // Dropped with the record it stamped: sinceLastSent() must not report an elapsed time for a + // registration last() says doesn't exist. + setLong(KEY_SENT_AT, 0L) + } } /** @@ -116,10 +117,11 @@ class PushRegistrationStore internal constructor( * and the loser still falls back to the server prune. */ fun queuePendingDelete(registration: PushRegistration) { - prefs.setLong(KEY_PENDING_DEL_REGION_ID, registration.regionId) - prefs.setString(KEY_PENDING_DEL_BASE, registration.sidecarBaseUrl) - // The commit sentinel, written last — mirroring [record]. - prefs.setString(KEY_PENDING_DEL_TOKEN, registration.token) + prefs.edit { + setLong(KEY_PENDING_DEL_REGION_ID, registration.regionId) + setString(KEY_PENDING_DEL_BASE, registration.sidecarBaseUrl) + setString(KEY_PENDING_DEL_TOKEN, registration.token) + } } /** The registration awaiting a retried DELETE, or null if none is queued. */ @@ -137,7 +139,12 @@ class PushRegistrationStore internal constructor( } fun clearPendingDelete() { - prefs.setString(KEY_PENDING_DEL_TOKEN, null) + prefs.edit { stagePendingDeleteClear() } + } + + /** The one place that knows how a queued pending delete is dropped: null its presence marker. */ + private fun PreferencesEditor.stagePendingDeleteClear() { + setString(KEY_PENDING_DEL_TOKEN, null) } private companion object { diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/testing/FakePreferencesRepository.kt b/onebusaway-android/src/test/java/org/onebusaway/android/testing/FakePreferencesRepository.kt index 5735bbfa94..feddd7b086 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/testing/FakePreferencesRepository.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/testing/FakePreferencesRepository.kt @@ -18,6 +18,7 @@ package org.onebusaway.android.testing import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf +import org.onebusaway.android.preferences.PreferencesEditor import org.onebusaway.android.preferences.PreferencesRepository /** @@ -101,4 +102,8 @@ class FakePreferencesRepository(private val observeValue: Boolean = true) : Pref override fun setFloat(key: String, value: Float) { values[key] = value } + + // In-memory, so there is no commit to tear: replaying the block through the setters honors edit's + // atomicity contract exactly. + override fun edit(block: PreferencesEditor.() -> Unit) = block(this) } From 2812ca26d30fa2f5918867a5ecc36667f52d8f0e Mon Sep 17 00:00:00 2001 From: Brandon Martin-Anderson Date: Wed, 22 Jul 2026 06:05:54 +0000 Subject: [PATCH 19/26] Keep transient push-registration failures (429/5xx) out of Crashlytics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../android/push/PushRegistrationClient.kt | 26 ++++++++++++++----- .../push/PushRegistrationManagerTest.kt | 25 +++++++++++++++++- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationClient.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationClient.kt index 9f707fe3c0..a2303a343a 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationClient.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationClient.kt @@ -37,8 +37,8 @@ class PushRegistrationException(message: String) : Exception(message) * Performs the two push_registrations calls (issue #1957) and answers a single question about each: * **did the server end up in the state we wanted?** Everything a caller would otherwise have to get * right about an unreliable network lives here — URL assembly, which non-2xx codes still count as - * success, and making failures visible — so [PushRegistrationManager] can reason about reconciliation - * rather than about HTTP. + * success, and which failures are visible where (all log locally; only systematic rejections report + * remotely) — so [PushRegistrationManager] can reason about reconciliation rather than about HTTP. * * Nothing here retries. A false return means "not achieved this time"; the manager's next reconcile * pass tries again, which is the only retry policy the feature needs given it already runs on every @@ -105,9 +105,11 @@ class PushRegistrationClient internal constructor( * same doomed request then repeats on every foreground — which is exactly how the missing- * `description` 422 went unnoticed until a packet capture. * - * Server rejections also go to the remote channel: registrations are the server's ONLY audience - * source for service alerts, so a systematic failure — a field added server-side, a contract change, - * a throttle — must not be visible solely in one developer's logcat. + * Systematic rejections also go to the remote channel: registrations are the server's ONLY audience + * source for service alerts, so a contract failure — a field added server-side, a schema change — + * must not be visible solely in one developer's logcat. [Transient][isTransient] statuses stay + * local-only, like transport failures: they heal on a later reconcile, and reporting each one would + * flood the channel and drown the systematic signal it exists to carry. */ private fun reportHttpFailure( operation: String, @@ -119,9 +121,18 @@ class PushRegistrationClient internal constructor( val detail = if (body.isEmpty()) "" else " $body" val message = "${failurePrefix(operation, registration)}: HTTP ${response.code()}$detail" logWarning(message, null) - reportError(PushRegistrationException(message)) + if (!isTransient(response.code())) reportError(PushRegistrationException(message)) } + /** + * Whether [code] signals a transient condition rather than a systematic rejection: throttling (429 — + * the endpoint is unauthenticated and limited per IP, so on a shared-NAT network — public wifi, + * carrier CGNAT — other clients can spend this device's budget) or server-side trouble (5xx). A + * later reconcile pass retries both; only statuses that indicate *this request* can never succeed + * are worth a remote report. + */ + private fun isTransient(code: Int): Boolean = code == HTTP_TOO_MANY_REQUESTS || code in 500..599 + /** * Logs a transport-level failure. Local only — being offline is ordinary and self-healing, and * reporting every such moment remotely would drown the systematic-rejection signal above. @@ -140,5 +151,8 @@ class PushRegistrationClient internal constructor( private companion object { const val TAG = "PushRegistration" + + /** RFC 6585 Too Many Requests — [HttpURLConnection] predates it and has no constant. */ + const val HTTP_TOO_MANY_REQUESTS = 429 } } diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt index 6328796303..5666fd3581 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt @@ -436,7 +436,28 @@ class PushRegistrationManagerTest { } @Test - fun `a failed unregister response is logged too`() = runTest { + fun `a throttled register is logged locally but not reported, and retries next sync`() = runTest { + val f = Fixture(this) + f.setToken("T1") + // The endpoint is rate-limited per IP and unauthenticated, so on a shared-NAT network (public + // wifi, carrier CGNAT) a 429 says nothing about this client. Since a failed register persists + // nothing, every foreground would re-report it — flooding Crashlytics with throttling noise and + // drowning the systematic-rejection signal the reporting exists to carry. + f.service.onRegister = { Response.error(429, "".toResponseBody(null)) } + + f.sync() + + assertTrue("throttle must still be logged", f.loggedWarnings.single().contains("429")) + assertTrue("throttle must not be reported remotely", f.reportedErrors.isEmpty()) + + // Transient means the next reconcile retries; once the throttle lifts, registration succeeds. + f.service.onRegister = { Response.success(Unit) } + f.sync() + assertEquals(2, f.service.registerCalls.size) + } + + @Test + fun `a failed unregister response is logged too, and a 5xx is not reported remotely`() = runTest { val f = Fixture(this).registered("T1") f.notificationsEnabled = false @@ -445,6 +466,8 @@ class PushRegistrationManagerTest { val warning = f.loggedWarnings.single() assertTrue("status code must be logged: $warning", warning.contains("500")) + // Server-side trouble is transient and shared-fate across the fleet — local log only. + assertTrue("a 5xx must not be reported remotely", f.reportedErrors.isEmpty()) } /** From 57c5d39511bd5d1263c8c46fa44a1a51a151b1e8 Mon Sep 17 00:00:00 2001 From: Brandon Martin-Anderson Date: Wed, 22 Jul 2026 06:13:19 +0000 Subject: [PATCH 20/26] Don't DELETE the push registration on OS-level opt-out (match iOS / #1957) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../android/push/PushRegistrationDecision.kt | 60 ++++++++++++------- .../android/push/PushRegistrationStore.kt | 6 +- .../push/DeriveDesiredRegistrationTest.kt | 21 ++++--- .../push/PushRegistrationDecisionTest.kt | 22 +++++-- .../push/PushRegistrationManagerTest.kt | 22 ++++--- 5 files changed, 87 insertions(+), 44 deletions(-) diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt index 1d01550ea0..f71fa5dfcf 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt @@ -73,13 +73,14 @@ fun PushRegistration.sameEndpoint(other: PushRegistration): Boolean = regionId = token == other.token /** - * The registration this device should have, derived from the current inputs. Three-valued, not a - * nullable [PushRegistration], because "no registration" genuinely means two different things here and - * conflating them under one `null` is exactly how a stale registration leaks: an *unresolved* answer - * must freeze reconciliation (deciding on a half-read state fires spurious DELETEs), while a *resolved* - * "none" must drive it (a registration on record that shouldn't exist has to be unregistered). The - * split is enforced at the type level — [decidePushRegistration] accepts only [Resolved], so the only - * way to skip reconciling is an explicit match on [Unresolved]. + * The registration this device should have, derived from the current inputs. Four-valued, not a + * nullable [PushRegistration], because "no registration" genuinely means three different things here + * and conflating them is exactly how a registration bug leaks: an *unresolved* answer must freeze + * reconciliation (deciding on a half-read state fires spurious DELETEs), an [OptedOut] answer must + * leave the server row alone (issue #1957: an OS-level disable needs no DELETE), and only a + * [NoSidecar] answer actively unregisters what is on record. The splits are enforced at the type + * level — [decidePushRegistration] accepts only [Resolved], so the only way to skip reconciling is an + * explicit match on [Unresolved]. */ sealed interface DesiredRegistration { @@ -94,11 +95,23 @@ sealed interface DesiredRegistration { sealed interface Resolved : DesiredRegistration /** - * Definitively, no registration should exist: the rider opted out, or the current region has no - * sidecar host to register with. Reconciling this *unregisters* whatever is on record — a rider who - * moves from a sidecar region to a sidecar-less one must stop receiving the old region's alerts. + * The rider has notifications off at the OS level — nothing should be POSTed, and the server row + * (if any) is deliberately left in place. Issue #1957: an OS-level disable "doesn't need a DELETE — + * FCM bounces feed back to the server and it cleans up unregistered tokens itself"; DELETE is + * reserved for an *in-app* opt-out, which this app doesn't have. The iOS client behaves the same + * way (its `PushRegistrationManager` never DELETEs). Deleting here would also risk dropping the + * delivery target of an already-scheduled trip alarm the moment the rider toggles notifications + * off, if the server couples alarm delivery to the registration row. */ - data object None : Resolved + data object OptedOut : Resolved + + /** + * The current region has no sidecar host to register with, so no registration should exist here — + * and one on record from a *previous* region must be unregistered, or the rider who moved keeps + * receiving the old region's alerts until the server's 180-day prune (the iOS docs sanction exactly + * this old-row DELETE on a region change). + */ + data object NoSidecar : Resolved /** Definitively, [target] should be registered. */ data class Wanted(val target: PushRegistration) : Resolved @@ -106,11 +119,12 @@ sealed interface DesiredRegistration { /** * The pure derivation of [DesiredRegistration] from the raw inputs (issue #1957). Every early exit - * names which of the three answers it is — there is no `null` through which "this region has no - * sidecar" (a resolved fact, [DesiredRegistration.None]) can masquerade as "the region hasn't loaded - * yet" (an unresolved one) — see [DesiredRegistration] for why that distinction is load-bearing. - * Notifications off at the OS level is the **only opt-out signal** (there is no in-app toggle), and it - * is definitive regardless of every other input. + * names which of the four answers it is — there is no `null` through which "this region has no + * sidecar" (a resolved fact, [DesiredRegistration.NoSidecar]) can masquerade as "the region hasn't + * loaded yet" (an unresolved one) — see [DesiredRegistration] for why that distinction is load-bearing. + * Notifications off at the OS level is the **only opt-out signal** (there is no in-app toggle), it is + * definitive regardless of every other input, and it deliberately does not unregister — see + * [DesiredRegistration.OptedOut]. * * The test-device [description][PushRegistration.description] is honoured only once the rider has * named the device: the server rejects a `test_device=true` registration with a blank `description` @@ -134,9 +148,9 @@ fun deriveDesiredRegistration( testDeviceEnabled: Boolean, testDeviceName: String? ): DesiredRegistration { - if (!notificationsEnabled) return DesiredRegistration.None + if (!notificationsEnabled) return DesiredRegistration.OptedOut if (region == null) return DesiredRegistration.Unresolved - val base = region.sidecarBaseUrl?.takeIf { it.isNotBlank() } ?: return DesiredRegistration.None + val base = region.sidecarBaseUrl?.takeIf { it.isNotBlank() } ?: return DesiredRegistration.NoSidecar if (token.isEmpty()) return DesiredRegistration.Unresolved val description = testDeviceName ?.takeIf { testDeviceEnabled } @@ -166,7 +180,10 @@ sealed interface PushRegistrationAction { /** POST [target]: either the first registration, or a metadata refresh on the same token+region. */ data class Register(val target: PushRegistration) : PushRegistrationAction - /** DELETE [previous]: the rider opted out (or the token/region became unavailable). */ + /** + * DELETE [previous]: its destination no longer applies (the rider's region has no sidecar). Never + * produced for an OS-level opt-out — see [DesiredRegistration.OptedOut]. + */ data class Unregister(val previous: PushRegistration) : PushRegistrationAction /** @@ -197,7 +214,10 @@ fun decidePushRegistration( last: PushRegistration?, sinceLastSent: Duration? ): PushRegistrationAction = when (desired) { - DesiredRegistration.None -> + // The server row is left alone on an OS-level opt-out (issue #1957; iOS parity) — the record is + // kept too, so re-enabling reads as "unchanged" rather than a fresh registration. + DesiredRegistration.OptedOut -> PushRegistrationAction.NoOp + DesiredRegistration.NoSidecar -> if (last == null) PushRegistrationAction.NoOp else PushRegistrationAction.Unregister(last) is DesiredRegistration.Wanted -> { val target = desired.target diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationStore.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationStore.kt index 0ecc0b7ed9..aa4cf9e09a 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationStore.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationStore.kt @@ -27,9 +27,9 @@ import org.onebusaway.android.time.WallTime * since the push_registrations API offers no GET. Two records live here: * * - **The live registration** ([last]) plus when it was sent ([sinceLastSent]). Needed to address the - * DELETE on opt-out (by which point the current token/region may already have changed), to skip - * redundant POSTs against a 30 req/min/IP endpoint, and to drive the [PUSH_REFRESH_INTERVAL] - * keep-alive. + * DELETE of a stale row when the endpoint changes or the region loses its sidecar (by which point + * the current token/region may already have changed), to skip redundant POSTs against a + * 30 req/min/IP endpoint, and to drive the [PUSH_REFRESH_INTERVAL] keep-alive. * - **A pending delete** ([pendingDelete]) — a registration whose DELETE is still owed. See * [queuePendingDelete]. * diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/push/DeriveDesiredRegistrationTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/push/DeriveDesiredRegistrationTest.kt index 71f1587674..c78d3d7a3b 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/push/DeriveDesiredRegistrationTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/push/DeriveDesiredRegistrationTest.kt @@ -22,10 +22,13 @@ import org.onebusaway.android.region.region /** * Unit tests for [deriveDesiredRegistration] — the pure classification of the raw inputs into - * wanted / none / not-yet-resolved. The load-bearing distinction is [DesiredRegistration.None] vs - * [DesiredRegistration.Unresolved]: `None` reconciles a stale registration away, `Unresolved` freezes - * reconciliation until the input settles, and collapsing the two (a nullable target) is exactly the - * bug that let a region-A registration outlive a switch to a sidecar-less region B. + * wanted / opted-out / no-sidecar / not-yet-resolved. The load-bearing distinctions: + * [DesiredRegistration.NoSidecar] vs [DesiredRegistration.Unresolved] (`NoSidecar` reconciles a stale + * registration away, `Unresolved` freezes reconciliation until the input settles — collapsing the two + * under a nullable target is exactly the bug that let a region-A registration outlive a switch to a + * sidecar-less region B), and [DesiredRegistration.OptedOut] vs `NoSidecar` (an OS-level opt-out must + * NOT unregister — issue #1957 leaves that cleanup to FCM bounces and the server prune, and the iOS + * client never DELETEs). */ class DeriveDesiredRegistrationTest { @@ -65,9 +68,9 @@ class DeriveDesiredRegistrationTest { @Test fun `notifications off is a definitive opt-out regardless of the other inputs`() { - assertEquals(DesiredRegistration.None, derive(notificationsEnabled = false)) + assertEquals(DesiredRegistration.OptedOut, derive(notificationsEnabled = false)) // Definitive even while the async inputs are still settling: opting out never waits. - assertEquals(DesiredRegistration.None, derive(notificationsEnabled = false, region = null, token = "")) + assertEquals(DesiredRegistration.OptedOut, derive(notificationsEnabled = false, region = null, token = "")) } @Test @@ -78,12 +81,12 @@ class DeriveDesiredRegistrationTest { } @Test - fun `a region without a sidecar host is definitively none, not unresolved`() { + fun `a region without a sidecar host is definitively no-sidecar, not unresolved`() { // The regression this type exists to prevent: a resolved region whose sidecarBaseUrl is blank // (the field defaults to "") is a fact — there is nowhere to be registered — so a registration // left over from a sidecar region must be reconciled away, not frozen in place. - assertEquals(DesiredRegistration.None, derive(region = region(2).copy(sidecarBaseUrl = ""))) - assertEquals(DesiredRegistration.None, derive(region = region(2).copy(sidecarBaseUrl = null))) + assertEquals(DesiredRegistration.NoSidecar, derive(region = region(2).copy(sidecarBaseUrl = ""))) + assertEquals(DesiredRegistration.NoSidecar, derive(region = region(2).copy(sidecarBaseUrl = null))) } @Test diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationDecisionTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationDecisionTest.kt index 3cc80ec7b8..194b33b726 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationDecisionTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationDecisionTest.kt @@ -21,7 +21,8 @@ import kotlin.time.Duration.Companion.hours import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test -import org.onebusaway.android.push.DesiredRegistration.None +import org.onebusaway.android.push.DesiredRegistration.NoSidecar +import org.onebusaway.android.push.DesiredRegistration.OptedOut import org.onebusaway.android.push.DesiredRegistration.Wanted /** @@ -50,7 +51,8 @@ class PushRegistrationDecisionTest { @Test fun `nothing desired and nothing on record is a no-op`() { - assertEquals(PushRegistrationAction.NoOp, decide(None, last = null)) + assertEquals(PushRegistrationAction.NoOp, decide(OptedOut, last = null)) + assertEquals(PushRegistrationAction.NoOp, decide(NoSidecar, last = null)) } @Test @@ -59,8 +61,20 @@ class PushRegistrationDecisionTest { } @Test - fun `opting out unregisters the recorded registration`() { - assertEquals(PushRegistrationAction.Unregister(base), decide(None, last = base)) + fun `opting out leaves the recorded registration and its server row alone`() { + // Issue #1957: an OS-level disable needs no DELETE — FCM bounces and the server's prune own + // that cleanup, and the iOS client never DELETEs. A DELETE here could also drop the delivery + // target of an already-scheduled trip alarm. + assertEquals(PushRegistrationAction.NoOp, decide(OptedOut, last = base)) + // Stale record while opted out changes nothing: no keep-alive POST either. + assertEquals(PushRegistrationAction.NoOp, decide(OptedOut, last = base, sinceLastSent = 30.days)) + } + + @Test + fun `losing the sidecar unregisters the recorded registration`() { + // Unlike an opt-out, a rider who moved to a sidecar-less region must stop receiving the old + // region's alerts — the sanctioned old-row DELETE on a region change. + assertEquals(PushRegistrationAction.Unregister(base), decide(NoSidecar, last = base)) } @Test diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt index 5666fd3581..5a71b8acd8 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt @@ -98,19 +98,24 @@ class PushRegistrationManagerTest { } @Test - fun `opt-out unregisters the recorded token and clears the record`() = runTest { + fun `opt-out leaves the server row alone - FCM bounces and the server prune own that cleanup`() = runTest { val f = Fixture(this).registered("T1") - // Rider turns notifications off in system settings; the ON_START resync reconciles. This is the - // ONLY definitive opt-out — contrast the missing-region case below, which must not DELETE. + // Rider turns notifications off in system settings; the ON_START resync reconciles. Per issue + // #1957 an OS-level disable needs no DELETE (and the iOS client never DELETEs) — one here could + // drop the delivery target of an already-scheduled trip alarm. f.notificationsEnabled = false f.sync() - assertEquals("T1", f.service.unregisterCalls.single().token) + assertTrue("opt-out must not DELETE", f.service.unregisterCalls.isEmpty()) + assertEquals("opt-out must not POST either", 1, f.service.registerCalls.size) - // Record cleared → a further resync is a NoOp (no duplicate DELETE). + // Re-enabling within the refresh window: the kept record still matches → pure NoOp, no + // spurious re-POST of an unchanged registration whose server row never went away. + f.notificationsEnabled = true f.sync() - assertEquals(1, f.service.unregisterCalls.size) + assertEquals(1, f.service.registerCalls.size) + assertTrue(f.service.unregisterCalls.isEmpty()) } @Test @@ -261,7 +266,8 @@ class PushRegistrationManagerTest { fun `unregister treats a 404 as already-gone and clears the record`() = runTest { val f = Fixture(this).registered("T1") - f.notificationsEnabled = false + // Moving to a sidecar-less region is the path that unregisters (an OS opt-out doesn't). + f.regions.emit(region(2).copy(sidecarBaseUrl = "")) f.service.onUnregister = { Response.error(404, "".toResponseBody(null)) } f.sync() assertEquals(1, f.service.unregisterCalls.size) @@ -460,7 +466,7 @@ class PushRegistrationManagerTest { fun `a failed unregister response is logged too, and a 5xx is not reported remotely`() = runTest { val f = Fixture(this).registered("T1") - f.notificationsEnabled = false + f.regions.emit(region(2).copy(sidecarBaseUrl = "")) f.service.onUnregister = { Response.error(500, "boom".toResponseBody(null)) } f.sync() From 6381afa5b07063872fed84458a0b22336e721953 Mon Sep 17 00:00:00 2001 From: Brandon Martin-Anderson Date: Wed, 22 Jul 2026 06:19:33 +0000 Subject: [PATCH 21/26] Make the description cap surrogate-safe: never split a UTF-16 pair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../android/push/PushRegistrationDecision.kt | 31 ++++++++++++++++--- .../ui/settings/AdvancedSettingsViewModel.kt | 3 +- .../push/DeriveDesiredRegistrationTest.kt | 15 +++++++++ .../settings/AdvancedSettingsViewModelTest.kt | 5 +++ 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt index f71fa5dfcf..83d47d643e 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt @@ -58,12 +58,35 @@ val PUSH_REFRESH_INTERVAL: Duration = 24.hours /** * The server's cap on a test device's `description` (OBACloud's push-notifications documentation: - * "Free text ≤255 chars identifying the device to admins"). Enforced client-side so a long name is - * truncated rather than POSTed and rejected — a `description` that violates this is a 422, and since a - * failed registration persists nothing, the doomed request would otherwise repeat on every foreground. + * "Free text ≤255 chars identifying the device to admins"). Enforced client-side — via + * [truncatedToDescriptionCap] — so a long name is truncated rather than POSTed and rejected: a + * `description` that violates this is a 422, and since a failed registration persists nothing, the + * doomed request would otherwise repeat on every foreground. */ const val PUSH_DESCRIPTION_MAX_LENGTH = 255 +/** + * Truncates to at most [PUSH_DESCRIPTION_MAX_LENGTH] UTF-16 units without ever splitting a surrogate + * pair — a bare `take()` counts code units and can cut between a pair's halves, sending a lone + * surrogate (mangled to `?`/U+FFFD by the form encoding) as the final character. When the cut would + * land mid-pair, the pair is dropped whole. + * + * The budget deliberately stays in UTF-16 units: it is the strictest of the plausible readings of the + * server's documented "≤255 chars" (the OBACloud source is private, so its exact counting can't be + * read) — a string of at most N UTF-16 units also has at most N code points, so the result is legal + * under either interpretation. Grapheme clusters (ZWJ emoji sequences, combining marks) can still be + * cut between code points; that renders imperfectly but is valid Unicode and within the server limit. + */ +fun String.truncatedToDescriptionCap(): String { + if (length <= PUSH_DESCRIPTION_MAX_LENGTH) return this + val end = if (this[PUSH_DESCRIPTION_MAX_LENGTH - 1].isHighSurrogate()) { + PUSH_DESCRIPTION_MAX_LENGTH - 1 + } else { + PUSH_DESCRIPTION_MAX_LENGTH + } + return substring(0, end) +} + /** * True when [this] and [other] address the same server row — the region, host, and token a DELETE (or an * upsert POST) targets. Locale and test-device aren't part of the row's identity, so they don't count. @@ -155,7 +178,7 @@ fun deriveDesiredRegistration( val description = testDeviceName ?.takeIf { testDeviceEnabled } ?.trim() - ?.take(PUSH_DESCRIPTION_MAX_LENGTH) + ?.truncatedToDescriptionCap() ?.takeIf { it.isNotEmpty() } return DesiredRegistration.Wanted( PushRegistration( diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModel.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModel.kt index a776e68cd5..dc1ca05ad4 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModel.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModel.kt @@ -36,6 +36,7 @@ import org.onebusaway.android.donations.DonationsManager import org.onebusaway.android.map.StopsMapController import org.onebusaway.android.preferences.PreferencesRepository import org.onebusaway.android.push.PUSH_DESCRIPTION_MAX_LENGTH +import org.onebusaway.android.push.truncatedToDescriptionCap import org.onebusaway.android.region.ApiUrlValidator import org.onebusaway.android.region.Region import org.onebusaway.android.region.RegionRepository @@ -244,7 +245,7 @@ internal fun applyMapStopCacheSize(text: String, prefs: PreferencesRepository): * name, so unlike [applyMapStopCacheSize] there is no reject-on-invalid case. */ internal fun applyPushTestDeviceName(value: String, prefs: PreferencesRepository): Boolean { - val name = value.trim().take(PUSH_DESCRIPTION_MAX_LENGTH).ifEmpty { null } + val name = value.trim().truncatedToDescriptionCap().ifEmpty { null } prefs.setString(R.string.preference_key_push_test_device_name, name) return true } diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/push/DeriveDesiredRegistrationTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/push/DeriveDesiredRegistrationTest.kt index c78d3d7a3b..f0cd6196ef 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/push/DeriveDesiredRegistrationTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/push/DeriveDesiredRegistrationTest.kt @@ -125,4 +125,19 @@ class DeriveDesiredRegistrationTest { (desired as DesiredRegistration.Wanted).target.description ) } + + @Test + fun `truncation never splits a surrogate pair`() { + // 254 single-unit chars + a two-unit emoji = 256 UTF-16 units: a bare take(255) would cut the + // pair in half, sending a lone surrogate (mangled to '?'/U+FFFD) as the final character. + val desired = derive(testDeviceEnabled = true, testDeviceName = "N".repeat(254) + "😀") + assertEquals("N".repeat(254), (desired as DesiredRegistration.Wanted).target.description) + } + + @Test + fun `an emoji that fits exactly within the limit is kept whole`() { + val name = "N".repeat(PUSH_DESCRIPTION_MAX_LENGTH - 2) + "😀" + val desired = derive(testDeviceEnabled = true, testDeviceName = name) + assertEquals(name, (desired as DesiredRegistration.Wanted).target.description) + } } diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModelTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModelTest.kt index 451d2a51af..77c6219a6e 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModelTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModelTest.kt @@ -145,6 +145,11 @@ class AdvancedSettingsViewModelTest { applyPushTestDeviceName("N".repeat(300), prefs) assertEquals("N".repeat(PUSH_DESCRIPTION_MAX_LENGTH), prefs.getString(pushTestDeviceName, null)) + + // The cap counts UTF-16 units but never splits a surrogate pair: an emoji straddling the + // boundary is dropped whole rather than stored as a lone (mangled) surrogate. + applyPushTestDeviceName("N".repeat(PUSH_DESCRIPTION_MAX_LENGTH - 1) + "😀", prefs) + assertEquals("N".repeat(PUSH_DESCRIPTION_MAX_LENGTH - 1), prefs.getString(pushTestDeviceName, null)) } @Test From ac0dbbf60e3336cddb031f7c9c3057cf02df8a92 Mon Sep 17 00:00:00 2001 From: Brandon Martin-Anderson Date: Wed, 22 Jul 2026 06:26:15 +0000 Subject: [PATCH 22/26] Polish the last three push commits: fix doc drift, dedup rationale, tighten the cap helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../android/push/PushRegistrationClient.kt | 8 ++++---- .../android/push/PushRegistrationDecision.kt | 12 ++++-------- .../android/push/PushRegistrationManager.kt | 7 ++++--- .../android/push/DeriveDesiredRegistrationTest.kt | 7 +++---- .../android/push/PushRegistrationDecisionTest.kt | 4 +--- .../android/push/PushRegistrationManagerTest.kt | 15 ++++++--------- .../ui/settings/AdvancedSettingsViewModelTest.kt | 5 ----- 7 files changed, 22 insertions(+), 36 deletions(-) diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationClient.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationClient.kt index a2303a343a..eaa98e3353 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationClient.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationClient.kt @@ -108,8 +108,7 @@ class PushRegistrationClient internal constructor( * Systematic rejections also go to the remote channel: registrations are the server's ONLY audience * source for service alerts, so a contract failure — a field added server-side, a schema change — * must not be visible solely in one developer's logcat. [Transient][isTransient] statuses stay - * local-only, like transport failures: they heal on a later reconcile, and reporting each one would - * flood the channel and drown the systematic signal it exists to carry. + * local-only, like transport failures. */ private fun reportHttpFailure( operation: String, @@ -128,8 +127,9 @@ class PushRegistrationClient internal constructor( * Whether [code] signals a transient condition rather than a systematic rejection: throttling (429 — * the endpoint is unauthenticated and limited per IP, so on a shared-NAT network — public wifi, * carrier CGNAT — other clients can spend this device's budget) or server-side trouble (5xx). A - * later reconcile pass retries both; only statuses that indicate *this request* can never succeed - * are worth a remote report. + * later reconcile pass retries both, so reporting each occurrence would flood the remote channel + * and drown the systematic signal it exists to carry; only statuses that indicate *this request* + * can never succeed are worth a report. */ private fun isTransient(code: Int): Boolean = code == HTTP_TOO_MANY_REQUESTS || code in 500..599 diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt index 83d47d643e..3b4678b323 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt @@ -77,14 +77,10 @@ const val PUSH_DESCRIPTION_MAX_LENGTH = 255 * under either interpretation. Grapheme clusters (ZWJ emoji sequences, combining marks) can still be * cut between code points; that renders imperfectly but is valid Unicode and within the server limit. */ -fun String.truncatedToDescriptionCap(): String { - if (length <= PUSH_DESCRIPTION_MAX_LENGTH) return this - val end = if (this[PUSH_DESCRIPTION_MAX_LENGTH - 1].isHighSurrogate()) { - PUSH_DESCRIPTION_MAX_LENGTH - 1 - } else { - PUSH_DESCRIPTION_MAX_LENGTH - } - return substring(0, end) +fun String.truncatedToDescriptionCap(): String = if (length <= PUSH_DESCRIPTION_MAX_LENGTH) { + this +} else { + take(PUSH_DESCRIPTION_MAX_LENGTH).dropLastWhile { it.isHighSurrogate() } } /** diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt index f04e95ff0b..555ec0d9b9 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt @@ -44,8 +44,8 @@ import org.onebusaway.android.region.RegionRepository * * 1. **Desired state** ([deriveDesiredRegistration]) — a pure classification of the raw inputs (OS * notifications-enabled — the opt-in signal; there is no separate in-app toggle — the current - * region, the FCM token, the device locale, and the test-device settings) into wanted / none / - * not-yet-resolved. This class only gathers the inputs. + * region, the FCM token, the device locale, and the test-device settings) into wanted / opted-out / + * no-sidecar / not-yet-resolved. This class only gathers the inputs. * 2. **The decision** ([decidePushRegistration]) — a pure function comparing desired against the record * of what was last sent, yielding register / re-register / unregister / no-op. * 3. **Applying it** — this class, which owns only the rule for *what to record given which calls @@ -147,7 +147,8 @@ class PushRegistrationManager internal constructor( testDeviceName = prefs.getString(R.string.preference_key_push_test_device_name, null) ) // Unresolved (an async input hasn't settled) is the only state that may skip the decision — a - // later trigger re-runs us. See DesiredRegistration for why it must never be read as an opt-out. + // later trigger re-runs us. See DesiredRegistration for why it must never be read as a resolved + // state (deciding on a half-read state fires spurious DELETEs). if (desired !is DesiredRegistration.Resolved) return@withLock when (val action = decidePushRegistration(desired, store.last(), store.sinceLastSent())) { diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/push/DeriveDesiredRegistrationTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/push/DeriveDesiredRegistrationTest.kt index f0cd6196ef..335954957c 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/push/DeriveDesiredRegistrationTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/push/DeriveDesiredRegistrationTest.kt @@ -27,8 +27,7 @@ import org.onebusaway.android.region.region * registration away, `Unresolved` freezes reconciliation until the input settles — collapsing the two * under a nullable target is exactly the bug that let a region-A registration outlive a switch to a * sidecar-less region B), and [DesiredRegistration.OptedOut] vs `NoSidecar` (an OS-level opt-out must - * NOT unregister — issue #1957 leaves that cleanup to FCM bounces and the server prune, and the iOS - * client never DELETEs). + * NOT unregister — see [DesiredRegistration.OptedOut] for the rationale). */ class DeriveDesiredRegistrationTest { @@ -74,9 +73,9 @@ class DeriveDesiredRegistrationTest { } @Test - fun `a missing region is unresolved, not an opt-out`() { + fun `a missing region is unresolved, not no-sidecar`() { // The region flow is briefly null at cold start (RegionRepository seeds it asynchronously); - // reading that as None would DELETE the registration and re-POST it seconds later. + // reading that as NoSidecar would DELETE the registration and re-POST it seconds later. assertEquals(DesiredRegistration.Unresolved, derive(region = null)) } diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationDecisionTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationDecisionTest.kt index 194b33b726..f3654d40f3 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationDecisionTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationDecisionTest.kt @@ -62,9 +62,7 @@ class PushRegistrationDecisionTest { @Test fun `opting out leaves the recorded registration and its server row alone`() { - // Issue #1957: an OS-level disable needs no DELETE — FCM bounces and the server's prune own - // that cleanup, and the iOS client never DELETEs. A DELETE here could also drop the delivery - // target of an already-scheduled trip alarm. + // An OS-level disable never DELETEs — see [DesiredRegistration.OptedOut] for the rationale. assertEquals(PushRegistrationAction.NoOp, decide(OptedOut, last = base)) // Stale record while opted out changes nothing: no keep-alive POST either. assertEquals(PushRegistrationAction.NoOp, decide(OptedOut, last = base, sinceLastSent = 30.days)) diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt index 5a71b8acd8..6f7ca66a64 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt @@ -101,9 +101,8 @@ class PushRegistrationManagerTest { fun `opt-out leaves the server row alone - FCM bounces and the server prune own that cleanup`() = runTest { val f = Fixture(this).registered("T1") - // Rider turns notifications off in system settings; the ON_START resync reconciles. Per issue - // #1957 an OS-level disable needs no DELETE (and the iOS client never DELETEs) — one here could - // drop the delivery target of an already-scheduled trip alarm. + // Rider turns notifications off in system settings; the ON_START resync reconciles. An OS-level + // disable never DELETEs — see [DesiredRegistration.OptedOut] for the rationale. f.notificationsEnabled = false f.sync() @@ -377,11 +376,11 @@ class PushRegistrationManagerTest { } @Test - fun `a missing region is treated as not-yet-known rather than an opt-out`() = runTest { + fun `a missing region is treated as not-yet-known rather than resolved`() = runTest { val f = Fixture(this).registered("T1") // The region flow is briefly null at cold start (RegionRepository seeds it asynchronously). - // Treating that as an opt-out would DELETE the registration and re-POST seconds later. + // Treating that as no-sidecar would DELETE the registration and re-POST seconds later. f.regions.emit(null) f.sync() @@ -445,10 +444,8 @@ class PushRegistrationManagerTest { fun `a throttled register is logged locally but not reported, and retries next sync`() = runTest { val f = Fixture(this) f.setToken("T1") - // The endpoint is rate-limited per IP and unauthenticated, so on a shared-NAT network (public - // wifi, carrier CGNAT) a 429 says nothing about this client. Since a failed register persists - // nothing, every foreground would re-report it — flooding Crashlytics with throttling noise and - // drowning the systematic-rejection signal the reporting exists to carry. + // A 429 says nothing about this client (see [PushRegistrationClient.isTransient]), and since a + // failed register persists nothing, every foreground would re-report it. f.service.onRegister = { Response.error(429, "".toResponseBody(null)) } f.sync() diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModelTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModelTest.kt index 77c6219a6e..451d2a51af 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModelTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/ui/settings/AdvancedSettingsViewModelTest.kt @@ -145,11 +145,6 @@ class AdvancedSettingsViewModelTest { applyPushTestDeviceName("N".repeat(300), prefs) assertEquals("N".repeat(PUSH_DESCRIPTION_MAX_LENGTH), prefs.getString(pushTestDeviceName, null)) - - // The cap counts UTF-16 units but never splits a surrogate pair: an emoji straddling the - // boundary is dropped whole rather than stored as a lone (mangled) surrogate. - applyPushTestDeviceName("N".repeat(PUSH_DESCRIPTION_MAX_LENGTH - 1) + "😀", prefs) - assertEquals("N".repeat(PUSH_DESCRIPTION_MAX_LENGTH - 1), prefs.getString(pushTestDeviceName, null)) } @Test From 9b6c97330b029ccae3a466afbe85eac45e01b409 Mon Sep 17 00:00:00 2001 From: Brandon Martin-Anderson Date: Wed, 22 Jul 2026 06:29:33 +0000 Subject: [PATCH 23/26] Extract sidecarRegionUrl(): one home for the sidecar URL shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../android/api/contract/SidecarUrls.kt | 34 +++++++++++++++++++ .../android/push/PushRegistrationClient.kt | 13 ++++--- .../android/ui/tripinfo/TripInfoRepository.kt | 11 +++--- 3 files changed, 49 insertions(+), 9 deletions(-) create mode 100644 onebusaway-android/src/main/java/org/onebusaway/android/api/contract/SidecarUrls.kt diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/api/contract/SidecarUrls.kt b/onebusaway-android/src/main/java/org/onebusaway/android/api/contract/SidecarUrls.kt new file mode 100644 index 0000000000..e0fc12ef58 --- /dev/null +++ b/onebusaway-android/src/main/java/org/onebusaway/android/api/contract/SidecarUrls.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2026 Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.onebusaway.android.api.contract + +/** + * Assembles a region-scoped sidecar endpoint URL: + * `{sidecarBaseUrl}{regionsPath}{regionId}/{resource}` — e.g. + * `https://sidecar.onebusaway.org/api/v2/regions/1/alarms`. The single source of the sidecar URL + * shape, shared by the alarms client (`TripInfoRepository`) and `PushRegistrationClient`; a change to + * the path scheme is made here once. + * + * [regionsPath] is the resolved `R.string.arrivals_reminders_api_endpoint` (`/api/v2/regions/`). It + * stays a parameter rather than a constant because it is a string *resource* — a brand override + * point — and because Context-free callers inject it for JVM testability. + */ +fun sidecarRegionUrl( + sidecarBaseUrl: String, + regionsPath: String, + regionId: Long, + resource: String +): String = "$sidecarBaseUrl$regionsPath$regionId/$resource" diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationClient.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationClient.kt index eaa98e3353..b9ec6cf3fd 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationClient.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationClient.kt @@ -24,6 +24,7 @@ import javax.inject.Inject import javax.inject.Singleton import org.onebusaway.android.R import org.onebusaway.android.api.contract.PushRegistrationWebService +import org.onebusaway.android.api.contract.sidecarRegionUrl import org.onebusaway.android.util.runCatchingCancellable import retrofit2.Response @@ -92,11 +93,13 @@ class PushRegistrationClient internal constructor( removed }.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 + - registrationsEndpointPath + - registration.regionId + - "/push_registrations" + /** `{sidecarBaseUrl}/api/v2/regions/{regionId}/push_registrations`. */ + private fun registrationUrl(registration: PushRegistration): String = sidecarRegionUrl( + sidecarBaseUrl = registration.sidecarBaseUrl, + regionsPath = registrationsEndpointPath, + regionId = registration.regionId, + resource = "push_registrations" + ) /** * Reports a non-2xx response. Without this an HTTP error would be *invisible*: a failed status is a diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripinfo/TripInfoRepository.kt b/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripinfo/TripInfoRepository.kt index b90924df43..7cc65e8cd9 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripinfo/TripInfoRepository.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/ui/tripinfo/TripInfoRepository.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.onebusaway.android.R import org.onebusaway.android.api.contract.ReminderWebService +import org.onebusaway.android.api.contract.sidecarRegionUrl import org.onebusaway.android.database.oba.ImportGate import org.onebusaway.android.database.oba.RouteDao import org.onebusaway.android.database.oba.StopDao @@ -260,10 +261,12 @@ class DefaultTripInfoRepository @Inject constructor( // push registration completes); the legacy builder returned null in that case. val userPushId = firebaseMessagingManager.userPushId() if (userPushId.isEmpty()) return null - val url = base + - context.getString(R.string.arrivals_reminders_api_endpoint) + - region.id + - "/alarms" + val url = sidecarRegionUrl( + sidecarBaseUrl = base, + regionsPath = context.getString(R.string.arrivals_reminders_api_endpoint), + regionId = region.id, + resource = "alarms" + ) // runCatchingCancellable keeps a cancelled save from being reported to the UI as a save failure. return runCatchingCancellable { reminderService.createAlarm( From 6d8e8e0f90793056844b8b03e9a4aa9e8d51ae38 Mon Sep 17 00:00:00 2001 From: Brandon Martin-Anderson Date: Wed, 22 Jul 2026 06:37:07 +0000 Subject: [PATCH 24/26] Drop the persisted pending-delete queue: the Reregister DELETE is best-effort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../android/push/PushRegistrationManager.kt | 24 ++---- .../android/push/PushRegistrationStore.kt | 77 +++---------------- .../push/PushRegistrationManagerTest.kt | 55 ++----------- 3 files changed, 23 insertions(+), 133 deletions(-) diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt index 555ec0d9b9..edc2710f0f 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt @@ -134,10 +134,6 @@ class PushRegistrationManager internal constructor( /** Reconciles the recorded registration with the currently desired one. */ private suspend fun sync() = mutex.withLock { - // Settle any DELETE still owed from an earlier Reregister whose POST landed but whose DELETE - // didn't, before reconciling, so the stale registration can't linger past this pass. - drainPendingDelete() - val desired = deriveDesiredRegistration( notificationsEnabled = notificationsEnabled(), region = regionRepository.region.value, @@ -163,31 +159,23 @@ class PushRegistrationManager internal constructor( /** * DELETEs the stale registration, then POSTs the new one, recording only once the POST lands. Both - * calls can fail independently, and all four quadrants have to leave the device recoverable: + * calls can fail independently: * * - DELETE ok + POST ok → record(target): old gone, new on record. * - DELETE ok + POST fail → clear(): the server holds nothing, so the next sync re-Registers. * - DELETE fail + POST fail → keep `previous`: the next sync retries the whole Reregister. - * - DELETE fail + POST ok → record(target) AND queue `previous` for a retried DELETE, rather than - * dropping it — otherwise the old region keeps pushing to a still-valid token until the server's - * 180-day prune. + * - DELETE fail + POST ok → record(target); the failed DELETE is dropped, **best-effort by + * design**: the old row lingers until the server's 180-day prune, which is the iOS client's + * baseline for *every* region change (it never DELETEs, and issue #1957 marks the old-row DELETE + * as optional). Persisting the miss for a retried DELETE was deliberately rejected as + * machinery disproportionate to that corner (PR #1958 review). */ private suspend fun applyReregister(action: PushRegistrationAction.Reregister) { val cleaned = client.unregister(action.previous) if (client.register(action.target)) { store.record(action.target) - if (!cleaned) store.queuePendingDelete(action.previous) } else if (cleaned) { store.clear() } } - - /** - * Retries the DELETE queued by an earlier Reregister. Runs at the top of every sync; a no-op (one - * prefs read, no network) when nothing is queued. - */ - private suspend fun drainPendingDelete() { - val pending = store.pendingDelete() ?: return - if (client.unregister(pending)) store.clearPendingDelete() - } } diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationStore.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationStore.kt index aa4cf9e09a..0106049168 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationStore.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationStore.kt @@ -18,27 +18,22 @@ package org.onebusaway.android.push import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration -import org.onebusaway.android.preferences.PreferencesEditor import org.onebusaway.android.preferences.PreferencesRepository import org.onebusaway.android.time.WallTime /** * The durable memory of what this device has told OBACloud (issue #1957) — the only record of it, - * since the push_registrations API offers no GET. Two records live here: + * since the push_registrations API offers no GET: the registration last successfully sent ([last]) + * plus when it was sent ([sinceLastSent]). Needed to address the DELETE of a stale row when the + * endpoint changes or the region loses its sidecar (by which point the current token/region may + * already have changed), to skip redundant POSTs against a 30 req/min/IP endpoint, and to drive the + * [PUSH_REFRESH_INTERVAL] keep-alive. * - * - **The live registration** ([last]) plus when it was sent ([sinceLastSent]). Needed to address the - * DELETE of a stale row when the endpoint changes or the region loses its sidecar (by which point - * the current token/region may already have changed), to skip redundant POSTs against a - * 30 req/min/IP endpoint, and to drive the [PUSH_REFRESH_INTERVAL] keep-alive. - * - **A pending delete** ([pendingDelete]) — a registration whose DELETE is still owed. See - * [queuePendingDelete]. - * - * Each record spans several preference slots, with the token as the **presence marker**: a read - * returns nothing unless it is present, and clearing a record nulls it. Every multi-slot write goes - * through [PreferencesRepository.edit], which commits all of a record's slots atomically — in process - * and on disk (#1978) — so a record persists whole or not at all across a process death. A record - * whose marker is present but whose addressing fields are missing (which for a pending delete would - * aim its DELETE at region -1, whose 404 reads as success) therefore cannot exist. + * The record spans several preference slots, with the token as the **presence marker**: a read + * returns nothing unless it is present, and clearing the record nulls it. Every multi-slot write goes + * through [PreferencesRepository.edit], which commits all of the record's slots atomically — in + * process and on disk (#1978) — so the record persists whole or not at all across a process death, + * and a record whose marker is present but whose addressing fields are missing cannot exist. */ @Singleton class PushRegistrationStore internal constructor( @@ -80,10 +75,6 @@ class PushRegistrationStore internal constructor( /** Commits [registration] as the live record and stamps the refresh clock. */ fun record(registration: PushRegistration) { - // This endpoint is live again, so the same commit drops any DELETE still owed for it — otherwise - // returning to an old endpoint (region/token switched away, then back) would delete the row we - // just POSTed. This is what makes a single pending-delete slot safe. - val dropsPendingDelete = pendingDelete()?.sameEndpoint(registration) == true prefs.edit { setLong(KEY_REGION_ID, registration.regionId) setString(KEY_BASE, registration.sidecarBaseUrl) @@ -91,7 +82,6 @@ class PushRegistrationStore internal constructor( setString(KEY_DESCRIPTION, registration.description) setLong(KEY_SENT_AT, now().epochMs) setString(KEY_TOKEN, registration.token) - if (dropsPendingDelete) stagePendingDeleteClear() } } @@ -105,48 +95,6 @@ class PushRegistrationStore internal constructor( } } - /** - * Remembers that [registration] still needs deleting server-side. Written when a re-registration's - * POST lands but its DELETE doesn't: the new registration must be recorded, yet the old row is still - * on the server pointing at a token this device still holds, so without this it would keep pushing - * until the 180-day prune. - * - * Only the DELETE-addressing fields (region + host + token) are kept; locale and the test-device - * fields don't affect a DELETE. There is deliberately one slot — a second orphan before the first - * drains overwrites it, which takes two region changes with both old hosts unreachable back to back, - * and the loser still falls back to the server prune. - */ - fun queuePendingDelete(registration: PushRegistration) { - prefs.edit { - setLong(KEY_PENDING_DEL_REGION_ID, registration.regionId) - setString(KEY_PENDING_DEL_BASE, registration.sidecarBaseUrl) - setString(KEY_PENDING_DEL_TOKEN, registration.token) - } - } - - /** The registration awaiting a retried DELETE, or null if none is queued. */ - fun pendingDelete(): PushRegistration? { - 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), - sidecarBaseUrl = base, - token = token, - // Unused when deleting — a DELETE is addressed by region + host + token only. - locale = "", - description = null - ) - } - - fun clearPendingDelete() { - prefs.edit { stagePendingDeleteClear() } - } - - /** The one place that knows how a queued pending delete is dropped: null its presence marker. */ - private fun PreferencesEditor.stagePendingDeleteClear() { - setString(KEY_PENDING_DEL_TOKEN, null) - } - private companion object { // Internal bookkeeping slots (not user-facing) for the last successful registration. const val KEY_REGION_ID = "push_reg_region_id" @@ -155,10 +103,5 @@ class PushRegistrationStore internal constructor( const val KEY_LOCALE = "push_reg_locale" const val KEY_DESCRIPTION = "push_reg_description" const val KEY_SENT_AT = "push_reg_sent_at" - - // Slots for a registration whose DELETE is owed but not yet confirmed (see [queuePendingDelete]). - const val KEY_PENDING_DEL_REGION_ID = "push_pending_del_region_id" - const val KEY_PENDING_DEL_BASE = "push_pending_del_base" - const val KEY_PENDING_DEL_TOKEN = "push_pending_del_token" } } diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt index 6f7ca66a64..eff3a0f38a 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt @@ -200,11 +200,12 @@ class PushRegistrationManagerTest { } @Test - fun `reregister with a failed delete but successful post queues the delete for a later retry`() = runTest { + fun `reregister with a failed delete but successful post drops the delete, best-effort`() = runTest { val f = Fixture(this).registered("T1") - // Region-change-style Reregister where DELETE(T1) fails but POST(T2) lands: T2 is live, yet - // the old T1 registration is still on the server. It must be queued, not dropped. + // Reregister where DELETE(T1) fails but POST(T2) lands: T2 is recorded and the missed DELETE is + // deliberately dropped — the old row falls back to the server's 180-day prune, which is the iOS + // client's baseline for every region change (see applyReregister). f.setToken("T2") f.service.onUnregister = { throw IOException("offline") } f.sync() @@ -212,53 +213,11 @@ class PushRegistrationManagerTest { assertEquals(listOf("T1", "T2"), f.service.registerCalls.map { it.token }) assertEquals(1, f.service.unregisterCalls.count { it.token == "T1" }) - // Next sync, old host reachable again: the queued DELETE(T1) is retried and clears, while the - // live T2 registration is left untouched (no re-POST). + // T2 is settled on record: further syncs neither retry the DELETE nor re-POST. f.service.onUnregister = { Response.success(Unit) } - val postsBefore = f.service.registerCalls.size f.sync() - assertEquals(2, f.service.unregisterCalls.count { it.token == "T1" }) - assertEquals(postsBefore, f.service.registerCalls.size) - - // Drained → a further sync neither DELETEs nor POSTs. - f.sync() - assertEquals(2, f.service.unregisterCalls.count { it.token == "T1" }) - assertEquals(postsBefore, f.service.registerCalls.size) - } - - @Test - fun `a queued delete never removes the live registration after returning to the old token`() = runTest { - val f = Fixture(this).registered("T1") - - // T1 -> T2 with DELETE(T1) failing: T2 live, T1 queued for a retried DELETE. - f.setToken("T2") - f.service.onUnregister = { throw IOException("offline") } - f.sync() - - // Return to T1 while the old host is still unreachable: the reconcile re-POSTs T1 (now live), - // and committing T1 must drop the still-queued DELETE(T1) so it can't later kill the live row - // (only the newly-stale T2 stays queued). - f.setToken("T1") - f.sync() - - // Old host reachable again. Draining must NOT delete T1 (it's live); only DELETE(T2) fires. - f.service.onUnregister = { Response.success(Unit) } - val deletesT1Before = f.service.unregisterCalls.count { it.token == "T1" } - f.sync() - - assertEquals( - "the live T1 registration must not be deleted", - deletesT1Before, - f.service.unregisterCalls.count { it.token == "T1" } - ) - assertTrue("the stale T2 must be drained", f.service.unregisterCalls.any { it.token == "T2" }) - - // T1 remains the live, on-record registration → a further sync is a pure NoOp. - val posts = f.service.registerCalls.size - val deletes = f.service.unregisterCalls.size - f.sync() - assertEquals(posts, f.service.registerCalls.size) - assertEquals(deletes, f.service.unregisterCalls.size) + assertEquals(1, f.service.unregisterCalls.size) + assertEquals(2, f.service.registerCalls.size) } @Test From 7efe9f5b1cef07382024d9f9040d2986a42e00ce Mon Sep 17 00:00:00 2001 From: Brandon Martin-Anderson Date: Wed, 22 Jul 2026 06:49:18 +0000 Subject: [PATCH 25/26] Simplify applyReregister to its structural two-step; honest SidecarUrls KDoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../android/api/contract/SidecarUrls.kt | 13 +++++----- .../android/push/PushRegistrationClient.kt | 7 +++--- .../android/push/PushRegistrationDecision.kt | 6 ++++- .../android/push/PushRegistrationManager.kt | 24 ++++++------------- .../push/PushRegistrationManagerTest.kt | 6 ++--- 5 files changed, 26 insertions(+), 30 deletions(-) diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/api/contract/SidecarUrls.kt b/onebusaway-android/src/main/java/org/onebusaway/android/api/contract/SidecarUrls.kt index e0fc12ef58..84c3a0769a 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/api/contract/SidecarUrls.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/api/contract/SidecarUrls.kt @@ -16,15 +16,16 @@ package org.onebusaway.android.api.contract /** - * Assembles a region-scoped sidecar endpoint URL: + * Assembles a region-scoped **v2** sidecar endpoint URL: * `{sidecarBaseUrl}{regionsPath}{regionId}/{resource}` — e.g. - * `https://sidecar.onebusaway.org/api/v2/regions/1/alarms`. The single source of the sidecar URL - * shape, shared by the alarms client (`TripInfoRepository`) and `PushRegistrationClient`; a change to - * the path scheme is made here once. + * `https://sidecar.onebusaway.org/api/v2/regions/1/alarms`. The one home of that shape, shared by the + * alarms client (`TripInfoRepository`) and `PushRegistrationClient`. (The v1 sidecar endpoints — + * weather, surveys — are a separate pre-existing idiom: each embeds the region id in its own string + * resource via a `regionID` placeholder.) * * [regionsPath] is the resolved `R.string.arrivals_reminders_api_endpoint` (`/api/v2/regions/`). It - * stays a parameter rather than a constant because it is a string *resource* — a brand override - * point — and because Context-free callers inject it for JVM testability. + * stays a parameter because the segment lives in that string resource — overridable per brand, + * though no brand overrides it today — and Context-free callers inject the resolved value. */ fun sidecarRegionUrl( sidecarBaseUrl: String, diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationClient.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationClient.kt index b9ec6cf3fd..3a6954cb64 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationClient.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationClient.kt @@ -48,7 +48,8 @@ class PushRegistrationException(message: String) : Exception(message) @Singleton class PushRegistrationClient internal constructor( private val service: PushRegistrationWebService, - private val registrationsEndpointPath: String, + // The resolved /api/v2/regions/ segment (a string resource, so it can't be a compile-time const). + private val regionsPath: String, // Seams for the Android-only reporting sinks, so failure handling is assertable from a plain JVM // test: android.util.Log and Crashlytics are both unavailable there. private val logWarning: (String, Throwable?) -> Unit, @@ -62,7 +63,7 @@ class PushRegistrationClient internal constructor( service: PushRegistrationWebService ) : this( service = service, - registrationsEndpointPath = context.getString(R.string.arrivals_reminders_api_endpoint), + regionsPath = context.getString(R.string.arrivals_reminders_api_endpoint), logWarning = { message, cause -> Log.w(TAG, message, cause) }, reportError = { FirebaseCrashlytics.getInstance().recordException(it) } ) @@ -96,7 +97,7 @@ class PushRegistrationClient internal constructor( /** `{sidecarBaseUrl}/api/v2/regions/{regionId}/push_registrations`. */ private fun registrationUrl(registration: PushRegistration): String = sidecarRegionUrl( sidecarBaseUrl = registration.sidecarBaseUrl, - regionsPath = registrationsEndpointPath, + regionsPath = regionsPath, regionId = registration.regionId, resource = "push_registrations" ) diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt index 3b4678b323..458df67734 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationDecision.kt @@ -207,7 +207,11 @@ sealed interface PushRegistrationAction { /** * The token or the region changed while still enabled: DELETE the stale [previous] registration - * (so the old region/token stops receiving) and POST the new [target]. + * (so the old region/token stops receiving) and POST the new [target]. The DELETE is + * **best-effort by design**: a miss is dropped, and the old row lingers until the server's + * 180-day prune — the iOS client's baseline for *every* region change (it never DELETEs, and + * issue #1957 marks the old-row DELETE as optional). A persisted retry queue was deliberately + * rejected as disproportionate to that corner (PR #1958 review). */ data class Reregister( val previous: PushRegistration, diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt index edc2710f0f..ff7cb0e3c8 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/push/PushRegistrationManager.kt @@ -158,24 +158,14 @@ class PushRegistrationManager internal constructor( } /** - * DELETEs the stale registration, then POSTs the new one, recording only once the POST lands. Both - * calls can fail independently: - * - * - DELETE ok + POST ok → record(target): old gone, new on record. - * - DELETE ok + POST fail → clear(): the server holds nothing, so the next sync re-Registers. - * - DELETE fail + POST fail → keep `previous`: the next sync retries the whole Reregister. - * - DELETE fail + POST ok → record(target); the failed DELETE is dropped, **best-effort by - * design**: the old row lingers until the server's 180-day prune, which is the iOS client's - * baseline for *every* region change (it never DELETEs, and issue #1957 marks the old-row DELETE - * as optional). Persisting the miss for a retried DELETE was deliberately rejected as - * machinery disproportionate to that corner (PR #1958 review). + * The [PushRegistrationAction.Reregister] DELETE and POST as two independent steps, each + * persisting its own outcome exactly like the standalone Unregister/Register branches in [sync]: + * a cleared-but-unrecorded state re-Registers next sync, both calls failing keeps `previous` so + * the whole Reregister retries, and a missed DELETE alone is dropped — best-effort, see + * [PushRegistrationAction.Reregister]. */ private suspend fun applyReregister(action: PushRegistrationAction.Reregister) { - val cleaned = client.unregister(action.previous) - if (client.register(action.target)) { - store.record(action.target) - } else if (cleaned) { - store.clear() - } + if (client.unregister(action.previous)) store.clear() + if (client.register(action.target)) store.record(action.target) } } diff --git a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt index eff3a0f38a..98b40421ae 100644 --- a/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt +++ b/onebusaway-android/src/test/java/org/onebusaway/android/push/PushRegistrationManagerTest.kt @@ -213,8 +213,8 @@ class PushRegistrationManagerTest { assertEquals(listOf("T1", "T2"), f.service.registerCalls.map { it.token }) assertEquals(1, f.service.unregisterCalls.count { it.token == "T1" }) - // T2 is settled on record: further syncs neither retry the DELETE nor re-POST. - f.service.onUnregister = { Response.success(Unit) } + // T2 is settled on record: further syncs neither retry the DELETE nor re-POST. (onUnregister is + // left throwing, so this pins that no DELETE is even *attempted*.) f.sync() assertEquals(1, f.service.unregisterCalls.size) assertEquals(2, f.service.registerCalls.size) @@ -462,7 +462,7 @@ class PushRegistrationManagerTest { val manager = PushRegistrationManager( client = PushRegistrationClient( service = service, - registrationsEndpointPath = "/api/v2/regions/", + regionsPath = "/api/v2/regions/", logWarning = { message, _ -> loggedWarnings += message }, reportError = { reportedErrors += it } ), From d2e0e2402614c1c553b9cd4ea1dd74f456d6a559 Mon Sep 17 00:00:00 2001 From: Brandon Martin-Anderson Date: Wed, 22 Jul 2026 07:08:31 +0000 Subject: [PATCH 26/26] Serialize the cache read-modify-write in PreferencesRepository.commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../android/preferences/PreferencesRepository.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/onebusaway-android/src/main/java/org/onebusaway/android/preferences/PreferencesRepository.kt b/onebusaway-android/src/main/java/org/onebusaway/android/preferences/PreferencesRepository.kt index 2d3da633b9..d232e75c42 100644 --- a/onebusaway-android/src/main/java/org/onebusaway/android/preferences/PreferencesRepository.kt +++ b/onebusaway-android/src/main/java/org/onebusaway/android/preferences/PreferencesRepository.kt @@ -253,9 +253,18 @@ class DefaultPreferencesRepository @Inject constructor( /** A staged write: sets [value] (null removes the key) on whatever preferences it is applied to. */ private fun stage(key: Preferences.Key, value: T?): (MutablePreferences) -> Unit = { prefs -> if (value == null) prefs.remove(key) else prefs[key] = value } + // Guards the cache read-modify-write in [commit]: @Volatile gives readers visibility but not + // atomicity, so two unsynchronized commits could snapshot the same cache and the later swap would + // silently drop the earlier one's keys — forever, since the cache is never re-mirrored from + // DataStore. Reads stay lock-free; the async persist stays outside (DataStore serializes its own + // edits). + private val cacheLock = Any() + /** Apply [op] to the cache in one swap, then persist it as one async DataStore edit. */ private fun commit(op: (MutablePreferences) -> Unit) { - cache = cache.toMutablePreferences().also(op).toPreferences() + synchronized(cacheLock) { + cache = cache.toMutablePreferences().also(op).toPreferences() + } scope.launch { dataStore.edit { prefs -> op(prefs) } }