Merge pull request #5726 from element-hq/feature/bma/notificationCleanup

Notification robustness
This commit is contained in:
Benoit Marty 2025-11-14 18:02:45 +01:00 committed by GitHub
commit b40ccd94c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 1254 additions and 480 deletions

View file

@ -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<AsyncData<Unit>>) {
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()

View file

@ -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<LoggedInState> {
override val values: Sequence<LoggedInState>

View file

@ -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

View file

@ -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<MatrixClient, PushProvider, Distributor, Result<Unit>> { _, _, _ ->
val lambda = lambdaRecorder<Result<Unit>> {
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<MatrixClient, PushProvider, Distributor, Result<Unit>> { _, _, _ ->
Result.success(Unit)
}
val lambda = lambdaRecorder<Result<Unit>> { 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<MatrixClient, PushProvider, Distributor, Result<Unit>> { _, _, _ ->
Result.failure(AN_EXCEPTION)
}
val lambda = lambdaRecorder<Result<Unit>> { Result.failure(AN_EXCEPTION) }
val sessionVerificationService = FakeSessionVerificationService(
initialSessionVerifiedStatus = SessionVerifiedStatus.Verified
)
val pushService = createFakePushService(
registerWithLambda = lambda,
ensurePusherIsRegisteredResult = lambda,
)
createLoggedInPresenter(
pushService = pushService,
@ -213,158 +202,36 @@ 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]),
)
// 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 - ensure current provider is registered with current distributor`() = runTest {
val lambda = lambdaRecorder<MatrixClient, PushProvider, Distributor, Result<Unit>> { _, _, _ ->
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),
)
}
}
@Test
fun `present - if current push provider does not have current distributor, the first one is used`() = runTest {
val lambda = lambdaRecorder<MatrixClient, PushProvider, Distributor, Result<Unit>> { _, _, _ ->
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 },
)
val pushService = createFakePushService(
pushProvider0 = 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(),
// 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<MatrixClient, PushProvider, Distributor, Result<Unit>> { _, _, _ ->
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<MatrixClient, PushProvider, Distributor, Result<Unit>> { _, _, _ ->
Result.success(Unit)
}
val sessionVerificationService = FakeSessionVerificationService(SessionVerifiedStatus.Verified)
fun `present - ensure default pusher is registered with default provider - fail to register - do not show again`() = runTest {
val lambda = lambdaRecorder<Result<Unit>> { Result.failure(AN_EXCEPTION) }
val setIgnoreRegistrationErrorLambda = lambdaRecorder<SessionId, Boolean, Unit> { _, _ -> }
val sessionVerificationService = FakeSessionVerificationService(
initialSessionVerifiedStatus = SessionVerifiedStatus.Verified
)
val pushService = createFakePushService(
pushProvider0 = null,
pushProvider1 = null,
registerWithLambda = lambda,
ensurePusherIsRegisteredResult = lambda,
setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda,
)
createLoggedInPresenter(
pushService = pushService,
sessionVerificationService = sessionVerificationService,
matrixClient = FakeMatrixClient(
accountManagementUrlResult = { Result.success(null) },
),
).test {
val finalState = awaitFirstItem()
assertThat(finalState.pusherRegistrationState.errorOrNull())
.isInstanceOf(PusherRegistrationFailure.NoProvidersAvailable::class.java)
assertThat(finalState.pusherRegistrationState.isFailure()).isTrue()
lambda.assertions()
.isNeverCalled()
.isCalledOnce()
// 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<MatrixClient, PushProvider, Distributor, Result<Unit>> { _, _, _ ->
Result.success(Unit)
}
val selectPushProviderLambda = lambdaRecorder<SessionId, PushProvider, Unit> { _, _ -> }
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<MatrixClient, PushProvider, Distributor, Result<Unit>> { _, _, _ ->
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<Unit> = { _, _, _ ->
ensurePusherIsRegisteredResult: () -> Result<Unit> = {
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,

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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<FullScreenIntentPermissionsState>,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
) : Presenter<NotificationSettingsState> {
@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)
}
}
}

View file

@ -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<MatrixClient, Result<Unit>> { Result.success(Unit) }
val ensurePusherIsRegisteredResult = lambdaRecorder<Result<Unit>> { 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<Unit> = { _, _, _ ->
registerWithLambda: (MatrixClient, PushProvider, Distributor) -> Result<Unit> = { _, _, _ ->
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,
)
}
}

View file

@ -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<Unit>
/**
* 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<Unit>
/**
* 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)
}

View file

@ -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

View file

@ -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,
}

View file

@ -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<PushService>())
@ -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<Unit> {
val verificationStatus = matrixClient.sessionVerificationService.sessionVerifiedStatus.first()
if (verificationStatus != SessionVerifiedStatus.Verified) {
return Result.failure<Unit>(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<Unit>(PusherRegistrationFailure.NoProvidersAvailable())
.also { Timber.w("No push providers available") }
val distributor = pushProvider.getDistributors().firstOrNull()
?: return Result.failure<Unit>(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<Unit>(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)
}
}

View file

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

View file

@ -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,25 @@ class DefaultNotificationCreator(
.build()
}
override fun createUnregistrationNotification(
notificationAccountParams: NotificationAccountParams,
): Notification {
val userId = notificationAccountParams.user.userId
val text = stringProvider.getString(R.string.notification_error_unified_push_unregistered_android)
return NotificationCompat.Builder(context, notificationChannels.getChannelIdForTest())
.setSubText(userId.value)
// The text is long and can be truncated so use BigTextStyle.
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
.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<NotifiableMessageEvent>,
imageLoader: ImageLoader,
@ -483,7 +476,7 @@ class DefaultNotificationCreator(
.build()
).also {
it.conversationTitle = if (isThread) {
stringProvider.getString(CommonStrings.notification_thread_in_room, roomName)
stringProvider.getString(R.string.notification_thread_in_room, roomName)
} else {
roomName
}

View file

@ -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)
}
}

View file

@ -42,6 +42,7 @@
<string name="notification_sender_me">"Já"</string>
<string name="notification_sender_mention_reply">"%1$s zmínil(a) nebo odpověděl(a)"</string>
<string name="notification_test_push_notification_content">"Prohlížíte si oznámení! Klikněte na mě!"</string>
<string name="notification_thread_in_room">"Vlákno v %1$s"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<plurals name="notification_unread_notified_messages">

View file

@ -38,6 +38,7 @@
<string name="notification_sender_me">"Mig"</string>
<string name="notification_sender_mention_reply">"%1$s nævnt eller besvaret"</string>
<string name="notification_test_push_notification_content">"Du ser notifikationen! Klik på mig!"</string>
<string name="notification_thread_in_room">"Tråd i %1$s"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<plurals name="notification_unread_notified_messages">

View file

@ -38,6 +38,7 @@
<string name="notification_sender_me">"Ich"</string>
<string name="notification_sender_mention_reply">"%1$s hat Dich erwähnt oder geantwortet"</string>
<string name="notification_test_push_notification_content">"Du siehst dir die Benachrichtigung an! Klicke hier!"</string>
<string name="notification_thread_in_room">"Thread in %1$s"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<plurals name="notification_unread_notified_messages">

View file

@ -38,6 +38,7 @@
<string name="notification_sender_me">"Mina"</string>
<string name="notification_sender_mention_reply">"%1$s mainis või vastas"</string>
<string name="notification_test_push_notification_content">"See ongi teavitus! Klõpsi mind!"</string>
<string name="notification_thread_in_room">"Jutulõng „%1$s“ jututoas"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<plurals name="notification_unread_notified_messages">

View file

@ -38,6 +38,7 @@
<string name="notification_sender_me">"Moi"</string>
<string name="notification_sender_mention_reply">"%1$s mentionné ou en réponse"</string>
<string name="notification_test_push_notification_content">"Vous êtes en train de voir la notification ! Cliquez-moi !"</string>
<string name="notification_thread_in_room">"Discussion dans %1$s"</string>
<string name="notification_ticker_text_dm">"%1$s : %2$s"</string>
<string name="notification_ticker_text_group">"%1$s : %2$s %3$s"</string>
<plurals name="notification_unread_notified_messages">

View file

@ -42,6 +42,7 @@
<string name="notification_sender_me">"Ja"</string>
<string name="notification_sender_mention_reply">"%1$s spomenul/a alebo odpovedal/a"</string>
<string name="notification_test_push_notification_content">"Prezeráte si oznámenie! Kliknite na mňa!"</string>
<string name="notification_thread_in_room">"Vlákno v %1$s"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<plurals name="notification_unread_notified_messages">

View file

@ -34,6 +34,7 @@
<string name="notification_sender_me">"我"</string>
<string name="notification_sender_mention_reply">"%1$s 提及或回覆"</string>
<string name="notification_test_push_notification_content">"您正在查看通知!點我!"</string>
<string name="notification_thread_in_room">"在 %1$s 的討論串"</string>
<string name="notification_ticker_text_dm">"%1$s%2$s"</string>
<string name="notification_ticker_text_group">"%1$s%2$s %3$s"</string>
<plurals name="notification_unread_notified_messages">

View file

@ -13,6 +13,7 @@
<item quantity="one">"%d notification"</item>
<item quantity="other">"%d notifications"</item>
</plurals>
<string name="notification_error_unified_push_unregistered_android">"The UnifiedPush notification distributor couldn\'t be registered, so you will not receive notifications anymore. Please check the notifications settings of the app and the status of the push distributor."</string>
<string name="notification_fallback_content">"You have new messages."</string>
<string name="notification_incoming_call">"📹 Incoming call"</string>
<string name="notification_inline_reply_failed">"** Failed to send - please open room"</string>
@ -38,6 +39,7 @@
<string name="notification_sender_me">"Me"</string>
<string name="notification_sender_mention_reply">"%1$s mentioned or replied"</string>
<string name="notification_test_push_notification_content">"You are viewing the notification! Click me!"</string>
<string name="notification_thread_in_room">"Thread in %1$s"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<plurals name="notification_unread_notified_messages">

View file

@ -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<MatrixClient, Distributor, Result<Unit>> { _, _ ->
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<MatrixClient, Distributor, Result<Unit>> { _, _ ->
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<MatrixClient, Distributor, Result<Unit>> { _, _ ->
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<MatrixClient, Distributor, Result<Unit>> { _, _ ->
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<MatrixClient, Distributor, Result<Unit>> { _, _ ->
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<MatrixClient, Distributor, Result<Unit>> { _, _ ->
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<MatrixClient, Distributor, Result<Unit>> { _, _ ->
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<MatrixClient, Distributor, Result<Unit>> { _, _ ->
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,
)
}
}

View file

@ -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()

View file

@ -41,6 +41,8 @@ class FakeNotificationCreator(
> = lambdaRecorder { _, _, _, _, _ -> A_NOTIFICATION },
var createDiagnosticNotificationResult: LambdaOneParamRecorder<Int, Notification> =
lambdaRecorder<Int, Notification> { _ -> A_NOTIFICATION },
val createUnregistrationNotificationResult: LambdaOneParamRecorder<NotificationAccountParams, Notification> =
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)
}
}

View file

@ -24,6 +24,7 @@ class FakeNotificationDisplayer(
var cancelNotificationResult: LambdaTwoParamsRecorder<String?, Int, Unit> = lambdaRecorder { _, _ -> },
var displayDiagnosticNotificationResult: LambdaOneParamRecorder<Notification, Boolean> = lambdaRecorder { _ -> true },
var dismissDiagnosticNotificationResult: LambdaNoParamRecorder<Unit> = lambdaRecorder { -> },
var displayUnregistrationNotificationResult: LambdaOneParamRecorder<Notification, Boolean> = 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)))

View file

@ -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<NotificationAccountParams, Notification> { notification }
val displayUnregistrationNotificationResult = lambdaRecorder<Notification, Boolean> { 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<NotificationAccountParams, Notification> { notification }
val displayUnregistrationNotificationResult = lambdaRecorder<Notification, Boolean> { 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,
)
}

View file

@ -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)
}
}

View file

@ -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<PushProvider> = emptyList(),
private val registerWithLambda: suspend (MatrixClient, PushProvider, Distributor) -> Result<Unit> = { _, _, _ ->
private val registerWithLambda: (MatrixClient, PushProvider, Distributor) -> Result<Unit> = { _, _, _ ->
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<Unit> = { 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<Unit> {
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)
}
}

View file

@ -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)

View file

@ -39,7 +39,7 @@ class DefaultUnifiedPushNewGatewayHandler(
val userId = pushClientSecret.getUserIdFromSecret(clientSecret) ?: return Result.failure<Unit>(
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(

View file

@ -0,0 +1,126 @@
/*
* 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.MatrixClient
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<Unit>
}
@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 {
/**
* The application has been informed by the UnifiedPush distributor that the topic has been deleted.
* So this code aim to unregister the pusher from the homeserver, register a new topic on the
* UnifiedPush application then register a new pusher to the homeserver.
* No registration will happen if the topic deletion has already occurred in the last minute.
*/
override suspend fun handle(clientSecret: String): Result<Unit> {
val sessionId = pushClientSecret.getUserIdFromSecret(clientSecret) ?: return Result.failure<Unit>(
IllegalStateException("Unable to retrieve session")
).also {
Timber.tag(loggerTag.value).w("Unable to retrieve session")
}
return matrixClientProvider
.getOrRestore(sessionId)
.onFailure {
// Silently ignore this error (do not invoke onServiceUnregistered)
Timber.tag(loggerTag.value).w(it, "Fails to restore client")
}
.flatMap { client ->
client.rotateRegistration(clientSecret = clientSecret)
.onFailure {
Timber.tag(loggerTag.value).w(it, "Issue during pusher unregistration / re registration")
// Let the user know
pushService.onServiceUnregistered(sessionId)
}
}
}
/**
* Unregister the pusher for the session. Then register again if possible.
*/
private suspend fun MatrixClient.rotateRegistration(clientSecret: String): Result<Unit> {
val unregisterResult = unregisterUnifiedPushUseCase.unregister(
matrixClient = this,
clientSecret = clientSecret,
unregisterUnifiedPush = false,
).onFailure {
Timber.tag(loggerTag.value).w(it, "Unable to unregister pusher")
}
return unregisterResult.flatMap {
registerAgain()
}
}
/**
* Attempt to register again, if possible i.e. the current configuration is known and the
* deletion of data in the UnifiedPush application has not already occurred in the last minute.
*/
private suspend fun MatrixClient.registerAgain(): Result<Unit> {
return if (unifiedPushRemovedGatewayThrottler.canRegisterAgain()) {
val pushProvider = pushService.getCurrentPushProvider(sessionId)
val distributor = pushProvider?.getCurrentDistributor(sessionId)
if (pushProvider != null && distributor != null) {
pushService.registerWith(
matrixClient = this,
pushProvider = pushProvider,
distributor = distributor,
).onFailure {
Timber.tag(loggerTag.value).w(it, "Unable to register with current data")
}
} else {
Result.failure(IllegalStateException("Unable to register again"))
}
} else {
Timber.tag(loggerTag.value).w("Second removal in less than 1 minute, do not register again")
Result.failure(IllegalStateException("Too many requests to register again"))
}
}
}

