From 2015fe75615e49f10d8dc44266325fe8b4722868 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 13:16:33 -0700 Subject: [PATCH] feat(notifications): device-token lifecycle behind a push-token seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the whole lifecycle around a push device token while Firebase is still blocked (#45): the token itself sits behind a `PushTokenProvider` seam whose only implementation today reports "unavailable", so nothing in the lifecycle knows FCM exists and #47 lands as a single binding swap. - `PushRegistrationRepository` over `POST /api/push/register` and `DELETE /api/push/unregister` (body-carrying DELETE via `@HTTP`), always sending `platform = "android"` and an `environment` derived from the build type: the installed app's `FLAG_DEBUGGABLE`, since a library module's `BuildConfig.DEBUG` tracks its own variant rather than the app's. - `PushRegistrationManager` owns when: register on first token availability and on rotation, re-register on every app launch (the docs ask for it — a StateFlow replay plus a fresh per-process record gives it for free), retire a superseded token on rotation, and unregister on session end. - Sign-out and account deletion both funnel through `AuthRepository.logout()`, so the unregister hangs off a new `SessionTeardownTask` multibinding run there while the bearer token is still valid. No new lifecycle hook, and no exit from a session that can skip it — a stale registration would deliver one account's notifications to whoever signs in next. - `POST_NOTIFICATIONS` moves off cold start to the moment the user switches a "Push" channel on in Notification preferences, which is the same signal the WorkManager poll already filters tray notifications by. Denial is inert: the preference still saves, the poll still runs, the in-app tray is unaffected, and no registration is issued for a device that cannot display a push. The WorkManager poll is untouched and remains the delivery mechanism (#48). Closes #46 --- .../navigation/InterlinedListNavHost.kt | 36 ++-- .../common/session/SessionTeardownTask.kt | 24 +++ .../auth/data/DefaultAuthRepository.kt | 13 ++ .../android/feature/auth/di/AuthModule.kt | 11 + .../auth/data/DefaultAuthRepositoryTest.kt | 68 +++++- .../data/DefaultPushRegistrationRepository.kt | 47 +++++ .../data/PushRegistrationRepository.kt | 24 +++ .../notifications/data/remote/PushApi.kt | 28 +++ .../notifications/data/remote/dto/PushDtos.kt | 43 ++++ .../feature/notifications/di/PushModule.kt | 79 +++++++ .../push/NotificationPermissionChecker.kt | 30 +++ .../push/NotificationPermissionPrompt.kt | 33 +++ .../notifications/push/PushEnvironment.kt | 32 +++ .../push/PushRegistrationManager.kt | 100 +++++++++ .../push/PushRegistrationViewModel.kt | 31 +++ .../notifications/push/PushTokenProvider.kt | 41 ++++ .../push/PushTokenSessionTeardown.kt | 21 ++ .../ui/NotificationPreferencesScreen.kt | 42 +++- .../DefaultPushRegistrationRepositoryTest.kt | 138 ++++++++++++ .../push/NotificationPermissionPromptTest.kt | 74 +++++++ .../notifications/push/PushEnvironmentTest.kt | 19 ++ .../push/PushRegistrationManagerTest.kt | 196 ++++++++++++++++++ .../notifications/push/PushTestDoubles.kt | 46 ++++ 23 files changed, 1153 insertions(+), 23 deletions(-) create mode 100644 core/common/src/main/kotlin/com/interlinedlist/android/core/common/session/SessionTeardownTask.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/DefaultPushRegistrationRepository.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/PushRegistrationRepository.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/PushApi.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/PushDtos.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/di/PushModule.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPermissionChecker.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPermissionPrompt.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/PushEnvironment.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/PushRegistrationManager.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/PushRegistrationViewModel.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/PushTokenProvider.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/PushTokenSessionTeardown.kt create mode 100644 feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/DefaultPushRegistrationRepositoryTest.kt create mode 100644 feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPermissionPromptTest.kt create mode 100644 feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/PushEnvironmentTest.kt create mode 100644 feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/PushRegistrationManagerTest.kt create mode 100644 feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/PushTestDoubles.kt diff --git a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt index a8515aa..9b84116 100644 --- a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt +++ b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt @@ -1,10 +1,5 @@ package com.interlinedlist.android.navigation -import android.Manifest -import android.content.pm.PackageManager -import android.os.Build -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.List @@ -24,7 +19,6 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext -import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavGraph.Companion.findStartDestination @@ -67,6 +61,7 @@ import com.interlinedlist.android.feature.messages.ui.detail.MessageDetailRoute import com.interlinedlist.android.feature.messages.ui.feed.MessagesRoute import com.interlinedlist.android.feature.messages.ui.scheduled.ScheduledMessagesRoute import com.interlinedlist.android.feature.notifications.push.NotificationsSyncScheduler +import com.interlinedlist.android.feature.notifications.push.PushRegistrationViewModel import com.interlinedlist.android.feature.notifications.ui.NotificationPreferencesRoute import com.interlinedlist.android.feature.notifications.ui.NotificationsRoute import com.interlinedlist.android.feature.organizations.ui.detail.OrganizationDetailRoute @@ -250,26 +245,29 @@ private fun MainShell( // Bootstrap the notification poll for the signed-in session: register the periodic // near-real-time poll and kick a one-shot so the last-seen marker seeds immediately. - // On Android 13+ request POST_NOTIFICATIONS first (silently ignored below 13, where - // the permission does not exist). Scheduling must never crash the shell, so failures - // are swallowed. Runs once when the shell enters. - val requestNotificationsPermission = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission(), - ) { /* result ignored: the poll still runs; posting is a no-op if denied */ } + // Scheduling must never crash the shell, so failures are swallowed. Runs once when + // the shell enters. + // + // POST_NOTIFICATIONS is deliberately NOT requested here any more: a cold-start + // prompt arrives with no context and spends one of Android 13's two attempts for + // nothing. It is asked instead at the moment the user switches a "Push" channel on + // in Notification preferences (see NotificationPreferencesRoute). The poll runs + // either way, and posting is already a no-op when the permission is absent. LaunchedEffect(Unit) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - val granted = ContextCompat.checkSelfPermission( - context, - Manifest.permission.POST_NOTIFICATIONS, - ) == PackageManager.PERMISSION_GRANTED - if (!granted) requestNotificationsPermission.launch(Manifest.permission.POST_NOTIFICATIONS) - } runCatching { NotificationsSyncScheduler.schedulePeriodic(context) NotificationsSyncScheduler.syncNow(context) } } + // Device-token lifecycle. Entering this shell is exactly "app launch while signed + // in" plus "just signed in", which is where the push docs want a re-registration; + // collection then continues for the session so a rotated token re-registers too. + // The matching unregister hangs off the auth module's sign-out teardown, so it + // cannot be skipped by whichever exit the user takes. + val pushRegistration: PushRegistrationViewModel = hiltViewModel() + LaunchedEffect(Unit) { pushRegistration.runForSession() } + // Route straight to a tapped notification's destination once, when present. val pendingRoute by rememberUpdatedState(notificationRoute) LaunchedEffect(Unit) { diff --git a/core/common/src/main/kotlin/com/interlinedlist/android/core/common/session/SessionTeardownTask.kt b/core/common/src/main/kotlin/com/interlinedlist/android/core/common/session/SessionTeardownTask.kt new file mode 100644 index 0000000..9e4d140 --- /dev/null +++ b/core/common/src/main/kotlin/com/interlinedlist/android/core/common/session/SessionTeardownTask.kt @@ -0,0 +1,24 @@ +package com.interlinedlist.android.core.common.session + +/** + * A unit of work that must run while the session is still usable, immediately + * before it is torn down by sign-out or account deletion. + * + * Declared here alongside [SessionTokenProvider] for the same reason: `:feature:auth` + * owns the single sign-out path and needs to run contributed teardown steps without + * depending on the feature modules that contribute them (which would invert the module + * graph). Implementations are contributed with Dagger's `@IntoSet`, so adding one is a + * one-line `@Binds @IntoSet` in the owning feature module — no change here or in + * `:feature:auth`. + * + * Contract for implementations: + * - the bearer token is still persisted, so authenticated calls are allowed; + * - be idempotent — teardown may run for a session that was already partly cleaned up; + * - failures are swallowed by the caller; a broken step must never strand a user + * signed in. + */ +interface SessionTeardownTask { + + /** Runs while the session's bearer token is still valid. */ + suspend fun onSessionEnding() +} diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepository.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepository.kt index 036a4fe..a9ed84b 100644 --- a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepository.kt +++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepository.kt @@ -5,6 +5,7 @@ import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.database.dao.UserDao import com.interlinedlist.android.core.database.entity.CachedUserEntity import com.interlinedlist.android.core.datastore.SessionStore +import com.interlinedlist.android.core.common.session.SessionTeardownTask import com.interlinedlist.android.core.model.User import com.interlinedlist.android.core.network.api.InterlinedListApi import com.interlinedlist.android.core.network.dto.SyncTokenRequest @@ -26,6 +27,12 @@ class DefaultAuthRepository @Inject constructor( private val userDao: UserDao, private val json: Json, private val dispatchers: DispatcherProvider, + /** + * Steps contributed by other feature modules that must run while the session is + * still valid (e.g. unregistering this device's push token). Dagger supplies an + * empty set when nothing contributes — see `AuthModule.sessionTeardownTasks`. + */ + private val sessionTeardownTasks: Set<@JvmSuppressWildcards SessionTeardownTask>, ) : AuthRepository { override fun isLoggedIn(): Boolean = sessionStore.isLoggedIn @@ -85,6 +92,12 @@ class DefaultAuthRepository @Inject constructor( } override suspend fun logout() = withContext(dispatchers.io) { + // Teardown runs FIRST, while the bearer token is still persisted, because the + // contributed steps make authenticated calls (push-token unregister). This is + // the app's single sign-out path — account deletion funnels through it too — + // so a teardown step cannot be skipped by some other exit. A failing step must + // never strand the user signed in, so each is individually guarded. + sessionTeardownTasks.forEach { task -> runCatching { task.onSessionEnding() } } sessionStore.clear() userDao.clear() } diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/di/AuthModule.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/di/AuthModule.kt index d5400a8..ac9077c 100644 --- a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/di/AuthModule.kt +++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/di/AuthModule.kt @@ -1,5 +1,6 @@ package com.interlinedlist.android.feature.auth.di +import com.interlinedlist.android.core.common.session.SessionTeardownTask import com.interlinedlist.android.feature.auth.data.AuthRepository import com.interlinedlist.android.feature.auth.data.DefaultAuthRepository import com.interlinedlist.android.feature.auth.data.remote.AuthApi @@ -8,6 +9,7 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import dagger.multibindings.Multibinds import retrofit2.Retrofit import javax.inject.Singleton @@ -18,6 +20,15 @@ abstract class AuthModule { @Binds @Singleton abstract fun bindAuthRepository(impl: DefaultAuthRepository): AuthRepository + + /** + * Declares the set of session-teardown steps run on sign-out / account deletion so + * it can be injected even when no module contributes one (Dagger then supplies an + * empty set). Feature modules opt in with `@Binds @IntoSet` — see + * `PushTokenSessionTeardown` in `:feature:notifications`. + */ + @Multibinds + abstract fun sessionTeardownTasks(): Set } /** diff --git a/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepositoryTest.kt b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepositoryTest.kt index accfb65..4517c0d 100644 --- a/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepositoryTest.kt +++ b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepositoryTest.kt @@ -3,6 +3,7 @@ package com.interlinedlist.android.feature.auth.data import com.google.common.truth.Truth.assertThat import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.core.common.session.SessionTeardownTask import com.interlinedlist.android.core.datastore.SessionStore import com.interlinedlist.android.core.network.api.InterlinedListApi import com.interlinedlist.android.feature.auth.data.remote.AuthApi @@ -53,13 +54,16 @@ class DefaultAuthRepositoryTest { @After fun tearDown() = server.shutdown() - private fun repository() = DefaultAuthRepository( + private fun repository( + teardownTasks: Set = emptySet(), + ) = DefaultAuthRepository( api = api, authApi = authApi, sessionStore = session, userDao = dao, json = json, dispatchers = TestDispatcherProvider(dispatcher), + sessionTeardownTasks = teardownTasks, ) private fun enqueue(code: Int, body: String = "") { @@ -200,4 +204,66 @@ class DefaultAuthRepositoryTest { assertThat(result).isInstanceOf(ApiResult.Success::class.java) assertThat(server.takeRequest().path).contains("api/auth/send-verification-email") } + + // ---- sign-out / account deletion teardown ------------------------------ + + @Test + fun `sign-out runs session teardown while the token is still valid, then clears it`() = + runTest(dispatcher) { + session.saveToken("il_tok_abc") + session.userId = "u1" + val teardown = RecordingTeardown(session) + + repository(teardownTasks = setOf(teardown)).logout() + + assertThat(teardown.runCount).isEqualTo(1) + // The push-token unregister is an authenticated call, so the token MUST + // still be readable at teardown time. + assertThat(teardown.tokenSeen).isEqualTo("il_tok_abc") + assertThat(session.currentToken()).isNull() + assertThat(dao.cleared).isTrue() + } + + @Test + fun `account deletion signs out through the same path, so teardown still runs`() = + runTest(dispatcher) { + // AccountSettings deletes the account and then calls this very logout(), so + // there is no second sign-out path that could bypass the teardown hook. + session.saveToken("il_tok_abc") + val teardown = RecordingTeardown(session) + + repository(teardownTasks = setOf(teardown)).logout() + + assertThat(teardown.runCount).isEqualTo(1) + assertThat(teardown.tokenSeen).isEqualTo("il_tok_abc") + assertThat(session.currentToken()).isNull() + } + + @Test + fun `a failing teardown step never strands the user signed in`() = runTest(dispatcher) { + session.saveToken("il_tok_abc") + val exploding = object : SessionTeardownTask { + override suspend fun onSessionEnding() = error("push unregister failed") + } + val teardown = RecordingTeardown(session) + + repository(teardownTasks = setOf(exploding, teardown)).logout() + + assertThat(teardown.runCount).isEqualTo(1) // the other step still ran + assertThat(session.currentToken()).isNull() + assertThat(dao.cleared).isTrue() + } +} + +/** Records that teardown ran, and what the session looked like at that moment. */ +private class RecordingTeardown(private val session: SessionStore) : SessionTeardownTask { + var runCount = 0 + private set + var tokenSeen: String? = null + private set + + override suspend fun onSessionEnding() { + runCount++ + tokenSeen = session.currentToken() + } } diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/DefaultPushRegistrationRepository.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/DefaultPushRegistrationRepository.kt new file mode 100644 index 0000000..6d1963e --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/DefaultPushRegistrationRepository.kt @@ -0,0 +1,47 @@ +package com.interlinedlist.android.feature.notifications.data + +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.map +import com.interlinedlist.android.core.network.error.safeApiCall +import com.interlinedlist.android.feature.notifications.data.remote.PushApi +import com.interlinedlist.android.feature.notifications.data.remote.dto.PushRegistrationRequest +import com.interlinedlist.android.feature.notifications.data.remote.dto.PushUnregisterRequest +import com.interlinedlist.android.feature.notifications.push.PushEnvironment +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import javax.inject.Inject + +/** + * `POST /api/push/register` + `DELETE /api/push/unregister` over the shared authed + * Retrofit stack. Stateless on purpose: WHEN to call these is the lifecycle's job + * ([com.interlinedlist.android.feature.notifications.push.PushRegistrationManager]). + */ +class DefaultPushRegistrationRepository @Inject constructor( + private val api: PushApi, + private val environment: PushEnvironment, + private val json: Json, + private val dispatchers: DispatcherProvider, +) : PushRegistrationRepository { + + override suspend fun register(token: String): ApiResult = withContext(dispatchers.io) { + safeApiCall(json) { + api.register( + PushRegistrationRequest( + token = token, + // The server accepts exactly "ios" or "android". + platform = ANDROID_PLATFORM, + environment = environment.apiValue, + ), + ) + }.map { } + } + + override suspend fun unregister(token: String): ApiResult = withContext(dispatchers.io) { + safeApiCall(json) { api.unregister(PushUnregisterRequest(token)) } + } + + private companion object { + const val ANDROID_PLATFORM = "android" + } +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/PushRegistrationRepository.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/PushRegistrationRepository.kt new file mode 100644 index 0000000..d3f70fb --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/PushRegistrationRepository.kt @@ -0,0 +1,24 @@ +package com.interlinedlist.android.feature.notifications.data + +import com.interlinedlist.android.core.common.result.ApiResult + +/** + * The device-token half of push notifications: tells the server which device the + * signed-in account's pushes should go to, and — just as importantly — that they + * should stop. + */ +interface PushRegistrationRepository { + + /** + * Registers (or updates) [token] for the signed-in user as an `android` device. + * Re-registering a token the server already knows updates the existing record, so + * this is safe to call on every launch. + */ + suspend fun register(token: String): ApiResult + + /** + * Removes [token]'s registration. Idempotent — an unknown token still succeeds — + * so it can be called defensively on sign-out without tracking what was registered. + */ + suspend fun unregister(token: String): ApiResult +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/PushApi.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/PushApi.kt new file mode 100644 index 0000000..9b5bb92 --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/PushApi.kt @@ -0,0 +1,28 @@ +package com.interlinedlist.android.feature.notifications.data.remote + +import com.interlinedlist.android.feature.notifications.data.remote.dto.PushRegistrationRequest +import com.interlinedlist.android.feature.notifications.data.remote.dto.PushRegistrationResponse +import com.interlinedlist.android.feature.notifications.data.remote.dto.PushUnregisterRequest +import retrofit2.http.Body +import retrofit2.http.HTTP +import retrofit2.http.POST + +/** + * Retrofit description of the device-token endpoints. Provided from the shared, + * already-authenticated [retrofit2.Retrofit] (base URL + Bearer interceptor), so both + * calls carry the current session's token — which is what binds a device token to an + * account, and why [unregister] must run BEFORE the session is cleared on sign-out. + */ +interface PushApi { + + /** Registers (or updates) this device's push token for the signed-in user. */ + @POST("api/push/register") + suspend fun register(@Body body: PushRegistrationRequest): PushRegistrationResponse + + /** + * Removes this device's registration. `@HTTP(hasBody = true)` rather than `@DELETE` + * because Retrofit's `@DELETE` forbids a body and the server expects the token in one. + */ + @HTTP(method = "DELETE", path = "api/push/unregister", hasBody = true) + suspend fun unregister(@Body body: PushUnregisterRequest) +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/PushDtos.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/PushDtos.kt new file mode 100644 index 0000000..88380df --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/PushDtos.kt @@ -0,0 +1,43 @@ +package com.interlinedlist.android.feature.notifications.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * Body for `POST /api/push/register`, per the published contract + * (https://interlinedlist.com/help/api/push-notifications): + * + * ``` + * { "token": "", "platform": "android", "environment": "production" } + * ``` + * + * [platform] must be exactly `ios` or `android`; [environment] is optional + * (`sandbox` / `production`, inferred server-side when omitted) but we always send it + * so a debug install can never have its token treated as a production device. + * Re-registering an existing token updates the record rather than duplicating it. + */ +@Serializable +data class PushRegistrationRequest( + val token: String, + val platform: String, + val environment: String, +) + +/** + * Response for `POST /api/push/register`: `{ "registered": true }`. Defaulted and + * decoded with the shared `ignoreUnknownKeys` Json, so an empty or extended body + * still parses; the HTTP status is what decides success. + */ +@Serializable +data class PushRegistrationResponse( + val registered: Boolean = true, +) + +/** + * Body for `DELETE /api/push/unregister`: `{ "token": … }`. The token travels in the + * REQUEST BODY (not a query parameter), and the call is idempotent — unregistering an + * unknown token still returns 200. + */ +@Serializable +data class PushUnregisterRequest( + val token: String, +) diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/di/PushModule.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/di/PushModule.kt new file mode 100644 index 0000000..c6bdce7 --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/di/PushModule.kt @@ -0,0 +1,79 @@ +package com.interlinedlist.android.feature.notifications.di + +import android.content.Context +import android.content.pm.ApplicationInfo +import com.interlinedlist.android.core.common.session.SessionTeardownTask +import com.interlinedlist.android.feature.notifications.data.DefaultPushRegistrationRepository +import com.interlinedlist.android.feature.notifications.data.PushRegistrationRepository +import com.interlinedlist.android.feature.notifications.data.remote.PushApi +import com.interlinedlist.android.feature.notifications.push.NotificationPermissionChecker +import com.interlinedlist.android.feature.notifications.push.PushEnvironment +import com.interlinedlist.android.feature.notifications.push.PushTokenProvider +import com.interlinedlist.android.feature.notifications.push.PushTokenSessionTeardown +import com.interlinedlist.android.feature.notifications.push.SystemNotificationPermissionChecker +import com.interlinedlist.android.feature.notifications.push.UnavailablePushTokenProvider +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet +import retrofit2.Retrofit +import javax.inject.Singleton + +/** Binds the push device-token lifecycle collaborators to their implementations. */ +@Module +@InstallIn(SingletonComponent::class) +abstract class PushRegistrationModule { + + @Binds + @Singleton + abstract fun bindPushRegistrationRepository( + impl: DefaultPushRegistrationRepository, + ): PushRegistrationRepository + + /** + * TODO(#45/#47): swap for the FCM-backed provider once `google-services.json` + * exists. Until then no token is available and nothing registers — by design. + */ + @Binds + @Singleton + abstract fun bindPushTokenProvider(impl: UnavailablePushTokenProvider): PushTokenProvider + + /** + * Contributes the push-token unregister to `:feature:auth`'s sign-out teardown, so + * signing out (or deleting the account) always retires this device's registration. + */ + @Binds + @IntoSet + abstract fun bindPushTokenSessionTeardown(impl: PushTokenSessionTeardown): SessionTeardownTask +} + +/** Provides the push data layer and the build-derived registration environment. */ +@Module +@InstallIn(SingletonComponent::class) +object PushDataModule { + + @Provides + @Singleton + fun providePushApi(retrofit: Retrofit): PushApi = retrofit.create(PushApi::class.java) + + /** + * Derives `environment` from the build type: the installed app's debuggable flag is + * the debug/release signal that is visible from a library module at runtime (see + * [PushEnvironment] for why `BuildConfig.DEBUG` is the wrong one here). + */ + @Provides + @Singleton + fun providePushEnvironment(@ApplicationContext context: Context): PushEnvironment = + PushEnvironment.fromDebuggable( + (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0, + ) + + @Provides + @Singleton + fun provideNotificationPermissionChecker( + @ApplicationContext context: Context, + ): NotificationPermissionChecker = SystemNotificationPermissionChecker(context) +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPermissionChecker.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPermissionChecker.kt new file mode 100644 index 0000000..a5b50d9 --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPermissionChecker.kt @@ -0,0 +1,30 @@ +package com.interlinedlist.android.feature.notifications.push + +import android.content.Context +import androidx.core.app.NotificationManagerCompat + +/** + * Whether this app may actually show a notification right now. Abstracted (DIP) so the + * registration lifecycle is unit-testable without an Android runtime. + */ +interface NotificationPermissionChecker { + + /** True when notifications can be posted (POST_NOTIFICATIONS held and not muted). */ + fun canPostNotifications(): Boolean +} + +/** + * Real check, delegating to [NotificationManagerCompat.areNotificationsEnabled] — the + * same gate [SystemNotificationPoster] already applies before raising a tray + * notification from the background poll. Using one signal for both keeps "we told the + * server to push to this device" and "this device can display a push" in step: it + * covers the Android 13+ runtime permission AND the user switching notifications off + * in system settings afterwards. + */ +class SystemNotificationPermissionChecker( + private val context: Context, +) : NotificationPermissionChecker { + + override fun canPostNotifications(): Boolean = + NotificationManagerCompat.from(context).areNotificationsEnabled() +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPermissionPrompt.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPermissionPrompt.kt new file mode 100644 index 0000000..8830895 --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPermissionPrompt.kt @@ -0,0 +1,33 @@ +package com.interlinedlist.android.feature.notifications.push + +import android.os.Build +import com.interlinedlist.android.feature.notifications.domain.NotificationChannel + +/** + * Decides whether a preference toggle is the moment to ask for `POST_NOTIFICATIONS`. + * + * The app raises its tray notifications from the WorkManager poll + * ([SystemNotificationPoster]), gated by [NotificationPushFilter] on the recipient's + * per-event **push** preference. So the moment that earns the grant is the moment the + * user switches a "Push" channel ON in Notification preferences: they have just asked, + * in so many words, to be notified — the system dialog then answers a question they + * themselves posed, instead of ambushing them on cold start (which burns one of + * Android 13's two prompts on a user with no context). + * + * A denial changes nothing else: the preference is still saved server-side, the poll + * still runs, the in-app tray still works, and [SystemNotificationPoster] already + * no-ops when notifications are disabled. + * + * Pure function so the rule is unit-testable; [sdkInt] is a parameter for the same reason. + */ +fun shouldRequestPostNotifications( + channel: NotificationChannel, + enabled: Boolean, + alreadyGranted: Boolean, + sdkInt: Int = Build.VERSION.SDK_INT, +): Boolean = + channel == NotificationChannel.PUSH && + enabled && + !alreadyGranted && + // Below Android 13 the permission does not exist and is granted implicitly. + sdkInt >= Build.VERSION_CODES.TIRAMISU diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/PushEnvironment.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/PushEnvironment.kt new file mode 100644 index 0000000..91dd402 --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/PushEnvironment.kt @@ -0,0 +1,32 @@ +package com.interlinedlist.android.feature.notifications.push + +/** + * The `environment` value sent with a device-token registration. + * + * The field is optional — the server infers it when omitted — but we always send it so + * a token minted by a developer build can never be filed as a production device and + * start receiving real pushes for an account. + * + * It is derived from the BUILD TYPE rather than hard-coded, and specifically from + * whether the installed application is debuggable (`ApplicationInfo.FLAG_DEBUGGABLE`) + * rather than from a `BuildConfig.DEBUG` constant. Two reasons: + * 1. `BuildConfig` in a library module reflects that library's own variant, not the + * variant of the app that embeds it, so it is the wrong signal here (and generating + * one would mean enabling `buildConfig` just for a single boolean); + * 2. the debuggable flag is the exact debug/release distinction the push providers + * themselves draw between their sandbox and production gateways. + */ +enum class PushEnvironment(val apiValue: String) { + /** Developer builds — keeps debug tokens off the production gateway. */ + SANDBOX("sandbox"), + + /** Release builds. */ + PRODUCTION("production"), + ; + + companion object { + /** Maps the installed app's debuggable flag onto the wire value. */ + fun fromDebuggable(debuggable: Boolean): PushEnvironment = + if (debuggable) SANDBOX else PRODUCTION + } +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/PushRegistrationManager.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/PushRegistrationManager.kt new file mode 100644 index 0000000..9f548c9 --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/PushRegistrationManager.kt @@ -0,0 +1,100 @@ +package com.interlinedlist.android.feature.notifications.push + +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.notifications.data.PushRegistrationRepository +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Owns the device-token lifecycle. It is the only place that decides WHEN this device + * is registered for push and when that registration is torn down. + * + * Lifecycle, in the order the server contract asks for it: + * - **first availability + rotation** — [runForSession] collects [PushTokenProvider.token] + * for as long as a signed-in session is on screen, so a token that appears late or + * changes is registered as soon as it does; + * - **every app launch** — the provider's flow is a `StateFlow`, so collection replays + * the current token immediately; a fresh process has registered nothing yet and + * therefore re-registers, exactly as the docs require ("re-register on every app + * launch in case the token rotated"); + * - **sign-out and account deletion** — [unregisterCurrentToken], driven from + * `:feature:auth`'s single sign-out path via [PushTokenSessionTeardown]. + * + * Registration is additionally gated on the device actually being able to show a + * notification ([NotificationPermissionChecker]): there is no point asking the server + * to push to a device whose notifications are switched off, and the app stays fully + * functional in that state (the in-app tray and the WorkManager poll are untouched). + * [onNotificationPermissionGranted] closes the loop when the user grants it later. + */ +@Singleton +class PushRegistrationManager @Inject constructor( + private val tokenProvider: PushTokenProvider, + private val repository: PushRegistrationRepository, + private val permissions: NotificationPermissionChecker, +) { + + /** Guards [registeredToken] against concurrent rotation/teardown. */ + private val mutex = Mutex() + + /** + * The token this process last registered successfully. Kept so sign-out can retire + * it even if the provider has since rotated to a different one. + */ + private var registeredToken: String? = null + + /** + * Runs the registration lifecycle for a signed-in session. Suspends until the + * caller's scope is cancelled (i.e. until sign-out), registering the current token + * immediately and every rotation thereafter. + */ + suspend fun runForSession() { + tokenProvider.token.collect { token -> registerIfPossible(token) } + } + + /** + * Re-attempts registration right after POST_NOTIFICATIONS is granted, since the + * token itself has not changed and so will not be re-emitted. + */ + suspend fun onNotificationPermissionGranted() { + registerIfPossible(tokenProvider.token.value) + } + + /** + * Retires this device's registration so notifications for the account being signed + * out of can no longer reach it — the security-relevant half of the lifecycle. + * + * Both the token this process registered and the provider's current token are + * retired. They normally coincide — a rotation retires the token it supersedes + * straight away — but they diverge if a registration failed, and unregister is + * idempotent, so the redundant call is harmless where a missed one is not. + */ + suspend fun unregisterCurrentToken() { + val tokens = mutex.withLock { + val pending = setOfNotNull( + registeredToken?.takeIf { it.isNotBlank() }, + tokenProvider.token.value?.takeIf { it.isNotBlank() }, + ) + registeredToken = null + pending + } + tokens.forEach { repository.unregister(it) } + } + + private suspend fun registerIfPossible(token: String?) { + if (token.isNullOrBlank()) return + // Denied (or switched off in system settings): stay silent. No registration is + // issued and nothing else in the app is affected. + if (!permissions.canPostNotifications()) return + val previous = mutex.withLock { registeredToken } + if (token == previous) return + + if (repository.register(token) !is ApiResult.Success) return + mutex.withLock { registeredToken = token } + // The token rotated: retire the registration it replaced, rather than leaving + // the server holding a dead one for this device. Done only after the new + // registration succeeded, so a failure never leaves the device unreachable. + if (previous != null) repository.unregister(previous) + } +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/PushRegistrationViewModel.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/PushRegistrationViewModel.kt new file mode 100644 index 0000000..651cda0 --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/PushRegistrationViewModel.kt @@ -0,0 +1,31 @@ +package com.interlinedlist.android.feature.notifications.push + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Thin Compose-facing adapter over the app-scoped [PushRegistrationManager], so the + * signed-in shell and the notification-preferences screen can drive the device-token + * lifecycle without either of them holding Android/DI plumbing. The manager is a + * singleton, so every instance of this view model talks to the same state. + */ +@HiltViewModel +class PushRegistrationViewModel @Inject constructor( + private val registrationManager: PushRegistrationManager, +) : ViewModel() { + + /** + * Runs the device-token lifecycle for as long as the caller's coroutine lives: + * registers the current token now (app launch / just-signed-in) and on every + * rotation. Suspends until cancelled. + */ + suspend fun runForSession() = registrationManager.runForSession() + + /** Registers the device now that the user has just granted POST_NOTIFICATIONS. */ + fun onNotificationPermissionGranted() { + viewModelScope.launch { registrationManager.onNotificationPermissionGranted() } + } +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/PushTokenProvider.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/PushTokenProvider.kt new file mode 100644 index 0000000..31b69ae --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/PushTokenProvider.kt @@ -0,0 +1,41 @@ +package com.interlinedlist.android.feature.notifications.push + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The seam between "this device's push token" and everything that manages its + * lifecycle. Nothing outside an implementation of this interface knows which push + * provider issued the token — [PushRegistrationManager] just sees an opaque string. + * + * The token is exposed as a [StateFlow] so a single collector covers all three cases + * the server contract cares about: the current value replays on collection (re-register + * on every app launch), the first non-null value arrives when the token first becomes + * available, and later values arrive on rotation. + */ +interface PushTokenProvider { + + /** The current device token, or `null` while none is available. */ + val token: StateFlow +} + +/** + * The only implementation that can exist today: there is no Firebase project and no + * `google-services.json` in this repo, so no FCM token can be obtained and the token + * is permanently `null`. Everything downstream degrades cleanly — nothing registers, + * and the WorkManager notification poll keeps delivering the tray notifications. + * + * TODO(#45/#47): once the repo owner lands the Firebase project and + * `google-services.json` (#45), replace this binding in `PushModule` with an + * FCM-backed provider (#47) that seeds `token` from `FirebaseMessaging.getToken()` + * and pushes rotations in from `FirebaseMessagingService.onNewToken`. That is the + * ONLY change the lifecycle needs — registration, launch re-registration, sign-out + * unregistration and the permission prompt are all already wired against this seam. + */ +@Singleton +class UnavailablePushTokenProvider @Inject constructor() : PushTokenProvider { + override val token: StateFlow = MutableStateFlow(null).asStateFlow() +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/PushTokenSessionTeardown.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/PushTokenSessionTeardown.kt new file mode 100644 index 0000000..3794059 --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/push/PushTokenSessionTeardown.kt @@ -0,0 +1,21 @@ +package com.interlinedlist.android.feature.notifications.push + +import com.interlinedlist.android.core.common.session.SessionTeardownTask +import javax.inject.Inject + +/** + * Hooks the device-token teardown into `:feature:auth`'s existing sign-out path rather + * than adding a parallel one. `DefaultAuthRepository.logout()` runs every contributed + * [SessionTeardownTask] while the bearer token is still persisted, and BOTH user-facing + * exits — the Account hub's "Sign out" and account deletion — go through that single + * method, so there is no route out of a session that skips this. + * + * Why it matters: a device token left registered keeps delivering the previous + * account's notifications to a phone that someone else may now be signed in on. + */ +class PushTokenSessionTeardown @Inject constructor( + private val registrationManager: PushRegistrationManager, +) : SessionTeardownTask { + + override suspend fun onSessionEnding() = registrationManager.unregisterCurrentToken() +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationPreferencesScreen.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationPreferencesScreen.kt index f52f2a5..bf51460 100644 --- a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationPreferencesScreen.kt +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationPreferencesScreen.kt @@ -1,5 +1,9 @@ package com.interlinedlist.android.feature.notifications.ui +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -34,12 +38,16 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme import com.interlinedlist.android.feature.notifications.domain.NotificationChannel import com.interlinedlist.android.feature.notifications.domain.NotificationPreference +import com.interlinedlist.android.feature.notifications.push.PushRegistrationViewModel +import com.interlinedlist.android.feature.notifications.push.shouldRequestPostNotifications /** Stable test tags for the notification-preferences screen. */ object NotificationPreferencesTags { @@ -62,8 +70,15 @@ private fun NotificationChannel.displayLabel(): String = when (this) { } /** - * Hilt-wired notification-preferences entry point. Reached from the Account hub as a - * drill-down; mirrors the back pattern used by the other detail screens. + * Hilt-wired notification-preferences entry point. Reached from the Account hub and + * from the notifications tray as a drill-down; mirrors the back pattern used by the + * other detail screens. + * + * This is also where `POST_NOTIFICATIONS` is requested — deliberately here and NOT on + * cold start. Switching a "Push" channel on is the user asking to be notified, so the + * system dialog lands in context (see `shouldRequestPostNotifications`). A denial is a + * no-op for the rest of the app: the preference is still saved, the poll still runs and + * the in-app tray is unaffected; only the device-token registration stays on hold. * * @param onBack pops the preferences screen off the back stack. */ @@ -72,17 +87,38 @@ fun NotificationPreferencesRoute( onBack: () -> Unit, modifier: Modifier = Modifier, viewModel: NotificationPreferencesViewModel = hiltViewModel(), + pushRegistrationViewModel: PushRegistrationViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val requestPostNotifications = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + // Granted: the token can be registered now — it has not changed, so the token + // flow will not re-emit and the manager has to be nudged. Denied: nothing to do. + if (granted) pushRegistrationViewModel.onNotificationPermissionGranted() + } NotificationPreferencesScreen( state = state, onBack = onBack, onRetry = viewModel::refresh, - onToggle = viewModel::onToggle, + onToggle = { key, channel, enabled -> + viewModel.onToggle(key, channel, enabled) + val granted = ContextCompat.checkSelfPermission( + context, + POST_NOTIFICATIONS_PERMISSION, + ) == PackageManager.PERMISSION_GRANTED + if (shouldRequestPostNotifications(channel, enabled, alreadyGranted = granted)) { + requestPostNotifications.launch(POST_NOTIFICATIONS_PERMISSION) + } + }, modifier = modifier, ) } +/** The Android 13+ runtime permission guarding tray notifications. */ +private const val POST_NOTIFICATIONS_PERMISSION = Manifest.permission.POST_NOTIFICATIONS + /** Stateless preferences UI — drives the list/empty/error/loading states from [state]. */ @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/DefaultPushRegistrationRepositoryTest.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/DefaultPushRegistrationRepositoryTest.kt new file mode 100644 index 0000000..1c6974b --- /dev/null +++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/DefaultPushRegistrationRepositoryTest.kt @@ -0,0 +1,138 @@ +package com.interlinedlist.android.feature.notifications.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.notifications.data.remote.PushApi +import com.interlinedlist.android.feature.notifications.push.PushEnvironment +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit + +/** + * Pins the wire contract of `POST /api/push/register` and `DELETE /api/push/unregister` + * (https://interlinedlist.com/help/api/push-notifications) against a MockWebServer. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultPushRegistrationRepositoryTest { + + private val dispatcher = StandardTestDispatcher() + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + + private lateinit var server: MockWebServer + private lateinit var api: PushApi + + @Before + fun setUp() { + server = MockWebServer() + server.start() + api = Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + .create(PushApi::class.java) + } + + @After + fun tearDown() = server.shutdown() + + private fun repository(environment: PushEnvironment = PushEnvironment.PRODUCTION) = + DefaultPushRegistrationRepository( + api = api, + environment = environment, + json = json, + dispatchers = TestDispatcherProvider(dispatcher), + ) + + private fun enqueue(code: Int, body: String = "") { + server.enqueue(MockResponse().setResponseCode(code).setBody(body)) + } + + // ---- register ---------------------------------------------------------- + + @Test + fun `register posts the token as an android device in the production environment`() = + runTest(dispatcher) { + enqueue(200, """{ "registered": true }""") + + val result = repository(PushEnvironment.PRODUCTION).register("dev-token-abc") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("POST") + assertThat(recorded.path).isEqualTo("/api/push/register") + val body = Json.parseToJsonElement(recorded.body.readUtf8()).jsonObject + assertThat(body["token"]?.jsonPrimitive?.content).isEqualTo("dev-token-abc") + // The server accepts exactly "ios" or "android" — never "Android"/"fcm". + assertThat(body["platform"]?.jsonPrimitive?.content).isEqualTo("android") + assertThat(body["environment"]?.jsonPrimitive?.content).isEqualTo("production") + assertThat(body.keys).containsExactly("token", "platform", "environment") + } + + @Test + fun `register sends the sandbox environment for a debuggable build`() = runTest(dispatcher) { + enqueue(200, """{ "registered": true }""") + + repository(PushEnvironment.fromDebuggable(debuggable = true)).register("dev-token-abc") + + val body = Json.parseToJsonElement(server.takeRequest().body.readUtf8()).jsonObject + assertThat(body["platform"]?.jsonPrimitive?.content).isEqualTo("android") + assertThat(body["environment"]?.jsonPrimitive?.content).isEqualTo("sandbox") + } + + @Test + fun `register tolerates a body without the registered flag`() = runTest(dispatcher) { + enqueue(200, "{}") + + assertThat(repository().register("dev-token-abc")) + .isInstanceOf(ApiResult.Success::class.java) + } + + @Test + fun `register maps a 401 to an unauthorized failure`() = runTest(dispatcher) { + enqueue(401, """{ "error": "Unauthorized", "code": "unauthorized" }""") + + val result = repository().register("dev-token-abc") + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error).isInstanceOf(AppError.Unauthorized::class.java) + } + + // ---- unregister -------------------------------------------------------- + + @Test + fun `unregister sends the token in the DELETE request body`() = runTest(dispatcher) { + enqueue(200, """{ "unregistered": true }""") + + val result = repository().unregister("dev-token-abc") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("DELETE") + assertThat(recorded.path).isEqualTo("/api/push/unregister") + val body = Json.parseToJsonElement(recorded.body.readUtf8()).jsonObject + assertThat(body["token"]?.jsonPrimitive?.content).isEqualTo("dev-token-abc") + assertThat(body.keys).containsExactly("token") + } + + @Test + fun `unregistering an unknown token still succeeds`() = runTest(dispatcher) { + // Documented as idempotent: an unknown token returns 200, so sign-out can call + // this defensively without tracking what the server actually holds. + enqueue(200, "") + + assertThat(repository().unregister("never-registered")) + .isInstanceOf(ApiResult.Success::class.java) + } +} diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPermissionPromptTest.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPermissionPromptTest.kt new file mode 100644 index 0000000..93f7b58 --- /dev/null +++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/NotificationPermissionPromptTest.kt @@ -0,0 +1,74 @@ +package com.interlinedlist.android.feature.notifications.push + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.notifications.domain.NotificationChannel +import org.junit.Test + +/** The rule behind "ask at a moment that earns the grant, not on cold start". */ +class NotificationPermissionPromptTest { + + private val tiramisu = 33 + private val preTiramisu = 32 + + @Test + fun `asks when the user switches push on`() { + assertThat( + shouldRequestPostNotifications( + channel = NotificationChannel.PUSH, + enabled = true, + alreadyGranted = false, + sdkInt = tiramisu, + ), + ).isTrue() + } + + @Test + fun `does not ask when the user switches push off`() { + assertThat( + shouldRequestPostNotifications( + channel = NotificationChannel.PUSH, + enabled = false, + alreadyGranted = false, + sdkInt = tiramisu, + ), + ).isFalse() + } + + @Test + fun `does not ask for the in-app or email channels`() { + listOf(NotificationChannel.IN_APP, NotificationChannel.EMAIL).forEach { channel -> + assertThat( + shouldRequestPostNotifications( + channel = channel, + enabled = true, + alreadyGranted = false, + sdkInt = tiramisu, + ), + ).isFalse() + } + } + + @Test + fun `does not ask again once the permission is held`() { + assertThat( + shouldRequestPostNotifications( + channel = NotificationChannel.PUSH, + enabled = true, + alreadyGranted = true, + sdkInt = tiramisu, + ), + ).isFalse() + } + + @Test + fun `does not ask below Android 13 where the permission does not exist`() { + assertThat( + shouldRequestPostNotifications( + channel = NotificationChannel.PUSH, + enabled = true, + alreadyGranted = false, + sdkInt = preTiramisu, + ), + ).isFalse() + } +} diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/PushEnvironmentTest.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/PushEnvironmentTest.kt new file mode 100644 index 0000000..93667c5 --- /dev/null +++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/PushEnvironmentTest.kt @@ -0,0 +1,19 @@ +package com.interlinedlist.android.feature.notifications.push + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class PushEnvironmentTest { + + @Test + fun `debuggable builds register against the sandbox`() { + assertThat(PushEnvironment.fromDebuggable(true)).isEqualTo(PushEnvironment.SANDBOX) + assertThat(PushEnvironment.fromDebuggable(true).apiValue).isEqualTo("sandbox") + } + + @Test + fun `release builds register against production`() { + assertThat(PushEnvironment.fromDebuggable(false)).isEqualTo(PushEnvironment.PRODUCTION) + assertThat(PushEnvironment.fromDebuggable(false).apiValue).isEqualTo("production") + } +} diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/PushRegistrationManagerTest.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/PushRegistrationManagerTest.kt new file mode 100644 index 0000000..3eb3f7e --- /dev/null +++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/PushRegistrationManagerTest.kt @@ -0,0 +1,196 @@ +package com.interlinedlist.android.feature.notifications.push + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** + * The device-token lifecycle: register on availability/rotation, re-register on every + * launch, unregister on sign-out and account deletion, and stay quiet — but functional — + * when notifications are not permitted. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class PushRegistrationManagerTest { + + private val repository = RecordingPushRegistrationRepository() + private val permissions = FakeNotificationPermissionChecker(allowed = true) + + private fun manager(provider: PushTokenProvider) = + PushRegistrationManager(provider, repository, permissions) + + /** + * Models the signed-in shell being entered: the lifecycle collects for the session + * in the background. Nothing here delays, so [runCurrent] settles it. + */ + private fun TestScope.startSession(manager: PushRegistrationManager) { + backgroundScope.launch { manager.runForSession() } + runCurrent() + } + + // ---- registration ------------------------------------------------------ + + @Test + fun `registers the device as soon as a token first becomes available`() = runTest { + val provider = FakePushTokenProvider() // no token yet + startSession(manager(provider)) + assertThat(repository.registered).isEmpty() + + provider.rotate("token-1") + runCurrent() + + assertThat(repository.registered).containsExactly("token-1") + } + + @Test + fun `registers again when the token rotates`() = runTest { + val provider = FakePushTokenProvider("token-1") + startSession(manager(provider)) + + provider.rotate("token-2") + runCurrent() + + assertThat(repository.registered).containsExactly("token-1", "token-2").inOrder() + } + + @Test + fun `re-registers on app launch with an unchanged token`() = runTest { + // A fresh process: the token survived from the previous launch but this manager + // has registered nothing, so it must register again — the docs ask for exactly + // that, in case the token rotated while the app was not running. + val provider = FakePushTokenProvider("token-1") + + startSession(manager(provider)) + + assertThat(repository.registered).containsExactly("token-1") + } + + @Test + fun `does not re-register the same token twice within one session`() = runTest { + val provider = FakePushTokenProvider("token-1") + startSession(manager(provider)) + + provider.rotate("token-1") // same value re-published + runCurrent() + + assertThat(repository.registered).containsExactly("token-1") + } + + @Test + fun `a failed registration is retried on the next rotation`() = runTest { + val provider = FakePushTokenProvider() + repository.registerResult = ApiResult.Failure(AppError.Network("offline")) + startSession(manager(provider)) + + provider.rotate("token-1") + runCurrent() + repository.registerResult = ApiResult.Success(Unit) + provider.rotate("token-1-again") + runCurrent() + + assertThat(repository.registered).containsExactly("token-1", "token-1-again").inOrder() + } + + // ---- denied permission ------------------------------------------------- + + @Test + fun `denied notification permission issues no registration and breaks nothing`() = runTest { + permissions.allowed = false + val provider = FakePushTokenProvider("token-1") + + startSession(manager(provider)) + provider.rotate("token-2") + runCurrent() + + // No registration at all — there is no point pushing to a device that cannot + // display it — and the lifecycle keeps running rather than failing. + assertThat(repository.registered).isEmpty() + assertThat(repository.unregistered).isEmpty() + } + + @Test + fun `granting the permission later registers the current token`() = runTest { + permissions.allowed = false + val provider = FakePushTokenProvider("token-1") + val manager = manager(provider) + startSession(manager) + assertThat(repository.registered).isEmpty() + + permissions.allowed = true + manager.onNotificationPermissionGranted() + runCurrent() + + assertThat(repository.registered).containsExactly("token-1") + } + + // ---- teardown ---------------------------------------------------------- + + @Test + fun `sign-out unregisters the device token`() = runTest { + val provider = FakePushTokenProvider("token-1") + val manager = manager(provider) + startSession(manager) + + PushTokenSessionTeardown(manager).onSessionEnding() + + assertThat(repository.unregistered).containsExactly("token-1") + } + + @Test + fun `account deletion unregisters the device token through the same teardown`() = runTest { + // Account deletion signs out via AuthRepository.logout(), which runs exactly the + // teardown task exercised here — there is no separate deletion path to miss. + val provider = FakePushTokenProvider("token-1") + val manager = manager(provider) + startSession(manager) + + PushTokenSessionTeardown(manager).onSessionEnding() + + assertThat(repository.unregistered).containsExactly("token-1") + } + + @Test + fun `a rotation retires the registration it replaced, and sign-out retires the rest`() = + runTest { + val provider = FakePushTokenProvider("token-1") + val manager = manager(provider) + startSession(manager) + + provider.rotate("token-2") + runCurrent() + // The superseded token goes immediately, so the server never holds a dead + // registration for this device. + assertThat(repository.unregistered).containsExactly("token-1") + + manager.unregisterCurrentToken() + + assertThat(repository.unregistered).containsExactly("token-1", "token-2").inOrder() + } + + @Test + fun `sign-out with no token available issues no call`() = runTest { + val manager = manager(FakePushTokenProvider()) + + manager.unregisterCurrentToken() + + assertThat(repository.unregistered).isEmpty() + } + + @Test + fun `signing back in registers again after a teardown`() = runTest { + val provider = FakePushTokenProvider("token-1") + val manager = manager(provider) + startSession(manager) + manager.unregisterCurrentToken() + + // A new session starts (the shell is re-entered after signing in again). + startSession(manager) + + assertThat(repository.registered).containsExactly("token-1", "token-1").inOrder() + } +} diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/PushTestDoubles.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/PushTestDoubles.kt new file mode 100644 index 0000000..e93964a --- /dev/null +++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/push/PushTestDoubles.kt @@ -0,0 +1,46 @@ +package com.interlinedlist.android.feature.notifications.push + +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.notifications.data.PushRegistrationRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Stands in for whatever push SDK eventually supplies the token (#47). [rotate] models + * both "the token has just become available" and "the token rotated". + */ +class FakePushTokenProvider(initial: String? = null) : PushTokenProvider { + private val _token = MutableStateFlow(initial) + override val token: StateFlow = _token.asStateFlow() + + fun rotate(value: String?) { + _token.value = value + } +} + +/** Records every register/unregister the lifecycle issues. */ +class RecordingPushRegistrationRepository : PushRegistrationRepository { + val registered = mutableListOf() + val unregistered = mutableListOf() + + var registerResult: ApiResult = ApiResult.Success(Unit) + var unregisterResult: ApiResult = ApiResult.Success(Unit) + + override suspend fun register(token: String): ApiResult { + registered += token + return registerResult + } + + override suspend fun unregister(token: String): ApiResult { + unregistered += token + return unregisterResult + } +} + +/** Permission checker pinned to a fixed answer. */ +class FakeNotificationPermissionChecker( + var allowed: Boolean = true, +) : NotificationPermissionChecker { + override fun canPostNotifications(): Boolean = allowed +}