diff --git a/AGENTS.md b/AGENTS.md
index d13a6e24..8c56ebd8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -11,3 +11,4 @@ Minimum expectations for any change:
- Run `./gradlew :app:lintDebug` before pushing.
- Do not introduce new `!!` in production code.
- For paged post feeds, keep using the existing Paging overlay and deduplication patterns.
+- When resolving merge conflicts, fast-forward local `main` from `origin/main` first (otherwise the merge silently uses a stale base), then after the merge inspect files touched by both branches against `main` directly — git's 3-way auto-merge can drop one side's hunks without surfacing a conflict marker.
diff --git a/CONVENTION.md b/CONVENTION.md
index 74359416..d36fb2a5 100644
--- a/CONVENTION.md
+++ b/CONVENTION.md
@@ -276,6 +276,22 @@ assertEquals(expected, outer.sharedPost!!.field) // setUp guarantees sharedPost
New or substantially refactored ViewModels ship with tests in the same PR. Reference tests live in `app/src/test/java/pub/hackers/android/ui/screens/**/*ViewModelTest.kt`.
+### §9.4 Build real `ApolloResponse` instead of mocking it
+
+`ApolloResponse` exposes `data`, `errors`, and `requestUuid` as `@JvmField`s, so mockk cannot intercept the field reads — `every { response.data } returns ...` fails with "missing mocked calls" because there is no getter to record. Construct real instances via the public Builder:
+
+```kotlin
+import com.apollographql.apollo.api.ApolloResponse
+import com.benasher44.uuid.uuid4
+
+val response = ApolloResponse.Builder(operation = mutation, requestUuid = uuid4())
+ .data(data)
+ .errors(errors)
+ .build()
+```
+
+Mock the `ApolloCall` chain (`apolloClient.mutation(any())`) and have `coEvery { call.execute() } returns response`. Reference: `app/src/test/java/pub/hackers/android/data/messaging/FcmTokenManagerTest.kt`.
+
---
## §10 Build and variants
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 4c9224e4..390e79f0 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -128,6 +128,7 @@ dependencies {
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.crashlytics)
implementation(libs.firebase.analytics)
+ implementation(libs.firebase.messaging)
implementation(libs.androidx.browser)
implementation(libs.androidx.credentials)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 80716519..b0b994d3 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -60,6 +60,14 @@
android:pathPrefix="/tags/" />
+
+
+
+
+
= Build.VERSION_CODES.TIRAMISU) {
lifecycleScope.launch {
- val isLoggedIn = sessionManager.isLoggedIn.first()
- if (isLoggedIn && ContextCompat.checkSelfPermission(
- this@MainActivity, Manifest.permission.POST_NOTIFICATIONS
- ) != PackageManager.PERMISSION_GRANTED
- ) {
- requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ sessionManager.isLoggedIn
+ .distinctUntilChanged()
+ .filter { it }
+ .collect {
+ if (ContextCompat.checkSelfPermission(
+ this@MainActivity, Manifest.permission.POST_NOTIFICATIONS
+ ) != PackageManager.PERMISSION_GRANTED
+ ) {
+ requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ }
}
}
}
diff --git a/app/src/main/java/pub/hackers/android/data/messaging/FcmTokenManager.kt b/app/src/main/java/pub/hackers/android/data/messaging/FcmTokenManager.kt
new file mode 100644
index 00000000..bdb72132
--- /dev/null
+++ b/app/src/main/java/pub/hackers/android/data/messaging/FcmTokenManager.kt
@@ -0,0 +1,115 @@
+package pub.hackers.android.data.messaging
+
+import android.util.Log
+import com.apollographql.apollo.ApolloClient
+import com.google.firebase.messaging.FirebaseMessaging
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.tasks.await
+import pub.hackers.android.data.local.SessionManager
+import pub.hackers.android.graphql.RegisterFcmDeviceTokenMutation
+import pub.hackers.android.graphql.UnregisterFcmDeviceTokenMutation
+import pub.hackers.android.graphql.type.RegisterFcmDeviceTokenInput
+import pub.hackers.android.graphql.type.UnregisterFcmDeviceTokenInput
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class FcmTokenManager @Inject constructor(
+ private val apolloClient: ApolloClient,
+ private val sessionManager: SessionManager,
+) {
+ companion object {
+ private const val TAG = "FcmTokenManager"
+ }
+
+ suspend fun registerCurrentToken() {
+ val isLoggedIn = sessionManager.isLoggedIn.first()
+ if (!isLoggedIn) return
+
+ val token = try {
+ FirebaseMessaging.getInstance().token.await()
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to get FCM token", e)
+ return
+ }
+ registerToken(token)
+ }
+
+ suspend fun registerToken(token: String) {
+ val isLoggedIn = sessionManager.isLoggedIn.first()
+ if (!isLoggedIn) return
+
+ try {
+ val response = apolloClient.mutation(
+ RegisterFcmDeviceTokenMutation(
+ RegisterFcmDeviceTokenInput(deviceToken = token)
+ )
+ ).execute()
+
+ if (response.hasErrors()) {
+ Log.w(TAG, "FCM token registration returned errors: ${response.errors}")
+ return
+ }
+
+ val result = response.data?.registerFcmDeviceToken
+ when {
+ result?.onRegisterFcmDeviceTokenPayload != null -> {
+ Log.d(TAG, "FCM token registered")
+ if (!sessionManager.isLoggedIn.first()) {
+ Log.w(TAG, "Session ended during registration; rolling back")
+ unregisterToken(token)
+ }
+ }
+ result?.onRegisterFcmDeviceTokenFailedError != null ->
+ Log.w(TAG, "FCM token registration failed: ${result.onRegisterFcmDeviceTokenFailedError.message}")
+ result?.onInvalidInputError != null ->
+ Log.w(TAG, "FCM token registration invalid input: ${result.onInvalidInputError.inputPath}")
+ result?.onNotAuthenticatedError != null ->
+ Log.w(TAG, "FCM token registration not authenticated")
+ else ->
+ Log.w(TAG, "FCM token registration unexpected result")
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to register FCM token", e)
+ }
+ }
+
+ suspend fun unregisterCurrentToken() {
+ val token = try {
+ FirebaseMessaging.getInstance().token.await()
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to get FCM token", e)
+ return
+ }
+ unregisterToken(token)
+ }
+
+ suspend fun unregisterToken(token: String) {
+ try {
+ val response = apolloClient.mutation(
+ UnregisterFcmDeviceTokenMutation(
+ UnregisterFcmDeviceTokenInput(deviceToken = token)
+ )
+ ).execute()
+
+ if (response.hasErrors()) {
+ Log.w(TAG, "FCM token unregistration returned errors: ${response.errors}")
+ return
+ }
+
+ val result = response.data?.unregisterFcmDeviceToken
+ when {
+ result?.onUnregisterFcmDeviceTokenPayload != null ->
+ Log.d(TAG, "FCM token unregistered")
+ result?.onInvalidInputError != null ->
+ Log.w(TAG, "FCM token unregistration invalid input: ${result.onInvalidInputError.inputPath}")
+ result?.onNotAuthenticatedError != null ->
+ Log.w(TAG, "FCM token unregistration not authenticated")
+ else ->
+ Log.w(TAG, "FCM token unregistration unexpected result")
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to unregister FCM token", e)
+ }
+ }
+}
diff --git a/app/src/main/java/pub/hackers/android/data/messaging/HackersPubMessagingService.kt b/app/src/main/java/pub/hackers/android/data/messaging/HackersPubMessagingService.kt
new file mode 100644
index 00000000..6171ee24
--- /dev/null
+++ b/app/src/main/java/pub/hackers/android/data/messaging/HackersPubMessagingService.kt
@@ -0,0 +1,96 @@
+package pub.hackers.android.data.messaging
+
+import android.Manifest
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import androidx.core.content.ContextCompat
+import com.google.firebase.messaging.FirebaseMessagingService
+import com.google.firebase.messaging.RemoteMessage
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import pub.hackers.android.HackersPubApplication
+import pub.hackers.android.MainActivity
+import pub.hackers.android.R
+import pub.hackers.android.data.local.NotificationStateManager
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class HackersPubMessagingService : FirebaseMessagingService() {
+
+ @Inject
+ lateinit var fcmTokenManager: FcmTokenManager
+
+ @Inject
+ lateinit var notificationStateManager: NotificationStateManager
+
+ private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ override fun onDestroy() {
+ serviceScope.cancel()
+ super.onDestroy()
+ }
+
+ override fun onNewToken(token: String) {
+ serviceScope.launch {
+ fcmTokenManager.registerToken(token)
+ }
+ }
+
+ override fun onMessageReceived(message: RemoteMessage) {
+ val data = message.data
+ if (data.isEmpty()) return
+
+ val alert = data["alert"] ?: return
+ val notificationId = data["notificationId"] ?: return
+
+ serviceScope.launch {
+ notificationStateManager.updateLastPolledId(notificationId)
+ }
+
+ if (hasNotificationPermission()) {
+ showNotification(notificationId, alert)
+ }
+ }
+
+ private fun hasNotificationPermission(): Boolean {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ ContextCompat.checkSelfPermission(
+ this,
+ Manifest.permission.POST_NOTIFICATIONS
+ ) == PackageManager.PERMISSION_GRANTED
+ } else {
+ true
+ }
+ }
+
+ private fun showNotification(notificationId: String, text: String) {
+ val intent = Intent(this, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ putExtra("navigate_to", "notifications")
+ }
+ val pendingIntent = PendingIntent.getActivity(
+ this, 0, intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val notification = NotificationCompat.Builder(this, HackersPubApplication.NOTIFICATION_CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentTitle(getString(R.string.app_name))
+ .setContentText(text)
+ .setAutoCancel(true)
+ .setContentIntent(pendingIntent)
+ .build()
+
+ val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ manager.notify(notificationId.hashCode(), notification)
+ }
+}
diff --git a/app/src/main/java/pub/hackers/android/ui/AppViewModel.kt b/app/src/main/java/pub/hackers/android/ui/AppViewModel.kt
index ded669b6..cfc4e26d 100644
--- a/app/src/main/java/pub/hackers/android/ui/AppViewModel.kt
+++ b/app/src/main/java/pub/hackers/android/ui/AppViewModel.kt
@@ -8,19 +8,16 @@ import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
-import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import pub.hackers.android.data.local.NotificationStateManager
import pub.hackers.android.data.local.PreferencesManager
import pub.hackers.android.data.local.SessionManager
-import pub.hackers.android.data.repository.HackersPubRepository
+import pub.hackers.android.data.messaging.FcmTokenManager
import pub.hackers.android.data.worker.NotificationWorker
import pub.hackers.android.ui.screens.timeline.TimelineRefreshTrigger
import java.util.concurrent.TimeUnit
@@ -32,13 +29,12 @@ class AppViewModel @Inject constructor(
val preferencesManager: PreferencesManager,
private val notificationStateManager: NotificationStateManager,
private val workManager: WorkManager,
- private val repository: HackersPubRepository,
val timelineRefreshTrigger: TimelineRefreshTrigger,
+ private val fcmTokenManager: FcmTokenManager,
) : ViewModel() {
companion object {
const val NOTIFICATION_WORK_NAME = "notification_poll"
- const val FOREGROUND_POLL_INTERVAL_MS = 60_000L // 1 minute
}
val isLoggedIn: Flow = sessionManager.isLoggedIn
@@ -46,8 +42,6 @@ class AppViewModel @Inject constructor(
val hasUnread: StateFlow = notificationStateManager.hasUnread
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
- private var foregroundPollJob: Job? = null
-
init {
ensureNotificationPolling()
}
@@ -57,11 +51,22 @@ class AppViewModel @Inject constructor(
val loggedIn = sessionManager.isLoggedIn.first()
if (loggedIn) {
enqueueNotificationPolling()
- startForegroundPolling()
}
}
}
+ fun registerFcmToken() {
+ viewModelScope.launch {
+ fcmTokenManager.registerCurrentToken()
+ }
+ }
+
+ fun unregisterFcmToken() {
+ viewModelScope.launch {
+ fcmTokenManager.unregisterCurrentToken()
+ }
+ }
+
fun enqueueNotificationPolling() {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
@@ -77,36 +82,4 @@ class AppViewModel @Inject constructor(
workRequest
)
}
-
- fun startForegroundPolling() {
- if (foregroundPollJob?.isActive == true) return
- foregroundPollJob = viewModelScope.launch {
- while (isActive) {
- delay(FOREGROUND_POLL_INTERVAL_MS)
- pollNotifications()
- }
- }
- }
-
- fun stopForegroundPolling() {
- foregroundPollJob?.cancel()
- foregroundPollJob = null
- }
-
- private suspend fun pollNotifications() {
- val loggedIn = sessionManager.isLoggedIn.first()
- if (!loggedIn) return
-
- repository.getNotifications(refresh = true)
- .onSuccess { result ->
- val notifications = result.notifications
- if (notifications.isNotEmpty()) {
- val newestId = notifications.first().id
- val previousId = notificationStateManager.getLastPolledId()
- if (newestId != previousId) {
- notificationStateManager.updateLastPolledId(newestId)
- }
- }
- }
- }
}
diff --git a/app/src/main/java/pub/hackers/android/ui/HackersPubApp.kt b/app/src/main/java/pub/hackers/android/ui/HackersPubApp.kt
index c737fca9..0def6b38 100644
--- a/app/src/main/java/pub/hackers/android/ui/HackersPubApp.kt
+++ b/app/src/main/java/pub/hackers/android/ui/HackersPubApp.kt
@@ -15,14 +15,12 @@ import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.LifecycleEventObserver
-import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
@@ -208,31 +206,18 @@ fun HackersPubApp(
}
}
- // Enqueue notification polling when user logs in
+ // Enqueue background polling fallback and register FCM token on login;
+ // unregister on the true→false transition so cold starts while logged
+ // out don't fire a guaranteed-to-fail unauthenticated mutation.
+ var prevLoggedIn by remember { mutableStateOf(null) }
LaunchedEffect(isLoggedIn) {
if (isLoggedIn) {
viewModel.enqueueNotificationPolling()
- viewModel.startForegroundPolling()
- } else {
- viewModel.stopForegroundPolling()
- }
- }
-
- // Start/stop foreground polling based on app lifecycle
- val lifecycleOwner = LocalLifecycleOwner.current
- DisposableEffect(lifecycleOwner, isLoggedIn) {
- val observer = LifecycleEventObserver { _, event ->
- if (!isLoggedIn) return@LifecycleEventObserver
- when (event) {
- Lifecycle.Event.ON_START -> viewModel.startForegroundPolling()
- Lifecycle.Event.ON_STOP -> viewModel.stopForegroundPolling()
- else -> {}
- }
- }
- lifecycleOwner.lifecycle.addObserver(observer)
- onDispose {
- lifecycleOwner.lifecycle.removeObserver(observer)
+ viewModel.registerFcmToken()
+ } else if (prevLoggedIn == true) {
+ viewModel.unregisterFcmToken()
}
+ prevLoggedIn = isLoggedIn
}
ProvideInAppBrowserUriHandler(
diff --git a/app/src/main/java/pub/hackers/android/ui/screens/settings/SettingsViewModel.kt b/app/src/main/java/pub/hackers/android/ui/screens/settings/SettingsViewModel.kt
index 832c9074..d6b93aa7 100644
--- a/app/src/main/java/pub/hackers/android/ui/screens/settings/SettingsViewModel.kt
+++ b/app/src/main/java/pub/hackers/android/ui/screens/settings/SettingsViewModel.kt
@@ -24,6 +24,7 @@ import kotlinx.coroutines.withContext
import pub.hackers.android.data.auth.PasskeyManager
import pub.hackers.android.data.local.PreferencesManager
import pub.hackers.android.data.local.SessionManager
+import pub.hackers.android.data.messaging.FcmTokenManager
import pub.hackers.android.data.repository.HackersPubRepository
import pub.hackers.android.domain.model.Passkey
import pub.hackers.android.ui.theme.ThemeMode
@@ -58,6 +59,7 @@ class SettingsViewModel @Inject constructor(
private val apolloClient: ApolloClient,
private val notificationStateManager: NotificationStateManager,
private val passkeyManager: PasskeyManager,
+ private val fcmTokenManager: FcmTokenManager,
private val workManager: WorkManager,
@ApplicationContext private val context: Context
) : ViewModel() {
@@ -156,6 +158,7 @@ class SettingsViewModel @Inject constructor(
fun signOut() {
viewModelScope.launch {
+ fcmTokenManager.unregisterCurrentToken()
val sessionId = sessionManager.sessionToken.first()
if (sessionId != null) {
repository.revokeSession(sessionId)
diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml
new file mode 100644
index 00000000..2998bc39
--- /dev/null
+++ b/app/src/main/res/drawable/ic_notification.xml
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/src/test/java/pub/hackers/android/data/messaging/FcmTokenManagerTest.kt b/app/src/test/java/pub/hackers/android/data/messaging/FcmTokenManagerTest.kt
new file mode 100644
index 00000000..9470c99b
--- /dev/null
+++ b/app/src/test/java/pub/hackers/android/data/messaging/FcmTokenManagerTest.kt
@@ -0,0 +1,255 @@
+package pub.hackers.android.data.messaging
+
+import com.apollographql.apollo.ApolloCall
+import com.apollographql.apollo.ApolloClient
+import com.apollographql.apollo.api.ApolloResponse
+import com.apollographql.apollo.api.Error
+import com.apollographql.apollo.api.Operation
+import com.benasher44.uuid.uuid4
+import io.mockk.CapturingSlot
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import pub.hackers.android.data.local.SessionManager
+import pub.hackers.android.graphql.RegisterFcmDeviceTokenMutation
+import pub.hackers.android.graphql.UnregisterFcmDeviceTokenMutation
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [35])
+class FcmTokenManagerTest {
+
+ private val apolloClient = mockk()
+ private val sessionManager = mockk()
+
+ private fun newManager() = FcmTokenManager(apolloClient, sessionManager)
+
+ private fun stubLoggedIn(value: Boolean) {
+ every { sessionManager.isLoggedIn } returns flowOf(value)
+ }
+
+ private fun buildResponse(
+ operation: com.apollographql.apollo.api.Operation,
+ data: D?,
+ errors: List? = null,
+ ): ApolloResponse = ApolloResponse.Builder(operation, uuid4())
+ .data(data)
+ .errors(errors)
+ .build()
+
+ private fun stubRegisterMutation(
+ data: RegisterFcmDeviceTokenMutation.Data?,
+ errors: List? = null,
+ ): CapturingSlot {
+ val slot = slot()
+ val call = mockk>()
+ every { apolloClient.mutation(capture(slot)) } answers {
+ coEvery { call.execute() } returns buildResponse(slot.captured, data, errors)
+ call
+ }
+ return slot
+ }
+
+ private fun stubUnregisterMutation(
+ data: UnregisterFcmDeviceTokenMutation.Data?,
+ errors: List? = null,
+ ): CapturingSlot {
+ val slot = slot()
+ val call = mockk>()
+ every { apolloClient.mutation(capture(slot)) } answers {
+ coEvery { call.execute() } returns buildResponse(slot.captured, data, errors)
+ call
+ }
+ return slot
+ }
+
+ private fun registerData(
+ typename: String,
+ payload: RegisterFcmDeviceTokenMutation.OnRegisterFcmDeviceTokenPayload? = null,
+ failed: RegisterFcmDeviceTokenMutation.OnRegisterFcmDeviceTokenFailedError? = null,
+ invalidInput: RegisterFcmDeviceTokenMutation.OnInvalidInputError? = null,
+ notAuthenticated: RegisterFcmDeviceTokenMutation.OnNotAuthenticatedError? = null,
+ ) = RegisterFcmDeviceTokenMutation.Data(
+ registerFcmDeviceToken = RegisterFcmDeviceTokenMutation.RegisterFcmDeviceToken(
+ __typename = typename,
+ onRegisterFcmDeviceTokenPayload = payload,
+ onRegisterFcmDeviceTokenFailedError = failed,
+ onInvalidInputError = invalidInput,
+ onNotAuthenticatedError = notAuthenticated,
+ )
+ )
+
+ private fun unregisterData(
+ typename: String,
+ payload: UnregisterFcmDeviceTokenMutation.OnUnregisterFcmDeviceTokenPayload? = null,
+ invalidInput: UnregisterFcmDeviceTokenMutation.OnInvalidInputError? = null,
+ notAuthenticated: UnregisterFcmDeviceTokenMutation.OnNotAuthenticatedError? = null,
+ ) = UnregisterFcmDeviceTokenMutation.Data(
+ unregisterFcmDeviceToken = UnregisterFcmDeviceTokenMutation.UnregisterFcmDeviceToken(
+ __typename = typename,
+ onUnregisterFcmDeviceTokenPayload = payload,
+ onInvalidInputError = invalidInput,
+ onNotAuthenticatedError = notAuthenticated,
+ )
+ )
+
+ @Test
+ fun `registerToken returns early without calling Apollo when logged out`() = runTest {
+ stubLoggedIn(false)
+
+ newManager().registerToken("token-x")
+
+ verify(exactly = 0) { apolloClient.mutation(any()) }
+ }
+
+ @Test
+ fun `registerToken sends mutation with provided token when logged in`() = runTest {
+ stubLoggedIn(true)
+ val slot = stubRegisterMutation(
+ registerData(
+ typename = "RegisterFcmDeviceTokenPayload",
+ payload = RegisterFcmDeviceTokenMutation.OnRegisterFcmDeviceTokenPayload(
+ deviceToken = "token-x",
+ created = "2026-01-01T00:00:00Z",
+ updated = "2026-01-01T00:00:00Z",
+ ),
+ )
+ )
+
+ newManager().registerToken("token-x")
+
+ assertEquals("token-x", slot.captured.input.deviceToken)
+ }
+
+ @Test
+ fun `registerToken handles hasErrors response without throwing`() = runTest {
+ stubLoggedIn(true)
+ stubRegisterMutation(
+ data = null,
+ errors = listOf(Error.Builder(message = "boom").build()),
+ )
+
+ newManager().registerToken("token-x")
+ }
+
+ @Test
+ fun `registerToken handles all union variants without throwing`() = runTest {
+ val cases = listOf(
+ registerData(
+ "RegisterFcmDeviceTokenPayload",
+ payload = RegisterFcmDeviceTokenMutation.OnRegisterFcmDeviceTokenPayload(
+ "t", "c", "u"
+ ),
+ ),
+ registerData(
+ "RegisterFcmDeviceTokenFailedError",
+ failed = RegisterFcmDeviceTokenMutation.OnRegisterFcmDeviceTokenFailedError(
+ message = "limit reached", limit = 5
+ ),
+ ),
+ registerData(
+ "InvalidInputError",
+ invalidInput = RegisterFcmDeviceTokenMutation.OnInvalidInputError(
+ inputPath = "input.deviceToken"
+ ),
+ ),
+ registerData(
+ "NotAuthenticatedError",
+ notAuthenticated = RegisterFcmDeviceTokenMutation.OnNotAuthenticatedError(
+ notAuthenticated = "login required"
+ ),
+ ),
+ registerData("UnknownTypename"),
+ )
+ for (data in cases) {
+ stubLoggedIn(true)
+ stubRegisterMutation(data)
+ newManager().registerToken("token-x")
+ }
+ }
+
+ @Test
+ fun `registerToken does not throw when Apollo execute throws`() = runTest {
+ stubLoggedIn(true)
+ val call = mockk>()
+ coEvery { call.execute() } throws RuntimeException("network down")
+ every { apolloClient.mutation(any()) } returns call
+
+ newManager().registerToken("token-x")
+ }
+
+ @Test
+ fun `unregisterToken sends mutation with provided token`() = runTest {
+ val slot = stubUnregisterMutation(
+ unregisterData(
+ typename = "UnregisterFcmDeviceTokenPayload",
+ payload = UnregisterFcmDeviceTokenMutation.OnUnregisterFcmDeviceTokenPayload(
+ deviceToken = "token-x",
+ unregistered = true,
+ ),
+ )
+ )
+
+ newManager().unregisterToken("token-x")
+
+ assertEquals("token-x", slot.captured.input.deviceToken)
+ }
+
+ @Test
+ fun `unregisterToken handles hasErrors response without throwing`() = runTest {
+ stubUnregisterMutation(
+ data = null,
+ errors = listOf(Error.Builder(message = "boom").build()),
+ )
+
+ newManager().unregisterToken("token-x")
+ }
+
+ @Test
+ fun `unregisterToken handles all union variants without throwing`() = runTest {
+ val cases = listOf(
+ unregisterData(
+ "UnregisterFcmDeviceTokenPayload",
+ payload = UnregisterFcmDeviceTokenMutation.OnUnregisterFcmDeviceTokenPayload(
+ "t", true
+ ),
+ ),
+ unregisterData(
+ "InvalidInputError",
+ invalidInput = UnregisterFcmDeviceTokenMutation.OnInvalidInputError(
+ "input.deviceToken"
+ ),
+ ),
+ unregisterData(
+ "NotAuthenticatedError",
+ notAuthenticated = UnregisterFcmDeviceTokenMutation.OnNotAuthenticatedError(
+ "login required"
+ ),
+ ),
+ unregisterData("UnknownTypename"),
+ )
+ for (data in cases) {
+ stubUnregisterMutation(data)
+ newManager().unregisterToken("token-x")
+ }
+ }
+
+ @Test
+ fun `unregisterToken does not throw when Apollo execute throws`() = runTest {
+ val call = mockk>()
+ coEvery { call.execute() } throws RuntimeException("network down")
+ every { apolloClient.mutation(any()) } returns call
+
+ newManager().unregisterToken("token-x")
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 1cc27919..7161ee46 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -76,6 +76,7 @@ compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-mani
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" }
firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" }
+firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" }
androidx-paging-runtime = { group = "androidx.paging", name = "paging-runtime-ktx", version.ref = "paging" }
androidx-paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paging" }