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 acf10ace35
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.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.push.api.PushService 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 io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@ -71,7 +71,17 @@ class LoggedInPresenter(
when (sessionVerifiedStatus) { when (sessionVerifiedStatus) {
SessionVerifiedStatus.Unknown -> Unit SessionVerifiedStatus.Unknown -> Unit
SessionVerifiedStatus.Verified -> { 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 -> { SessionVerifiedStatus.NotVerified -> {
pusherRegistrationState.value = AsyncData.Failure(PusherRegistrationFailure.AccountNotVerified()) pusherRegistrationState.value = AsyncData.Failure(PusherRegistrationFailure.AccountNotVerified())
@ -133,59 +143,6 @@ class LoggedInPresenter(
currentSlidingSyncVersion == SlidingSyncVersion.Proxy 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) { private fun reportCryptoStatusToAnalytics(verificationState: SessionVerifiedStatus, recoveryState: RecoveryState) {
// Update first the user property, to store the current status for that posthog user // Update first the user property, to store the current status for that posthog user
val userVerificationState = verificationState.toAnalyticsUserPropertyValue() val userVerificationState = verificationState.toAnalyticsUserPropertyValue()

View file

@ -10,6 +10,7 @@ package io.element.android.appnav.loggedin
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.push.api.PusherRegistrationFailure
open class LoggedInStateProvider : PreviewParameterProvider<LoggedInState> { open class LoggedInStateProvider : PreviewParameterProvider<LoggedInState> {
override val values: Sequence<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.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.matrix.api.exception.isNetworkError 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 import io.element.android.libraries.ui.strings.CommonStrings
@Composable @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.sync.FakeSyncService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.push.api.PushService 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.push.test.FakePushService
import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.api.Distributor
import io.element.android.libraries.pushproviders.api.PushProvider 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.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate 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.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
@ -115,7 +115,9 @@ class LoggedInPresenterTest {
encryptionService = encryptionService, encryptionService = encryptionService,
), ),
syncService = FakeSyncService(initialSyncState = SyncState.Running), syncService = FakeSyncService(initialSyncState = SyncState.Running),
pushService = FakePushService(), pushService = FakePushService(
ensurePusherIsRegisteredResult = { Result.success(Unit) },
),
sessionVerificationService = verificationService, sessionVerificationService = verificationService,
analyticsService = analyticsService, analyticsService = analyticsService,
encryptionService = encryptionService, encryptionService = encryptionService,
@ -139,10 +141,10 @@ class LoggedInPresenterTest {
@Test @Test
fun `present - ensure default pusher is not registered if session is not verified`() = runTest { 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) Result.success(Unit)
} }
val pushService = createFakePushService(registerWithLambda = lambda) val pushService = createFakePushService(ensurePusherIsRegisteredResult = lambda)
val verificationService = FakeSessionVerificationService( val verificationService = FakeSessionVerificationService(
initialSessionVerifiedStatus = SessionVerifiedStatus.NotVerified initialSessionVerifiedStatus = SessionVerifiedStatus.NotVerified
) )
@ -153,21 +155,18 @@ class LoggedInPresenterTest {
val finalState = awaitFirstItem() val finalState = awaitFirstItem()
assertThat(finalState.pusherRegistrationState.errorOrNull()) assertThat(finalState.pusherRegistrationState.errorOrNull())
.isInstanceOf(PusherRegistrationFailure.AccountNotVerified::class.java) .isInstanceOf(PusherRegistrationFailure.AccountNotVerified::class.java)
lambda.assertions() lambda.assertions().isNeverCalled()
.isNeverCalled()
} }
} }
@Test @Test
fun `present - ensure default pusher is registered with default provider`() = runTest { fun `present - ensure default pusher is registered with default provider`() = runTest {
val lambda = lambdaRecorder<MatrixClient, PushProvider, Distributor, Result<Unit>> { _, _, _ -> val lambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
Result.success(Unit)
}
val sessionVerificationService = FakeSessionVerificationService( val sessionVerificationService = FakeSessionVerificationService(
initialSessionVerifiedStatus = SessionVerifiedStatus.Verified initialSessionVerifiedStatus = SessionVerifiedStatus.Verified
) )
val pushService = createFakePushService( val pushService = createFakePushService(
registerWithLambda = lambda, ensurePusherIsRegisteredResult = lambda,
) )
createLoggedInPresenter( createLoggedInPresenter(
pushService = pushService, pushService = pushService,
@ -180,27 +179,17 @@ class LoggedInPresenterTest {
assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue() assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue()
lambda.assertions() lambda.assertions()
.isCalledOnce() .isCalledOnce()
.with(
// MatrixClient
any(),
// PushProvider with highest priority (lower index)
value(pushService.getAvailablePushProviders()[0]),
// First distributor
value(pushService.getAvailablePushProviders()[0].getDistributors()[0]),
)
} }
} }
@Test @Test
fun `present - ensure default pusher is registered with default provider - fail to register`() = runTest { fun `present - ensure default pusher is registered with default provider - fail to register`() = runTest {
val lambda = lambdaRecorder<MatrixClient, PushProvider, Distributor, Result<Unit>> { _, _, _ -> val lambda = lambdaRecorder<Result<Unit>> { Result.failure(AN_EXCEPTION) }
Result.failure(AN_EXCEPTION)
}
val sessionVerificationService = FakeSessionVerificationService( val sessionVerificationService = FakeSessionVerificationService(
initialSessionVerifiedStatus = SessionVerifiedStatus.Verified initialSessionVerifiedStatus = SessionVerifiedStatus.Verified
) )
val pushService = createFakePushService( val pushService = createFakePushService(
registerWithLambda = lambda, ensurePusherIsRegisteredResult = lambda,
) )
createLoggedInPresenter( createLoggedInPresenter(
pushService = pushService, pushService = pushService,
@ -213,158 +202,36 @@ class LoggedInPresenterTest {
assertThat(finalState.pusherRegistrationState.isFailure()).isTrue() assertThat(finalState.pusherRegistrationState.isFailure()).isTrue()
lambda.assertions() lambda.assertions()
.isCalledOnce() .isCalledOnce()
.with( // Reset the error and do not show again
// MatrixClient finalState.eventSink(LoggedInEvents.CloseErrorDialog(doNotShowAgain = false))
any(), val lastState = awaitItem()
// PushProvider with highest priority (lower index) assertThat(lastState.pusherRegistrationState.isUninitialized()).isTrue()
value(pushService.getAvailablePushProviders()[0]), assertThat(lastState.ignoreRegistrationError).isFalse()
// First distributor
value(pushService.getAvailablePushProviders()[0].getDistributors()[0]),
)
} }
} }
@Test @Test
fun `present - ensure current provider is registered with current distributor`() = runTest { fun `present - ensure default pusher is registered with default provider - fail to register - do not show again`() = runTest {
val lambda = lambdaRecorder<MatrixClient, PushProvider, Distributor, Result<Unit>> { _, _, _ -> val lambda = lambdaRecorder<Result<Unit>> { Result.failure(AN_EXCEPTION) }
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)
val setIgnoreRegistrationErrorLambda = lambdaRecorder<SessionId, Boolean, Unit> { _, _ -> } val setIgnoreRegistrationErrorLambda = lambdaRecorder<SessionId, Boolean, Unit> { _, _ -> }
val sessionVerificationService = FakeSessionVerificationService(
initialSessionVerifiedStatus = SessionVerifiedStatus.Verified
)
val pushService = createFakePushService( val pushService = createFakePushService(
pushProvider0 = null, ensurePusherIsRegisteredResult = lambda,
pushProvider1 = null,
registerWithLambda = lambda,
setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda, setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda,
) )
createLoggedInPresenter( createLoggedInPresenter(
pushService = pushService, pushService = pushService,
sessionVerificationService = sessionVerificationService, sessionVerificationService = sessionVerificationService,
matrixClient = FakeMatrixClient(
accountManagementUrlResult = { Result.success(null) },
),
).test { ).test {
val finalState = awaitFirstItem() val finalState = awaitFirstItem()
assertThat(finalState.pusherRegistrationState.errorOrNull()) assertThat(finalState.pusherRegistrationState.isFailure()).isTrue()
.isInstanceOf(PusherRegistrationFailure.NoProvidersAvailable::class.java)
lambda.assertions() lambda.assertions()
.isNeverCalled() .isCalledOnce()
// Reset the error and do not show again // Reset the error and do not show again
finalState.eventSink(LoggedInEvents.CloseErrorDialog(doNotShowAgain = true)) finalState.eventSink(LoggedInEvents.CloseErrorDialog(doNotShowAgain = true))
skipItems(1) 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( private fun createFakePushService(
pushProvider0: PushProvider? = FakePushProvider( pushProvider0: PushProvider? = FakePushProvider(
index = 0, index = 0,
@ -484,7 +262,7 @@ class LoggedInPresenterTest {
distributors = listOf(Distributor("aDistributorValue1", "aDistributorName1")), distributors = listOf(Distributor("aDistributorValue1", "aDistributorName1")),
currentDistributor = { null }, currentDistributor = { null },
), ),
registerWithLambda: (MatrixClient, PushProvider, Distributor) -> Result<Unit> = { _, _, _ -> ensurePusherIsRegisteredResult: () -> Result<Unit> = {
Result.success(Unit) Result.success(Unit)
}, },
selectPushProviderLambda: (SessionId, PushProvider) -> Unit = { _, _ -> lambdaError() }, selectPushProviderLambda: (SessionId, PushProvider) -> Unit = { _, _ -> lambdaError() },
@ -493,7 +271,7 @@ class LoggedInPresenterTest {
): PushService { ): PushService {
return FakePushService( return FakePushService(
availablePushProviders = listOfNotNull(pushProvider0, pushProvider1), availablePushProviders = listOfNotNull(pushProvider0, pushProvider1),
registerWithLambda = registerWithLambda, ensurePusherIsRegisteredResult = ensurePusherIsRegisteredResult,
currentPushProvider = currentPushProvider, currentPushProvider = currentPushProvider,
selectPushProviderLambda = selectPushProviderLambda, selectPushProviderLambda = selectPushProviderLambda,
setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda, setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda,

View file

@ -72,7 +72,7 @@ class RingingCallNotificationCreator(
): Notification? { ): Notification? {
val matrixClient = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return null val matrixClient = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return null
val imageLoader = imageLoaderHolder.get(matrixClient) val imageLoader = imageLoaderHolder.get(matrixClient)
val largeIcon = notificationBitmapLoader.getUserIcon( val userIcon = notificationBitmapLoader.getUserIcon(
avatarData = AvatarData( avatarData = AvatarData(
id = roomId.value, id = roomId.value,
name = roomName, name = roomName,
@ -84,7 +84,7 @@ class RingingCallNotificationCreator(
val caller = Person.Builder() val caller = Person.Builder()
.setName(senderDisplayName) .setName(senderDisplayName)
.setIcon(largeIcon) .setIcon(userIcon)
.setImportant(true) .setImportant(true)
.build() .build()
@ -133,12 +133,8 @@ class RingingCallNotificationCreator(
.setWhen(timestamp) .setWhen(timestamp)
.setOngoing(true) .setOngoing(true)
.setShowWhen(false) .setShowWhen(false)
.apply { // If textContent is null, the content text is set by the style (will be "Incoming call")
if (textContent != null) { .setContentText(textContent)
setContentText(textContent)
// Else the content text is set by the style (will be "Incoming call")
}
}
.setSound(Settings.System.DEFAULT_RINGTONE_URI, AudioManager.STREAM_RING) .setSound(Settings.System.DEFAULT_RINGTONE_URI, AudioManager.STREAM_RING)
.setTimeoutAfter(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds.inWholeMilliseconds) .setTimeoutAfter(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds.inWholeMilliseconds)
.setContentIntent(answerIntent) .setContentIntent(answerIntent)

View file

@ -22,7 +22,6 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat import androidx.core.app.PendingIntentCompat
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat 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.R
import io.element.android.features.call.impl.ui.ElementCallActivity import io.element.android.features.call.impl.ui.ElementCallActivity
import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.extensions.runCatchingExceptions
@ -69,7 +68,7 @@ class CallForegroundService : Service() {
val callActivityIntent = Intent(this, ElementCallActivity::class.java) val callActivityIntent = Intent(this, ElementCallActivity::class.java)
val pendingIntent = PendingIntentCompat.getActivity(this, 0, callActivityIntent, 0, false) val pendingIntent = PendingIntentCompat.getActivity(this, 0, callActivityIntent, 0, false)
val notification = NotificationCompat.Builder(this, foregroundServiceChannel.id) 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)) .setContentTitle(getString(R.string.call_foreground_service_title_android))
.setContentText(getString(R.string.call_foreground_service_message_android)) .setContentText(getString(R.string.call_foreground_service_message_android))
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)

View file

@ -100,7 +100,7 @@ class DefaultActiveCallManager(
private val imageLoaderHolder: ImageLoaderHolder, private val imageLoaderHolder: ImageLoaderHolder,
private val systemClock: SystemClock, private val systemClock: SystemClock,
) : ActiveCallManager { ) : ActiveCallManager {
private val tag = "DefaultActiveCallManager" private val tag = "ActiveCallManager"
private var timedOutCallJob: Job? = null private var timedOutCallJob: Job? = null
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @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.Presenter
import io.element.android.libraries.architecture.runUpdatingStateNoSuccess import io.element.android.libraries.architecture.runUpdatingStateNoSuccess
import io.element.android.libraries.core.extensions.runCatchingExceptions 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.fullscreenintent.api.FullScreenIntentPermissionsState
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
@ -51,6 +52,8 @@ class NotificationSettingsPresenter(
private val pushService: PushService, private val pushService: PushService,
private val systemNotificationsEnabledProvider: SystemNotificationsEnabledProvider, private val systemNotificationsEnabledProvider: SystemNotificationsEnabledProvider,
private val fullScreenIntentPermissionsPresenter: Presenter<FullScreenIntentPermissionsState>, private val fullScreenIntentPermissionsPresenter: Presenter<FullScreenIntentPermissionsState>,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
) : Presenter<NotificationSettingsState> { ) : Presenter<NotificationSettingsState> {
@Composable @Composable
override fun present(): NotificationSettingsState { override fun present(): NotificationSettingsState {
@ -141,7 +144,7 @@ class NotificationSettingsPresenter(
is NotificationSettingsEvents.SetInviteForMeNotificationsEnabled -> { is NotificationSettingsEvents.SetInviteForMeNotificationsEnabled -> {
localCoroutineScope.setInviteForMeNotificationsEnabled(event.enabled, changeNotificationSettingAction) localCoroutineScope.setInviteForMeNotificationsEnabled(event.enabled, changeNotificationSettingAction)
} }
is NotificationSettingsEvents.SetNotificationsEnabled -> localCoroutineScope.setNotificationsEnabled(userPushStore, event.enabled) is NotificationSettingsEvents.SetNotificationsEnabled -> sessionCoroutineScope.setNotificationsEnabled(userPushStore, event.enabled)
NotificationSettingsEvents.ClearConfigurationMismatchError -> { NotificationSettingsEvents.ClearConfigurationMismatchError -> {
matrixSettings.value = NotificationSettingsState.MatrixSettings.Invalid(fixFailed = false) matrixSettings.value = NotificationSettingsState.MatrixSettings.Invalid(fixFailed = false)
} }
@ -262,5 +265,10 @@ class NotificationSettingsPresenter(
private fun CoroutineScope.setNotificationsEnabled(userPushStore: UserPushStore, enabled: Boolean) = launch { private fun CoroutineScope.setNotificationsEnabled(userPushStore: UserPushStore, enabled: Boolean) = launch {
userPushStore.setNotificationEnabledForDevice(enabled) 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 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 com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState 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.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory
import io.element.android.tests.testutils.awaitLastSequentialItem import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate 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 kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@ -36,9 +36,7 @@ class NotificationSettingsPresenterTest {
@Test @Test
fun `present - ensures initial state is correct`() = runTest { fun `present - ensures initial state is correct`() = runTest {
val presenter = createNotificationSettingsPresenter() val presenter = createNotificationSettingsPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.appSettings.appNotificationsEnabled).isFalse() assertThat(initialState.appSettings.appNotificationsEnabled).isFalse()
assertThat(initialState.appSettings.systemNotificationsEnabled).isTrue() assertThat(initialState.appSettings.systemNotificationsEnabled).isTrue()
@ -62,9 +60,7 @@ class NotificationSettingsPresenterTest {
fun `present - default group notification mode changed`() = runTest { fun `present - default group notification mode changed`() = runTest {
val notificationSettingsService = FakeNotificationSettingsService() val notificationSettingsService = FakeNotificationSettingsService()
val presenter = createNotificationSettingsPresenter(notificationSettingsService) val presenter = createNotificationSettingsPresenter(notificationSettingsService)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = true, isOneToOne = false, mode = RoomNotificationMode.ALL_MESSAGES) notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = true, isOneToOne = false, mode = RoomNotificationMode.ALL_MESSAGES)
notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = false, isOneToOne = false, mode = RoomNotificationMode.ALL_MESSAGES) notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = false, isOneToOne = false, mode = RoomNotificationMode.ALL_MESSAGES)
val updatedState = consumeItemsUntilPredicate { val updatedState = consumeItemsUntilPredicate {
@ -80,9 +76,7 @@ class NotificationSettingsPresenterTest {
fun `present - notification settings mismatched`() = runTest { fun `present - notification settings mismatched`() = runTest {
val notificationSettingsService = FakeNotificationSettingsService() val notificationSettingsService = FakeNotificationSettingsService()
val presenter = createNotificationSettingsPresenter(notificationSettingsService) val presenter = createNotificationSettingsPresenter(notificationSettingsService)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
notificationSettingsService.setDefaultRoomNotificationMode( notificationSettingsService.setDefaultRoomNotificationMode(
isEncrypted = true, isEncrypted = true,
isOneToOne = false, isOneToOne = false,
@ -110,9 +104,7 @@ class NotificationSettingsPresenterTest {
initialOneToOneDefaultMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY initialOneToOneDefaultMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
) )
val presenter = createNotificationSettingsPresenter(notificationSettingsService) val presenter = createNotificationSettingsPresenter(notificationSettingsService)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(NotificationSettingsEvents.FixConfigurationMismatch) initialState.eventSink(NotificationSettingsEvents.FixConfigurationMismatch)
val fixedState = consumeItemsUntilPredicate(timeout = 2000.milliseconds) { val fixedState = consumeItemsUntilPredicate(timeout = 2000.milliseconds) {
@ -125,10 +117,19 @@ class NotificationSettingsPresenterTest {
@Test @Test
fun `present - set notifications enabled`() = runTest { fun `present - set notifications enabled`() = runTest {
val presenter = createNotificationSettingsPresenter() val unregisterWithResult = lambdaRecorder<MatrixClient, Result<Unit>> { Result.success(Unit) }
moleculeFlow(RecompositionMode.Immediate) { val ensurePusherIsRegisteredResult = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
presenter.present() val presenter = createNotificationSettingsPresenter(
}.test { pushService = FakePushService(
currentPushProvider = {
FakePushProvider(
unregisterWithResult = unregisterWithResult,
)
},
ensurePusherIsRegisteredResult = ensurePusherIsRegisteredResult,
)
)
presenter.test {
val loadedState = consumeItemsUntilPredicate { val loadedState = consumeItemsUntilPredicate {
it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid
}.last() }.last()
@ -138,16 +139,21 @@ class NotificationSettingsPresenterTest {
!it.appSettings.appNotificationsEnabled !it.appSettings.appNotificationsEnabled
}.last() }.last()
assertThat(updatedState.appSettings.appNotificationsEnabled).isFalse() 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 @Test
fun `present - set call notifications enabled`() = runTest { fun `present - set call notifications enabled`() = runTest {
val presenter = createNotificationSettingsPresenter() val presenter = createNotificationSettingsPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val loadedState = consumeItemsUntilPredicate { val loadedState = consumeItemsUntilPredicate {
(it.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid)?.callNotificationsEnabled == false (it.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid)?.callNotificationsEnabled == false
}.last() }.last()
@ -166,9 +172,7 @@ class NotificationSettingsPresenterTest {
@Test @Test
fun `present - set invite for me notifications enabled`() = runTest { fun `present - set invite for me notifications enabled`() = runTest {
val presenter = createNotificationSettingsPresenter() val presenter = createNotificationSettingsPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val loadedState = consumeItemsUntilPredicate { val loadedState = consumeItemsUntilPredicate {
(it.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid)?.inviteForMeNotificationsEnabled == false (it.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid)?.inviteForMeNotificationsEnabled == false
}.last() }.last()
@ -187,9 +191,7 @@ class NotificationSettingsPresenterTest {
@Test @Test
fun `present - set atRoom notifications enabled`() = runTest { fun `present - set atRoom notifications enabled`() = runTest {
val presenter = createNotificationSettingsPresenter() val presenter = createNotificationSettingsPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val loadedState = consumeItemsUntilPredicate { val loadedState = consumeItemsUntilPredicate {
(it.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid)?.atRoomNotificationsEnabled == false (it.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid)?.atRoomNotificationsEnabled == false
}.last() }.last()
@ -210,9 +212,7 @@ class NotificationSettingsPresenterTest {
val notificationSettingsService = FakeNotificationSettingsService() val notificationSettingsService = FakeNotificationSettingsService()
val presenter = createNotificationSettingsPresenter(notificationSettingsService) val presenter = createNotificationSettingsPresenter(notificationSettingsService)
notificationSettingsService.givenSetAtRoomError(AN_EXCEPTION) notificationSettingsService.givenSetAtRoomError(AN_EXCEPTION)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val loadedState = consumeItemsUntilPredicate { val loadedState = consumeItemsUntilPredicate {
(it.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid)?.atRoomNotificationsEnabled == false (it.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid)?.atRoomNotificationsEnabled == false
}.last() }.last()
@ -237,9 +237,7 @@ class NotificationSettingsPresenterTest {
val presenter = createNotificationSettingsPresenter( val presenter = createNotificationSettingsPresenter(
pushService = createFakePushService(), pushService = createFakePushService(),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitLastSequentialItem() val initialState = awaitLastSequentialItem()
assertThat(initialState.currentPushDistributor).isEqualTo(AsyncData.Success(Distributor(value = "aDistributorValue0", name = "aDistributorName0"))) assertThat(initialState.currentPushDistributor).isEqualTo(AsyncData.Success(Distributor(value = "aDistributorValue0", name = "aDistributorName0")))
assertThat(initialState.availablePushDistributors).containsExactly( assertThat(initialState.availablePushDistributors).containsExactly(
@ -271,9 +269,7 @@ class NotificationSettingsPresenterTest {
val presenter = createNotificationSettingsPresenter( val presenter = createNotificationSettingsPresenter(
pushService = createFakePushService(), pushService = createFakePushService(),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitLastSequentialItem() val initialState = awaitLastSequentialItem()
assertThat(initialState.currentPushDistributor).isEqualTo(AsyncData.Success(Distributor(value = "aDistributorValue0", name = "aDistributorName0"))) assertThat(initialState.currentPushDistributor).isEqualTo(AsyncData.Success(Distributor(value = "aDistributorValue0", name = "aDistributorName0")))
assertThat(initialState.availablePushDistributors).containsExactly( assertThat(initialState.availablePushDistributors).containsExactly(
@ -298,9 +294,7 @@ class NotificationSettingsPresenterTest {
pushService = createFakePushService(), pushService = createFakePushService(),
fullScreenIntentPermissionsStateLambda = fullScreenIntentPermissionsStateLambda, fullScreenIntentPermissionsStateLambda = fullScreenIntentPermissionsStateLambda,
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitLastSequentialItem() val initialState = awaitLastSequentialItem()
assertThat(initialState.fullScreenIntentPermissionsState.permissionGranted).isFalse() assertThat(initialState.fullScreenIntentPermissionsState.permissionGranted).isFalse()
@ -324,9 +318,7 @@ class NotificationSettingsPresenterTest {
}, },
), ),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitLastSequentialItem() val initialState = awaitLastSequentialItem()
initialState.eventSink.invoke(NotificationSettingsEvents.ChangePushProvider) initialState.eventSink.invoke(NotificationSettingsEvents.ChangePushProvider)
val withDialog = awaitItem() val withDialog = awaitItem()
@ -341,7 +333,7 @@ class NotificationSettingsPresenterTest {
} }
private fun createFakePushService( private fun createFakePushService(
registerWithLambda: suspend (MatrixClient, PushProvider, Distributor) -> Result<Unit> = { _, _, _ -> registerWithLambda: (MatrixClient, PushProvider, Distributor) -> Result<Unit> = { _, _, _ ->
Result.success(Unit) Result.success(Unit)
} }
): PushService { ): PushService {
@ -361,7 +353,7 @@ class NotificationSettingsPresenterTest {
) )
} }
private fun createNotificationSettingsPresenter( private fun TestScope.createNotificationSettingsPresenter(
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(), notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
pushService: PushService = FakePushService(), pushService: PushService = FakePushService(),
fullScreenIntentPermissionsStateLambda: () -> FullScreenIntentPermissionsState = { aFullScreenIntentPermissionsState() }, fullScreenIntentPermissionsStateLambda: () -> FullScreenIntentPermissionsState = { aFullScreenIntentPermissionsState() },
@ -374,6 +366,7 @@ class NotificationSettingsPresenterTest {
pushService = pushService, pushService = pushService,
systemNotificationsEnabledProvider = FakeSystemNotificationsEnabledProvider(), systemNotificationsEnabledProvider = FakeSystemNotificationsEnabledProvider(),
fullScreenIntentPermissionsPresenter = { fullScreenIntentPermissionsStateLambda() }, 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.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId 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.push.api.history.PushHistoryItem
import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.api.Distributor
import io.element.android.libraries.pushproviders.api.PushProvider import io.element.android.libraries.pushproviders.api.PushProvider
@ -37,6 +38,15 @@ interface PushService {
distributor: Distributor, distributor: Distributor,
): Result<Unit> ): 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. * Store the given push provider as the current one, but do not register.
* To be used when there is no distributor available. * To be used when there is no distributor available.
@ -73,4 +83,9 @@ interface PushService {
* Reset the battery optimization state. * Reset the battery optimization state.
*/ */
suspend fun resetBatteryOptimizationState() 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. * 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 import io.element.android.libraries.matrix.api.exception.ClientException

View file

@ -33,7 +33,7 @@ object NotificationIdProvider {
} }
fun getForegroundServiceNotificationId(type: ForegroundServiceType): Int { 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 { private fun getOffset(sessionId: SessionId): Int {
@ -50,7 +50,7 @@ object NotificationIdProvider {
private const val FOREGROUND_SERVICE_NOTIFICATION_ID = 4 private const val FOREGROUND_SERVICE_NOTIFICATION_ID = 4
} }
enum class ForegroundServiceType(val id: Int) { enum class ForegroundServiceType {
INCOMING_CALL(1), INCOMING_CALL,
ONGOING_CALL(2), ONGOING_CALL,
} }

View file

@ -14,19 +14,25 @@ import dev.zacsweers.metro.SingleIn
import dev.zacsweers.metro.binding import dev.zacsweers.metro.binding
import io.element.android.libraries.matrix.api.MatrixClient 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.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.GetCurrentPushProvider
import io.element.android.libraries.push.api.PushService 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.api.history.PushHistoryItem
import io.element.android.libraries.push.impl.push.MutableBatteryOptimizationStore 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.store.PushDataStore
import io.element.android.libraries.push.impl.test.TestPush 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.Distributor
import io.element.android.libraries.pushproviders.api.PushProvider 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.UserPushStoreFactory
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecretStore 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.SessionListener
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import timber.log.Timber import timber.log.Timber
@ContributesBinding(AppScope::class, binding = binding<PushService>()) @ContributesBinding(AppScope::class, binding = binding<PushService>())
@ -40,6 +46,7 @@ class DefaultPushService(
private val pushClientSecretStore: PushClientSecretStore, private val pushClientSecretStore: PushClientSecretStore,
private val pushDataStore: PushDataStore, private val pushDataStore: PushDataStore,
private val mutableBatteryOptimizationStore: MutableBatteryOptimizationStore, private val mutableBatteryOptimizationStore: MutableBatteryOptimizationStore,
private val serviceUnregisteredHandler: ServiceUnregisteredHandler,
) : PushService, SessionListener { ) : PushService, SessionListener {
init { init {
observeSessions() observeSessions()
@ -81,6 +88,59 @@ class DefaultPushService(
return pushProvider.registerWith(matrixClient, distributor) 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( override suspend fun selectPushProvider(
sessionId: SessionId, sessionId: SessionId,
pushProvider: PushProvider, pushProvider: PushProvider,
@ -141,4 +201,8 @@ class DefaultPushService(
override suspend fun resetBatteryOptimizationState() { override suspend fun resetBatteryOptimizationState() {
mutableBatteryOptimizationStore.reset() 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 cancelNotification(tag: String?, id: Int)
fun displayDiagnosticNotification(notification: Notification): Boolean fun displayDiagnosticNotification(notification: Notification): Boolean
fun dismissDiagnosticNotification() fun dismissDiagnosticNotification()
fun displayUnregistrationNotification(notification: Notification): Boolean
} }
@ContributesBinding(AppScope::class) @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 { companion object {
private const val TAG_DIAGNOSTIC = "DIAGNOSTIC" private const val TAG_DIAGNOSTIC = "DIAGNOSTIC"
@ -67,5 +76,6 @@ class DefaultNotificationDisplayer(
* IDs for notifications * IDs for notifications
* ========================================================================================== */ * ========================================================================================== */
private const val NOTIFICATION_ID_DIAGNOSTIC = 888 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.app.Notification
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.drawable.Icon
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.MessagingStyle import androidx.core.app.NotificationCompat.MessagingStyle
@ -92,6 +91,10 @@ interface NotificationCreator {
@ColorInt color: Int, @ColorInt color: Int,
): Notification ): Notification
fun createUnregistrationNotification(
notificationAccountParams: NotificationAccountParams,
): Notification
companion object { companion object {
/** /**
* Creates a tag for a message notification given its [roomId] and optional [threadId]. * Creates a tag for a message notification given its [roomId] and optional [threadId].
@ -143,14 +146,21 @@ class DefaultNotificationCreator(
} else { } else {
notificationChannels.getChannelIdForMessage(noisy = roomInfo.shouldBing) 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) { val builder = if (existingNotification != null) {
NotificationCompat.Builder(context, existingNotification) NotificationCompat.Builder(context, existingNotification)
// Clear existing actions
.clearActions()
} else { } else {
NotificationCompat.Builder(context, channelId) 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+ // ID of the corresponding shortcut, for conversation features under API 30+
// Must match those created in the ShortcutInfoCompat.Builder() // Must match those created in the ShortcutInfoCompat.Builder()
// for the notification to appear as a "Conversation": // for the notification to appear as a "Conversation":
@ -166,7 +176,6 @@ class DefaultNotificationCreator(
// Remove notification after opening it or using an action // Remove notification after opening it or using an action
.setAutoCancel(true) .setAutoCancel(true)
} }
val messagingStyle = existingNotification?.let { val messagingStyle = existingNotification?.let {
MessagingStyle.extractMessagingStyleFromNotification(it) MessagingStyle.extractMessagingStyleFromNotification(it)
} ?: createMessagingStyleFromCurrentUser( } ?: createMessagingStyleFromCurrentUser(
@ -176,54 +185,35 @@ class DefaultNotificationCreator(
isThread = threadId != null, isThread = threadId != null,
roomIsGroup = !roomInfo.isDm, roomIsGroup = !roomInfo.isDm,
) )
messagingStyle.addMessagesFromEvents(events, imageLoader) messagingStyle.addMessagesFromEvents(events, imageLoader)
return builder return builder
.setCategory(category)
.setNumber(events.size) .setNumber(events.size)
.setOnlyAlertOnce(roomInfo.isUpdated) .setOnlyAlertOnce(roomInfo.isUpdated)
.setWhen(lastMessageTimestamp) .setWhen(lastMessageTimestamp)
// MESSAGING_STYLE sets title and content for API 16 and above devices. // MESSAGING_STYLE sets title and content for API 16 and above devices.
.setStyle(messagingStyle) .setStyle(messagingStyle)
.configureWith(notificationAccountParams) .configureWith(notificationAccountParams)
// Sets priority for 25 and below. For 26 and above, 'priority' is deprecated for // Mark room/thread as read
// 'importance' which is set in the NotificationChannel. The integers representing .addAction(markAsReadActionFactory.create(roomInfo, threadId))
// 'priority' are different from 'importance', so make sure you don't mix them. .setContentIntent(openIntent)
.setLargeIcon(largeIcon)
.setDeleteIntent(pendingIntentFactory.createDismissRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId))
.apply { .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) { if (roomInfo.shouldBing) {
// Compat
priority = NotificationCompat.PRIORITY_DEFAULT priority = NotificationCompat.PRIORITY_DEFAULT
/*
vectorPreferences.getNotificationRingTone()?.let {
setSound(it)
}
*/
setLights(notificationAccountParams.color, 500, 500) setLights(notificationAccountParams.color, 500, 500)
} else { } else {
priority = NotificationCompat.PRIORITY_LOW 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 // Quick reply
if (!roomInfo.hasSmartReplyError) { if (!roomInfo.hasSmartReplyError) {
val latestEventId = events.lastOrNull()?.eventId val latestEventId = events.lastOrNull()?.eventId
addAction(quickReplyActionFactory.create(roomInfo, latestEventId, threadId)) 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) .setTicker(tickerText)
.build() .build()
@ -240,32 +230,26 @@ class DefaultNotificationCreator(
.setContentText(inviteNotifiableEvent.description.annotateForDebug(6)) .setContentText(inviteNotifiableEvent.description.annotateForDebug(6))
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.configureWith(notificationAccountParams) .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 { .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) { if (inviteNotifiableEvent.noisy) {
// Compat // Compat
priority = NotificationCompat.PRIORITY_DEFAULT priority = NotificationCompat.PRIORITY_DEFAULT
/*
vectorPreferences.getNotificationRingTone()?.let {
setSound(it)
}
*/
setLights(notificationAccountParams.color, 500, 500) setLights(notificationAccountParams.color, 500, 500)
} else { } else {
priority = NotificationCompat.PRIORITY_LOW priority = NotificationCompat.PRIORITY_LOW
} }
setDeleteIntent(
pendingIntentFactory.createDismissInvitePendingIntent(
inviteNotifiableEvent.sessionId,
inviteNotifiableEvent.roomId,
)
)
setAutoCancel(true)
} }
.setDeleteIntent(
pendingIntentFactory.createDismissInvitePendingIntent(
inviteNotifiableEvent.sessionId,
inviteNotifiableEvent.roomId,
)
)
.setAutoCancel(true)
.build() .build()
} }
@ -286,11 +270,6 @@ class DefaultNotificationCreator(
if (simpleNotifiableEvent.noisy) { if (simpleNotifiableEvent.noisy) {
// Compat // Compat
priority = NotificationCompat.PRIORITY_DEFAULT priority = NotificationCompat.PRIORITY_DEFAULT
/*
vectorPreferences.getNotificationRingTone()?.let {
setSound(it)
}
*/
setLights(notificationAccountParams.color, 500, 500) setLights(notificationAccountParams.color, 500, 500)
} else { } else {
priority = NotificationCompat.PRIORITY_LOW priority = NotificationCompat.PRIORITY_LOW
@ -349,11 +328,6 @@ class DefaultNotificationCreator(
if (noisy) { if (noisy) {
// Compat // Compat
priority = NotificationCompat.PRIORITY_DEFAULT priority = NotificationCompat.PRIORITY_DEFAULT
/*
vectorPreferences.getNotificationRingTone()?.let {
setSound(it)
}
*/
setLights(notificationAccountParams.color, 500, 500) setLights(notificationAccountParams.color, 500, 500)
} else { } else {
// compat // compat
@ -382,6 +356,25 @@ class DefaultNotificationCreator(
.build() .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( private suspend fun MessagingStyle.addMessagesFromEvents(
events: List<NotifiableMessageEvent>, events: List<NotifiableMessageEvent>,
imageLoader: ImageLoader, imageLoader: ImageLoader,
@ -483,7 +476,7 @@ class DefaultNotificationCreator(
.build() .build()
).also { ).also {
it.conversationTitle = if (isThread) { it.conversationTitle = if (isThread) {
stringProvider.getString(CommonStrings.notification_thread_in_room, roomName) stringProvider.getString(R.string.notification_thread_in_room, roomName)
} else { } else {
roomName 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_me">"Já"</string>
<string name="notification_sender_mention_reply">"%1$s zmínil(a) nebo odpověděl(a)"</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_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_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string> <string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<plurals name="notification_unread_notified_messages"> <plurals name="notification_unread_notified_messages">

View file

@ -38,6 +38,7 @@
<string name="notification_sender_me">"Mig"</string> <string name="notification_sender_me">"Mig"</string>
<string name="notification_sender_mention_reply">"%1$s nævnt eller besvaret"</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_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_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string> <string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<plurals name="notification_unread_notified_messages"> <plurals name="notification_unread_notified_messages">

View file

@ -38,6 +38,7 @@
<string name="notification_sender_me">"Ich"</string> <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_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_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_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string> <string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<plurals name="notification_unread_notified_messages"> <plurals name="notification_unread_notified_messages">

View file

@ -38,6 +38,7 @@
<string name="notification_sender_me">"Mina"</string> <string name="notification_sender_me">"Mina"</string>
<string name="notification_sender_mention_reply">"%1$s mainis või vastas"</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_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_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string> <string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<plurals name="notification_unread_notified_messages"> <plurals name="notification_unread_notified_messages">

View file

@ -38,6 +38,7 @@
<string name="notification_sender_me">"Moi"</string> <string name="notification_sender_me">"Moi"</string>
<string name="notification_sender_mention_reply">"%1$s mentionné ou en réponse"</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_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_dm">"%1$s : %2$s"</string>
<string name="notification_ticker_text_group">"%1$s : %2$s %3$s"</string> <string name="notification_ticker_text_group">"%1$s : %2$s %3$s"</string>
<plurals name="notification_unread_notified_messages"> <plurals name="notification_unread_notified_messages">

View file

@ -42,6 +42,7 @@
<string name="notification_sender_me">"Ja"</string> <string name="notification_sender_me">"Ja"</string>
<string name="notification_sender_mention_reply">"%1$s spomenul/a alebo odpovedal/a"</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_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_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string> <string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<plurals name="notification_unread_notified_messages"> <plurals name="notification_unread_notified_messages">

View file

@ -34,6 +34,7 @@
<string name="notification_sender_me">"我"</string> <string name="notification_sender_me">"我"</string>
<string name="notification_sender_mention_reply">"%1$s 提及或回覆"</string> <string name="notification_sender_mention_reply">"%1$s 提及或回覆"</string>
<string name="notification_test_push_notification_content">"您正在查看通知!點我!"</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_dm">"%1$s%2$s"</string>
<string name="notification_ticker_text_group">"%1$s%2$s %3$s"</string> <string name="notification_ticker_text_group">"%1$s%2$s %3$s"</string>
<plurals name="notification_unread_notified_messages"> <plurals name="notification_unread_notified_messages">

View file

@ -13,6 +13,7 @@
<item quantity="one">"%d notification"</item> <item quantity="one">"%d notification"</item>
<item quantity="other">"%d notifications"</item> <item quantity="other">"%d notifications"</item>
</plurals> </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_fallback_content">"You have new messages."</string>
<string name="notification_incoming_call">"📹 Incoming call"</string> <string name="notification_incoming_call">"📹 Incoming call"</string>
<string name="notification_inline_reply_failed">"** Failed to send - please open room"</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_me">"Me"</string>
<string name="notification_sender_mention_reply">"%1$s mentioned or replied"</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_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_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string> <string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<plurals name="notification_unread_notified_messages"> <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 com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.MatrixClient 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.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_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EXCEPTION 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_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_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.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.GetCurrentPushProvider
import io.element.android.libraries.push.api.PusherRegistrationFailure
import io.element.android.libraries.push.api.history.PushHistoryItem 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.FakeMutableBatteryOptimizationStore
import io.element.android.libraries.push.impl.push.MutableBatteryOptimizationStore 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.store.PushDataStore
import io.element.android.libraries.push.impl.test.FakeTestPush 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.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.push.test.FakeGetCurrentPushProvider
import io.element.android.libraries.pushproviders.api.Config import io.element.android.libraries.pushproviders.api.Config
import io.element.android.libraries.pushproviders.api.Distributor 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.pushstore.test.userpushstore.clientsecret.InMemoryPushClientSecretStore
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
import io.element.android.libraries.sessionstorage.test.observer.NoOpSessionObserver 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.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.flow.first 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( private fun createDefaultPushService(
testPush: TestPush = FakeTestPush(), testPush: TestPush = FakeTestPush(),
userPushStoreFactory: UserPushStoreFactory = FakeUserPushStoreFactory(), userPushStoreFactory: UserPushStoreFactory = FakeUserPushStoreFactory(),
@ -346,6 +627,7 @@ class DefaultPushServiceTest {
pushClientSecretStore: PushClientSecretStore = InMemoryPushClientSecretStore(), pushClientSecretStore: PushClientSecretStore = InMemoryPushClientSecretStore(),
pushDataStore: PushDataStore = InMemoryPushDataStore(), pushDataStore: PushDataStore = InMemoryPushDataStore(),
mutableBatteryOptimizationStore: MutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(), mutableBatteryOptimizationStore: MutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(),
serviceUnregisteredHandler: ServiceUnregisteredHandler = FakeServiceUnregisteredHandler(),
): DefaultPushService { ): DefaultPushService {
return DefaultPushService( return DefaultPushService(
testPush = testPush, testPush = testPush,
@ -356,6 +638,7 @@ class DefaultPushServiceTest {
pushClientSecretStore = pushClientSecretStore, pushClientSecretStore = pushClientSecretStore,
pushDataStore = pushDataStore, pushDataStore = pushDataStore,
mutableBatteryOptimizationStore = mutableBatteryOptimizationStore, 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 @Test
fun `test createFallbackNotification`() { fun `test createFallbackNotification`() {
val sut = createNotificationCreator() val sut = createNotificationCreator()

View file

@ -41,6 +41,8 @@ class FakeNotificationCreator(
> = lambdaRecorder { _, _, _, _, _ -> A_NOTIFICATION }, > = lambdaRecorder { _, _, _, _, _ -> A_NOTIFICATION },
var createDiagnosticNotificationResult: LambdaOneParamRecorder<Int, Notification> = var createDiagnosticNotificationResult: LambdaOneParamRecorder<Int, Notification> =
lambdaRecorder<Int, Notification> { _ -> A_NOTIFICATION }, lambdaRecorder<Int, Notification> { _ -> A_NOTIFICATION },
val createUnregistrationNotificationResult: LambdaOneParamRecorder<NotificationAccountParams, Notification> =
lambdaRecorder { _ -> A_NOTIFICATION },
) : NotificationCreator { ) : NotificationCreator {
override suspend fun createMessagesListNotification( override suspend fun createMessagesListNotification(
notificationAccountParams: NotificationAccountParams, notificationAccountParams: NotificationAccountParams,
@ -93,4 +95,8 @@ class FakeNotificationCreator(
): Notification { ): Notification {
return createDiagnosticNotificationResult(color) 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 cancelNotificationResult: LambdaTwoParamsRecorder<String?, Int, Unit> = lambdaRecorder { _, _ -> },
var displayDiagnosticNotificationResult: LambdaOneParamRecorder<Notification, Boolean> = lambdaRecorder { _ -> true }, var displayDiagnosticNotificationResult: LambdaOneParamRecorder<Notification, Boolean> = lambdaRecorder { _ -> true },
var dismissDiagnosticNotificationResult: LambdaNoParamRecorder<Unit> = lambdaRecorder { -> }, var dismissDiagnosticNotificationResult: LambdaNoParamRecorder<Unit> = lambdaRecorder { -> },
var displayUnregistrationNotificationResult: LambdaOneParamRecorder<Notification, Boolean> = lambdaRecorder { _ -> true },
) : NotificationDisplayer { ) : NotificationDisplayer {
override fun showNotification(tag: String?, id: Int, notification: Notification): Boolean { override fun showNotification(tag: String?, id: Int, notification: Notification): Boolean {
return showNotificationResult(tag, id, notification) return showNotificationResult(tag, id, notification)
@ -41,6 +42,10 @@ class FakeNotificationDisplayer(
return dismissDiagnosticNotificationResult() return dismissDiagnosticNotificationResult()
} }
override fun displayUnregistrationNotification(notification: Notification): Boolean {
return displayUnregistrationNotificationResult(notification)
}
fun verifySummaryCancelled(times: Int = 1) { fun verifySummaryCancelled(times: Int = 1) {
cancelNotificationResult.assertions().isCalledExactly(times).withSequence( cancelNotificationResult.assertions().isCalledExactly(times).withSequence(
listOf(value(null), value(NotificationIdProvider.getSummaryNotificationId(A_SESSION_ID))) 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.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId 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.PushService
import io.element.android.libraries.push.api.history.PushHistoryItem import io.element.android.libraries.push.api.history.PushHistoryItem
import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.api.Distributor
@ -22,7 +23,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
class FakePushService( class FakePushService(
private val testPushBlock: suspend (SessionId) -> Boolean = { true }, private val testPushBlock: suspend (SessionId) -> Boolean = { true },
private val availablePushProviders: List<PushProvider> = emptyList(), 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) Result.success(Unit)
}, },
private val currentPushProvider: (SessionId) -> PushProvider? = { availablePushProviders.firstOrNull() }, private val currentPushProvider: (SessionId) -> PushProvider? = { availablePushProviders.firstOrNull() },
@ -30,6 +31,8 @@ class FakePushService(
private val setIgnoreRegistrationErrorLambda: (SessionId, Boolean) -> Unit = { _, _ -> lambdaError() }, private val setIgnoreRegistrationErrorLambda: (SessionId, Boolean) -> Unit = { _, _ -> lambdaError() },
private val resetPushHistoryResult: () -> Unit = { lambdaError() }, private val resetPushHistoryResult: () -> Unit = { lambdaError() },
private val resetBatteryOptimizationStateResult: () -> Unit = { lambdaError() }, private val resetBatteryOptimizationStateResult: () -> Unit = { lambdaError() },
private val onServiceUnregisteredResult: (UserId) -> Unit = { lambdaError() },
private val ensurePusherIsRegisteredResult: () -> Result<Unit> = { lambdaError() },
) : PushService { ) : PushService {
override suspend fun getCurrentPushProvider(sessionId: SessionId): PushProvider? { override suspend fun getCurrentPushProvider(sessionId: SessionId): PushProvider? {
return registeredPushProvider ?: currentPushProvider(sessionId) 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) { override suspend fun selectPushProvider(sessionId: SessionId, pushProvider: PushProvider) {
selectPushProviderLambda(sessionId, pushProvider) selectPushProviderLambda(sessionId, pushProvider)
} }
@ -98,4 +105,8 @@ class FakePushService(
override suspend fun resetBatteryOptimizationState() { override suspend fun resetBatteryOptimizationState() {
resetBatteryOptimizationStateResult() resetBatteryOptimizationStateResult()
} }
override suspend fun onServiceUnregistered(userId: UserId) {
onServiceUnregisteredResult(userId)
}
} }

View file

@ -24,6 +24,7 @@ dependencies {
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.push.api)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
api(projects.libraries.troubleshoot.api) api(projects.libraries.troubleshoot.api)

View file

@ -39,7 +39,7 @@ class DefaultUnifiedPushNewGatewayHandler(
val userId = pushClientSecret.getUserIdFromSecret(clientSecret) ?: return Result.failure<Unit>( val userId = pushClientSecret.getUserIdFromSecret(clientSecret) ?: return Result.failure<Unit>(
IllegalStateException("Unable to retrieve session") IllegalStateException("Unable to retrieve session")
).also { ).also {
Timber.w("Unable to retrieve session") Timber.tag(loggerTag.value).w("Unable to retrieve session")
} }
val userDataStore = userPushStoreFactory.getOrCreate(userId) val userDataStore = userPushStoreFactory.getOrCreate(userId)
return if (userDataStore.getPushProviderName() == UnifiedPushConfig.NAME) { return if (userDataStore.getPushProviderName() == UnifiedPushConfig.NAME) {
@ -48,6 +48,9 @@ class DefaultUnifiedPushNewGatewayHandler(
.flatMap { client -> .flatMap { client ->
pusherSubscriber.registerPusher(client, endpoint, pushGateway) pusherSubscriber.registerPusher(client, endpoint, pushGateway)
} }
.onFailure {
Timber.tag(loggerTag.value).w(it, "Unable to register pusher")
}
} else { } else {
Timber.tag(loggerTag.value).d("This session is not using UnifiedPush pusher") Timber.tag(loggerTag.value).d("This session is not using UnifiedPush pusher")
Result.failure( 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 { 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. * 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) @ContributesBinding(AppScope::class)
@ -35,7 +42,11 @@ class DefaultUnregisterUnifiedPushUseCase(
private val unifiedPushStore: UnifiedPushStore, private val unifiedPushStore: UnifiedPushStore,
private val pusherSubscriber: PusherSubscriber, private val pusherSubscriber: PusherSubscriber,
) : UnregisterUnifiedPushUseCase { ) : 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 endpoint = unifiedPushStore.getEndpoint(clientSecret)
val gateway = unifiedPushStore.getPushGateway(clientSecret) val gateway = unifiedPushStore.getPushGateway(clientSecret)
if (endpoint == null || gateway == null) { if (endpoint == null || gateway == null) {
@ -46,13 +57,15 @@ class DefaultUnregisterUnifiedPushUseCase(
} }
return pusherSubscriber.unregisterPusher(matrixClient, endpoint, gateway) return pusherSubscriber.unregisterPusher(matrixClient, endpoint, gateway)
.onSuccess { .onSuccess {
cleanup(clientSecret) cleanup(clientSecret, unregisterUnifiedPush)
} }
} }
override fun cleanup(clientSecret: String) { override fun cleanup(clientSecret: String, unregisterUnifiedPush: Boolean) {
unifiedPushStore.storeUpEndpoint(clientSecret, null) unifiedPushStore.storeUpEndpoint(clientSecret, null)
unifiedPushStore.storePushGateway(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 unifiedPushGatewayResolver: UnifiedPushGatewayResolver
@Inject lateinit var unifiedPushGatewayUrlResolver: UnifiedPushGatewayUrlResolver @Inject lateinit var unifiedPushGatewayUrlResolver: UnifiedPushGatewayUrlResolver
@Inject lateinit var newGatewayHandler: UnifiedPushNewGatewayHandler @Inject lateinit var newGatewayHandler: UnifiedPushNewGatewayHandler
@Inject lateinit var removedGatewayHandler: UnifiedPushRemovedGatewayHandler
@Inject lateinit var endpointRegistrationHandler: EndpointRegistrationHandler @Inject lateinit var endpointRegistrationHandler: EndpointRegistrationHandler
@AppCoroutineScope @AppCoroutineScope
@Inject lateinit var coroutineScope: CoroutineScope @Inject lateinit var coroutineScope: CoroutineScope
@ -104,30 +106,23 @@ class VectorUnifiedPushMessagingReceiver : MessagingReceiver() {
*/ */
override fun onRegistrationFailed(context: Context, reason: FailedReason, instance: String) { override fun onRegistrationFailed(context: Context, reason: FailedReason, instance: String) {
Timber.tag(loggerTag.value).e("onRegistrationFailed for $instance, reason: $reason") Timber.tag(loggerTag.value).e("onRegistrationFailed for $instance, reason: $reason")
/* coroutineScope.launch {
Toast.makeText(context, "Push service registration failed", Toast.LENGTH_SHORT).show() endpointRegistrationHandler.registrationDone(
val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME RegistrationResult(
pushDataStore.setFdroidSyncBackgroundMode(mode) clientSecret = instance,
guardServiceStarter.start() result = Result.failure(Exception("Registration failed. Reason: $reason")),
*/ )
)
}
} }
/** /**
* Called when this application is unregistered from receiving push messages. * Called when this application is unregistered from receiving push messages.
*/ */
override fun onUnregistered(context: Context, instance: String) { override fun onUnregistered(context: Context, instance: String) {
Timber.tag(loggerTag.value).w("UnifiedPush: Unregistered") Timber.tag(loggerTag.value).w("onUnregistered $instance")
/* coroutineScope.launch {
val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME removedGatewayHandler.handle(instance)
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")
}
} }
*/
} }
} }

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 import io.element.android.tests.testutils.lambda.lambdaError
class FakeUnregisterUnifiedPushUseCase( class FakeUnregisterUnifiedPushUseCase(
private val unregisterLambda: (MatrixClient, String) -> Result<Unit> = { _, _ -> lambdaError() }, private val unregisterLambda: (MatrixClient, String, Boolean) -> Result<Unit> = { _, _, _ -> lambdaError() },
private val cleanupLambda: (String) -> Unit = { lambdaError() }, private val cleanupLambda: (String, Boolean) -> Unit = { _, _ -> lambdaError() },
) : UnregisterUnifiedPushUseCase { ) : UnregisterUnifiedPushUseCase {
override suspend fun unregister(matrixClient: MatrixClient, clientSecret: String): Result<Unit> { override suspend fun unregister(
return unregisterLambda(matrixClient, clientSecret) matrixClient: MatrixClient,
clientSecret: String,
unregisterUnifiedPush: Boolean,
): Result<Unit> {
return unregisterLambda(matrixClient, clientSecret, unregisterUnifiedPush)
} }
override fun cleanup(clientSecret: String) { override fun cleanup(
cleanupLambda(clientSecret) clientSecret: String,
unregisterUnifiedPush: Boolean,
) {
cleanupLambda(clientSecret, unregisterUnifiedPush)
} }
} }

View file

@ -118,7 +118,7 @@ class UnifiedPushProviderTest {
fun `unregister ok`() = runTest { fun `unregister ok`() = runTest {
val matrixClient = FakeMatrixClient() val matrixClient = FakeMatrixClient()
val getSecretForUserResultLambda = lambdaRecorder<SessionId, String> { A_SECRET } 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( val unifiedPushProvider = createUnifiedPushProvider(
pushClientSecret = FakePushClientSecret( pushClientSecret = FakePushClientSecret(
getSecretForUserResult = getSecretForUserResultLambda, getSecretForUserResult = getSecretForUserResultLambda,
@ -134,14 +134,14 @@ class UnifiedPushProviderTest {
.with(value(A_SESSION_ID)) .with(value(A_SESSION_ID))
unregisterLambda.assertions() unregisterLambda.assertions()
.isCalledOnce() .isCalledOnce()
.with(value(matrixClient), value(A_SECRET)) .with(value(matrixClient), value(A_SECRET), value(true))
} }
@Test @Test
fun `unregister ko`() = runTest { fun `unregister ko`() = runTest {
val matrixClient = FakeMatrixClient() val matrixClient = FakeMatrixClient()
val getSecretForUserResultLambda = lambdaRecorder<SessionId, String> { A_SECRET } 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( val unifiedPushProvider = createUnifiedPushProvider(
pushClientSecret = FakePushClientSecret( pushClientSecret = FakePushClientSecret(
getSecretForUserResult = getSecretForUserResultLambda, getSecretForUserResult = getSecretForUserResultLambda,
@ -157,7 +157,7 @@ class UnifiedPushProviderTest {
.with(value(A_SESSION_ID)) .with(value(A_SESSION_ID))
unregisterLambda.assertions() unregisterLambda.assertions()
.isCalledOnce() .isCalledOnce()
.with(value(matrixClient), value(A_SECRET)) .with(value(matrixClient), value(A_SECRET), value(true))
} }
@Test @Test
@ -230,7 +230,7 @@ class UnifiedPushProviderTest {
@Test @Test
fun `onSessionDeleted should do the cleanup`() = runTest { fun `onSessionDeleted should do the cleanup`() = runTest {
val cleanupLambda = lambdaRecorder<String, Unit> { } val cleanupLambda = lambdaRecorder<String, Boolean, Unit> { _, _ -> }
val unifiedPushProvider = createUnifiedPushProvider( val unifiedPushProvider = createUnifiedPushProvider(
pushClientSecret = FakePushClientSecret( pushClientSecret = FakePushClientSecret(
getSecretForUserResult = { A_SECRET } getSecretForUserResult = { A_SECRET }
@ -240,7 +240,7 @@ class UnifiedPushProviderTest {
), ),
) )
unifiedPushProvider.onSessionDeleted(A_SESSION_ID) unifiedPushProvider.onSessionDeleted(A_SESSION_ID)
cleanupLambda.assertions().isCalledOnce().with(value(A_SECRET)) cleanupLambda.assertions().isCalledOnce().with(value(A_SECRET), value(true))
} }
private fun createUnifiedPushProvider( 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.api.PushHandler
import io.element.android.libraries.pushproviders.unifiedpush.registration.EndpointRegistrationHandler import io.element.android.libraries.pushproviders.unifiedpush.registration.EndpointRegistrationHandler
import io.element.android.libraries.pushproviders.unifiedpush.registration.RegistrationResult 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.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -51,10 +52,17 @@ class VectorUnifiedPushMessagingReceiverTest {
} }
@Test @Test
fun `onUnregistered does nothing`() = runTest { fun `onUnregistered invokes the removedGatewayHandler`() = runTest {
val context = InstrumentationRegistry.getInstrumentation().context 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) vectorUnifiedPushMessagingReceiver.onUnregistered(context, A_SECRET)
advanceUntilIdle()
handleResult.assertions().isCalledOnce().with(value(A_SECRET))
} }
@Test @Test
@ -199,6 +207,7 @@ class VectorUnifiedPushMessagingReceiverTest {
unifiedPushGatewayUrlResolver: UnifiedPushGatewayUrlResolver = FakeUnifiedPushGatewayUrlResolver(), unifiedPushGatewayUrlResolver: UnifiedPushGatewayUrlResolver = FakeUnifiedPushGatewayUrlResolver(),
unifiedPushNewGatewayHandler: UnifiedPushNewGatewayHandler = FakeUnifiedPushNewGatewayHandler(), unifiedPushNewGatewayHandler: UnifiedPushNewGatewayHandler = FakeUnifiedPushNewGatewayHandler(),
endpointRegistrationHandler: EndpointRegistrationHandler = EndpointRegistrationHandler(), endpointRegistrationHandler: EndpointRegistrationHandler = EndpointRegistrationHandler(),
removedGatewayHandler: UnifiedPushRemovedGatewayHandler = UnifiedPushRemovedGatewayHandler { lambdaError() },
): VectorUnifiedPushMessagingReceiver { ): VectorUnifiedPushMessagingReceiver {
return VectorUnifiedPushMessagingReceiver().apply { return VectorUnifiedPushMessagingReceiver().apply {
this.pushParser = unifiedPushParser this.pushParser = unifiedPushParser
@ -208,6 +217,7 @@ class VectorUnifiedPushMessagingReceiverTest {
this.unifiedPushGatewayResolver = unifiedPushGatewayResolver this.unifiedPushGatewayResolver = unifiedPushGatewayResolver
this.unifiedPushGatewayUrlResolver = unifiedPushGatewayUrlResolver this.unifiedPushGatewayUrlResolver = unifiedPushGatewayUrlResolver
this.newGatewayHandler = unifiedPushNewGatewayHandler this.newGatewayHandler = unifiedPushNewGatewayHandler
this.removedGatewayHandler = removedGatewayHandler
this.endpointRegistrationHandler = endpointRegistrationHandler this.endpointRegistrationHandler = endpointRegistrationHandler
this.coroutineScope = this@createVectorUnifiedPushMessagingReceiver 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_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="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="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="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_bug_report_a11y_screenshot">"Snímek obrazovky"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</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_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="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="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="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_bug_report_a11y_screenshot">"Skærmbillede"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</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_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="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="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="preference_rageshake">"Heftiges Schütteln um Fehler zu melden"</string>
<string name="screen_bug_report_a11y_screenshot">"Bildschirmfoto"</string> <string name="screen_bug_report_a11y_screenshot">"Bildschirmfoto"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</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_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="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="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="preference_rageshake">"Veast teatamiseks raputa nutiseadet ägedalt"</string>
<string name="screen_bug_report_a11y_screenshot">"Ekraanitõmmis"</string> <string name="screen_bug_report_a11y_screenshot">"Ekraanitõmmis"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</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_rich_title">"🔐️ Rejoignez-moi sur %1$s"</string>
<string name="invite_friends_text">"Salut, parle-moi sur %1$s : %2$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="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="preference_rageshake">"Rageshake pour signaler un problème"</string>
<string name="screen_bug_report_a11y_screenshot">"Capture décran"</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> <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_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="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="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="preference_rageshake">"Zúrivo potriasť pre nahlásenie chyby"</string>
<string name="screen_bug_report_a11y_screenshot">"Snímka obrazovky"</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> <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_rich_title">"🔐️ 在 %1$s 上加入我"</string>
<string name="invite_friends_text">"嘿,來 %1$s 和我聊天:%2$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="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="preference_rageshake">"憤怒搖晃以回報臭蟲"</string>
<string name="screen_bug_report_a11y_screenshot">"螢幕截圖"</string> <string name="screen_bug_report_a11y_screenshot">"螢幕截圖"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s%2$s"</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_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="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="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="preference_rageshake">"Rageshake to report bug"</string>
<string name="screen_bug_report_a11y_screenshot">"Screenshot"</string> <string name="screen_bug_report_a11y_screenshot">"Screenshot"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string> <string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string>

View file

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