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" }