UnifiedPush: emit error when registration fails.

Note that I did not manage to have the method `onRegistrationFailed` invoked. If the network is not available for instance, unregistering the previous pusher will fail first.
This commit is contained in:
Benoit Marty
2025-11-13 12:34:40 +01:00
parent 823a35231d
commit 2b4d80df01
16 changed files with 208 additions and 23 deletions

View File

@@ -10,6 +10,7 @@ package io.element.android.libraries.push.api
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.push.api.history.PushHistoryItem
import io.element.android.libraries.pushproviders.api.Distributor
import io.element.android.libraries.pushproviders.api.PushProvider
@@ -73,4 +74,9 @@ interface PushService {
* Reset the battery optimization state.
*/
suspend fun resetBatteryOptimizationState()
/**
* Notify the user that the service is un-registered.
*/
suspend fun onServiceUnregistered(userId: UserId)
}

View File

@@ -14,12 +14,14 @@ import dev.zacsweers.metro.SingleIn
import dev.zacsweers.metro.binding
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.push.api.GetCurrentPushProvider
import io.element.android.libraries.push.api.PushService
import io.element.android.libraries.push.api.history.PushHistoryItem
import io.element.android.libraries.push.impl.push.MutableBatteryOptimizationStore
import io.element.android.libraries.push.impl.store.PushDataStore
import io.element.android.libraries.push.impl.test.TestPush
import io.element.android.libraries.push.impl.unregistration.ServiceUnregisteredHandler
import io.element.android.libraries.pushproviders.api.Distributor
import io.element.android.libraries.pushproviders.api.PushProvider
import io.element.android.libraries.pushstore.api.UserPushStoreFactory
@@ -40,6 +42,7 @@ class DefaultPushService(
private val pushClientSecretStore: PushClientSecretStore,
private val pushDataStore: PushDataStore,
private val mutableBatteryOptimizationStore: MutableBatteryOptimizationStore,
private val serviceUnregisteredHandler: ServiceUnregisteredHandler,
) : PushService, SessionListener {
init {
observeSessions()
@@ -141,4 +144,8 @@ class DefaultPushService(
override suspend fun resetBatteryOptimizationState() {
mutableBatteryOptimizationStore.reset()
}
override suspend fun onServiceUnregistered(userId: UserId) {
serviceUnregisteredHandler.handle(userId)
}
}

View File

@@ -24,6 +24,7 @@ interface NotificationDisplayer {
fun cancelNotification(tag: String?, id: Int)
fun displayDiagnosticNotification(notification: Notification): Boolean
fun dismissDiagnosticNotification()
fun displayUnregistrationNotification(notification: Notification): Boolean
}
@ContributesBinding(AppScope::class)
@@ -60,6 +61,14 @@ class DefaultNotificationDisplayer(
)
}
override fun displayUnregistrationNotification(notification: Notification): Boolean {
return showNotification(
tag = TAG_DIAGNOSTIC,
id = NOTIFICATION_ID_UNREGISTRATION,
notification = notification,
)
}
companion object {
private const val TAG_DIAGNOSTIC = "DIAGNOSTIC"
@@ -67,5 +76,6 @@ class DefaultNotificationDisplayer(
* IDs for notifications
* ========================================================================================== */
private const val NOTIFICATION_ID_DIAGNOSTIC = 888
private const val NOTIFICATION_ID_UNREGISTRATION = 889
}
}

View File

@@ -91,6 +91,10 @@ interface NotificationCreator {
@ColorInt color: Int,
): Notification
fun createUnregistrationNotification(
notificationAccountParams: NotificationAccountParams,
): Notification
companion object {
/**
* Creates a tag for a message notification given its [roomId] and optional [threadId].
@@ -352,6 +356,28 @@ class DefaultNotificationCreator(
.build()
}
override fun createUnregistrationNotification(
notificationAccountParams: NotificationAccountParams,
): Notification {
val userId = notificationAccountParams.user.userId
val text = if (notificationAccountParams.showSessionId) {
// TODO i18n
"$userId will not receive notifications anymore."
} else {
// TODO i18n
"You will not receive notifications anymore."
}
return NotificationCompat.Builder(context, notificationChannels.getChannelIdForTest())
.setContentTitle(stringProvider.getString(CommonStrings.dialog_title_warning))
.setContentText(text)
.configureWith(notificationAccountParams)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setCategory(NotificationCompat.CATEGORY_ERROR)
.setAutoCancel(true)
.setContentIntent(pendingIntentFactory.createOpenSessionPendingIntent(userId))
.build()
}
private suspend fun MessagingStyle.addMessagesFromEvents(
events: List<NotifiableMessageEvent>,
imageLoader: ImageLoader,

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.displayDiagnosticNotification(notification)
}
}

View File

@@ -25,6 +25,8 @@ import io.element.android.libraries.push.impl.store.InMemoryPushDataStore
import io.element.android.libraries.push.impl.store.PushDataStore
import io.element.android.libraries.push.impl.test.FakeTestPush
import io.element.android.libraries.push.impl.test.TestPush
import io.element.android.libraries.push.impl.unregistration.FakeServiceUnregisteredHandler
import io.element.android.libraries.push.impl.unregistration.ServiceUnregisteredHandler
import io.element.android.libraries.push.test.FakeGetCurrentPushProvider
import io.element.android.libraries.pushproviders.api.Config
import io.element.android.libraries.pushproviders.api.Distributor
@@ -346,6 +348,7 @@ class DefaultPushServiceTest {
pushClientSecretStore: PushClientSecretStore = InMemoryPushClientSecretStore(),
pushDataStore: PushDataStore = InMemoryPushDataStore(),
mutableBatteryOptimizationStore: MutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(),
serviceUnregisteredHandler: ServiceUnregisteredHandler = FakeServiceUnregisteredHandler(),
): DefaultPushService {
return DefaultPushService(
testPush = testPush,
@@ -356,6 +359,7 @@ class DefaultPushServiceTest {
pushClientSecretStore = pushClientSecretStore,
pushDataStore = pushDataStore,
mutableBatteryOptimizationStore = mutableBatteryOptimizationStore,
serviceUnregisteredHandler = serviceUnregisteredHandler,
)
}
}

View File

@@ -41,6 +41,8 @@ class FakeNotificationCreator(
> = lambdaRecorder { _, _, _, _, _ -> A_NOTIFICATION },
var createDiagnosticNotificationResult: LambdaOneParamRecorder<Int, Notification> =
lambdaRecorder<Int, Notification> { _ -> A_NOTIFICATION },
val createUnregistrationNotificationResult: LambdaOneParamRecorder<NotificationAccountParams, Notification> =
lambdaRecorder { _ -> A_NOTIFICATION },
) : NotificationCreator {
override suspend fun createMessagesListNotification(
notificationAccountParams: NotificationAccountParams,
@@ -93,4 +95,8 @@ class FakeNotificationCreator(
): Notification {
return createDiagnosticNotificationResult(color)
}
override fun createUnregistrationNotification(notificationAccountParams: NotificationAccountParams): Notification {
return createUnregistrationNotificationResult(notificationAccountParams)
}
}

View File

@@ -24,6 +24,7 @@ class FakeNotificationDisplayer(
var cancelNotificationResult: LambdaTwoParamsRecorder<String?, Int, Unit> = lambdaRecorder { _, _ -> },
var displayDiagnosticNotificationResult: LambdaOneParamRecorder<Notification, Boolean> = lambdaRecorder { _ -> true },
var dismissDiagnosticNotificationResult: LambdaNoParamRecorder<Unit> = lambdaRecorder { -> },
var displayUnregistrationNotificationResult: LambdaOneParamRecorder<Notification, Boolean> = lambdaRecorder { _ -> true },
) : NotificationDisplayer {
override fun showNotification(tag: String?, id: Int, notification: Notification): Boolean {
return showNotificationResult(tag, id, notification)
@@ -41,6 +42,10 @@ class FakeNotificationDisplayer(
return dismissDiagnosticNotificationResult()
}
override fun displayUnregistrationNotification(notification: Notification): Boolean {
return displayUnregistrationNotificationResult(notification)
}
fun verifySummaryCancelled(times: Int = 1) {
cancelNotificationResult.assertions().isCalledExactly(times).withSequence(
listOf(value(null), value(NotificationIdProvider.getSummaryNotificationId(A_SESSION_ID)))

View File

@@ -0,0 +1,19 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.push.impl.unregistration
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.tests.testutils.lambda.lambdaError
class FakeServiceUnregisteredHandler(
private val handleResult: (UserId) -> Unit = { lambdaError() },
) : ServiceUnregisteredHandler {
override suspend fun handle(userId: UserId) {
handleResult(userId)
}
}

View File

@@ -10,6 +10,7 @@ package io.element.android.libraries.push.test
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.push.api.PushService
import io.element.android.libraries.push.api.history.PushHistoryItem
import io.element.android.libraries.pushproviders.api.Distributor
@@ -30,6 +31,7 @@ class FakePushService(
private val setIgnoreRegistrationErrorLambda: (SessionId, Boolean) -> Unit = { _, _ -> lambdaError() },
private val resetPushHistoryResult: () -> Unit = { lambdaError() },
private val resetBatteryOptimizationStateResult: () -> Unit = { lambdaError() },
private val onServiceUnregisteredResult: (UserId) -> Unit = { lambdaError() },
) : PushService {
override suspend fun getCurrentPushProvider(sessionId: SessionId): PushProvider? {
return registeredPushProvider ?: currentPushProvider(sessionId)
@@ -98,4 +100,8 @@ class FakePushService(
override suspend fun resetBatteryOptimizationState() {
resetBatteryOptimizationStateResult()
}
override suspend fun onServiceUnregistered(userId: UserId) {
onServiceUnregisteredResult(userId)
}
}

View File

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

View File

@@ -12,6 +12,7 @@ import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.core.log.logger.LoggerTag
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 timber.log.Timber
@@ -20,7 +21,7 @@ private val loggerTag = LoggerTag("UnifiedPushRemovedGatewayHandler", LoggerTag.
/**
* Handle new endpoint received from UnifiedPush. Will update the session matching the client secret.
*/
interface UnifiedPushRemovedGatewayHandler {
fun interface UnifiedPushRemovedGatewayHandler {
suspend fun handle(clientSecret: String): Result<Unit>
}
@@ -29,9 +30,10 @@ class DefaultUnifiedPushRemovedGatewayHandler(
private val unregisterUnifiedPushUseCase: UnregisterUnifiedPushUseCase,
private val pushClientSecret: PushClientSecret,
private val matrixClientProvider: MatrixClientProvider,
private val pushService: PushService,
) : UnifiedPushRemovedGatewayHandler {
override suspend fun handle(clientSecret: String): Result<Unit> {
// Unregister the pusher for the session with this client secret, if is it using UnifiedPush.
// Unregister the pusher for the session with this client secret.
val userId = pushClientSecret.getUserIdFromSecret(clientSecret) ?: return Result.failure<Unit>(
IllegalStateException("Unable to retrieve session")
).also {
@@ -39,15 +41,42 @@ class DefaultUnifiedPushRemovedGatewayHandler(
}
return matrixClientProvider
.getOrRestore(userId)
.onFailure {
Timber.tag(loggerTag.value).w(it, "Fails to restore client")
}
.flatMap { client ->
unregisterUnifiedPushUseCase.unregister(
matrixClient = client,
clientSecret = clientSecret,
unregisterUnifiedPush = false,
)
.onFailure {
Timber.tag(loggerTag.value).w(it, "Unable to unregister pusher")
}
.flatMap {
val pushProvider = pushService.getCurrentPushProvider(userId)
val distributor = pushProvider?.getCurrentDistributor(userId)
// Attempt to register again
if (pushProvider != null && distributor != null) {
pushService.registerWith(
client,
pushProvider,
distributor,
)
.onFailure {
Timber.tag(loggerTag.value).w(it, "Unable to register with current data")
}
} else {
Result.failure(IllegalStateException("Unable to register again"))
}
}
.onFailure {
// Let the user know
pushService.onServiceUnregistered(userId)
}
}
.onFailure {
Timber.tag(loggerTag.value).w(it, "Unable to unregister pusher")
Timber.tag(loggerTag.value).w(it, "Issue during pusher unregistration / re registration")
}
}
}

View File

@@ -106,12 +106,14 @@ class VectorUnifiedPushMessagingReceiver : MessagingReceiver() {
*/
override fun onRegistrationFailed(context: Context, reason: FailedReason, instance: String) {
Timber.tag(loggerTag.value).e("onRegistrationFailed for $instance, reason: $reason")
/*
Toast.makeText(context, "Push service registration failed", Toast.LENGTH_SHORT).show()
val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME
pushDataStore.setFdroidSyncBackgroundMode(mode)
guardServiceStarter.start()
*/
coroutineScope.launch {
endpointRegistrationHandler.registrationDone(
RegistrationResult(
clientSecret = instance,
result = Result.failure(Exception("Registration failed. Reason: $reason")),
)
)
}
}
/**

View File

@@ -12,14 +12,21 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.tests.testutils.lambda.lambdaError
class FakeUnregisterUnifiedPushUseCase(
private val unregisterLambda: (MatrixClient, String) -> Result<Unit> = { _, _ -> lambdaError() },
private val cleanupLambda: (String) -> Unit = { lambdaError() },
private val unregisterLambda: (MatrixClient, String, Boolean) -> Result<Unit> = { _, _, _ -> lambdaError() },
private val cleanupLambda: (String, Boolean) -> Unit = { _, _ -> lambdaError() },
) : UnregisterUnifiedPushUseCase {
override suspend fun unregister(matrixClient: MatrixClient, clientSecret: String): Result<Unit> {
return unregisterLambda(matrixClient, clientSecret)
override suspend fun unregister(
matrixClient: MatrixClient,
clientSecret: String,
unregisterUnifiedPush: Boolean,
): Result<Unit> {
return unregisterLambda(matrixClient, clientSecret, unregisterUnifiedPush)
}
override fun cleanup(clientSecret: String) {
cleanupLambda(clientSecret)
override fun cleanup(
clientSecret: String,
unregisterUnifiedPush: Boolean,
) {
cleanupLambda(clientSecret, unregisterUnifiedPush)
}
}

View File

@@ -118,7 +118,7 @@ class UnifiedPushProviderTest {
fun `unregister ok`() = runTest {
val matrixClient = FakeMatrixClient()
val getSecretForUserResultLambda = lambdaRecorder<SessionId, String> { A_SECRET }
val unregisterLambda = lambdaRecorder<MatrixClient, String, Result<Unit>> { _, _ -> Result.success(Unit) }
val unregisterLambda = lambdaRecorder<MatrixClient, String, Boolean, Result<Unit>> { _, _, _ -> Result.success(Unit) }
val unifiedPushProvider = createUnifiedPushProvider(
pushClientSecret = FakePushClientSecret(
getSecretForUserResult = getSecretForUserResultLambda,
@@ -134,14 +134,14 @@ class UnifiedPushProviderTest {
.with(value(A_SESSION_ID))
unregisterLambda.assertions()
.isCalledOnce()
.with(value(matrixClient), value(A_SECRET))
.with(value(matrixClient), value(A_SECRET), value(true))
}
@Test
fun `unregister ko`() = runTest {
val matrixClient = FakeMatrixClient()
val getSecretForUserResultLambda = lambdaRecorder<SessionId, String> { A_SECRET }
val unregisterLambda = lambdaRecorder<MatrixClient, String, Result<Unit>> { _, _ -> Result.failure(AN_EXCEPTION) }
val unregisterLambda = lambdaRecorder<MatrixClient, String, Boolean, Result<Unit>> { _, _, _ -> Result.failure(AN_EXCEPTION) }
val unifiedPushProvider = createUnifiedPushProvider(
pushClientSecret = FakePushClientSecret(
getSecretForUserResult = getSecretForUserResultLambda,
@@ -157,7 +157,7 @@ class UnifiedPushProviderTest {
.with(value(A_SESSION_ID))
unregisterLambda.assertions()
.isCalledOnce()
.with(value(matrixClient), value(A_SECRET))
.with(value(matrixClient), value(A_SECRET), value(true))
}
@Test
@@ -230,7 +230,7 @@ class UnifiedPushProviderTest {
@Test
fun `onSessionDeleted should do the cleanup`() = runTest {
val cleanupLambda = lambdaRecorder<String, Unit> { }
val cleanupLambda = lambdaRecorder<String, Boolean, Unit> { _, _ -> }
val unifiedPushProvider = createUnifiedPushProvider(
pushClientSecret = FakePushClientSecret(
getSecretForUserResult = { A_SECRET }
@@ -240,7 +240,7 @@ class UnifiedPushProviderTest {
),
)
unifiedPushProvider.onSessionDeleted(A_SESSION_ID)
cleanupLambda.assertions().isCalledOnce().with(value(A_SECRET))
cleanupLambda.assertions().isCalledOnce().with(value(A_SECRET), value(true))
}
private fun createUnifiedPushProvider(

View File

@@ -23,6 +23,7 @@ import io.element.android.libraries.pushproviders.api.PushData
import io.element.android.libraries.pushproviders.api.PushHandler
import io.element.android.libraries.pushproviders.unifiedpush.registration.EndpointRegistrationHandler
import io.element.android.libraries.pushproviders.unifiedpush.registration.RegistrationResult
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -51,10 +52,17 @@ class VectorUnifiedPushMessagingReceiverTest {
}
@Test
fun `onUnregistered does nothing`() = runTest {
fun `onUnregistered invokes the removedGatewayHandler`() = runTest {
val context = InstrumentationRegistry.getInstrumentation().context
val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver()
val handleResult = lambdaRecorder<String, Result<Unit>> {
Result.success(Unit)
}
val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver(
removedGatewayHandler = UnifiedPushRemovedGatewayHandler { handleResult(it) },
)
vectorUnifiedPushMessagingReceiver.onUnregistered(context, A_SECRET)
advanceUntilIdle()
handleResult.assertions().isCalledOnce().with(value(A_SECRET))
}
@Test
@@ -199,6 +207,7 @@ class VectorUnifiedPushMessagingReceiverTest {
unifiedPushGatewayUrlResolver: UnifiedPushGatewayUrlResolver = FakeUnifiedPushGatewayUrlResolver(),
unifiedPushNewGatewayHandler: UnifiedPushNewGatewayHandler = FakeUnifiedPushNewGatewayHandler(),
endpointRegistrationHandler: EndpointRegistrationHandler = EndpointRegistrationHandler(),
removedGatewayHandler: UnifiedPushRemovedGatewayHandler = UnifiedPushRemovedGatewayHandler { lambdaError() },
): VectorUnifiedPushMessagingReceiver {
return VectorUnifiedPushMessagingReceiver().apply {
this.pushParser = unifiedPushParser
@@ -208,6 +217,7 @@ class VectorUnifiedPushMessagingReceiverTest {
this.unifiedPushGatewayResolver = unifiedPushGatewayResolver
this.unifiedPushGatewayUrlResolver = unifiedPushGatewayUrlResolver
this.newGatewayHandler = unifiedPushNewGatewayHandler
this.removedGatewayHandler = removedGatewayHandler
this.endpointRegistrationHandler = endpointRegistrationHandler
this.coroutineScope = this@createVectorUnifiedPushMessagingReceiver
}