Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
8534def
Register devices for OBACloud service-alert push (#1957)
bmander Jul 19, 2026
0d32d80
Resync push registration when notification permission granted in-flow
bmander Jul 19, 2026
080a9fc
Extract shared notification-permission-request Compose helper
bmander Jul 19, 2026
6944d68
Don't orphan the previous registration when a Reregister fully fails
bmander Jul 19, 2026
669f523
Send push-unregister token as a query param; document the #1957 contract
bmander Jul 19, 2026
72b618b
Push registration nits: rename decide(), make sync() private
bmander Jul 19, 2026
33f3088
Cover PushRegistrationManager.sync() apply/persist semantics under JV…
bmander Jul 19, 2026
12f1318
Tidy push-registration test: Fixture.sync()/registered() helpers
bmander Jul 19, 2026
a38db10
Retry orphaned push DELETE and log registration failures
bmander Jul 19, 2026
d30b431
Send the required test-device description, surface failures, and refr…
bmander Jul 20, 2026
9a99bf7
Document that the commit-sentinel write order is in-process-exact, di…
bmander Jul 20, 2026
a2bff19
Split the push registration mechanisms out of the reconciler
bmander Jul 20, 2026
2051afa
Merge remote-tracking branch 'origin/main' into feature/1957-push-reg…
bmander Jul 21, 2026
2cd7ecf
Drop now-unused NOTIFICATION_PERMISSION_REQUEST after merge
bmander Jul 21, 2026
8630642
Cap the test-device description at the documented 255-char limit
bmander Jul 21, 2026
43a34eb
Simplify push registration: derive testDevice, own the name cap at it…
bmander Jul 21, 2026
8400733
Harden the push-registration clock edges; make the test-device settin…
bmander Jul 21, 2026
20a051b
Make the unresolved-vs-none push-registration distinction a compile-t…
bmander Jul 22, 2026
8a8f67a
Persist multi-slot push records atomically via a PreferencesRepositor…
bmander Jul 22, 2026
2812ca2
Keep transient push-registration failures (429/5xx) out of Crashlytics
bmander Jul 22, 2026
57c5d39
Don't DELETE the push registration on OS-level opt-out (match iOS / #…
bmander Jul 22, 2026
6381afa
Make the description cap surrogate-safe: never split a UTF-16 pair
bmander Jul 22, 2026
ac0dbbf
Polish the last three push commits: fix doc drift, dedup rationale, t…
bmander Jul 22, 2026
9b6c973
Extract sidecarRegionUrl(): one home for the sidecar URL shape
bmander Jul 22, 2026
6d8e8e0
Drop the persisted pending-delete queue: the Reregister DELETE is bes…
bmander Jul 22, 2026
7efe9f5
Simplify applyReregister to its structural two-step; honest SidecarUr…
bmander Jul 22, 2026
d2e0e24
Serialize the cache read-modify-write in PreferencesRepository.commit
bmander Jul 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,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" }

Expand Down
2 changes: 2 additions & 0 deletions onebusaway-android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -72,8 +107,15 @@ class PreferencesRepositoryReadYourWritesTest {
private class FakeDataStore(initial: Preferences = emptyPreferences()) : DataStore<Preferences> {
private val emissions = MutableSharedFlow<Preferences>(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)
Expand All @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* 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.Query
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` (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.
*
* [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.
*
* 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
suspend fun register(
@Url url: String,
@Field("token") token: String,
@Field("locale") locale: String,
@Field("test_device") testDevice: Boolean,
@Field("description") description: String?,
@Field("operating_system") operatingSystem: String = "android"
): Response<Unit>

/**
* 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 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<Unit>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* 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 **v2** sidecar endpoint URL:
* `{sidecarBaseUrl}{regionsPath}{regionId}/{resource}` — e.g.
* `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 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,
regionsPath: String,
regionId: Long,
resource: String
): String = "$sidecarBaseUrl$regionsPath$regionId/$resource"
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,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
Expand Down Expand Up @@ -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()
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,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
Expand Down Expand Up @@ -112,6 +113,14 @@ object NetworkModule {
@Singleton
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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* 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()
}
}
Loading