Merge pull request #4889 from element-hq/feature/bma/genericNotification

Show generic notification when Event cannot be resolved
This commit is contained in:
Benoit Marty
2025-06-19 08:56:37 +02:00
committed by GitHub
17 changed files with 197 additions and 12 deletions

View File

@@ -59,6 +59,7 @@ class DefaultClearCacheUseCase @Inject constructor(
seenInvitesStore.clear()
// Ensure any error will be displayed again
pushService.setIgnoreRegistrationError(matrixClient.sessionId, false)
pushService.resetBatteryOptimizationState()
// Ensure the app is restarted
defaultCacheService.onClearedCache(matrixClient.sessionId)
}

View File

@@ -46,8 +46,10 @@ class DefaultClearCacheUseCaseTest {
resetLambda = resetFtueLambda,
)
val setIgnoreRegistrationErrorLambda = lambdaRecorder<SessionId, Boolean, Unit> { _, _ -> }
val resetBatteryOptimizationStateResult = lambdaRecorder<Unit> { }
val pushService = FakePushService(
setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda
setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda,
resetBatteryOptimizationStateResult = resetBatteryOptimizationStateResult,
)
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID))
assertThat(seenInvitesStore.seenRoomIds().first()).isNotEmpty()
@@ -68,6 +70,7 @@ class DefaultClearCacheUseCaseTest {
resetFtueLambda.assertions().isCalledOnce()
setIgnoreRegistrationErrorLambda.assertions().isCalledOnce()
.with(value(matrixClient.sessionId), value(false))
resetBatteryOptimizationStateResult.assertions().isCalledOnce()
assertThat(awaitItem()).isEqualTo(matrixClient.sessionId)
assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty()
assertThat(activeRoomsHolder.getActiveRoom(A_SESSION_ID)).isNull()

View File

@@ -68,6 +68,7 @@ sealed interface NotificationContent {
) : MessageLike
data object RoomEncrypted : MessageLike
data object UnableToResolve : MessageLike
data class RoomMessage(
val senderId: UserId,
val messageType: MessageType

View File

@@ -12,6 +12,7 @@ import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.notification.NotificationContent
import io.element.android.libraries.matrix.api.notification.NotificationData
import io.element.android.libraries.matrix.api.notification.NotificationService
import io.element.android.services.toolbox.api.systemclock.SystemClock
@@ -24,7 +25,7 @@ class RustNotificationService(
private val sessionId: SessionId,
private val notificationClient: NotificationClient,
private val dispatchers: CoroutineDispatchers,
clock: SystemClock,
private val clock: SystemClock,
) : NotificationService {
private val notificationMapper: NotificationMapper = NotificationMapper(clock)
@@ -43,11 +44,32 @@ class RustNotificationService(
val eventIds = requests.flatMap { it.eventIds }
for (eventId in eventIds) {
val item = items[eventId]
val roomId = RoomId(requests.find { it.eventIds.contains(eventId) }?.roomId!!)
if (item != null) {
val roomId = RoomId(requests.find { it.eventIds.contains(eventId) }?.roomId!!)
put(EventId(eventId), notificationMapper.map(sessionId, EventId(eventId), roomId, item))
} else {
Timber.e("Could not retrieve event for notification with $eventId")
put(
EventId(eventId),
NotificationData(
sessionId = sessionId,
eventId = EventId(eventId),
threadId = null,
roomId = roomId,
senderAvatarUrl = null,
senderDisplayName = null,
senderIsNameAmbiguous = false,
roomAvatarUrl = null,
roomDisplayName = null,
isDirect = false,
isDm = false,
isEncrypted = false,
isNoisy = false,
timestamp = clock.epochMillis(),
content = NotificationContent.MessageLike.UnableToResolve,
hasMention = false
)
)
}
}
}

View File

@@ -14,8 +14,11 @@ import org.matrix.rustcomponents.sdk.NotificationItemsRequest
class FakeFfiNotificationClient(
var notificationItemResult: Map<String, NotificationItem> = emptyMap(),
val closeResult: () -> Unit = { }
) : NotificationClient(NoPointer) {
override suspend fun getNotifications(requests: List<NotificationItemsRequest>): Map<String, NotificationItem> {
return notificationItemResult
}
override fun close() = closeResult()
}

View File

@@ -19,6 +19,7 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.services.toolbox.api.systemclock.SystemClock
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
@@ -47,6 +48,33 @@ class RustNotificationServiceTest {
)
}
@Test
fun `test unable to resolve event`() = runTest {
val notificationClient = FakeFfiNotificationClient(
notificationItemResult = emptyMap(),
)
val sut = createRustNotificationService(
notificationClient = notificationClient,
)
val result = sut.getNotifications(mapOf(A_ROOM_ID to listOf(AN_EVENT_ID))).getOrThrow()[AN_EVENT_ID]!!
assertThat(result.content).isEqualTo(
NotificationContent.MessageLike.UnableToResolve
)
}
@Test
fun `close should invoke the close method of the service`() = runTest {
val closeResult = lambdaRecorder<Unit> { }
val notificationClient = FakeFfiNotificationClient(
closeResult = closeResult,
)
val sut = createRustNotificationService(
notificationClient = notificationClient,
)
sut.close()
closeResult.assertions().isCalledOnce()
}
private fun TestScope.createRustNotificationService(
notificationClient: NotificationClient = FakeFfiNotificationClient(),
clock: SystemClock = FakeSystemClock(),

View File

@@ -67,4 +67,9 @@ interface PushService {
* Reset the push history, including the push counter.
*/
suspend fun resetPushHistory()
/**
* Reset the battery optimization state.
*/
suspend fun resetBatteryOptimizationState()
}

View File

@@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.core.SessionId
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.pushproviders.api.Distributor
@@ -37,6 +38,7 @@ class DefaultPushService @Inject constructor(
private val sessionObserver: SessionObserver,
private val pushClientSecretStore: PushClientSecretStore,
private val pushDataStore: PushDataStore,
private val mutableBatteryOptimizationStore: MutableBatteryOptimizationStore,
) : PushService, SessionListener {
init {
observeSessions()
@@ -138,4 +140,8 @@ class DefaultPushService @Inject constructor(
override suspend fun resetPushHistory() {
pushDataStore.reset()
}
override suspend fun resetBatteryOptimizationState() {
mutableBatteryOptimizationStore.reset()
}
}

View File

@@ -225,6 +225,11 @@ class DefaultNotifiableEventResolver @Inject constructor(
val fallbackNotifiableEvent = fallbackNotifiableEvent(userId, roomId, eventId)
ResolvedPushEvent.Event(fallbackNotifiableEvent)
}
NotificationContent.MessageLike.UnableToResolve -> {
Timber.tag(loggerTag.value).w("Unable to resolve notification -> fallback")
val fallbackNotifiableEvent = fallbackNotifiableEvent(userId, roomId, eventId)
ResolvedPushEvent.Event(fallbackNotifiableEvent)
}
is NotificationContent.MessageLike.RoomRedaction -> {
// Note: this case will be handled below
val redactedEventId = content.redactedEventId

View File

@@ -25,6 +25,7 @@ import io.element.android.libraries.push.impl.history.onUnableToRetrieveSession
import io.element.android.libraries.push.impl.notifications.NotificationEventRequest
import io.element.android.libraries.push.impl.notifications.NotificationResolverQueue
import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
@@ -87,13 +88,24 @@ class DefaultPushHandler @Inject constructor(
} else {
result.fold(
onSuccess = {
pushHistoryService.onSuccess(
providerInfo = request.providerInfo,
eventId = request.eventId,
roomId = request.roomId,
sessionId = request.sessionId,
comment = "Push handled successfully",
)
if (it is ResolvedPushEvent.Event && it.notifiableEvent is FallbackNotifiableEvent) {
pushHistoryService.onUnableToResolveEvent(
providerInfo = request.providerInfo,
eventId = request.eventId,
roomId = request.roomId,
sessionId = request.sessionId,
reason = "Showing fallback notification",
)
mutableBatteryOptimizationStore.showBatteryOptimizationBanner()
} else {
pushHistoryService.onSuccess(
providerInfo = request.providerInfo,
eventId = request.eventId,
roomId = request.roomId,
sessionId = request.sessionId,
comment = "Push handled successfully",
)
}
},
onFailure = { exception ->
pushHistoryService.onUnableToResolveEvent(

View File

@@ -15,6 +15,7 @@ import javax.inject.Inject
interface MutableBatteryOptimizationStore {
suspend fun showBatteryOptimizationBanner()
suspend fun onOptimizationBannerDismissed()
suspend fun reset()
}
@ContributesBinding(AppScope::class)
@@ -28,4 +29,8 @@ class DefaultMutableBatteryOptimizationStore @Inject constructor(
override suspend fun onOptimizationBannerDismissed() {
defaultPushDataStore.setBatteryOptimizationBannerState(DefaultPushDataStore.BATTERY_OPTIMIZATION_BANNER_STATE_DISMISSED)
}
override suspend fun reset() {
defaultPushDataStore.setBatteryOptimizationBannerState(DefaultPushDataStore.BATTERY_OPTIMIZATION_BANNER_STATE_INIT)
}
}

View File

@@ -13,7 +13,7 @@
<item quantity="one">"%d notification"</item>
<item quantity="other">"%d notifications"</item>
</plurals>
<string name="notification_fallback_content">"Notification"</string>
<string name="notification_fallback_content">"You have new messages."</string>
<string name="notification_incoming_call">"📹 Incoming call"</string>
<string name="notification_inline_reply_failed">"** Failed to send - please open room"</string>
<string name="notification_invitation_action_join">"Join"</string>

View File

@@ -7,13 +7,19 @@
package io.element.android.libraries.push.impl
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.push.api.GetCurrentPushProvider
import io.element.android.libraries.push.api.history.PushHistoryItem
import io.element.android.libraries.push.impl.push.FakeMutableBatteryOptimizationStore
import io.element.android.libraries.push.impl.push.MutableBatteryOptimizationStore
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
@@ -283,6 +289,53 @@ class DefaultPushServiceTest {
assertThat(userPushStore.getPushProviderName()).isEqualTo(aPushProvider.name)
}
@Test
fun `resetBatteryOptimizationState invokes the store method`() = runTest {
val resetResult = lambdaRecorder<Unit> { }
val defaultPushService = createDefaultPushService(
mutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(
resetResult = resetResult,
),
)
defaultPushService.resetBatteryOptimizationState()
resetResult.assertions().isCalledOnce()
}
@Test
fun `resetPushHistory invokes the store method`() = runTest {
val resetResult = lambdaRecorder<Unit> { }
val defaultPushService = createDefaultPushService(
pushDataStore = InMemoryPushDataStore(
resetResult = resetResult
),
)
defaultPushService.resetPushHistory()
resetResult.assertions().isCalledOnce()
}
@Test
fun `getPushHistoryItemsFlow invokes the store method`() = runTest {
val store = InMemoryPushDataStore()
val aPushHistoryItem = PushHistoryItem(
pushDate = 0L,
formattedDate = "formattedDate",
providerInfo = "providerInfo",
eventId = AN_EVENT_ID,
roomId = A_ROOM_ID,
sessionId = A_SESSION_ID,
hasBeenResolved = false,
comment = null,
)
val defaultPushService = createDefaultPushService(
pushDataStore = store,
)
defaultPushService.getPushHistoryItemsFlow().test {
assertThat(awaitItem().isEmpty()).isTrue()
store.emitPushHistoryItems(listOf(aPushHistoryItem))
assertThat(awaitItem().first()).isEqualTo(aPushHistoryItem)
}
}
private fun createDefaultPushService(
testPush: TestPush = FakeTestPush(),
userPushStoreFactory: UserPushStoreFactory = FakeUserPushStoreFactory(),
@@ -291,6 +344,7 @@ class DefaultPushServiceTest {
sessionObserver: SessionObserver = NoOpSessionObserver(),
pushClientSecretStore: PushClientSecretStore = InMemoryPushClientSecretStore(),
pushDataStore: PushDataStore = InMemoryPushDataStore(),
mutableBatteryOptimizationStore: MutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(),
): DefaultPushService {
return DefaultPushService(
testPush = testPush,
@@ -300,6 +354,7 @@ class DefaultPushServiceTest {
sessionObserver = sessionObserver,
pushClientSecretStore = pushClientSecretStore,
pushDataStore = pushDataStore,
mutableBatteryOptimizationStore = mutableBatteryOptimizationStore,
)
}
}

View File

@@ -608,7 +608,32 @@ class DefaultNotifiableEventResolverTest {
roomId = A_ROOM_ID,
eventId = AN_EVENT_ID,
editedEventId = null,
description = "Notification",
description = "You have new messages.",
canBeReplaced = true,
isRedacted = false,
isUpdated = false,
timestamp = A_FAKE_TIMESTAMP,
)
)
assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult))
}
@Test
fun `resolve UnableToResolve`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(AN_EVENT_ID to aNotificationData(content = NotificationContent.MessageLike.UnableToResolve))
)
)
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
val expectedResult = ResolvedPushEvent.Event(
FallbackNotifiableEvent(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
eventId = AN_EVENT_ID,
editedEventId = null,
description = "You have new messages.",
canBeReplaced = true,
isRedacted = false,
isUpdated = false,

View File

@@ -12,6 +12,7 @@ import io.element.android.tests.testutils.lambda.lambdaError
class FakeMutableBatteryOptimizationStore(
private val showBatteryOptimizationBannerResult: () -> Unit = { lambdaError() },
private val onOptimizationBannerDismissedResult: () -> Unit = { lambdaError() },
private val resetResult: () -> Unit = { lambdaError() },
) : MutableBatteryOptimizationStore {
override suspend fun showBatteryOptimizationBanner() {
showBatteryOptimizationBannerResult()
@@ -20,4 +21,8 @@ class FakeMutableBatteryOptimizationStore(
override suspend fun onOptimizationBannerDismissed() {
onOptimizationBannerDismissedResult()
}
override suspend fun reset() {
resetResult()
}
}

View File

@@ -31,6 +31,10 @@ class InMemoryPushDataStore(
return mutablePushHistoryItemsFlow.asStateFlow()
}
suspend fun emitPushHistoryItems(items: List<PushHistoryItem>) {
mutablePushHistoryItemsFlow.emit(items)
}
override suspend fun reset() {
resetResult()
}

View File

@@ -28,6 +28,7 @@ class FakePushService(
private val selectPushProviderLambda: suspend (SessionId, PushProvider) -> Unit = { _, _ -> lambdaError() },
private val setIgnoreRegistrationErrorLambda: (SessionId, Boolean) -> Unit = { _, _ -> lambdaError() },
private val resetPushHistoryResult: () -> Unit = { lambdaError() },
private val resetBatteryOptimizationStateResult: () -> Unit = { lambdaError() },
) : PushService {
override suspend fun getCurrentPushProvider(): PushProvider? {
return registeredPushProvider ?: currentPushProvider()
@@ -92,4 +93,8 @@ class FakePushService(
override suspend fun resetPushHistory() = simulateLongTask {
resetPushHistoryResult()
}
override suspend fun resetBatteryOptimizationState() {
resetBatteryOptimizationStateResult()
}
}