diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt index 9a2ed21755e..184766e3230 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt @@ -36,7 +36,7 @@ import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.push.api.PushService -import io.element.android.libraries.pushproviders.api.RegistrationFailure +import io.element.android.libraries.push.api.PusherRegistrationFailure import io.element.android.services.analytics.api.AnalyticsService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.combine @@ -71,7 +71,17 @@ class LoggedInPresenter( when (sessionVerifiedStatus) { SessionVerifiedStatus.Unknown -> Unit SessionVerifiedStatus.Verified -> { - ensurePusherIsRegistered(pusherRegistrationState) + Timber.tag(pusherTag.value).d("Ensure pusher is registered") + pushService.ensurePusherIsRegistered(matrixClient).fold( + onSuccess = { + Timber.tag(pusherTag.value).d("Pusher registered") + pusherRegistrationState.value = AsyncData.Success(Unit) + }, + onFailure = { + Timber.tag(pusherTag.value).e(it, "Failed to register pusher") + pusherRegistrationState.value = AsyncData.Failure(it) + }, + ) } SessionVerifiedStatus.NotVerified -> { pusherRegistrationState.value = AsyncData.Failure(PusherRegistrationFailure.AccountNotVerified()) @@ -133,59 +143,6 @@ class LoggedInPresenter( currentSlidingSyncVersion == SlidingSyncVersion.Proxy } - private suspend fun ensurePusherIsRegistered(pusherRegistrationState: MutableState>) { - Timber.tag(pusherTag.value).d("Ensure pusher is registered") - val currentPushProvider = pushService.getCurrentPushProvider(matrixClient.sessionId) - val result = if (currentPushProvider == null) { - Timber.tag(pusherTag.value).d("Register with the first available push provider with at least one distributor") - val pushProvider = pushService.getAvailablePushProviders() - .firstOrNull { it.getDistributors().isNotEmpty() } - // Else fallback to the first available push provider (the list should never be empty) - ?: pushService.getAvailablePushProviders().firstOrNull() - ?: return Unit - .also { Timber.tag(pusherTag.value).w("No push providers available") } - .also { pusherRegistrationState.value = AsyncData.Failure(PusherRegistrationFailure.NoProvidersAvailable()) } - val distributor = pushProvider.getDistributors().firstOrNull() - ?: return Unit - .also { Timber.tag(pusherTag.value).w("No distributors available") } - .also { - // In this case, consider the push provider is chosen. - pushService.selectPushProvider(matrixClient.sessionId, pushProvider) - } - .also { pusherRegistrationState.value = AsyncData.Failure(PusherRegistrationFailure.NoDistributorsAvailable()) } - pushService.registerWith(matrixClient, pushProvider, distributor) - } else { - val currentPushDistributor = currentPushProvider.getCurrentDistributor(matrixClient.sessionId) - if (currentPushDistributor == null) { - Timber.tag(pusherTag.value).d("Register with the first available distributor") - val distributor = currentPushProvider.getDistributors().firstOrNull() - ?: return Unit - .also { Timber.tag(pusherTag.value).w("No distributors available") } - .also { pusherRegistrationState.value = AsyncData.Failure(PusherRegistrationFailure.NoDistributorsAvailable()) } - pushService.registerWith(matrixClient, currentPushProvider, distributor) - } else { - Timber.tag(pusherTag.value).d("Re-register with the current distributor") - pushService.registerWith(matrixClient, currentPushProvider, currentPushDistributor) - } - } - result.fold( - onSuccess = { - Timber.tag(pusherTag.value).d("Pusher registered") - pusherRegistrationState.value = AsyncData.Success(Unit) - }, - onFailure = { - Timber.tag(pusherTag.value).e(it, "Failed to register pusher") - if (it is RegistrationFailure) { - pusherRegistrationState.value = AsyncData.Failure( - PusherRegistrationFailure.RegistrationFailure(it.clientException, it.isRegisteringAgain) - ) - } else { - pusherRegistrationState.value = AsyncData.Failure(it) - } - } - ) - } - private fun reportCryptoStatusToAnalytics(verificationState: SessionVerifiedStatus, recoveryState: RecoveryState) { // Update first the user property, to store the current status for that posthog user val userVerificationState = verificationState.toAnalyticsUserPropertyValue() diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt index 9f92a19506b..b2f54075198 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt @@ -10,6 +10,7 @@ package io.element.android.appnav.loggedin import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.push.api.PusherRegistrationFailure open class LoggedInStateProvider : PreviewParameterProvider { override val values: Sequence diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt index bb908ac0fd9..62d8de8c297 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt @@ -25,6 +25,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.utils.OnLifecycleEvent import io.element.android.libraries.matrix.api.exception.isNetworkError +import io.element.android.libraries.push.api.PusherRegistrationFailure import io.element.android.libraries.ui.strings.CommonStrings @Composable diff --git a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt index aca9545ae7f..849dfa85b2a 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt @@ -34,6 +34,7 @@ import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.sync.FakeSyncService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService import io.element.android.libraries.push.api.PushService +import io.element.android.libraries.push.api.PusherRegistrationFailure import io.element.android.libraries.push.test.FakePushService import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.api.PushProvider @@ -42,7 +43,6 @@ import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.consumeItemsUntilPredicate -import io.element.android.tests.testutils.lambda.any import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value @@ -115,7 +115,9 @@ class LoggedInPresenterTest { encryptionService = encryptionService, ), syncService = FakeSyncService(initialSyncState = SyncState.Running), - pushService = FakePushService(), + pushService = FakePushService( + ensurePusherIsRegisteredResult = { Result.success(Unit) }, + ), sessionVerificationService = verificationService, analyticsService = analyticsService, encryptionService = encryptionService, @@ -139,10 +141,10 @@ class LoggedInPresenterTest { @Test fun `present - ensure default pusher is not registered if session is not verified`() = runTest { - val lambda = lambdaRecorder> { _, _, _ -> + val lambda = lambdaRecorder> { Result.success(Unit) } - val pushService = createFakePushService(registerWithLambda = lambda) + val pushService = createFakePushService(ensurePusherIsRegisteredResult = lambda) val verificationService = FakeSessionVerificationService( initialSessionVerifiedStatus = SessionVerifiedStatus.NotVerified ) @@ -153,21 +155,18 @@ class LoggedInPresenterTest { val finalState = awaitFirstItem() assertThat(finalState.pusherRegistrationState.errorOrNull()) .isInstanceOf(PusherRegistrationFailure.AccountNotVerified::class.java) - lambda.assertions() - .isNeverCalled() + lambda.assertions().isNeverCalled() } } @Test fun `present - ensure default pusher is registered with default provider`() = runTest { - val lambda = lambdaRecorder> { _, _, _ -> - Result.success(Unit) - } + val lambda = lambdaRecorder> { Result.success(Unit) } val sessionVerificationService = FakeSessionVerificationService( initialSessionVerifiedStatus = SessionVerifiedStatus.Verified ) val pushService = createFakePushService( - registerWithLambda = lambda, + ensurePusherIsRegisteredResult = lambda, ) createLoggedInPresenter( pushService = pushService, @@ -180,27 +179,17 @@ class LoggedInPresenterTest { assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue() lambda.assertions() .isCalledOnce() - .with( - // MatrixClient - any(), - // PushProvider with highest priority (lower index) - value(pushService.getAvailablePushProviders()[0]), - // First distributor - value(pushService.getAvailablePushProviders()[0].getDistributors()[0]), - ) } } @Test fun `present - ensure default pusher is registered with default provider - fail to register`() = runTest { - val lambda = lambdaRecorder> { _, _, _ -> - Result.failure(AN_EXCEPTION) - } + val lambda = lambdaRecorder> { Result.failure(AN_EXCEPTION) } val sessionVerificationService = FakeSessionVerificationService( initialSessionVerifiedStatus = SessionVerifiedStatus.Verified ) val pushService = createFakePushService( - registerWithLambda = lambda, + ensurePusherIsRegisteredResult = lambda, ) createLoggedInPresenter( pushService = pushService, @@ -213,83 +202,24 @@ class LoggedInPresenterTest { assertThat(finalState.pusherRegistrationState.isFailure()).isTrue() lambda.assertions() .isCalledOnce() - .with( - // MatrixClient - any(), - // PushProvider with highest priority (lower index) - value(pushService.getAvailablePushProviders()[0]), - // First distributor - value(pushService.getAvailablePushProviders()[0].getDistributors()[0]), - ) - } - } - - @Test - fun `present - ensure current provider is registered with current distributor`() = runTest { - val lambda = lambdaRecorder> { _, _, _ -> - Result.success(Unit) - } - val sessionVerificationService = FakeSessionVerificationService( - initialSessionVerifiedStatus = SessionVerifiedStatus.Verified - ) - val distributor = Distributor("aDistributorValue1", "aDistributorName1") - val pushProvider = FakePushProvider( - index = 0, - name = "aFakePushProvider0", - distributors = listOf( - Distributor("aDistributorValue0", "aDistributorName0"), - distributor, - ), - currentDistributor = { distributor }, - ) - val pushService = createFakePushService( - pushProvider1 = pushProvider, - currentPushProvider = { pushProvider }, - registerWithLambda = lambda, - ) - createLoggedInPresenter( - pushService = pushService, - sessionVerificationService = sessionVerificationService, - matrixClient = FakeMatrixClient( - accountManagementUrlResult = { Result.success(null) }, - ), - ).test { - val finalState = awaitFirstItem() - assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue() - lambda.assertions() - .isCalledOnce() - .with( - // MatrixClient - any(), - // Current push provider - value(pushProvider), - // Current distributor - value(distributor), - ) + // Reset the error and do not show again + finalState.eventSink(LoggedInEvents.CloseErrorDialog(doNotShowAgain = false)) + val lastState = awaitItem() + assertThat(lastState.pusherRegistrationState.isUninitialized()).isTrue() + assertThat(lastState.ignoreRegistrationError).isFalse() } } @Test - fun `present - if current push provider does not have current distributor, the first one is used`() = runTest { - val lambda = lambdaRecorder> { _, _, _ -> - Result.success(Unit) - } + fun `present - ensure default pusher is registered with default provider - fail to register - do not show again`() = runTest { + val lambda = lambdaRecorder> { Result.failure(AN_EXCEPTION) } + val setIgnoreRegistrationErrorLambda = lambdaRecorder { _, _ -> } val sessionVerificationService = FakeSessionVerificationService( initialSessionVerifiedStatus = SessionVerifiedStatus.Verified ) - val pushProvider = FakePushProvider( - index = 0, - name = "aFakePushProvider0", - distributors = listOf( - Distributor("aDistributorValue0", "aDistributorName0"), - Distributor("aDistributorValue1", "aDistributorName1"), - ), - currentDistributor = { null }, - ) val pushService = createFakePushService( - pushProvider0 = pushProvider, - currentPushProvider = { pushProvider }, - registerWithLambda = lambda, + ensurePusherIsRegisteredResult = lambda, + setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda, ) createLoggedInPresenter( pushService = pushService, @@ -299,72 +229,9 @@ class LoggedInPresenterTest { ), ).test { val finalState = awaitFirstItem() - assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue() + assertThat(finalState.pusherRegistrationState.isFailure()).isTrue() lambda.assertions() .isCalledOnce() - .with( - // MatrixClient - any(), - // PushProvider with highest priority (lower index) - value(pushService.getAvailablePushProviders()[0]), - // First distributor - value(pushService.getAvailablePushProviders()[0].getDistributors()[0]), - ) - } - } - - @Test - fun `present - if current push provider does not have distributors, nothing happen`() = runTest { - val lambda = lambdaRecorder> { _, _, _ -> - Result.success(Unit) - } - val sessionVerificationService = FakeSessionVerificationService( - initialSessionVerifiedStatus = SessionVerifiedStatus.Verified - ) - val pushProvider = FakePushProvider( - index = 0, - name = "aFakePushProvider0", - distributors = emptyList(), - ) - val pushService = createFakePushService( - pushProvider0 = pushProvider, - currentPushProvider = { pushProvider }, - registerWithLambda = lambda, - ) - createLoggedInPresenter( - pushService = pushService, - sessionVerificationService = sessionVerificationService, - ).test { - val finalState = awaitFirstItem() - assertThat(finalState.pusherRegistrationState.errorOrNull()) - .isInstanceOf(PusherRegistrationFailure.NoDistributorsAvailable::class.java) - lambda.assertions() - .isNeverCalled() - } - } - - @Test - fun `present - case no push provider available provider`() = runTest { - val lambda = lambdaRecorder> { _, _, _ -> - Result.success(Unit) - } - val sessionVerificationService = FakeSessionVerificationService(SessionVerifiedStatus.Verified) - val setIgnoreRegistrationErrorLambda = lambdaRecorder { _, _ -> } - val pushService = createFakePushService( - pushProvider0 = null, - pushProvider1 = null, - registerWithLambda = lambda, - setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda, - ) - createLoggedInPresenter( - pushService = pushService, - sessionVerificationService = sessionVerificationService, - ).test { - val finalState = awaitFirstItem() - assertThat(finalState.pusherRegistrationState.errorOrNull()) - .isInstanceOf(PusherRegistrationFailure.NoProvidersAvailable::class.java) - lambda.assertions() - .isNeverCalled() // Reset the error and do not show again finalState.eventSink(LoggedInEvents.CloseErrorDialog(doNotShowAgain = true)) skipItems(1) @@ -382,95 +249,6 @@ class LoggedInPresenterTest { } } - @Test - fun `present - case one push provider but no distributor available`() = runTest { - val lambda = lambdaRecorder> { _, _, _ -> - Result.success(Unit) - } - val selectPushProviderLambda = lambdaRecorder { _, _ -> } - val sessionVerificationService = FakeSessionVerificationService( - initialSessionVerifiedStatus = SessionVerifiedStatus.Verified - ) - val pushProvider = FakePushProvider( - index = 0, - name = "aFakePushProvider", - distributors = emptyList(), - ) - val pushService = createFakePushService( - pushProvider0 = pushProvider, - pushProvider1 = null, - registerWithLambda = lambda, - selectPushProviderLambda = selectPushProviderLambda, - ) - createLoggedInPresenter( - pushService = pushService, - sessionVerificationService = sessionVerificationService, - ).test { - val finalState = awaitFirstItem() - assertThat(finalState.pusherRegistrationState.errorOrNull()) - .isInstanceOf(PusherRegistrationFailure.NoDistributorsAvailable::class.java) - lambda.assertions() - .isNeverCalled() - selectPushProviderLambda.assertions() - .isCalledOnce() - .with( - // SessionId - value(A_SESSION_ID), - // PushProvider - value(pushProvider), - ) - // Reset the error - finalState.eventSink(LoggedInEvents.CloseErrorDialog(doNotShowAgain = false)) - val lastState = awaitItem() - assertThat(lastState.pusherRegistrationState.isUninitialized()).isTrue() - } - } - - @Test - fun `present - case two push providers but first one does not have distributor - second one will be used`() = runTest { - val lambda = lambdaRecorder> { _, _, _ -> - Result.success(Unit) - } - val sessionVerificationService = FakeSessionVerificationService( - initialSessionVerifiedStatus = SessionVerifiedStatus.Verified - ) - val pushProvider0 = FakePushProvider( - index = 0, - name = "aFakePushProvider0", - distributors = emptyList(), - ) - val distributor = Distributor("aDistributorValue1", "aDistributorName1") - val pushProvider1 = FakePushProvider( - index = 1, - name = "aFakePushProvider1", - distributors = listOf(distributor), - ) - val pushService = createFakePushService( - pushProvider0 = pushProvider0, - pushProvider1 = pushProvider1, - registerWithLambda = lambda, - ) - createLoggedInPresenter( - pushService = pushService, - sessionVerificationService = sessionVerificationService, - matrixClient = FakeMatrixClient( - accountManagementUrlResult = { Result.success(null) }, - ), - ).test { - val finalState = awaitFirstItem() - assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue() - lambda.assertions().isCalledOnce() - .with( - // MatrixClient - any(), - // PushProvider with the distributor - value(pushProvider1), - // First distributor of second push provider - value(distributor), - ) - } - } - private fun createFakePushService( pushProvider0: PushProvider? = FakePushProvider( index = 0, @@ -484,7 +262,7 @@ class LoggedInPresenterTest { distributors = listOf(Distributor("aDistributorValue1", "aDistributorName1")), currentDistributor = { null }, ), - registerWithLambda: (MatrixClient, PushProvider, Distributor) -> Result = { _, _, _ -> + ensurePusherIsRegisteredResult: () -> Result = { Result.success(Unit) }, selectPushProviderLambda: (SessionId, PushProvider) -> Unit = { _, _ -> lambdaError() }, @@ -493,7 +271,7 @@ class LoggedInPresenterTest { ): PushService { return FakePushService( availablePushProviders = listOfNotNull(pushProvider0, pushProvider1), - registerWithLambda = registerWithLambda, + ensurePusherIsRegisteredResult = ensurePusherIsRegisteredResult, currentPushProvider = currentPushProvider, selectPushProviderLambda = selectPushProviderLambda, setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda, diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreator.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreator.kt index d1575c7a979..e7d270ef8e1 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreator.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreator.kt @@ -72,7 +72,7 @@ class RingingCallNotificationCreator( ): Notification? { val matrixClient = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return null val imageLoader = imageLoaderHolder.get(matrixClient) - val largeIcon = notificationBitmapLoader.getUserIcon( + val userIcon = notificationBitmapLoader.getUserIcon( avatarData = AvatarData( id = roomId.value, name = roomName, @@ -84,7 +84,7 @@ class RingingCallNotificationCreator( val caller = Person.Builder() .setName(senderDisplayName) - .setIcon(largeIcon) + .setIcon(userIcon) .setImportant(true) .build() @@ -133,12 +133,8 @@ class RingingCallNotificationCreator( .setWhen(timestamp) .setOngoing(true) .setShowWhen(false) - .apply { - if (textContent != null) { - setContentText(textContent) - // Else the content text is set by the style (will be "Incoming call") - } - } + // If textContent is null, the content text is set by the style (will be "Incoming call") + .setContentText(textContent) .setSound(Settings.System.DEFAULT_RINGTONE_URI, AudioManager.STREAM_RING) .setTimeoutAfter(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds.inWholeMilliseconds) .setContentIntent(answerIntent) diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/services/CallForegroundService.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/services/CallForegroundService.kt index 1e5b338549d..89c9fdb52ca 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/services/CallForegroundService.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/services/CallForegroundService.kt @@ -22,7 +22,6 @@ import androidx.core.app.NotificationManagerCompat import androidx.core.app.PendingIntentCompat import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat -import androidx.core.graphics.drawable.IconCompat import io.element.android.features.call.impl.R import io.element.android.features.call.impl.ui.ElementCallActivity import io.element.android.libraries.core.extensions.runCatchingExceptions @@ -69,7 +68,7 @@ class CallForegroundService : Service() { val callActivityIntent = Intent(this, ElementCallActivity::class.java) val pendingIntent = PendingIntentCompat.getActivity(this, 0, callActivityIntent, 0, false) val notification = NotificationCompat.Builder(this, foregroundServiceChannel.id) - .setSmallIcon(IconCompat.createWithResource(this, CommonDrawables.ic_notification)) + .setSmallIcon(CommonDrawables.ic_notification) .setContentTitle(getString(R.string.call_foreground_service_title_android)) .setContentText(getString(R.string.call_foreground_service_message_android)) .setContentIntent(pendingIntent) diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt index 70c2c38e008..91fcc2593e2 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt @@ -100,7 +100,7 @@ class DefaultActiveCallManager( private val imageLoaderHolder: ImageLoaderHolder, private val systemClock: SystemClock, ) : ActiveCallManager { - private val tag = "DefaultActiveCallManager" + private val tag = "ActiveCallManager" private var timedOutCallJob: Job? = null @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt index 24c569cc4b5..9d9e80b3f3c 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt @@ -25,6 +25,7 @@ import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runUpdatingStateNoSuccess import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService @@ -51,6 +52,8 @@ class NotificationSettingsPresenter( private val pushService: PushService, private val systemNotificationsEnabledProvider: SystemNotificationsEnabledProvider, private val fullScreenIntentPermissionsPresenter: Presenter, + @SessionCoroutineScope + private val sessionCoroutineScope: CoroutineScope, ) : Presenter { @Composable override fun present(): NotificationSettingsState { @@ -141,7 +144,7 @@ class NotificationSettingsPresenter( is NotificationSettingsEvents.SetInviteForMeNotificationsEnabled -> { localCoroutineScope.setInviteForMeNotificationsEnabled(event.enabled, changeNotificationSettingAction) } - is NotificationSettingsEvents.SetNotificationsEnabled -> localCoroutineScope.setNotificationsEnabled(userPushStore, event.enabled) + is NotificationSettingsEvents.SetNotificationsEnabled -> sessionCoroutineScope.setNotificationsEnabled(userPushStore, event.enabled) NotificationSettingsEvents.ClearConfigurationMismatchError -> { matrixSettings.value = NotificationSettingsState.MatrixSettings.Invalid(fixFailed = false) } @@ -262,5 +265,10 @@ class NotificationSettingsPresenter( private fun CoroutineScope.setNotificationsEnabled(userPushStore: UserPushStore, enabled: Boolean) = launch { userPushStore.setNotificationEnabledForDevice(enabled) + if (enabled) { + pushService.ensurePusherIsRegistered(matrixClient) + } else { + pushService.getCurrentPushProvider(matrixClient.sessionId)?.unregister(matrixClient) + } } } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt index 27cca104b18..9b36c477a4f 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt @@ -8,9 +8,6 @@ package io.element.android.features.preferences.impl.notifications -import app.cash.molecule.RecompositionMode -import app.cash.molecule.moleculeFlow -import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState @@ -28,6 +25,9 @@ import io.element.android.libraries.pushproviders.test.FakePushProvider import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory import io.element.android.tests.testutils.awaitLastSequentialItem import io.element.android.tests.testutils.consumeItemsUntilPredicate +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Test import kotlin.time.Duration.Companion.milliseconds @@ -36,9 +36,7 @@ class NotificationSettingsPresenterTest { @Test fun `present - ensures initial state is correct`() = runTest { val presenter = createNotificationSettingsPresenter() - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitItem() assertThat(initialState.appSettings.appNotificationsEnabled).isFalse() assertThat(initialState.appSettings.systemNotificationsEnabled).isTrue() @@ -62,9 +60,7 @@ class NotificationSettingsPresenterTest { fun `present - default group notification mode changed`() = runTest { val notificationSettingsService = FakeNotificationSettingsService() val presenter = createNotificationSettingsPresenter(notificationSettingsService) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = true, isOneToOne = false, mode = RoomNotificationMode.ALL_MESSAGES) notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = false, isOneToOne = false, mode = RoomNotificationMode.ALL_MESSAGES) val updatedState = consumeItemsUntilPredicate { @@ -80,9 +76,7 @@ class NotificationSettingsPresenterTest { fun `present - notification settings mismatched`() = runTest { val notificationSettingsService = FakeNotificationSettingsService() val presenter = createNotificationSettingsPresenter(notificationSettingsService) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { notificationSettingsService.setDefaultRoomNotificationMode( isEncrypted = true, isOneToOne = false, @@ -110,9 +104,7 @@ class NotificationSettingsPresenterTest { initialOneToOneDefaultMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY ) val presenter = createNotificationSettingsPresenter(notificationSettingsService) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitItem() initialState.eventSink(NotificationSettingsEvents.FixConfigurationMismatch) val fixedState = consumeItemsUntilPredicate(timeout = 2000.milliseconds) { @@ -125,10 +117,19 @@ class NotificationSettingsPresenterTest { @Test fun `present - set notifications enabled`() = runTest { - val presenter = createNotificationSettingsPresenter() - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + val unregisterWithResult = lambdaRecorder> { Result.success(Unit) } + val ensurePusherIsRegisteredResult = lambdaRecorder> { Result.success(Unit) } + val presenter = createNotificationSettingsPresenter( + pushService = FakePushService( + currentPushProvider = { + FakePushProvider( + unregisterWithResult = unregisterWithResult, + ) + }, + ensurePusherIsRegisteredResult = ensurePusherIsRegisteredResult, + ) + ) + presenter.test { val loadedState = consumeItemsUntilPredicate { it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid }.last() @@ -138,16 +139,21 @@ class NotificationSettingsPresenterTest { !it.appSettings.appNotificationsEnabled }.last() assertThat(updatedState.appSettings.appNotificationsEnabled).isFalse() - cancelAndIgnoreRemainingEvents() + unregisterWithResult.assertions().isCalledOnce() + // Enable notification again + loadedState.eventSink(NotificationSettingsEvents.SetNotificationsEnabled(true)) + val updatedState2 = consumeItemsUntilPredicate { + it.appSettings.appNotificationsEnabled + }.last() + assertThat(updatedState2.appSettings.appNotificationsEnabled).isTrue() + ensurePusherIsRegisteredResult.assertions().isCalledOnce() } } @Test fun `present - set call notifications enabled`() = runTest { val presenter = createNotificationSettingsPresenter() - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val loadedState = consumeItemsUntilPredicate { (it.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid)?.callNotificationsEnabled == false }.last() @@ -166,9 +172,7 @@ class NotificationSettingsPresenterTest { @Test fun `present - set invite for me notifications enabled`() = runTest { val presenter = createNotificationSettingsPresenter() - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val loadedState = consumeItemsUntilPredicate { (it.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid)?.inviteForMeNotificationsEnabled == false }.last() @@ -187,9 +191,7 @@ class NotificationSettingsPresenterTest { @Test fun `present - set atRoom notifications enabled`() = runTest { val presenter = createNotificationSettingsPresenter() - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val loadedState = consumeItemsUntilPredicate { (it.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid)?.atRoomNotificationsEnabled == false }.last() @@ -210,9 +212,7 @@ class NotificationSettingsPresenterTest { val notificationSettingsService = FakeNotificationSettingsService() val presenter = createNotificationSettingsPresenter(notificationSettingsService) notificationSettingsService.givenSetAtRoomError(AN_EXCEPTION) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val loadedState = consumeItemsUntilPredicate { (it.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid)?.atRoomNotificationsEnabled == false }.last() @@ -237,9 +237,7 @@ class NotificationSettingsPresenterTest { val presenter = createNotificationSettingsPresenter( pushService = createFakePushService(), ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitLastSequentialItem() assertThat(initialState.currentPushDistributor).isEqualTo(AsyncData.Success(Distributor(value = "aDistributorValue0", name = "aDistributorName0"))) assertThat(initialState.availablePushDistributors).containsExactly( @@ -271,9 +269,7 @@ class NotificationSettingsPresenterTest { val presenter = createNotificationSettingsPresenter( pushService = createFakePushService(), ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitLastSequentialItem() assertThat(initialState.currentPushDistributor).isEqualTo(AsyncData.Success(Distributor(value = "aDistributorValue0", name = "aDistributorName0"))) assertThat(initialState.availablePushDistributors).containsExactly( @@ -298,9 +294,7 @@ class NotificationSettingsPresenterTest { pushService = createFakePushService(), fullScreenIntentPermissionsStateLambda = fullScreenIntentPermissionsStateLambda, ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitLastSequentialItem() assertThat(initialState.fullScreenIntentPermissionsState.permissionGranted).isFalse() @@ -324,9 +318,7 @@ class NotificationSettingsPresenterTest { }, ), ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitLastSequentialItem() initialState.eventSink.invoke(NotificationSettingsEvents.ChangePushProvider) val withDialog = awaitItem() @@ -341,7 +333,7 @@ class NotificationSettingsPresenterTest { } private fun createFakePushService( - registerWithLambda: suspend (MatrixClient, PushProvider, Distributor) -> Result = { _, _, _ -> + registerWithLambda: (MatrixClient, PushProvider, Distributor) -> Result = { _, _, _ -> Result.success(Unit) } ): PushService { @@ -361,7 +353,7 @@ class NotificationSettingsPresenterTest { ) } - private fun createNotificationSettingsPresenter( + private fun TestScope.createNotificationSettingsPresenter( notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(), pushService: PushService = FakePushService(), fullScreenIntentPermissionsStateLambda: () -> FullScreenIntentPermissionsState = { aFullScreenIntentPermissionsState() }, @@ -374,6 +366,7 @@ class NotificationSettingsPresenterTest { pushService = pushService, systemNotificationsEnabledProvider = FakeSystemNotificationsEnabledProvider(), fullScreenIntentPermissionsPresenter = { fullScreenIntentPermissionsStateLambda() }, + sessionCoroutineScope = backgroundScope, ) } } diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt index 553bee1e170..a43ee36403e 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt @@ -10,6 +10,7 @@ package io.element.android.libraries.push.api import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.push.api.history.PushHistoryItem import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.api.PushProvider @@ -37,6 +38,15 @@ interface PushService { distributor: Distributor, ): Result + /** + * Ensure that the pusher with the current push provider and distributor is registered. + * If there is no current config, the default push provider with the default distributor will be used. + * Error can be [PusherRegistrationFailure]. + */ + suspend fun ensurePusherIsRegistered( + matrixClient: MatrixClient, + ): Result + /** * Store the given push provider as the current one, but do not register. * To be used when there is no distributor available. @@ -73,4 +83,9 @@ interface PushService { * Reset the battery optimization state. */ suspend fun resetBatteryOptimizationState() + + /** + * Notify the user that the service is un-registered. + */ + suspend fun onServiceUnregistered(userId: UserId) } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/PusherRegistrationFailure.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PusherRegistrationFailure.kt similarity index 95% rename from appnav/src/main/kotlin/io/element/android/appnav/loggedin/PusherRegistrationFailure.kt rename to libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PusherRegistrationFailure.kt index 4b386cd46a6..b8ae677aab7 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/PusherRegistrationFailure.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PusherRegistrationFailure.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.appnav.loggedin +package io.element.android.libraries.push.api import io.element.android.libraries.matrix.api.exception.ClientException diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationIdProvider.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationIdProvider.kt index ab0dfb04255..ff7119b647c 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationIdProvider.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationIdProvider.kt @@ -33,7 +33,7 @@ object NotificationIdProvider { } fun getForegroundServiceNotificationId(type: ForegroundServiceType): Int { - return type.id * 10 + FOREGROUND_SERVICE_NOTIFICATION_ID + return type.ordinal * 10 + FOREGROUND_SERVICE_NOTIFICATION_ID } private fun getOffset(sessionId: SessionId): Int { @@ -50,7 +50,7 @@ object NotificationIdProvider { private const val FOREGROUND_SERVICE_NOTIFICATION_ID = 4 } -enum class ForegroundServiceType(val id: Int) { - INCOMING_CALL(1), - ONGOING_CALL(2), +enum class ForegroundServiceType { + INCOMING_CALL, + ONGOING_CALL, } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt index 1ea30a77669..3f3a8d05b4a 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt @@ -14,19 +14,25 @@ import dev.zacsweers.metro.SingleIn import dev.zacsweers.metro.binding import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.push.api.GetCurrentPushProvider import io.element.android.libraries.push.api.PushService +import io.element.android.libraries.push.api.PusherRegistrationFailure import io.element.android.libraries.push.api.history.PushHistoryItem import io.element.android.libraries.push.impl.push.MutableBatteryOptimizationStore import io.element.android.libraries.push.impl.store.PushDataStore import io.element.android.libraries.push.impl.test.TestPush +import io.element.android.libraries.push.impl.unregistration.ServiceUnregisteredHandler import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.api.PushProvider +import io.element.android.libraries.pushproviders.api.RegistrationFailure import io.element.android.libraries.pushstore.api.UserPushStoreFactory import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecretStore import io.element.android.libraries.sessionstorage.api.observer.SessionListener import io.element.android.libraries.sessionstorage.api.observer.SessionObserver import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import timber.log.Timber @ContributesBinding(AppScope::class, binding = binding()) @@ -40,6 +46,7 @@ class DefaultPushService( private val pushClientSecretStore: PushClientSecretStore, private val pushDataStore: PushDataStore, private val mutableBatteryOptimizationStore: MutableBatteryOptimizationStore, + private val serviceUnregisteredHandler: ServiceUnregisteredHandler, ) : PushService, SessionListener { init { observeSessions() @@ -81,6 +88,59 @@ class DefaultPushService( return pushProvider.registerWith(matrixClient, distributor) } + override suspend fun ensurePusherIsRegistered(matrixClient: MatrixClient): Result { + val verificationStatus = matrixClient.sessionVerificationService.sessionVerifiedStatus.first() + if (verificationStatus != SessionVerifiedStatus.Verified) { + return Result.failure(PusherRegistrationFailure.AccountNotVerified()) + .also { Timber.w("Account is not verified") } + } + Timber.d("Ensure pusher is registered") + val currentPushProvider = getCurrentPushProvider(matrixClient.sessionId) + val result = if (currentPushProvider == null) { + Timber.d("Register with the first available push provider with at least one distributor") + val pushProvider = getAvailablePushProviders() + .firstOrNull { it.getDistributors().isNotEmpty() } + // Else fallback to the first available push provider (the list should never be empty) + ?: getAvailablePushProviders().firstOrNull() + ?: return Result.failure(PusherRegistrationFailure.NoProvidersAvailable()) + .also { Timber.w("No push providers available") } + val distributor = pushProvider.getDistributors().firstOrNull() + ?: return Result.failure(PusherRegistrationFailure.NoDistributorsAvailable()) + .also { Timber.w("No distributors available") } + .also { + // In this case, consider the push provider is chosen. + selectPushProvider(matrixClient.sessionId, pushProvider) + } + registerWith(matrixClient, pushProvider, distributor) + } else { + val currentPushDistributor = currentPushProvider.getCurrentDistributor(matrixClient.sessionId) + if (currentPushDistributor == null) { + Timber.d("Register with the first available distributor") + val distributor = currentPushProvider.getDistributors().firstOrNull() + ?: return Result.failure(PusherRegistrationFailure.NoDistributorsAvailable()) + .also { Timber.w("No distributors available") } + registerWith(matrixClient, currentPushProvider, distributor) + } else { + Timber.d("Re-register with the current distributor") + registerWith(matrixClient, currentPushProvider, currentPushDistributor) + } + } + return result.fold( + onSuccess = { + Timber.d("Pusher registered") + Result.success(Unit) + }, + onFailure = { + Timber.e(it, "Failed to register pusher") + if (it is RegistrationFailure) { + Result.failure(PusherRegistrationFailure.RegistrationFailure(it.clientException, it.isRegisteringAgain)) + } else { + Result.failure(it) + } + } + ) + } + override suspend fun selectPushProvider( sessionId: SessionId, pushProvider: PushProvider, @@ -141,4 +201,8 @@ class DefaultPushService( override suspend fun resetBatteryOptimizationState() { mutableBatteryOptimizationStore.reset() } + + override suspend fun onServiceUnregistered(userId: UserId) { + serviceUnregisteredHandler.handle(userId) + } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt index 3e9c917cfd8..d46bffce73c 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt @@ -24,6 +24,7 @@ interface NotificationDisplayer { fun cancelNotification(tag: String?, id: Int) fun displayDiagnosticNotification(notification: Notification): Boolean fun dismissDiagnosticNotification() + fun displayUnregistrationNotification(notification: Notification): Boolean } @ContributesBinding(AppScope::class) @@ -60,6 +61,14 @@ class DefaultNotificationDisplayer( ) } + override fun displayUnregistrationNotification(notification: Notification): Boolean { + return showNotification( + tag = TAG_DIAGNOSTIC, + id = NOTIFICATION_ID_UNREGISTRATION, + notification = notification, + ) + } + companion object { private const val TAG_DIAGNOSTIC = "DIAGNOSTIC" @@ -67,5 +76,6 @@ class DefaultNotificationDisplayer( * IDs for notifications * ========================================================================================== */ private const val NOTIFICATION_ID_DIAGNOSTIC = 888 + private const val NOTIFICATION_ID_UNREGISTRATION = 889 } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt index a69ce5e6736..98a12282972 100755 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt @@ -11,7 +11,6 @@ package io.element.android.libraries.push.impl.notifications.factories import android.app.Notification import android.content.Context import android.graphics.Bitmap -import android.graphics.drawable.Icon import androidx.annotation.ColorInt import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat.MessagingStyle @@ -92,6 +91,10 @@ interface NotificationCreator { @ColorInt color: Int, ): Notification + fun createUnregistrationNotification( + notificationAccountParams: NotificationAccountParams, + ): Notification + companion object { /** * Creates a tag for a message notification given its [roomId] and optional [threadId]. @@ -143,14 +146,21 @@ class DefaultNotificationCreator( } else { notificationChannels.getChannelIdForMessage(noisy = roomInfo.shouldBing) } + // A category allows groups of notifications to be ranked and filtered – per user or system settings. + // For example, alarm notifications should display before promo notifications, or message from known contact + // that can be displayed in not disturb mode if white listed (the later will need compat28.x) + // If any of the events are of rtc notification type it means a missed call, set the category to the right value + val category = if (containsMissedCall) { + NotificationCompat.CATEGORY_MISSED_CALL + } else { + NotificationCompat.CATEGORY_MESSAGE + } val builder = if (existingNotification != null) { NotificationCompat.Builder(context, existingNotification) + // Clear existing actions + .clearActions() } else { NotificationCompat.Builder(context, channelId) - // A category allows groups of notifications to be ranked and filtered – per user or system settings. - // For example, alarm notifications should display before promo notifications, or message from known contact - // that can be displayed in not disturb mode if white listed (the later will need compat28.x) - .setCategory(NotificationCompat.CATEGORY_MESSAGE) // ID of the corresponding shortcut, for conversation features under API 30+ // Must match those created in the ShortcutInfoCompat.Builder() // for the notification to appear as a "Conversation": @@ -166,7 +176,6 @@ class DefaultNotificationCreator( // Remove notification after opening it or using an action .setAutoCancel(true) } - val messagingStyle = existingNotification?.let { MessagingStyle.extractMessagingStyleFromNotification(it) } ?: createMessagingStyleFromCurrentUser( @@ -176,54 +185,35 @@ class DefaultNotificationCreator( isThread = threadId != null, roomIsGroup = !roomInfo.isDm, ) - messagingStyle.addMessagesFromEvents(events, imageLoader) - return builder + .setCategory(category) .setNumber(events.size) .setOnlyAlertOnce(roomInfo.isUpdated) .setWhen(lastMessageTimestamp) // MESSAGING_STYLE sets title and content for API 16 and above devices. .setStyle(messagingStyle) .configureWith(notificationAccountParams) - // Sets priority for 25 and below. For 26 and above, 'priority' is deprecated for - // 'importance' which is set in the NotificationChannel. The integers representing - // 'priority' are different from 'importance', so make sure you don't mix them. + // Mark room/thread as read + .addAction(markAsReadActionFactory.create(roomInfo, threadId)) + .setContentIntent(openIntent) + .setLargeIcon(largeIcon) + .setDeleteIntent(pendingIntentFactory.createDismissRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId)) .apply { + // Sets priority for 25 and below. For 26 and above, 'priority' is deprecated for + // 'importance' which is set in the NotificationChannel. The integers representing + // 'priority' are different from 'importance', so make sure you don't mix them. if (roomInfo.shouldBing) { - // Compat priority = NotificationCompat.PRIORITY_DEFAULT - /* - vectorPreferences.getNotificationRingTone()?.let { - setSound(it) - } - */ setLights(notificationAccountParams.color, 500, 500) } else { priority = NotificationCompat.PRIORITY_LOW } - // Clear existing actions since we might be updating an existing notification - clearActions() - // Add actions and notification intents - // Mark room/thread as read - addAction(markAsReadActionFactory.create(roomInfo, threadId)) // Quick reply if (!roomInfo.hasSmartReplyError) { val latestEventId = events.lastOrNull()?.eventId addAction(quickReplyActionFactory.create(roomInfo, latestEventId, threadId)) } - if (openIntent != null) { - setContentIntent(openIntent) - } - if (largeIcon != null) { - setLargeIcon(Icon.createWithBitmap(largeIcon)) - } - setDeleteIntent(pendingIntentFactory.createDismissRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId)) - - // If any of the events are of rtc notification type it means a missed call, set the category to the right value - if (events.any { it.type == EventType.RTC_NOTIFICATION }) { - setCategory(NotificationCompat.CATEGORY_MISSED_CALL) - } } .setTicker(tickerText) .build() @@ -240,32 +230,26 @@ class DefaultNotificationCreator( .setContentText(inviteNotifiableEvent.description.annotateForDebug(6)) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) .configureWith(notificationAccountParams) + .addAction(rejectInvitationActionFactory.create(inviteNotifiableEvent)) + .addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent)) + // Build the pending intent for when the notification is clicked + .setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent(inviteNotifiableEvent.sessionId, inviteNotifiableEvent.roomId, null)) .apply { - addAction(rejectInvitationActionFactory.create(inviteNotifiableEvent)) - addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent)) - // Build the pending intent for when the notification is clicked - setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent(inviteNotifiableEvent.sessionId, inviteNotifiableEvent.roomId, null)) - if (inviteNotifiableEvent.noisy) { // Compat priority = NotificationCompat.PRIORITY_DEFAULT - /* - vectorPreferences.getNotificationRingTone()?.let { - setSound(it) - } - */ setLights(notificationAccountParams.color, 500, 500) } else { priority = NotificationCompat.PRIORITY_LOW } - setDeleteIntent( - pendingIntentFactory.createDismissInvitePendingIntent( - inviteNotifiableEvent.sessionId, - inviteNotifiableEvent.roomId, - ) - ) - setAutoCancel(true) } + .setDeleteIntent( + pendingIntentFactory.createDismissInvitePendingIntent( + inviteNotifiableEvent.sessionId, + inviteNotifiableEvent.roomId, + ) + ) + .setAutoCancel(true) .build() } @@ -286,11 +270,6 @@ class DefaultNotificationCreator( if (simpleNotifiableEvent.noisy) { // Compat priority = NotificationCompat.PRIORITY_DEFAULT - /* - vectorPreferences.getNotificationRingTone()?.let { - setSound(it) - } - */ setLights(notificationAccountParams.color, 500, 500) } else { priority = NotificationCompat.PRIORITY_LOW @@ -349,11 +328,6 @@ class DefaultNotificationCreator( if (noisy) { // Compat priority = NotificationCompat.PRIORITY_DEFAULT - /* - vectorPreferences.getNotificationRingTone()?.let { - setSound(it) - } - */ setLights(notificationAccountParams.color, 500, 500) } else { // compat @@ -382,6 +356,28 @@ class DefaultNotificationCreator( .build() } + override fun createUnregistrationNotification( + notificationAccountParams: NotificationAccountParams, + ): Notification { + val userId = notificationAccountParams.user.userId + val text = if (notificationAccountParams.showSessionId) { + // TODO i18n + "$userId will not receive notifications anymore." + } else { + // TODO i18n + "You will not receive notifications anymore." + } + return NotificationCompat.Builder(context, notificationChannels.getChannelIdForTest()) + .setContentTitle(stringProvider.getString(CommonStrings.dialog_title_warning)) + .setContentText(text) + .configureWith(notificationAccountParams) + .setPriority(NotificationCompat.PRIORITY_MAX) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setAutoCancel(true) + .setContentIntent(pendingIntentFactory.createOpenSessionPendingIntent(userId)) + .build() + } + private suspend fun MessagingStyle.addMessagesFromEvents( events: List, imageLoader: ImageLoader, diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unregistration/ServiceUnregisteredHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unregistration/ServiceUnregisteredHandler.kt new file mode 100644 index 00000000000..29052b42cae --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unregistration/ServiceUnregisteredHandler.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.unregistration + +import androidx.compose.ui.graphics.toArgb +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.appconfig.NotificationConfig +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.push.impl.notifications.NotificationDisplayer +import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams +import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator +import io.element.android.libraries.sessionstorage.api.SessionStore +import kotlinx.coroutines.flow.first + +interface ServiceUnregisteredHandler { + suspend fun handle(userId: UserId) +} + +@ContributesBinding(AppScope::class) +class DefaultServiceUnregisteredHandler( + private val enterpriseService: EnterpriseService, + private val notificationCreator: NotificationCreator, + private val notificationDisplayer: NotificationDisplayer, + private val sessionStore: SessionStore, +) : ServiceUnregisteredHandler { + override suspend fun handle(userId: UserId) { + val color = enterpriseService.brandColorsFlow(userId).first()?.toArgb() + ?: NotificationConfig.NOTIFICATION_ACCENT_COLOR + val hasMultipleAccounts = sessionStore.numberOfSessions() > 1 + val notification = notificationCreator.createUnregistrationNotification( + NotificationAccountParams( + user = MatrixUser(userId), + color = color, + showSessionId = hasMultipleAccounts, + ) + ) + notificationDisplayer.displayUnregistrationNotification(notification) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPushServiceTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPushServiceTest.kt index d862e1cf390..f48b7ee612e 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPushServiceTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPushServiceTest.kt @@ -12,12 +12,15 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService import io.element.android.libraries.push.api.GetCurrentPushProvider +import io.element.android.libraries.push.api.PusherRegistrationFailure import io.element.android.libraries.push.api.history.PushHistoryItem import io.element.android.libraries.push.impl.push.FakeMutableBatteryOptimizationStore import io.element.android.libraries.push.impl.push.MutableBatteryOptimizationStore @@ -25,6 +28,8 @@ import io.element.android.libraries.push.impl.store.InMemoryPushDataStore import io.element.android.libraries.push.impl.store.PushDataStore import io.element.android.libraries.push.impl.test.FakeTestPush import io.element.android.libraries.push.impl.test.TestPush +import io.element.android.libraries.push.impl.unregistration.FakeServiceUnregisteredHandler +import io.element.android.libraries.push.impl.unregistration.ServiceUnregisteredHandler import io.element.android.libraries.push.test.FakeGetCurrentPushProvider import io.element.android.libraries.pushproviders.api.Config import io.element.android.libraries.pushproviders.api.Distributor @@ -38,6 +43,7 @@ import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushSto import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.InMemoryPushClientSecretStore import io.element.android.libraries.sessionstorage.api.observer.SessionObserver import io.element.android.libraries.sessionstorage.test.observer.NoOpSessionObserver +import io.element.android.tests.testutils.lambda.any import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import kotlinx.coroutines.flow.first @@ -337,6 +343,281 @@ class DefaultPushServiceTest { } } + @Test + fun `ensurePusher - error when account is not verified`() = runTest { + val sessionVerificationService = FakeSessionVerificationService( + initialSessionVerifiedStatus = SessionVerifiedStatus.NotVerified + ) + val pushService = createDefaultPushService() + val result = pushService.ensurePusherIsRegistered( + FakeMatrixClient( + sessionVerificationService = sessionVerificationService, + ) + ) + assertThat(result.exceptionOrNull()!!).isInstanceOf(PusherRegistrationFailure.AccountNotVerified::class.java) + } + + @Test + fun `ensurePusher - case two push providers but first one does not have distributor - second one will be used`() = runTest { + val lambda = lambdaRecorder> { _, _ -> + Result.success(Unit) + } + val sessionVerificationService = FakeSessionVerificationService( + initialSessionVerifiedStatus = SessionVerifiedStatus.Verified + ) + val pushProvider0 = FakePushProvider( + index = 0, + name = "aFakePushProvider0", + distributors = emptyList(), + ) + val distributor = Distributor("aDistributorValue1", "aDistributorName1") + val pushProvider1 = FakePushProvider( + index = 1, + name = "aFakePushProvider1", + distributors = listOf(distributor), + registerWithResult = lambda, + ) + val pushService = createDefaultPushService( + pushProviders = setOf( + pushProvider0, + pushProvider1, + ), + ) + val result = pushService.ensurePusherIsRegistered( + FakeMatrixClient( + sessionVerificationService = sessionVerificationService, + ) + ) + assertThat(result.isSuccess).isTrue() + lambda.assertions().isCalledOnce() + .with( + // MatrixClient + any(), + // First distributor of second push provider + value(distributor), + ) + } + + @Test + fun `ensurePusher - case one push provider but no distributor available`() = runTest { + val lambda = lambdaRecorder> { _, _ -> + Result.success(Unit) + } + val sessionVerificationService = FakeSessionVerificationService( + initialSessionVerifiedStatus = SessionVerifiedStatus.Verified + ) + val pushProvider = FakePushProvider( + index = 0, + name = "aFakePushProvider", + distributors = emptyList(), + registerWithResult = lambda, + ) + val pushService = createDefaultPushService( + pushProviders = setOf(pushProvider), + ) + val result = pushService.ensurePusherIsRegistered( + FakeMatrixClient( + sessionVerificationService = sessionVerificationService, + ) + ) + assertThat(result.exceptionOrNull()).isInstanceOf(PusherRegistrationFailure.NoDistributorsAvailable::class.java) + lambda.assertions().isNeverCalled() + } + + @Test + fun `ensurePusher - ensure default pusher is registered with default provider`() = runTest { + val lambda = lambdaRecorder> { _, _ -> + Result.success(Unit) + } + val sessionVerificationService = FakeSessionVerificationService( + initialSessionVerifiedStatus = SessionVerifiedStatus.Verified + ) + val pushService = createDefaultPushService( + pushProviders = setOf( + FakePushProvider( + index = 0, + name = "aFakePushProvider", + distributors = listOf(Distributor("aDistributorValue0", "aDistributorName0")), + registerWithResult = lambda, + ) + ), + ) + val result = pushService.ensurePusherIsRegistered( + FakeMatrixClient( + sessionVerificationService = sessionVerificationService, + ) + ) + assertThat(result.isSuccess).isTrue() + lambda.assertions() + .isCalledOnce() + .with( + // MatrixClient + any(), + // First distributor + value(pushService.getAvailablePushProviders()[0].getDistributors()[0]), + ) + } + + @Test + fun `ensurePusher - ensure default pusher is registered with default provider - fail to register`() = runTest { + val lambda = lambdaRecorder> { _, _ -> + Result.failure(AN_EXCEPTION) + } + val sessionVerificationService = FakeSessionVerificationService( + initialSessionVerifiedStatus = SessionVerifiedStatus.Verified + ) + val pushService = createDefaultPushService( + pushProviders = setOf( + FakePushProvider( + index = 0, + name = "aFakePushProvider", + distributors = listOf(Distributor("aDistributorValue0", "aDistributorName0")), + registerWithResult = lambda, + ) + ), + ) + val result = pushService.ensurePusherIsRegistered( + FakeMatrixClient( + sessionVerificationService = sessionVerificationService, + ) + ) + assertThat(result.isFailure).isTrue() + lambda.assertions() + .isCalledOnce() + .with( + // MatrixClient + any(), + // First distributor + value(pushService.getAvailablePushProviders()[0].getDistributors()[0]), + ) + } + + @Test + fun `ensurePusher - if current push provider does not have distributors, nothing happen`() = runTest { + val lambda = lambdaRecorder> { _, _ -> + Result.success(Unit) + } + val sessionVerificationService = FakeSessionVerificationService( + initialSessionVerifiedStatus = SessionVerifiedStatus.Verified + ) + val pushProvider = FakePushProvider( + index = 0, + name = "aFakePushProvider0", + distributors = emptyList(), + registerWithResult = lambda, + ) + val pushService = createDefaultPushService( + pushProviders = setOf(pushProvider), + getCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider = pushProvider.name), + ) + val result = pushService.ensurePusherIsRegistered( + FakeMatrixClient( + sessionVerificationService = sessionVerificationService, + ) + ) + assertThat(result.exceptionOrNull()) + .isInstanceOf(PusherRegistrationFailure.NoDistributorsAvailable::class.java) + lambda.assertions() + .isNeverCalled() + } + + @Test + fun `ensurePusher - ensure current provider is registered with current distributor`() = runTest { + val lambda = lambdaRecorder> { _, _ -> + Result.success(Unit) + } + val sessionVerificationService = FakeSessionVerificationService( + initialSessionVerifiedStatus = SessionVerifiedStatus.Verified + ) + val distributor = Distributor("aDistributorValue1", "aDistributorName1") + val pushProvider = FakePushProvider( + index = 0, + name = "aFakePushProvider0", + distributors = listOf( + Distributor("aDistributorValue0", "aDistributorName0"), + distributor, + ), + currentDistributor = { distributor }, + registerWithResult = lambda, + ) + val pushService = createDefaultPushService( + pushProviders = setOf(pushProvider), + getCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider = pushProvider.name), + ) + val result = pushService.ensurePusherIsRegistered( + FakeMatrixClient( + sessionVerificationService = sessionVerificationService, + ) + ) + assertThat(result.isSuccess).isTrue() + lambda.assertions() + .isCalledOnce() + .with( + // MatrixClient + any(), + // Current distributor + value(distributor), + ) + } + + @Test + fun `ensurePusher - case no push provider available provider`() = runTest { + val lambda = lambdaRecorder> { _, _ -> + Result.success(Unit) + } + val sessionVerificationService = FakeSessionVerificationService(SessionVerifiedStatus.Verified) + val pushService = createDefaultPushService( + pushProviders = emptySet(), + ) + val result = pushService.ensurePusherIsRegistered( + FakeMatrixClient( + sessionVerificationService = sessionVerificationService, + ) + ) + assertThat(result.exceptionOrNull()) + .isInstanceOf(PusherRegistrationFailure.NoProvidersAvailable::class.java) + lambda.assertions() + .isNeverCalled() + } + + @Test + fun `ensurePusher - if current push provider does not have current distributor, the first one is used`() = runTest { + val lambda = lambdaRecorder> { _, _ -> + Result.success(Unit) + } + val sessionVerificationService = FakeSessionVerificationService( + initialSessionVerifiedStatus = SessionVerifiedStatus.Verified + ) + val pushProvider = FakePushProvider( + index = 0, + name = "aFakePushProvider0", + distributors = listOf( + Distributor("aDistributorValue0", "aDistributorName0"), + Distributor("aDistributorValue1", "aDistributorName1"), + ), + currentDistributor = { null }, + registerWithResult = lambda, + ) + val pushService = createDefaultPushService( + pushProviders = setOf(pushProvider), + getCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider = pushProvider.name), + ) + val result = pushService.ensurePusherIsRegistered( + FakeMatrixClient( + sessionVerificationService = sessionVerificationService, + ) + ) + assertThat(result.isSuccess).isTrue() + lambda.assertions() + .isCalledOnce() + .with( + // MatrixClient + any(), + // First distributor + value(pushService.getAvailablePushProviders()[0].getDistributors()[0]), + ) + } + private fun createDefaultPushService( testPush: TestPush = FakeTestPush(), userPushStoreFactory: UserPushStoreFactory = FakeUserPushStoreFactory(), @@ -346,6 +627,7 @@ class DefaultPushServiceTest { pushClientSecretStore: PushClientSecretStore = InMemoryPushClientSecretStore(), pushDataStore: PushDataStore = InMemoryPushDataStore(), mutableBatteryOptimizationStore: MutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(), + serviceUnregisteredHandler: ServiceUnregisteredHandler = FakeServiceUnregisteredHandler(), ): DefaultPushService { return DefaultPushService( testPush = testPush, @@ -356,6 +638,7 @@ class DefaultPushServiceTest { pushClientSecretStore = pushClientSecretStore, pushDataStore = pushDataStore, mutableBatteryOptimizationStore = mutableBatteryOptimizationStore, + serviceUnregisteredHandler = serviceUnregisteredHandler, ) } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt index cec3631ea3b..0504ae433b2 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt @@ -63,6 +63,21 @@ class DefaultNotificationCreatorTest { ) } + @Test + fun `test createUnregistrationNotification`() { + val sut = createNotificationCreator() + val matrixUser = aMatrixUser() + val result = sut.createUnregistrationNotification( + notificationAccountParams = aNotificationAccountParams( + user = matrixUser, + ), + ) + result.commonAssertions( + expectedGroup = matrixUser.userId.value, + expectedCategory = NotificationCompat.CATEGORY_ERROR, + ) + } + @Test fun `test createFallbackNotification`() { val sut = createNotificationCreator() diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationCreator.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationCreator.kt index 76302d772e7..d5e4ad96959 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationCreator.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationCreator.kt @@ -41,6 +41,8 @@ class FakeNotificationCreator( > = lambdaRecorder { _, _, _, _, _ -> A_NOTIFICATION }, var createDiagnosticNotificationResult: LambdaOneParamRecorder = lambdaRecorder { _ -> A_NOTIFICATION }, + val createUnregistrationNotificationResult: LambdaOneParamRecorder = + lambdaRecorder { _ -> A_NOTIFICATION }, ) : NotificationCreator { override suspend fun createMessagesListNotification( notificationAccountParams: NotificationAccountParams, @@ -93,4 +95,8 @@ class FakeNotificationCreator( ): Notification { return createDiagnosticNotificationResult(color) } + + override fun createUnregistrationNotification(notificationAccountParams: NotificationAccountParams): Notification { + return createUnregistrationNotificationResult(notificationAccountParams) + } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDisplayer.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDisplayer.kt index 9ab32cc13eb..fd4af70a725 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDisplayer.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDisplayer.kt @@ -24,6 +24,7 @@ class FakeNotificationDisplayer( var cancelNotificationResult: LambdaTwoParamsRecorder = lambdaRecorder { _, _ -> }, var displayDiagnosticNotificationResult: LambdaOneParamRecorder = lambdaRecorder { _ -> true }, var dismissDiagnosticNotificationResult: LambdaNoParamRecorder = lambdaRecorder { -> }, + var displayUnregistrationNotificationResult: LambdaOneParamRecorder = lambdaRecorder { _ -> true }, ) : NotificationDisplayer { override fun showNotification(tag: String?, id: Int, notification: Notification): Boolean { return showNotificationResult(tag, id, notification) @@ -41,6 +42,10 @@ class FakeNotificationDisplayer( return dismissDiagnosticNotificationResult() } + override fun displayUnregistrationNotification(notification: Notification): Boolean { + return displayUnregistrationNotificationResult(notification) + } + fun verifySummaryCancelled(times: Int = 1) { cancelNotificationResult.assertions().isCalledExactly(times).withSequence( listOf(value(null), value(NotificationIdProvider.getSummaryNotificationId(A_SESSION_ID))) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/unregistration/DefaultServiceUnregisteredHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/unregistration/DefaultServiceUnregisteredHandlerTest.kt new file mode 100644 index 00000000000..bda82a3c3eb --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/unregistration/DefaultServiceUnregisteredHandlerTest.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.unregistration + +import android.app.Notification +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import io.element.android.appconfig.NotificationConfig +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID_2 +import io.element.android.libraries.push.impl.notifications.NotificationDisplayer +import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams +import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer +import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultServiceUnregisteredHandlerTest { + @Test + fun `handle will create a notification and render it`() = runTest { + val notification = A_NOTIFICATION + val createUnregistrationNotificationResult = lambdaRecorder { notification } + val displayUnregistrationNotificationResult = lambdaRecorder { true } + val sut = createDefaultServiceUnregisteredHandler( + notificationCreator = FakeNotificationCreator( + createUnregistrationNotificationResult = createUnregistrationNotificationResult, + ), + notificationDisplayer = FakeNotificationDisplayer( + displayUnregistrationNotificationResult = displayUnregistrationNotificationResult, + ) + ) + sut.handle(A_SESSION_ID) + createUnregistrationNotificationResult.assertions().isCalledOnce().with( + value( + NotificationAccountParams( + MatrixUser( + userId = A_SESSION_ID, + displayName = null, + avatarUrl = null, + ), + color = NotificationConfig.NOTIFICATION_ACCENT_COLOR, + showSessionId = false, + ) + ) + ) + displayUnregistrationNotificationResult.assertions().isCalledOnce().with( + value(notification) + ) + } + + @Test + fun `handle will create a notification and render it - custom color and multi accounts`() = runTest { + val notification = A_NOTIFICATION + val createUnregistrationNotificationResult = lambdaRecorder { notification } + val displayUnregistrationNotificationResult = lambdaRecorder { true } + val sut = createDefaultServiceUnregisteredHandler( + enterpriseService = FakeEnterpriseService( + initialBrandColor = Color.Red, + ), + notificationCreator = FakeNotificationCreator( + createUnregistrationNotificationResult = createUnregistrationNotificationResult, + ), + notificationDisplayer = FakeNotificationDisplayer( + displayUnregistrationNotificationResult = displayUnregistrationNotificationResult, + ), + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData(sessionId = A_SESSION_ID.value), + aSessionData(sessionId = A_SESSION_ID_2.value), + ) + ) + ) + sut.handle(A_SESSION_ID) + createUnregistrationNotificationResult.assertions().isCalledOnce().with( + value( + NotificationAccountParams( + MatrixUser( + userId = A_SESSION_ID, + displayName = null, + avatarUrl = null, + ), + color = Color.Red.toArgb(), + showSessionId = true, + ) + ) + ) + displayUnregistrationNotificationResult.assertions().isCalledOnce().with( + value(notification) + ) + } + + private fun createDefaultServiceUnregisteredHandler( + enterpriseService: EnterpriseService = FakeEnterpriseService(), + notificationCreator: NotificationCreator = FakeNotificationCreator(), + notificationDisplayer: NotificationDisplayer = FakeNotificationDisplayer(), + sessionStore: SessionStore = InMemorySessionStore(), + ) = DefaultServiceUnregisteredHandler( + enterpriseService = enterpriseService, + notificationCreator = notificationCreator, + notificationDisplayer = notificationDisplayer, + sessionStore = sessionStore, + ) +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/unregistration/FakeServiceUnregisteredHandler.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/unregistration/FakeServiceUnregisteredHandler.kt new file mode 100644 index 00000000000..0ae41909739 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/unregistration/FakeServiceUnregisteredHandler.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.unregistration + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeServiceUnregisteredHandler( + private val handleResult: (UserId) -> Unit = { lambdaError() }, +) : ServiceUnregisteredHandler { + override suspend fun handle(userId: UserId) { + handleResult(userId) + } +} diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePushService.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePushService.kt index d0198fba916..604ff887289 100644 --- a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePushService.kt +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePushService.kt @@ -10,6 +10,7 @@ package io.element.android.libraries.push.test import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.push.api.PushService import io.element.android.libraries.push.api.history.PushHistoryItem import io.element.android.libraries.pushproviders.api.Distributor @@ -22,7 +23,7 @@ import kotlinx.coroutines.flow.MutableStateFlow class FakePushService( private val testPushBlock: suspend (SessionId) -> Boolean = { true }, private val availablePushProviders: List = emptyList(), - private val registerWithLambda: suspend (MatrixClient, PushProvider, Distributor) -> Result = { _, _, _ -> + private val registerWithLambda: (MatrixClient, PushProvider, Distributor) -> Result = { _, _, _ -> Result.success(Unit) }, private val currentPushProvider: (SessionId) -> PushProvider? = { availablePushProviders.firstOrNull() }, @@ -30,6 +31,8 @@ class FakePushService( private val setIgnoreRegistrationErrorLambda: (SessionId, Boolean) -> Unit = { _, _ -> lambdaError() }, private val resetPushHistoryResult: () -> Unit = { lambdaError() }, private val resetBatteryOptimizationStateResult: () -> Unit = { lambdaError() }, + private val onServiceUnregisteredResult: (UserId) -> Unit = { lambdaError() }, + private val ensurePusherIsRegisteredResult: () -> Result = { lambdaError() }, ) : PushService { override suspend fun getCurrentPushProvider(sessionId: SessionId): PushProvider? { return registeredPushProvider ?: currentPushProvider(sessionId) @@ -54,6 +57,10 @@ class FakePushService( } } + override suspend fun ensurePusherIsRegistered(matrixClient: MatrixClient): Result { + return ensurePusherIsRegisteredResult() + } + override suspend fun selectPushProvider(sessionId: SessionId, pushProvider: PushProvider) { selectPushProviderLambda(sessionId, pushProvider) } @@ -98,4 +105,8 @@ class FakePushService( override suspend fun resetBatteryOptimizationState() { resetBatteryOptimizationStateResult() } + + override suspend fun onServiceUnregistered(userId: UserId) { + onServiceUnregisteredResult(userId) + } } diff --git a/libraries/pushproviders/unifiedpush/build.gradle.kts b/libraries/pushproviders/unifiedpush/build.gradle.kts index 6369399f287..1b24bc4b1fd 100644 --- a/libraries/pushproviders/unifiedpush/build.gradle.kts +++ b/libraries/pushproviders/unifiedpush/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.matrix.api) + implementation(projects.libraries.push.api) implementation(projects.libraries.uiStrings) api(projects.libraries.troubleshoot.api) diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushNewGatewayHandler.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushNewGatewayHandler.kt index 98e11c8bb76..d7de923a261 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushNewGatewayHandler.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushNewGatewayHandler.kt @@ -39,7 +39,7 @@ class DefaultUnifiedPushNewGatewayHandler( val userId = pushClientSecret.getUserIdFromSecret(clientSecret) ?: return Result.failure( IllegalStateException("Unable to retrieve session") ).also { - Timber.w("Unable to retrieve session") + Timber.tag(loggerTag.value).w("Unable to retrieve session") } val userDataStore = userPushStoreFactory.getOrCreate(userId) return if (userDataStore.getPushProviderName() == UnifiedPushConfig.NAME) { @@ -48,6 +48,9 @@ class DefaultUnifiedPushNewGatewayHandler( .flatMap { client -> pusherSubscriber.registerPusher(client, endpoint, pushGateway) } + .onFailure { + Timber.tag(loggerTag.value).w(it, "Unable to register pusher") + } } else { Timber.tag(loggerTag.value).d("This session is not using UnifiedPush pusher") Result.failure( diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushRemovedGatewayHandler.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushRemovedGatewayHandler.kt new file mode 100644 index 00000000000..2b302092458 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushRemovedGatewayHandler.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.androidutils.throttler.FirstThrottler +import io.element.android.libraries.core.extensions.flatMap +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.push.api.PushService +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import kotlinx.coroutines.CoroutineScope +import timber.log.Timber + +private val loggerTag = LoggerTag("UnifiedPushRemovedGatewayHandler", LoggerTag.PushLoggerTag) + +/** + * Handle endpoint removal received from UnifiedPush. Will try to register again. + */ +fun interface UnifiedPushRemovedGatewayHandler { + suspend fun handle(clientSecret: String): Result +} + +@Inject +@SingleIn(AppScope::class) +class UnifiedPushRemovedGatewayThrottler( + @AppCoroutineScope + private val appCoroutineScope: CoroutineScope, +) { + private val firstThrottler = FirstThrottler( + minimumInterval = 60_000, + coroutineScope = appCoroutineScope, + ) + + fun canRegisterAgain(): Boolean { + return firstThrottler.canHandle() + } +} + +@ContributesBinding(AppScope::class) +class DefaultUnifiedPushRemovedGatewayHandler( + private val unregisterUnifiedPushUseCase: UnregisterUnifiedPushUseCase, + private val pushClientSecret: PushClientSecret, + private val matrixClientProvider: MatrixClientProvider, + private val pushService: PushService, + private val unifiedPushRemovedGatewayThrottler: UnifiedPushRemovedGatewayThrottler, +) : UnifiedPushRemovedGatewayHandler { + override suspend fun handle(clientSecret: String): Result { + // Unregister the pusher for the session with this client secret. + val userId = pushClientSecret.getUserIdFromSecret(clientSecret) ?: return Result.failure( + IllegalStateException("Unable to retrieve session") + ).also { + Timber.tag(loggerTag.value).w("Unable to retrieve session") + } + return matrixClientProvider + .getOrRestore(userId) + .onFailure { + Timber.tag(loggerTag.value).w(it, "Fails to restore client") + } + .flatMap { client -> + unregisterUnifiedPushUseCase.unregister( + matrixClient = client, + clientSecret = clientSecret, + unregisterUnifiedPush = false, + ) + .onFailure { + Timber.tag(loggerTag.value).w(it, "Unable to unregister pusher") + } + .flatMap { + val pushProvider = pushService.getCurrentPushProvider(userId) + val distributor = pushProvider?.getCurrentDistributor(userId) + // Attempt to register again + if (pushProvider != null && distributor != null) { + if (unifiedPushRemovedGatewayThrottler.canRegisterAgain()) { + pushService.registerWith( + client, + pushProvider, + distributor, + ) + .onFailure { + Timber.tag(loggerTag.value).w(it, "Unable to register with current data") + } + } else { + // Let the user know + Timber.tag(loggerTag.value).w("Second removal in less than 1 minute, do not register again") + pushService.onServiceUnregistered(userId) + Result.success(Unit) + } + } else { + Result.failure(IllegalStateException("Unable to register again")) + } + } + .onFailure { + // Let the user know + pushService.onServiceUnregistered(userId) + } + } + .onFailure { + Timber.tag(loggerTag.value).w(it, "Issue during pusher unregistration / re registration") + } + } +} diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnregisterUnifiedPushUseCase.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnregisterUnifiedPushUseCase.kt index 4340344b14e..3157f1492f4 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnregisterUnifiedPushUseCase.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnregisterUnifiedPushUseCase.kt @@ -21,12 +21,19 @@ interface UnregisterUnifiedPushUseCase { /** * Unregister the app from the homeserver, then from UnifiedPush. */ - suspend fun unregister(matrixClient: MatrixClient, clientSecret: String): Result + suspend fun unregister( + matrixClient: MatrixClient, + clientSecret: String, + unregisterUnifiedPush: Boolean = true, + ): Result /** * Cleanup any remaining data for the given client secret and unregister the app from UnifiedPush. */ - fun cleanup(clientSecret: String) + fun cleanup( + clientSecret: String, + unregisterUnifiedPush: Boolean = true, + ) } @ContributesBinding(AppScope::class) @@ -35,7 +42,11 @@ class DefaultUnregisterUnifiedPushUseCase( private val unifiedPushStore: UnifiedPushStore, private val pusherSubscriber: PusherSubscriber, ) : UnregisterUnifiedPushUseCase { - override suspend fun unregister(matrixClient: MatrixClient, clientSecret: String): Result { + override suspend fun unregister( + matrixClient: MatrixClient, + clientSecret: String, + unregisterUnifiedPush: Boolean, + ): Result { val endpoint = unifiedPushStore.getEndpoint(clientSecret) val gateway = unifiedPushStore.getPushGateway(clientSecret) if (endpoint == null || gateway == null) { @@ -46,13 +57,15 @@ class DefaultUnregisterUnifiedPushUseCase( } return pusherSubscriber.unregisterPusher(matrixClient, endpoint, gateway) .onSuccess { - cleanup(clientSecret) + cleanup(clientSecret, unregisterUnifiedPush) } } - override fun cleanup(clientSecret: String) { + override fun cleanup(clientSecret: String, unregisterUnifiedPush: Boolean) { unifiedPushStore.storeUpEndpoint(clientSecret, null) unifiedPushStore.storePushGateway(clientSecret, null) - UnifiedPush.unregister(context, clientSecret) + if (unregisterUnifiedPush) { + UnifiedPush.unregister(context, clientSecret) + } } } diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt index 8180ac436be..05f6969fc53 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt @@ -35,7 +35,9 @@ class VectorUnifiedPushMessagingReceiver : MessagingReceiver() { @Inject lateinit var unifiedPushGatewayResolver: UnifiedPushGatewayResolver @Inject lateinit var unifiedPushGatewayUrlResolver: UnifiedPushGatewayUrlResolver @Inject lateinit var newGatewayHandler: UnifiedPushNewGatewayHandler + @Inject lateinit var removedGatewayHandler: UnifiedPushRemovedGatewayHandler @Inject lateinit var endpointRegistrationHandler: EndpointRegistrationHandler + @AppCoroutineScope @Inject lateinit var coroutineScope: CoroutineScope @@ -104,30 +106,23 @@ class VectorUnifiedPushMessagingReceiver : MessagingReceiver() { */ override fun onRegistrationFailed(context: Context, reason: FailedReason, instance: String) { Timber.tag(loggerTag.value).e("onRegistrationFailed for $instance, reason: $reason") - /* - Toast.makeText(context, "Push service registration failed", Toast.LENGTH_SHORT).show() - val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME - pushDataStore.setFdroidSyncBackgroundMode(mode) - guardServiceStarter.start() - */ + coroutineScope.launch { + endpointRegistrationHandler.registrationDone( + RegistrationResult( + clientSecret = instance, + result = Result.failure(Exception("Registration failed. Reason: $reason")), + ) + ) + } } /** * Called when this application is unregistered from receiving push messages. */ override fun onUnregistered(context: Context, instance: String) { - Timber.tag(loggerTag.value).w("UnifiedPush: Unregistered") - /* - val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME - pushDataStore.setFdroidSyncBackgroundMode(mode) - guardServiceStarter.start() - runBlocking { - try { - pushersManager.unregisterPusher(unifiedPushHelper.getEndpointOrToken().orEmpty()) - } catch (e: Exception) { - Timber.tag(loggerTag.value).d("Probably unregistering a non existing pusher") - } + Timber.tag(loggerTag.value).w("onUnregistered $instance") + coroutineScope.launch { + removedGatewayHandler.handle(instance) } - */ } } diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushRemovedGatewayHandlerTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushRemovedGatewayHandlerTest.kt new file mode 100644 index 00000000000..452013e18dd --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushRemovedGatewayHandlerTest.kt @@ -0,0 +1,298 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_SECRET +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.FakeMatrixClientProvider +import io.element.android.libraries.push.api.PushService +import io.element.android.libraries.push.test.FakePushService +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.api.PushProvider +import io.element.android.libraries.pushproviders.test.FakePushProvider +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret +import io.element.android.tests.testutils.lambda.any +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.time.Duration.Companion.seconds + +class DefaultUnifiedPushRemovedGatewayHandlerTest { + @Test + fun `handle returns error if the secret is unknown`() = runTest { + val sut = createDefaultUnifiedPushRemovedGatewayHandler( + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { null }, + ), + ) + val result = sut.handle(A_SECRET) + assertThat(result.isFailure).isTrue() + } + + @Test + fun `handle returns error if cannot restore the client`() = runTest { + val sut = createDefaultUnifiedPushRemovedGatewayHandler( + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_SESSION_ID }, + ), + matrixClientProvider = FakeMatrixClientProvider( + getClient = { Result.failure(AN_EXCEPTION) }, + ), + ) + val result = sut.handle(A_SECRET) + assertThat(result.isFailure).isTrue() + } + + @Test + fun `handle returns error if cannot unregister the pusher, and user is notified`() = runTest { + val onServiceUnregisteredResult = lambdaRecorder { } + val sut = createDefaultUnifiedPushRemovedGatewayHandler( + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_SESSION_ID }, + ), + matrixClientProvider = FakeMatrixClientProvider( + getClient = { Result.success(FakeMatrixClient()) }, + ), + unregisterUnifiedPushUseCase = FakeUnregisterUnifiedPushUseCase( + unregisterLambda = { _, _, _ -> Result.failure(AN_EXCEPTION) }, + ), + pushService = FakePushService( + onServiceUnregisteredResult = onServiceUnregisteredResult, + ), + ) + val result = sut.handle(A_SECRET) + assertThat(result.isFailure).isTrue() + onServiceUnregisteredResult.assertions().isCalledOnce().with(value(A_SESSION_ID)) + } + + @Test + fun `handle returns error if cannot get current push provider, and user is notified`() = runTest { + val onServiceUnregisteredResult = lambdaRecorder { } + val sut = createDefaultUnifiedPushRemovedGatewayHandler( + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_SESSION_ID }, + ), + matrixClientProvider = FakeMatrixClientProvider( + getClient = { Result.success(FakeMatrixClient()) }, + ), + unregisterUnifiedPushUseCase = FakeUnregisterUnifiedPushUseCase( + unregisterLambda = { _, _, _ -> Result.success(Unit) }, + ), + pushService = FakePushService( + currentPushProvider = { null }, + onServiceUnregisteredResult = onServiceUnregisteredResult, + ), + ) + val result = sut.handle(A_SECRET) + assertThat(result.isFailure).isTrue() + onServiceUnregisteredResult.assertions().isCalledOnce().with(value(A_SESSION_ID)) + } + + @Test + fun `handle returns error if cannot get current distributor, and user is notified`() = runTest { + val onServiceUnregisteredResult = lambdaRecorder { } + val sut = createDefaultUnifiedPushRemovedGatewayHandler( + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_SESSION_ID }, + ), + matrixClientProvider = FakeMatrixClientProvider( + getClient = { Result.success(FakeMatrixClient()) }, + ), + unregisterUnifiedPushUseCase = FakeUnregisterUnifiedPushUseCase( + unregisterLambda = { _, _, _ -> Result.success(Unit) }, + ), + pushService = FakePushService( + currentPushProvider = { + FakePushProvider( + currentDistributor = { null }, + ) + }, + onServiceUnregisteredResult = onServiceUnregisteredResult, + ), + ) + val result = sut.handle(A_SECRET) + assertThat(result.isFailure).isTrue() + onServiceUnregisteredResult.assertions().isCalledOnce().with(value(A_SESSION_ID)) + } + + @Test + fun `handle returns error if cannot register again, and user is notified`() = runTest { + val onServiceUnregisteredResult = lambdaRecorder { } + val sut = createDefaultUnifiedPushRemovedGatewayHandler( + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_SESSION_ID }, + ), + matrixClientProvider = FakeMatrixClientProvider( + getClient = { Result.success(FakeMatrixClient()) }, + ), + unregisterUnifiedPushUseCase = FakeUnregisterUnifiedPushUseCase( + unregisterLambda = { _, _, _ -> Result.success(Unit) }, + ), + pushService = FakePushService( + currentPushProvider = { + FakePushProvider( + currentDistributor = { Distributor("aValue", "aName") }, + ) + }, + registerWithLambda = { _, _, _ -> Result.failure(AN_EXCEPTION) }, + onServiceUnregisteredResult = onServiceUnregisteredResult, + ), + ) + val result = sut.handle(A_SECRET) + assertThat(result.isFailure).isTrue() + onServiceUnregisteredResult.assertions().isCalledOnce().with(value(A_SESSION_ID)) + } + + @Test + fun `handle returns success if can register again, and user is not notified`() = runTest { + val onServiceUnregisteredResult = lambdaRecorder { } + val unregisterLambda = lambdaRecorder> { _, _, _ -> Result.success(Unit) } + val sut = createDefaultUnifiedPushRemovedGatewayHandler( + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_SESSION_ID }, + ), + matrixClientProvider = FakeMatrixClientProvider( + getClient = { Result.success(FakeMatrixClient()) }, + ), + unregisterUnifiedPushUseCase = FakeUnregisterUnifiedPushUseCase( + unregisterLambda = unregisterLambda, + ), + pushService = FakePushService( + currentPushProvider = { + FakePushProvider( + currentDistributor = { Distributor("aValue", "aName") }, + ) + }, + registerWithLambda = { _, _, _ -> Result.success(Unit) }, + onServiceUnregisteredResult = onServiceUnregisteredResult, + ), + ) + val result = sut.handle(A_SECRET) + assertThat(result.isSuccess).isTrue() + unregisterLambda.assertions().isCalledOnce().with( + any(), + value(A_SECRET), + value(false), + ) + onServiceUnregisteredResult.assertions().isNeverCalled() + } + + @Test + fun `handle returns success if can register again, but after 2 removals user is notified`() = runTest { + val onServiceUnregisteredResult = lambdaRecorder { } + val unregisterLambda = lambdaRecorder> { _, _, _ -> Result.success(Unit) } + val registerWithLambda = lambdaRecorder> { _, _, _ -> Result.success(Unit) } + val sut = createDefaultUnifiedPushRemovedGatewayHandler( + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_SESSION_ID }, + ), + matrixClientProvider = FakeMatrixClientProvider( + getClient = { Result.success(FakeMatrixClient()) }, + ), + unregisterUnifiedPushUseCase = FakeUnregisterUnifiedPushUseCase( + unregisterLambda = unregisterLambda, + ), + pushService = FakePushService( + currentPushProvider = { + FakePushProvider( + currentDistributor = { Distributor("aValue", "aName") }, + ) + }, + registerWithLambda = registerWithLambda, + onServiceUnregisteredResult = onServiceUnregisteredResult, + ), + ) + val result = sut.handle(A_SECRET) + assertThat(result.isSuccess).isTrue() + unregisterLambda.assertions().isCalledOnce().with( + any(), + value(A_SECRET), + value(false), + ) + registerWithLambda.assertions().isCalledOnce() + onServiceUnregisteredResult.assertions().isNeverCalled() + // Second attempt in less than 1 minute + val result2 = sut.handle(A_SECRET) + assertThat(result2.isSuccess).isTrue() + unregisterLambda.assertions().isCalledExactly(2) + // Registration is not called twice + registerWithLambda.assertions().isCalledOnce() + onServiceUnregisteredResult.assertions().isCalledOnce().with(value(A_SESSION_ID)) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `handle returns success if can register again, but after 2 distant removals user is not notified`() = runTest { + val onServiceUnregisteredResult = lambdaRecorder { } + val unregisterLambda = lambdaRecorder> { _, _, _ -> Result.success(Unit) } + val registerWithLambda = lambdaRecorder> { _, _, _ -> Result.success(Unit) } + val sut = createDefaultUnifiedPushRemovedGatewayHandler( + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_SESSION_ID }, + ), + matrixClientProvider = FakeMatrixClientProvider( + getClient = { Result.success(FakeMatrixClient()) }, + ), + unregisterUnifiedPushUseCase = FakeUnregisterUnifiedPushUseCase( + unregisterLambda = unregisterLambda, + ), + pushService = FakePushService( + currentPushProvider = { + FakePushProvider( + currentDistributor = { Distributor("aValue", "aName") }, + ) + }, + registerWithLambda = registerWithLambda, + onServiceUnregisteredResult = onServiceUnregisteredResult, + ), + ) + val result = sut.handle(A_SECRET) + assertThat(result.isSuccess).isTrue() + unregisterLambda.assertions().isCalledOnce().with( + any(), + value(A_SECRET), + value(false), + ) + registerWithLambda.assertions().isCalledOnce() + onServiceUnregisteredResult.assertions().isNeverCalled() + // Second attempt in more than 1 minute + advanceTimeBy(61.seconds) + val result2 = sut.handle(A_SECRET) + assertThat(result2.isSuccess).isTrue() + unregisterLambda.assertions().isCalledExactly(2) + // Registration is not called twice + registerWithLambda.assertions().isCalledExactly(2) + onServiceUnregisteredResult.assertions().isNeverCalled() + } + + private fun TestScope.createDefaultUnifiedPushRemovedGatewayHandler( + unregisterUnifiedPushUseCase: UnregisterUnifiedPushUseCase = FakeUnregisterUnifiedPushUseCase(), + pushClientSecret: PushClientSecret = FakePushClientSecret(), + matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(), + pushService: PushService = FakePushService(), + ) = DefaultUnifiedPushRemovedGatewayHandler( + unregisterUnifiedPushUseCase = unregisterUnifiedPushUseCase, + pushClientSecret = pushClientSecret, + matrixClientProvider = matrixClientProvider, + pushService = pushService, + unifiedPushRemovedGatewayThrottler = UnifiedPushRemovedGatewayThrottler( + appCoroutineScope = backgroundScope, + ), + ) +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnregisterUnifiedPushUseCase.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnregisterUnifiedPushUseCase.kt index cda28554dba..182bb5f823e 100644 --- a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnregisterUnifiedPushUseCase.kt +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnregisterUnifiedPushUseCase.kt @@ -12,14 +12,21 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.tests.testutils.lambda.lambdaError class FakeUnregisterUnifiedPushUseCase( - private val unregisterLambda: (MatrixClient, String) -> Result = { _, _ -> lambdaError() }, - private val cleanupLambda: (String) -> Unit = { lambdaError() }, + private val unregisterLambda: (MatrixClient, String, Boolean) -> Result = { _, _, _ -> lambdaError() }, + private val cleanupLambda: (String, Boolean) -> Unit = { _, _ -> lambdaError() }, ) : UnregisterUnifiedPushUseCase { - override suspend fun unregister(matrixClient: MatrixClient, clientSecret: String): Result { - return unregisterLambda(matrixClient, clientSecret) + override suspend fun unregister( + matrixClient: MatrixClient, + clientSecret: String, + unregisterUnifiedPush: Boolean, + ): Result { + return unregisterLambda(matrixClient, clientSecret, unregisterUnifiedPush) } - override fun cleanup(clientSecret: String) { - cleanupLambda(clientSecret) + override fun cleanup( + clientSecret: String, + unregisterUnifiedPush: Boolean, + ) { + cleanupLambda(clientSecret, unregisterUnifiedPush) } } diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProviderTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProviderTest.kt index 10ed18118b6..e22c1c3bf46 100644 --- a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProviderTest.kt +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProviderTest.kt @@ -118,7 +118,7 @@ class UnifiedPushProviderTest { fun `unregister ok`() = runTest { val matrixClient = FakeMatrixClient() val getSecretForUserResultLambda = lambdaRecorder { A_SECRET } - val unregisterLambda = lambdaRecorder> { _, _ -> Result.success(Unit) } + val unregisterLambda = lambdaRecorder> { _, _, _ -> Result.success(Unit) } val unifiedPushProvider = createUnifiedPushProvider( pushClientSecret = FakePushClientSecret( getSecretForUserResult = getSecretForUserResultLambda, @@ -134,14 +134,14 @@ class UnifiedPushProviderTest { .with(value(A_SESSION_ID)) unregisterLambda.assertions() .isCalledOnce() - .with(value(matrixClient), value(A_SECRET)) + .with(value(matrixClient), value(A_SECRET), value(true)) } @Test fun `unregister ko`() = runTest { val matrixClient = FakeMatrixClient() val getSecretForUserResultLambda = lambdaRecorder { A_SECRET } - val unregisterLambda = lambdaRecorder> { _, _ -> Result.failure(AN_EXCEPTION) } + val unregisterLambda = lambdaRecorder> { _, _, _ -> Result.failure(AN_EXCEPTION) } val unifiedPushProvider = createUnifiedPushProvider( pushClientSecret = FakePushClientSecret( getSecretForUserResult = getSecretForUserResultLambda, @@ -157,7 +157,7 @@ class UnifiedPushProviderTest { .with(value(A_SESSION_ID)) unregisterLambda.assertions() .isCalledOnce() - .with(value(matrixClient), value(A_SECRET)) + .with(value(matrixClient), value(A_SECRET), value(true)) } @Test @@ -230,7 +230,7 @@ class UnifiedPushProviderTest { @Test fun `onSessionDeleted should do the cleanup`() = runTest { - val cleanupLambda = lambdaRecorder { } + val cleanupLambda = lambdaRecorder { _, _ -> } val unifiedPushProvider = createUnifiedPushProvider( pushClientSecret = FakePushClientSecret( getSecretForUserResult = { A_SECRET } @@ -240,7 +240,7 @@ class UnifiedPushProviderTest { ), ) unifiedPushProvider.onSessionDeleted(A_SESSION_ID) - cleanupLambda.assertions().isCalledOnce().with(value(A_SECRET)) + cleanupLambda.assertions().isCalledOnce().with(value(A_SECRET), value(true)) } private fun createUnifiedPushProvider( diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverTest.kt index 6cf82c1fc06..f10f6430f02 100644 --- a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverTest.kt +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverTest.kt @@ -23,6 +23,7 @@ import io.element.android.libraries.pushproviders.api.PushData import io.element.android.libraries.pushproviders.api.PushHandler import io.element.android.libraries.pushproviders.unifiedpush.registration.EndpointRegistrationHandler import io.element.android.libraries.pushproviders.unifiedpush.registration.RegistrationResult +import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -51,10 +52,17 @@ class VectorUnifiedPushMessagingReceiverTest { } @Test - fun `onUnregistered does nothing`() = runTest { + fun `onUnregistered invokes the removedGatewayHandler`() = runTest { val context = InstrumentationRegistry.getInstrumentation().context - val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver() + val handleResult = lambdaRecorder> { + Result.success(Unit) + } + val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver( + removedGatewayHandler = UnifiedPushRemovedGatewayHandler { handleResult(it) }, + ) vectorUnifiedPushMessagingReceiver.onUnregistered(context, A_SECRET) + advanceUntilIdle() + handleResult.assertions().isCalledOnce().with(value(A_SECRET)) } @Test @@ -199,6 +207,7 @@ class VectorUnifiedPushMessagingReceiverTest { unifiedPushGatewayUrlResolver: UnifiedPushGatewayUrlResolver = FakeUnifiedPushGatewayUrlResolver(), unifiedPushNewGatewayHandler: UnifiedPushNewGatewayHandler = FakeUnifiedPushNewGatewayHandler(), endpointRegistrationHandler: EndpointRegistrationHandler = EndpointRegistrationHandler(), + removedGatewayHandler: UnifiedPushRemovedGatewayHandler = UnifiedPushRemovedGatewayHandler { lambdaError() }, ): VectorUnifiedPushMessagingReceiver { return VectorUnifiedPushMessagingReceiver().apply { this.pushParser = unifiedPushParser @@ -208,6 +217,7 @@ class VectorUnifiedPushMessagingReceiverTest { this.unifiedPushGatewayResolver = unifiedPushGatewayResolver this.unifiedPushGatewayUrlResolver = unifiedPushGatewayUrlResolver this.newGatewayHandler = unifiedPushNewGatewayHandler + this.removedGatewayHandler = removedGatewayHandler this.endpointRegistrationHandler = endpointRegistrationHandler this.coroutineScope = this@createVectorUnifiedPushMessagingReceiver }