View file

@ -19,14 +19,21 @@ import timber.log.Timber
interface UnregisterUnifiedPushUseCase {
/**
* Unregister the app from the homeserver, then from UnifiedPush.
* Unregister the app from the homeserver, then from UnifiedPush if [unregisterUnifiedPush] is true.
*/
suspend fun unregister(matrixClient: MatrixClient, clientSecret: String): Result<Unit>
suspend fun unregister(
matrixClient: MatrixClient,
clientSecret: String,
unregisterUnifiedPush: Boolean = true,
): Result<Unit>
/**
* 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<Unit> {
override suspend fun unregister(
matrixClient: MatrixClient,
clientSecret: String,
unregisterUnifiedPush: Boolean,
): Result<Unit> {
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)
}
}
}

View file

@ -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)
}
*/
}
}

View file

@ -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<UserId, Unit> { }
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<UserId, Unit> { }
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<UserId, Unit> { }
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<UserId, Unit> { }
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<UserId, Unit> { }
val unregisterLambda = lambdaRecorder<MatrixClient, String, Boolean, Result<Unit>> { _, _, _ -> 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<UserId, Unit> { }
val unregisterLambda = lambdaRecorder<MatrixClient, String, Boolean, Result<Unit>> { _, _, _ -> Result.success(Unit) }
val registerWithLambda = lambdaRecorder<MatrixClient, PushProvider, Distributor, Result<Unit>> { _, _, _ -> 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.isFailure).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<UserId, Unit> { }
val unregisterLambda = lambdaRecorder<MatrixClient, String, Boolean, Result<Unit>> { _, _, _ -> Result.success(Unit) }
val registerWithLambda = lambdaRecorder<MatrixClient, PushProvider, Distributor, Result<Unit>> { _, _, _ -> 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,
),
)
}

View file

@ -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<Unit> = { _, _ -> lambdaError() },
private val cleanupLambda: (String) -> Unit = { lambdaError() },
private val unregisterLambda: (MatrixClient, String, Boolean) -> Result<Unit> = { _, _, _ -> lambdaError() },
private val cleanupLambda: (String, Boolean) -> Unit = { _, _ -> lambdaError() },
) : UnregisterUnifiedPushUseCase {
override suspend fun unregister(matrixClient: MatrixClient, clientSecret: String): Result<Unit> {
return unregisterLambda(matrixClient, clientSecret)
override suspend fun unregister(
matrixClient: MatrixClient,
clientSecret: String,
unregisterUnifiedPush: Boolean,
): Result<Unit> {
return unregisterLambda(matrixClient, clientSecret, unregisterUnifiedPush)
}
override fun cleanup(clientSecret: String) {
cleanupLambda(clientSecret)
override fun cleanup(
clientSecret: String,
unregisterUnifiedPush: Boolean,
) {
cleanupLambda(clientSecret, unregisterUnifiedPush)
}
}

View file

@ -118,7 +118,7 @@ class UnifiedPushProviderTest {
fun `unregister ok`() = runTest {
val matrixClient = FakeMatrixClient()
val getSecretForUserResultLambda = lambdaRecorder<SessionId, String> { A_SECRET }
val unregisterLambda = lambdaRecorder<MatrixClient, String, Result<Unit>> { _, _ -> Result.success(Unit) }
val unregisterLambda = lambdaRecorder<MatrixClient, String, Boolean, Result<Unit>> { _, _, _ -> 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<SessionId, String> { A_SECRET }
val unregisterLambda = lambdaRecorder<MatrixClient, String, Result<Unit>> { _, _ -> Result.failure(AN_EXCEPTION) }
val unregisterLambda = lambdaRecorder<MatrixClient, String, Boolean, Result<Unit>> { _, _, _ -> 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<String, Unit> { }
val cleanupLambda = lambdaRecorder<String, Boolean, Unit> { _, _ -> }
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(

View file

@ -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<String, Result<Unit>> {
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
}

View file

@ -427,7 +427,6 @@ Opravdu chcete pokračovat?"</string>
<string name="invite_friends_rich_title">"🔐️ Připojte se ke mně na %1$s"</string>
<string name="invite_friends_text">"Ahoj, ozvi se mi na %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="notification_thread_in_room">"Vlákno v %1$s"</string>
<string name="preference_rageshake">"Zatřeste zařízením pro nahlášení chyby"</string>
<string name="screen_bug_report_a11y_screenshot">"Snímek obrazovky"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string>

View file

@ -420,7 +420,6 @@ Er du sikker på, at du vil fortsætte?"</string>
<string name="invite_friends_rich_title">"🔐️ Kom med mig til %1$s"</string>
<string name="invite_friends_text">"Hej, lad os snakkes på %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="notification_thread_in_room">"Tråd i %1$s"</string>
<string name="preference_rageshake">"Ryst enheden i frustration for at anmelde en fejl"</string>
<string name="screen_bug_report_a11y_screenshot">"Skærmbillede"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string>

View file

@ -420,7 +420,6 @@ Möchtest du wirklich fortfahren?"</string>
<string name="invite_friends_rich_title">"🔐️ Begleite mich auf %1$s"</string>
<string name="invite_friends_text">"Hey, sprich mit mir auf %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="notification_thread_in_room">"Thread in %1$s"</string>
<string name="preference_rageshake">"Heftiges Schütteln um Fehler zu melden"</string>
<string name="screen_bug_report_a11y_screenshot">"Bildschirmfoto"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string>

View file

@ -421,7 +421,6 @@ Kas sa oled kindel, et soovid jätkata?"</string>
<string name="invite_friends_rich_title">"🔐️ Liitu minuga rakenduses %1$s"</string>
<string name="invite_friends_text">"Hei, suhtle minuga %1$s võrgus: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="notification_thread_in_room">"Jutulõng „%1$s“ jututoas"</string>
<string name="preference_rageshake">"Veast teatamiseks raputa nutiseadet ägedalt"</string>
<string name="screen_bug_report_a11y_screenshot">"Ekraanitõmmis"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string>

View file

@ -421,7 +421,6 @@ Raison : %1$s."</string>
<string name="invite_friends_rich_title">"🔐️ Rejoignez-moi sur %1$s"</string>
<string name="invite_friends_text">"Salut, parle-moi sur %1$s : %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="notification_thread_in_room">"Discussion dans %1$s"</string>
<string name="preference_rageshake">"Rageshake pour signaler un problème"</string>
<string name="screen_bug_report_a11y_screenshot">"Capture décran"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string>

View file

@ -429,7 +429,6 @@ Naozaj chcete pokračovať?"</string>
<string name="invite_friends_rich_title">"🔐️ Pripojte sa ku mne na %1$s"</string>
<string name="invite_friends_text">"Ahoj, porozprávajte sa so mnou na %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="notification_thread_in_room">"Vlákno v %1$s"</string>
<string name="preference_rageshake">"Zúrivo potriasť pre nahlásenie chyby"</string>
<string name="screen_bug_report_a11y_screenshot">"Snímka obrazovky"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string>

View file

@ -412,7 +412,6 @@
<string name="invite_friends_rich_title">"🔐️ 在 %1$s 上加入我"</string>
<string name="invite_friends_text">"嘿,來 %1$s 和我聊天:%2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="notification_thread_in_room">"在 %1$s 的討論串"</string>
<string name="preference_rageshake">"憤怒搖晃以回報臭蟲"</string>
<string name="screen_bug_report_a11y_screenshot">"螢幕截圖"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s%2$s"</string>

View file

@ -421,7 +421,6 @@ Are you sure you want to continue?"</string>
<string name="invite_friends_rich_title">"🔐️ Join me on %1$s"</string>
<string name="invite_friends_text">"Hey, talk to me on %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="notification_thread_in_room">"Thread in %1$s"</string>
<string name="preference_rageshake">"Rageshake to report bug"</string>
<string name="screen_bug_report_a11y_screenshot">"Screenshot"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string>

View file

@ -129,6 +129,7 @@
"includeRegex" : [
"push_.*",
"notification_.*",
"notification\\..*",
"troubleshoot_notifications\\.test_blocked_users\\..*",
"troubleshoot_notifications_test_current_push_provider.*",
"troubleshoot_notifications_test_detect_push_provider.*",