Introduce PushHistoryService to store data about the received push (#4573)

* Introduce PushHistoryService to store data about the received push

Add a push database.

* Update screenshots

* Improve preview.

* Update screenshots

* Add missing test.

* Add test for PushHistoryView

* Fix configuration issue.

Was: w: /libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenterTest.kt:35:27 Cannot access class 'PushProvider' in the expression type. While it may work, this case indicates a configuration mistake and can lead to avoidable compilation errors, so it may be forbidden soon. Check your module classpath for missing or conflicting dependencies.

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Benoit Marty 2025-04-11 12:56:54 +02:00 committed by GitHub
parent 2b1a66ff37
commit c7f0566dc1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
60 changed files with 1656 additions and 214 deletions

View file

@ -14,6 +14,8 @@ import io.element.android.libraries.matrix.api.MatrixClient
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.store.PushDataStore
import io.element.android.libraries.push.impl.test.TestPush
import io.element.android.libraries.pushproviders.api.Distributor
import io.element.android.libraries.pushproviders.api.PushProvider
@ -34,6 +36,7 @@ class DefaultPushService @Inject constructor(
private val getCurrentPushProvider: GetCurrentPushProvider,
private val sessionObserver: SessionObserver,
private val pushClientSecretStore: PushClientSecretStore,
private val pushDataStore: PushDataStore,
) : PushService, SessionListener {
init {
observeSessions()
@ -125,4 +128,14 @@ class DefaultPushService @Inject constructor(
pushClientSecretStore.resetSecret(sessionId)
userPushStore.reset()
}
override val pushCounter: Flow<Int> = pushDataStore.pushCounterFlow
override fun getPushHistoryItemsFlow(): Flow<List<PushHistoryItem>> {
return pushDataStore.getPushHistoryItemsFlow()
}
override suspend fun resetPushHistory() {
pushDataStore.reset()
}
}

View file

@ -0,0 +1,48 @@
/*
* Copyright 2025 New Vector 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.history
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
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.push.impl.PushDatabase
import io.element.android.libraries.push.impl.db.PushHistory
import io.element.android.services.toolbox.api.systemclock.SystemClock
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultPushHistoryService @Inject constructor(
private val pushDatabase: PushDatabase,
private val systemClock: SystemClock,
) : PushHistoryService {
override fun onPushReceived(
providerInfo: String,
eventId: EventId?,
roomId: RoomId?,
sessionId: SessionId?,
hasBeenResolved: Boolean,
comment: String?,
) {
pushDatabase.pushHistoryQueries.insertPushHistory(
PushHistory(
pushDate = systemClock.epochMillis(),
providerInfo = providerInfo,
eventId = eventId?.value,
roomId = roomId?.value,
sessionId = sessionId?.value,
hasBeenResolved = if (hasBeenResolved) 1 else 0,
comment = comment,
)
)
// Keep only the last 100 events
pushDatabase.pushHistoryQueries.removeOldest(100)
}
}

View file

@ -0,0 +1,98 @@
/*
* Copyright 2025 New Vector 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.history
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
interface PushHistoryService {
/**
* Create a new push history entry.
* Do not use directly, prefer using the extension functions.
*/
fun onPushReceived(
providerInfo: String,
eventId: EventId?,
roomId: RoomId?,
sessionId: SessionId?,
hasBeenResolved: Boolean,
comment: String?,
)
}
fun PushHistoryService.onInvalidPushReceived(
providerInfo: String,
) = onPushReceived(
providerInfo = providerInfo,
eventId = null,
roomId = null,
sessionId = null,
hasBeenResolved = false,
comment = "Invalid push data",
)
fun PushHistoryService.onUnableToRetrieveSession(
providerInfo: String,
eventId: EventId,
roomId: RoomId,
reason: String,
) = onPushReceived(
providerInfo = providerInfo,
eventId = eventId,
roomId = roomId,
sessionId = null,
hasBeenResolved = false,
comment = "Unable to retrieve session: $reason",
)
fun PushHistoryService.onUnableToResolveEvent(
providerInfo: String,
eventId: EventId,
roomId: RoomId,
sessionId: SessionId,
reason: String,
) = onPushReceived(
providerInfo = providerInfo,
eventId = eventId,
roomId = roomId,
sessionId = sessionId,
hasBeenResolved = false,
comment = "Unable to resolve event: $reason",
)
fun PushHistoryService.onSuccess(
providerInfo: String,
eventId: EventId,
roomId: RoomId,
sessionId: SessionId,
comment: String?,
) = onPushReceived(
providerInfo = providerInfo,
eventId = eventId,
roomId = roomId,
sessionId = sessionId,
hasBeenResolved = true,
comment = buildString {
append("Success")
if (comment.isNullOrBlank().not()) {
append(" - $comment")
}
},
)
fun PushHistoryService.onDiagnosticPush(
providerInfo: String,
) = onPushReceived(
providerInfo = providerInfo,
eventId = null,
roomId = null,
sessionId = null,
hasBeenResolved = true,
comment = "Diagnostic push",
)

View file

@ -0,0 +1,43 @@
/*
* Copyright 2025 New Vector 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.history.di
import android.content.Context
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.push.impl.PushDatabase
import io.element.encrypteddb.SqlCipherDriverFactory
import io.element.encrypteddb.passphrase.RandomSecretPassphraseProvider
@Module
@ContributesTo(AppScope::class)
object PushHistoryModule {
@Provides
@SingleIn(AppScope::class)
fun providePushDatabase(
@ApplicationContext context: Context,
): PushDatabase {
val name = "push_database"
val secretFile = context.getDatabasePath("$name.key")
// Make sure the parent directory of the key file exists, otherwise it will crash in older Android versions
val parentDir = secretFile.parentFile
if (parentDir != null && !parentDir.exists()) {
parentDir.mkdirs()
}
val passphraseProvider = RandomSecretPassphraseProvider(context, secretFile)
val driver = SqlCipherDriverFactory(passphraseProvider)
.create(PushDatabase.Schema, "$name.db", context)
return PushDatabase(driver)
}
}

View file

@ -30,16 +30,26 @@ interface CallNotificationEventResolver {
* @param forceNotify `true` to force the notification to be non-ringing, `false` to use the default behaviour. Default is `false`.
* @return a [NotifiableEvent] if the notification data is a call notification, null otherwise
*/
fun resolveEvent(sessionId: SessionId, notificationData: NotificationData, forceNotify: Boolean = false): NotifiableEvent?
fun resolveEvent(
sessionId: SessionId,
notificationData: NotificationData,
forceNotify: Boolean = false,
): Result<NotifiableEvent>
}
@ContributesBinding(AppScope::class)
class DefaultCallNotificationEventResolver @Inject constructor(
private val stringProvider: StringProvider,
) : CallNotificationEventResolver {
override fun resolveEvent(sessionId: SessionId, notificationData: NotificationData, forceNotify: Boolean): NotifiableEvent? {
val content = notificationData.content as? NotificationContent.MessageLike.CallNotify ?: return null
return notificationData.run {
override fun resolveEvent(
sessionId: SessionId,
notificationData: NotificationData,
forceNotify: Boolean
): Result<NotifiableEvent> = runCatching {
val content = notificationData.content as? NotificationContent.MessageLike.CallNotify
?: throw ResolvingException("content is not a call notify")
notificationData.run {
if (NotifiableRingingCallEvent.shouldRing(content.type, timestamp) && !forceNotify) {
NotifiableRingingCallEvent(
sessionId = sessionId,

View file

@ -11,6 +11,7 @@ import android.content.Context
import android.net.Uri
import androidx.core.content.FileProvider
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
@ -59,7 +60,7 @@ private val loggerTag = LoggerTag("DefaultNotifiableEventResolver", LoggerTag.No
* this pattern allow decoupling between the object responsible of displaying notifications and the matrix sdk.
*/
interface NotifiableEventResolver {
suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): ResolvedPushEvent?
suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): Result<ResolvedPushEvent>
}
@ContributesBinding(AppScope::class)
@ -73,31 +74,39 @@ class DefaultNotifiableEventResolver @Inject constructor(
private val callNotificationEventResolver: CallNotificationEventResolver,
private val appPreferencesStore: AppPreferencesStore,
) : NotifiableEventResolver {
override suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): ResolvedPushEvent? {
override suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): Result<ResolvedPushEvent> {
// Restore session
val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return null
val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return Result.failure(
ResolvingException("Unable to restore session for $sessionId")
)
val notificationService = client.notificationService()
val notificationData = notificationService.getNotification(
roomId = roomId,
eventId = eventId,
).onFailure {
Timber.tag(loggerTag.value).e(it, "Unable to resolve event: $eventId.")
}.getOrNull()
}
// TODO this notificationData is not always valid at the moment, sometimes the Rust SDK can't fetch the matching event
return notificationData?.asNotifiableEvent(client, sessionId)
return notificationData.flatMap {
if (it == null) {
Timber.tag(loggerTag.value).d("No notification data found for event $eventId")
return@flatMap Result.failure(ResolvingException("Unable to resolve event"))
} else {
it.asNotifiableEvent(client, sessionId)
}
}
}
private suspend fun NotificationData.asNotifiableEvent(
client: MatrixClient,
userId: SessionId,
): ResolvedPushEvent? {
val content = this.content
val notifiableEvent = when (content) {
): Result<ResolvedPushEvent> = runCatching {
when (val content = this.content) {
is NotificationContent.MessageLike.RoomMessage -> {
val senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId)
val messageBody = descriptionFromMessageContent(content, senderDisambiguatedDisplayName)
buildNotifiableMessageEvent(
val notifiableMessageEvent = buildNotifiableMessageEvent(
sessionId = userId,
senderId = content.senderId,
roomId = roomId,
@ -115,10 +124,11 @@ class DefaultNotifiableEventResolver @Inject constructor(
senderAvatarPath = senderAvatarUrl,
hasMentionOrReply = hasMention,
)
ResolvedPushEvent.Event(notifiableMessageEvent)
}
is NotificationContent.Invite -> {
val senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId)
InviteNotifiableEvent(
val inviteNotifiableEvent = InviteNotifiableEvent(
sessionId = userId,
roomId = roomId,
eventId = eventId,
@ -136,15 +146,16 @@ class DefaultNotifiableEventResolver @Inject constructor(
// TODO check if title is needed anymore
title = null,
)
ResolvedPushEvent.Event(inviteNotifiableEvent)
}
NotificationContent.MessageLike.CallAnswer,
NotificationContent.MessageLike.CallCandidates,
NotificationContent.MessageLike.CallHangup -> {
Timber.tag(loggerTag.value).d("Ignoring notification for call ${content.javaClass.simpleName}")
null
throw ResolvingException("Ignoring notification for call ${content.javaClass.simpleName}")
}
is NotificationContent.MessageLike.CallInvite -> {
buildNotifiableMessageEvent(
val notifiableMessageEvent = buildNotifiableMessageEvent(
sessionId = userId,
senderId = content.senderId,
roomId = roomId,
@ -158,9 +169,11 @@ class DefaultNotifiableEventResolver @Inject constructor(
roomAvatarPath = roomAvatarUrl,
senderAvatarPath = senderAvatarUrl,
)
ResolvedPushEvent.Event(notifiableMessageEvent)
}
is NotificationContent.MessageLike.CallNotify -> {
callNotificationEventResolver.resolveEvent(userId, this)
val notifiableEvent = callNotificationEventResolver.resolveEvent(userId, this).getOrThrow()
ResolvedPushEvent.Event(notifiableEvent)
}
NotificationContent.MessageLike.KeyVerificationAccept,
NotificationContent.MessageLike.KeyVerificationCancel,
@ -168,11 +181,12 @@ class DefaultNotifiableEventResolver @Inject constructor(
NotificationContent.MessageLike.KeyVerificationKey,
NotificationContent.MessageLike.KeyVerificationMac,
NotificationContent.MessageLike.KeyVerificationReady,
NotificationContent.MessageLike.KeyVerificationStart -> null.also {
NotificationContent.MessageLike.KeyVerificationStart -> {
Timber.tag(loggerTag.value).d("Ignoring notification for verification ${content.javaClass.simpleName}")
throw ResolvingException("Ignoring notification for verification ${content.javaClass.simpleName}")
}
is NotificationContent.MessageLike.Poll -> {
buildNotifiableMessageEvent(
val notifiableEventMessage = buildNotifiableMessageEvent(
sessionId = userId,
senderId = content.senderId,
roomId = roomId,
@ -187,19 +201,35 @@ class DefaultNotifiableEventResolver @Inject constructor(
roomAvatarPath = roomAvatarUrl,
senderAvatarPath = senderAvatarUrl,
)
ResolvedPushEvent.Event(notifiableEventMessage)
}
is NotificationContent.MessageLike.ReactionContent -> null.also {
is NotificationContent.MessageLike.ReactionContent -> {
Timber.tag(loggerTag.value).d("Ignoring notification for reaction")
throw ResolvingException("Ignoring notification for reaction")
}
NotificationContent.MessageLike.RoomEncrypted -> fallbackNotifiableEvent(userId, roomId, eventId).also {
NotificationContent.MessageLike.RoomEncrypted -> {
Timber.tag(loggerTag.value).w("Notification with encrypted content -> fallback")
val fallbackNotifiableEvent = fallbackNotifiableEvent(userId, roomId, eventId)
ResolvedPushEvent.Event(fallbackNotifiableEvent)
}
is NotificationContent.MessageLike.RoomRedaction -> {
// Note: this case will be handled below
null
val redactedEventId = content.redactedEventId
if (redactedEventId == null) {
Timber.tag(loggerTag.value).d("redactedEventId is null.")
throw ResolvingException("redactedEventId is null")
} else {
ResolvedPushEvent.Redaction(
sessionId = userId,
roomId = roomId,
redactedEventId = redactedEventId,
reason = content.reason,
)
}
}
NotificationContent.MessageLike.Sticker -> null.also {
NotificationContent.MessageLike.Sticker -> {
Timber.tag(loggerTag.value).d("Ignoring notification for sticker")
throw ResolvingException("Ignoring notification for reaction")
}
is NotificationContent.StateEvent.RoomMemberContent,
NotificationContent.StateEvent.PolicyRuleRoom,
@ -221,29 +251,11 @@ class DefaultNotifiableEventResolver @Inject constructor(
NotificationContent.StateEvent.RoomTombstone,
NotificationContent.StateEvent.RoomTopic,
NotificationContent.StateEvent.SpaceChild,
NotificationContent.StateEvent.SpaceParent -> null.also {
NotificationContent.StateEvent.SpaceParent -> {
Timber.tag(loggerTag.value).d("Ignoring notification for state event ${content.javaClass.simpleName}")
throw ResolvingException("Ignoring notification for state event ${content.javaClass.simpleName}")
}
}
return if (notifiableEvent != null) {
ResolvedPushEvent.Event(notifiableEvent)
} else if (content is NotificationContent.MessageLike.RoomRedaction) {
val redactedEventId = content.redactedEventId
if (redactedEventId == null) {
Timber.tag(loggerTag.value).d("redactedEventId is null.")
null
} else {
ResolvedPushEvent.Redaction(
sessionId = userId,
roomId = roomId,
redactedEventId = redactedEventId,
reason = content.reason,
)
}
} else {
null
}
}
private fun fallbackNotifiableEvent(

View file

@ -39,7 +39,7 @@ class DefaultOnMissedCallNotificationHandler @Inject constructor(
notificationData = notificationData,
// Make sure the notifiable event is not a ringing one
forceNotify = true,
)
).getOrNull()
notifiableEvent?.let { defaultNotificationDrawerManager.onNotifiableEventReceived(it) }
}
}

View file

@ -0,0 +1,10 @@
/*
* Copyright 2025 New Vector 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.notifications
class ResolvingException(message: String) : Exception(message)

View file

@ -14,6 +14,12 @@ import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.push.impl.history.PushHistoryService
import io.element.android.libraries.push.impl.history.onDiagnosticPush
import io.element.android.libraries.push.impl.history.onInvalidPushReceived
import io.element.android.libraries.push.impl.history.onSuccess
import io.element.android.libraries.push.impl.history.onUnableToResolveEvent
import io.element.android.libraries.push.impl.history.onUnableToRetrieveSession
import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver
import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels
import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
@ -43,13 +49,15 @@ class DefaultPushHandler @Inject constructor(
private val diagnosticPushHandler: DiagnosticPushHandler,
private val elementCallEntryPoint: ElementCallEntryPoint,
private val notificationChannels: NotificationChannels,
private val pushHistoryService: PushHistoryService,
) : PushHandler {
/**
* Called when message is received.
*
* @param pushData the data received in the push.
* @param providerInfo the provider info.
*/
override suspend fun handle(pushData: PushData) {
override suspend fun handle(pushData: PushData, providerInfo: String) {
Timber.tag(loggerTag.value).d("## handling pushData: ${pushData.roomId}/${pushData.eventId}")
if (buildMeta.lowPrivacyLoggingEnabled) {
Timber.tag(loggerTag.value).d("## pushData: $pushData")
@ -57,18 +65,25 @@ class DefaultPushHandler @Inject constructor(
incrementPushDataStore.incrementPushCounter()
// Diagnostic Push
if (pushData.eventId == DefaultTestPush.TEST_EVENT_ID) {
pushHistoryService.onDiagnosticPush(providerInfo)
diagnosticPushHandler.handlePush()
} else {
handleInternal(pushData)
handleInternal(pushData, providerInfo)
}
}
override suspend fun handleInvalid(providerInfo: String) {
incrementPushDataStore.incrementPushCounter()
pushHistoryService.onInvalidPushReceived(providerInfo)
}
/**
* Internal receive method.
*
* @param pushData Object containing message data.
* @param providerInfo the provider info.
*/
private suspend fun handleInternal(pushData: PushData) {
private suspend fun handleInternal(pushData: PushData, providerInfo: String) {
try {
if (buildMeta.lowPrivacyLoggingEnabled) {
Timber.tag(loggerTag.value).d("## handleInternal() : $pushData")
@ -77,42 +92,77 @@ class DefaultPushHandler @Inject constructor(
}
val clientSecret = pushData.clientSecret
// clientSecret should not be null. If this happens, restore default session
val userId = clientSecret
?.let {
// Get userId from client secret
pushClientSecret.getUserIdFromSecret(clientSecret)
var reason = if (clientSecret == null) "No client secret" else ""
val userId = clientSecret?.let {
// Get userId from client secret
pushClientSecret.getUserIdFromSecret(clientSecret).also {
if (it == null) {
reason = "Unable to get userId from client secret"
}
}
?: run {
matrixAuthenticationService.getLatestSessionId()
}
if (userId == null) {
Timber.w("Unable to get a session")
return
}
val resolvedPushEvent = notifiableEventResolver.resolveEvent(userId, pushData.roomId, pushData.eventId)
when (resolvedPushEvent) {
null -> Timber.tag(loggerTag.value).w("Unable to get a notification data")
is ResolvedPushEvent.Event -> {
when (val notifiableEvent = resolvedPushEvent.notifiableEvent) {
is NotifiableRingingCallEvent -> {
onNotifiableEventReceived.onNotifiableEventReceived(notifiableEvent)
handleRingingCallEvent(notifiableEvent)
}
else -> {
val userPushStore = userPushStoreFactory.getOrCreate(userId)
val areNotificationsEnabled = userPushStore.getNotificationEnabledForDevice().first()
if (areNotificationsEnabled) {
onNotifiableEventReceived.onNotifiableEventReceived(notifiableEvent)
} else {
Timber.tag(loggerTag.value).i("Notification are disabled for this device, ignore push.")
}
?: run {
matrixAuthenticationService.getLatestSessionId().also {
if (it == null) {
if (reason.isNotEmpty()) reason += " - "
reason += "Unable to get latest sessionId"
}
}
}
is ResolvedPushEvent.Redaction -> {
onRedactedEventReceived.onRedactedEventReceived(resolvedPushEvent)
}
if (userId == null) {
Timber.w("Unable to get a session")
pushHistoryService.onUnableToRetrieveSession(
providerInfo = providerInfo,
eventId = pushData.eventId,
roomId = pushData.roomId,
reason = reason,
)
return
}
notifiableEventResolver.resolveEvent(userId, pushData.roomId, pushData.eventId).fold(
onSuccess = { resolvedPushEvent ->
pushHistoryService.onSuccess(
providerInfo = providerInfo,
eventId = pushData.eventId,
roomId = pushData.roomId,
sessionId = userId,
comment = resolvedPushEvent.javaClass.simpleName,
)
when (resolvedPushEvent) {
is ResolvedPushEvent.Event -> {
when (val notifiableEvent = resolvedPushEvent.notifiableEvent) {
is NotifiableRingingCallEvent -> {
onNotifiableEventReceived.onNotifiableEventReceived(notifiableEvent)
handleRingingCallEvent(notifiableEvent)
}
else -> {
val userPushStore = userPushStoreFactory.getOrCreate(userId)
val areNotificationsEnabled = userPushStore.getNotificationEnabledForDevice().first()
if (areNotificationsEnabled) {
onNotifiableEventReceived.onNotifiableEventReceived(notifiableEvent)
} else {
Timber.tag(loggerTag.value).i("Notification are disabled for this device, ignore push.")
}
}
}
}
is ResolvedPushEvent.Redaction -> {
onRedactedEventReceived.onRedactedEventReceived(resolvedPushEvent)
}
}
},
onFailure = { failure ->
Timber.tag(loggerTag.value).w(failure, "Unable to get a notification data")
pushHistoryService.onUnableToResolveEvent(
providerInfo = providerInfo,
eventId = pushData.eventId,
roomId = pushData.roomId,
sessionId = userId,
reason = failure.message ?: failure.javaClass.simpleName,
)
}
)
} catch (e: Exception) {
Timber.tag(loggerTag.value).e(e, "## handleInternal() failed")
}

View file

@ -13,11 +13,20 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.coroutines.mapToList
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.push.api.store.PushDataStore
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.push.api.history.PushHistoryItem
import io.element.android.libraries.push.impl.PushDatabase
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
@ -28,6 +37,9 @@ private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(na
@ContributesBinding(AppScope::class)
class DefaultPushDataStore @Inject constructor(
@ApplicationContext private val context: Context,
private val pushDatabase: PushDatabase,
private val dateFormatter: DateFormatter,
private val dispatchers: CoroutineDispatchers,
) : PushDataStore {
private val pushCounter = intPreferencesKey("push_counter")
@ -41,4 +53,35 @@ class DefaultPushDataStore @Inject constructor(
settings[pushCounter] = currentCounterValue + 1
}
}
override fun getPushHistoryItemsFlow(): Flow<List<PushHistoryItem>> {
return pushDatabase.pushHistoryQueries.selectAll()
.asFlow()
.mapToList(dispatchers.io)
.map { items ->
items.map { pushHistory ->
PushHistoryItem(
pushDate = pushHistory.pushDate,
formattedDate = dateFormatter.format(
timestamp = pushHistory.pushDate,
mode = DateFormatterMode.Full,
useRelative = false,
),
providerInfo = pushHistory.providerInfo,
eventId = pushHistory.eventId?.let { EventId(it) },
roomId = pushHistory.roomId?.let { RoomId(it) },
sessionId = pushHistory.sessionId?.let { SessionId(it) },
hasBeenResolved = pushHistory.hasBeenResolved == 1L,
comment = pushHistory.comment,
)
}
}
}
override suspend fun reset() {
pushDatabase.pushHistoryQueries.removeAll()
context.dataStore.edit {
it.clear()
}
}
}

View file

@ -0,0 +1,25 @@
/*
* Copyright 2025 New Vector 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.store
import io.element.android.libraries.push.api.history.PushHistoryItem
import kotlinx.coroutines.flow.Flow
interface PushDataStore {
val pushCounterFlow: Flow<Int>
/**
* Get a flow of list of [PushHistoryItem].
*/
fun getPushHistoryItemsFlow(): Flow<List<PushHistoryItem>>
/**
* Reset the push counter to 0, and clear the database.
*/
suspend fun reset()
}

View file

@ -0,0 +1,22 @@
CREATE TABLE PushHistory (
pushDate INTEGER NOT NULL,
providerInfo TEXT NOT NULL,
eventId TEXT,
roomId TEXT,
sessionId TEXT,
hasBeenResolved INTEGER NOT NULL,
comment TEXT
);
selectAll:
SELECT * FROM PushHistory ORDER BY pushDate DESC;
insertPushHistory:
INSERT INTO PushHistory VALUES ?;
removeAll:
DELETE FROM PushHistory;
-- add query to keep only the last x entries
removeOldest:
DELETE FROM PushHistory WHERE rowid NOT IN (SELECT rowid FROM PushHistory ORDER BY pushDate DESC LIMIT ?);

View file

@ -14,6 +14,8 @@ import io.element.android.libraries.matrix.test.AN_EXCEPTION
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.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.test.FakeGetCurrentPushProvider
@ -288,6 +290,7 @@ class DefaultPushServiceTest {
getCurrentPushProvider: GetCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider = null),
sessionObserver: SessionObserver = NoOpSessionObserver(),
pushClientSecretStore: PushClientSecretStore = InMemoryPushClientSecretStore(),
pushDataStore: PushDataStore = InMemoryPushDataStore(),
): DefaultPushService {
return DefaultPushService(
testPush = testPush,
@ -296,6 +299,7 @@ class DefaultPushServiceTest {
getCurrentPushProvider = getCurrentPushProvider,
sessionObserver = sessionObserver,
pushClientSecretStore = pushClientSecretStore,
pushDataStore = pushDataStore,
)
}
}

View file

@ -0,0 +1,42 @@
/*
* Copyright 2025 New Vector 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.history
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.tests.testutils.lambda.lambdaError
class FakePushHistoryService(
private val onPushReceivedResult: (
String,
EventId?,
RoomId?,
SessionId?,
Boolean,
String?
) -> Unit = { _, _, _, _, _, _ -> lambdaError() }
) : PushHistoryService {
override fun onPushReceived(
providerInfo: String,
eventId: EventId?,
roomId: RoomId?,
sessionId: SessionId?,
hasBeenResolved: Boolean,
comment: String?,
) {
onPushReceivedResult(
providerInfo,
eventId,
roomId,
sessionId,
hasBeenResolved,
comment
)
}
}

View file

@ -69,7 +69,7 @@ class DefaultNotifiableEventResolverTest {
fun `resolve event no session`() = runTest {
val sut = createDefaultNotifiableEventResolver(notificationService = null)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
assertThat(result).isNull()
assertThat(result.isFailure).isTrue()
}
@Test
@ -78,7 +78,7 @@ class DefaultNotifiableEventResolverTest {
notificationResult = Result.failure(AN_EXCEPTION)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
assertThat(result).isNull()
assertThat(result.isFailure).isTrue()
}
@Test
@ -87,7 +87,7 @@ class DefaultNotifiableEventResolverTest {
notificationResult = Result.success(null)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
assertThat(result).isNull()
assertThat(result.isFailure).isTrue()
}
@Test
@ -106,7 +106,7 @@ class DefaultNotifiableEventResolverTest {
val expectedResult = ResolvedPushEvent.Event(
aNotifiableMessageEvent(body = "Hello world")
)
assertThat(result).isEqualTo(expectedResult)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
}
@Test
@ -127,7 +127,7 @@ class DefaultNotifiableEventResolverTest {
val expectedResult = ResolvedPushEvent.Event(
aNotifiableMessageEvent(body = "Hello world", hasMentionOrReply = true)
)
assertThat(result).isEqualTo(expectedResult)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
}
@Test
@ -152,7 +152,7 @@ class DefaultNotifiableEventResolverTest {
val expectedResult = ResolvedPushEvent.Event(
aNotifiableMessageEvent(body = "Hello world")
)
assertThat(result).isEqualTo(expectedResult)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
}
@Test
@ -177,7 +177,7 @@ class DefaultNotifiableEventResolverTest {
val expectedResult = ResolvedPushEvent.Event(
aNotifiableMessageEvent(body = "Hello world")
)
assertThat(result).isEqualTo(expectedResult)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
}
@Test
@ -196,7 +196,7 @@ class DefaultNotifiableEventResolverTest {
val expectedResult = ResolvedPushEvent.Event(
aNotifiableMessageEvent(body = "Audio")
)
assertThat(result).isEqualTo(expectedResult)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
}
@Test
@ -215,7 +215,7 @@ class DefaultNotifiableEventResolverTest {
val expectedResult = ResolvedPushEvent.Event(
aNotifiableMessageEvent(body = "Video")
)
assertThat(result).isEqualTo(expectedResult)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
}
@Test
@ -234,7 +234,7 @@ class DefaultNotifiableEventResolverTest {
val expectedResult = ResolvedPushEvent.Event(
aNotifiableMessageEvent(body = "Voice message")
)
assertThat(result).isEqualTo(expectedResult)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
}
@Test
@ -253,7 +253,7 @@ class DefaultNotifiableEventResolverTest {
val expectedResult = ResolvedPushEvent.Event(
aNotifiableMessageEvent(body = "Image")
)
assertThat(result).isEqualTo(expectedResult)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
}
@Test
@ -272,7 +272,7 @@ class DefaultNotifiableEventResolverTest {
val expectedResult = ResolvedPushEvent.Event(
aNotifiableMessageEvent(body = "Sticker")
)
assertThat(result).isEqualTo(expectedResult)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
}
@Test
@ -291,7 +291,7 @@ class DefaultNotifiableEventResolverTest {
val expectedResult = ResolvedPushEvent.Event(
aNotifiableMessageEvent(body = "File")
)
assertThat(result).isEqualTo(expectedResult)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
}
@Test
@ -310,7 +310,7 @@ class DefaultNotifiableEventResolverTest {
val expectedResult = ResolvedPushEvent.Event(
aNotifiableMessageEvent(body = "Location")
)
assertThat(result).isEqualTo(expectedResult)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
}
@Test
@ -329,7 +329,7 @@ class DefaultNotifiableEventResolverTest {
val expectedResult = ResolvedPushEvent.Event(
aNotifiableMessageEvent(body = "Notice")
)
assertThat(result).isEqualTo(expectedResult)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
}
@Test
@ -348,7 +348,7 @@ class DefaultNotifiableEventResolverTest {
val expectedResult = ResolvedPushEvent.Event(
aNotifiableMessageEvent(body = "* Bob is happy")
)
assertThat(result).isEqualTo(expectedResult)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
}
@Test
@ -367,7 +367,7 @@ class DefaultNotifiableEventResolverTest {
val expectedResult = ResolvedPushEvent.Event(
aNotifiableMessageEvent(body = "Poll: A question")
)
assertThat(result).isEqualTo(expectedResult)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
}
@Test
@ -384,7 +384,7 @@ class DefaultNotifiableEventResolverTest {
)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
assertThat(result).isNull()
assertThat(result.getOrNull()).isNull()
}
@Test
@ -418,7 +418,7 @@ class DefaultNotifiableEventResolverTest {
isUpdated = false,
)
)
assertThat(result).isEqualTo(expectedResult)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
}
@Test
@ -452,7 +452,7 @@ class DefaultNotifiableEventResolverTest {
isUpdated = false,
)
)
assertThat(result).isEqualTo(expectedResult)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
}
@Test
@ -487,7 +487,7 @@ class DefaultNotifiableEventResolverTest {
isUpdated = false,
)
)
assertThat(result).isEqualTo(expectedResult)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
}
@Test
@ -522,7 +522,7 @@ class DefaultNotifiableEventResolverTest {
isUpdated = false,
)
)
assertThat(result).isEqualTo(expectedResult)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
}
@Test
@ -538,7 +538,7 @@ class DefaultNotifiableEventResolverTest {
)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
assertThat(result).isNull()
assertThat(result.getOrNull()).isNull()
}
@Test
@ -564,7 +564,7 @@ class DefaultNotifiableEventResolverTest {
timestamp = A_FAKE_TIMESTAMP,
)
)
assertThat(result).isEqualTo(expectedResult)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
}
@Test
@ -602,7 +602,7 @@ class DefaultNotifiableEventResolverTest {
isUpdated = false
)
)
assertThat(result).isEqualTo(expectedResult)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
}
@Test
@ -638,7 +638,7 @@ class DefaultNotifiableEventResolverTest {
)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
assertThat(result).isEqualTo(expectedResult)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
}
@Test
@ -675,7 +675,7 @@ class DefaultNotifiableEventResolverTest {
)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
assertThat(result).isEqualTo(expectedResult)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
}
@Test
@ -711,7 +711,7 @@ class DefaultNotifiableEventResolverTest {
)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
assertThat(result).isEqualTo(expectedResult)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
}
@Test
@ -733,7 +733,7 @@ class DefaultNotifiableEventResolverTest {
reason = A_REDACTION_REASON,
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
assertThat(result).isEqualTo(expectedResult)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
}
@Test
@ -749,46 +749,46 @@ class DefaultNotifiableEventResolverTest {
)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
assertThat(result).isNull()
assertThat(result.isFailure).isTrue()
}
@Test
fun `resolve null cases`() {
testNull(NotificationContent.MessageLike.CallAnswer)
testNull(NotificationContent.MessageLike.CallHangup)
testNull(NotificationContent.MessageLike.CallCandidates)
testNull(NotificationContent.MessageLike.KeyVerificationReady)
testNull(NotificationContent.MessageLike.KeyVerificationStart)
testNull(NotificationContent.MessageLike.KeyVerificationCancel)
testNull(NotificationContent.MessageLike.KeyVerificationAccept)
testNull(NotificationContent.MessageLike.KeyVerificationKey)
testNull(NotificationContent.MessageLike.KeyVerificationMac)
testNull(NotificationContent.MessageLike.KeyVerificationDone)
testNull(NotificationContent.MessageLike.ReactionContent(relatedEventId = AN_EVENT_ID_2.value))
testNull(NotificationContent.MessageLike.Sticker)
testNull(NotificationContent.StateEvent.PolicyRuleRoom)
testNull(NotificationContent.StateEvent.PolicyRuleServer)
testNull(NotificationContent.StateEvent.PolicyRuleUser)
testNull(NotificationContent.StateEvent.RoomAliases)
testNull(NotificationContent.StateEvent.RoomAvatar)
testNull(NotificationContent.StateEvent.RoomCanonicalAlias)
testNull(NotificationContent.StateEvent.RoomCreate)
testNull(NotificationContent.StateEvent.RoomEncryption)
testNull(NotificationContent.StateEvent.RoomGuestAccess)
testNull(NotificationContent.StateEvent.RoomHistoryVisibility)
testNull(NotificationContent.StateEvent.RoomJoinRules)
testNull(NotificationContent.StateEvent.RoomName)
testNull(NotificationContent.StateEvent.RoomPinnedEvents)
testNull(NotificationContent.StateEvent.RoomPowerLevels)
testNull(NotificationContent.StateEvent.RoomServerAcl)
testNull(NotificationContent.StateEvent.RoomThirdPartyInvite)
testNull(NotificationContent.StateEvent.RoomTombstone)
testNull(NotificationContent.StateEvent.RoomTopic)
testNull(NotificationContent.StateEvent.SpaceChild)
testNull(NotificationContent.StateEvent.SpaceParent)
testFailure(NotificationContent.MessageLike.CallAnswer)
testFailure(NotificationContent.MessageLike.CallHangup)
testFailure(NotificationContent.MessageLike.CallCandidates)
testFailure(NotificationContent.MessageLike.KeyVerificationReady)
testFailure(NotificationContent.MessageLike.KeyVerificationStart)
testFailure(NotificationContent.MessageLike.KeyVerificationCancel)
testFailure(NotificationContent.MessageLike.KeyVerificationAccept)
testFailure(NotificationContent.MessageLike.KeyVerificationKey)
testFailure(NotificationContent.MessageLike.KeyVerificationMac)
testFailure(NotificationContent.MessageLike.KeyVerificationDone)
testFailure(NotificationContent.MessageLike.ReactionContent(relatedEventId = AN_EVENT_ID_2.value))
testFailure(NotificationContent.MessageLike.Sticker)
testFailure(NotificationContent.StateEvent.PolicyRuleRoom)
testFailure(NotificationContent.StateEvent.PolicyRuleServer)
testFailure(NotificationContent.StateEvent.PolicyRuleUser)
testFailure(NotificationContent.StateEvent.RoomAliases)
testFailure(NotificationContent.StateEvent.RoomAvatar)
testFailure(NotificationContent.StateEvent.RoomCanonicalAlias)
testFailure(NotificationContent.StateEvent.RoomCreate)
testFailure(NotificationContent.StateEvent.RoomEncryption)
testFailure(NotificationContent.StateEvent.RoomGuestAccess)
testFailure(NotificationContent.StateEvent.RoomHistoryVisibility)
testFailure(NotificationContent.StateEvent.RoomJoinRules)
testFailure(NotificationContent.StateEvent.RoomName)
testFailure(NotificationContent.StateEvent.RoomPinnedEvents)
testFailure(NotificationContent.StateEvent.RoomPowerLevels)
testFailure(NotificationContent.StateEvent.RoomServerAcl)
testFailure(NotificationContent.StateEvent.RoomThirdPartyInvite)
testFailure(NotificationContent.StateEvent.RoomTombstone)
testFailure(NotificationContent.StateEvent.RoomTopic)
testFailure(NotificationContent.StateEvent.SpaceChild)
testFailure(NotificationContent.StateEvent.SpaceParent)
}
private fun testNull(content: NotificationContent) = runTest {
private fun testFailure(content: NotificationContent) = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
aNotificationData(
@ -797,7 +797,7 @@ class DefaultNotifiableEventResolverTest {
)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
assertThat(result).isNull()
assertThat(result.isFailure).isTrue()
}
private fun createDefaultNotifiableEventResolver(

View file

@ -60,7 +60,9 @@ class DefaultOnMissedCallNotificationHandlerTest {
imageLoaderHolder = FakeImageLoaderHolder(),
activeNotificationsProvider = FakeActiveNotificationsProvider(),
),
callNotificationEventResolver = FakeCallNotificationEventResolver(resolveEventLambda = { _, _, _ -> aNotifiableMessageEvent() }),
callNotificationEventResolver = FakeCallNotificationEventResolver(resolveEventLambda = { _, _, _ ->
Result.success(aNotifiableMessageEvent())
}),
)
defaultOnMissedCallNotificationHandler.addMissedCallNotification(

View file

@ -14,9 +14,9 @@ import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEv
import io.element.android.tests.testutils.lambda.lambdaError
class FakeNotifiableEventResolver(
private val notifiableEventResult: (SessionId, RoomId, EventId) -> ResolvedPushEvent? = { _, _, _ -> lambdaError() }
private val notifiableEventResult: (SessionId, RoomId, EventId) -> Result<ResolvedPushEvent> = { _, _, _ -> lambdaError() }
) : NotifiableEventResolver {
override suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): ResolvedPushEvent? {
override suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): Result<ResolvedPushEvent> {
return notifiableEventResult(sessionId, roomId, eventId)
}
}

View file

@ -28,7 +28,10 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.push.impl.history.FakePushHistoryService
import io.element.android.libraries.push.impl.history.PushHistoryService
import io.element.android.libraries.push.impl.notifications.FakeNotifiableEventResolver
import io.element.android.libraries.push.impl.notifications.ResolvingException
import io.element.android.libraries.push.impl.notifications.channels.FakeNotificationChannels
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableCallEvent
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
@ -42,6 +45,7 @@ import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret
import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore
import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory
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.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
@ -50,16 +54,41 @@ import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.time.Instant
private const val A_PUSHER_INFO = "info"
class DefaultPushHandlerTest {
@Test
fun `check handleInvalid behavior`() = runTest {
val incrementPushCounterResult = lambdaRecorder<Unit> {}
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, String?, Unit> { _, _, _, _, _, _ -> }
val pushHistoryService = FakePushHistoryService(
onPushReceivedResult = onPushReceivedResult,
)
val defaultPushHandler = createDefaultPushHandler(
incrementPushCounterResult = incrementPushCounterResult,
pushHistoryService = pushHistoryService,
)
defaultPushHandler.handleInvalid(A_PUSHER_INFO)
incrementPushCounterResult.assertions()
.isCalledOnce()
onPushReceivedResult.assertions()
.isCalledOnce()
.with(value(A_PUSHER_INFO), value(null), value(null), value(null), value(false), value("Invalid push data"))
}
@Test
fun `when classical PushData is received, the notification drawer is informed`() = runTest {
val aNotifiableMessageEvent = aNotifiableMessageEvent()
val notifiableEventResult =
lambdaRecorder<SessionId, RoomId, EventId, ResolvedPushEvent> { _, _, _ ->
ResolvedPushEvent.Event(aNotifiableMessageEvent)
lambdaRecorder<SessionId, RoomId, EventId, Result<ResolvedPushEvent>> { _, _, _ ->
Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent))
}
val onNotifiableEventReceived = lambdaRecorder<NotifiableEvent, Unit> {}
val incrementPushCounterResult = lambdaRecorder<Unit> {}
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, String?, Unit> { _, _, _, _, _, _ -> }
val pushHistoryService = FakePushHistoryService(
onPushReceivedResult = onPushReceivedResult,
)
val aPushData = PushData(
eventId = AN_EVENT_ID,
roomId = A_ROOM_ID,
@ -72,9 +101,10 @@ class DefaultPushHandlerTest {
pushClientSecret = FakePushClientSecret(
getUserIdFromSecretResult = { A_USER_ID }
),
incrementPushCounterResult = incrementPushCounterResult
incrementPushCounterResult = incrementPushCounterResult,
pushHistoryService = pushHistoryService,
)
defaultPushHandler.handle(aPushData)
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
incrementPushCounterResult.assertions()
.isCalledOnce()
notifiableEventResult.assertions()
@ -83,6 +113,8 @@ class DefaultPushHandlerTest {
onNotifiableEventReceived.assertions()
.isCalledOnce()
.with(value(aNotifiableMessageEvent))
onPushReceivedResult.assertions()
.isCalledOnce()
}
@Test
@ -90,8 +122,8 @@ class DefaultPushHandlerTest {
runTest {
val aNotifiableMessageEvent = aNotifiableMessageEvent()
val notifiableEventResult =
lambdaRecorder<SessionId, RoomId, EventId, ResolvedPushEvent.Event> { _, _, _ ->
ResolvedPushEvent.Event(aNotifiableMessageEvent)
lambdaRecorder<SessionId, RoomId, EventId, Result<ResolvedPushEvent.Event>> { _, _, _ ->
Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent))
}
val onNotifiableEventReceived = lambdaRecorder<NotifiableEvent, Unit> {}
val incrementPushCounterResult = lambdaRecorder<Unit> {}
@ -101,6 +133,10 @@ class DefaultPushHandlerTest {
unread = 0,
clientSecret = A_SECRET,
)
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, String?, Unit> { _, _, _, _, _, _ -> }
val pushHistoryService = FakePushHistoryService(
onPushReceivedResult = onPushReceivedResult,
)
val defaultPushHandler = createDefaultPushHandler(
onNotifiableEventReceived = onNotifiableEventReceived,
notifiableEventResult = notifiableEventResult,
@ -110,15 +146,18 @@ class DefaultPushHandlerTest {
userPushStore = FakeUserPushStore().apply {
setNotificationEnabledForDevice(false)
},
incrementPushCounterResult = incrementPushCounterResult
incrementPushCounterResult = incrementPushCounterResult,
pushHistoryService = pushHistoryService,
)
defaultPushHandler.handle(aPushData)
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
incrementPushCounterResult.assertions()
.isCalledOnce()
notifiableEventResult.assertions()
.isCalledOnce()
onNotifiableEventReceived.assertions()
.isNeverCalled()
onPushReceivedResult.assertions()
.isCalledOnce()
}
@Test
@ -126,8 +165,8 @@ class DefaultPushHandlerTest {
runTest {
val aNotifiableMessageEvent = aNotifiableMessageEvent()
val notifiableEventResult =
lambdaRecorder<SessionId, RoomId, EventId, ResolvedPushEvent.Event> { _, _, _ ->
ResolvedPushEvent.Event(aNotifiableMessageEvent)
lambdaRecorder<SessionId, RoomId, EventId, Result<ResolvedPushEvent.Event>> { _, _, _ ->
Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent))
}
val onNotifiableEventReceived = lambdaRecorder<NotifiableEvent, Unit> {}
val incrementPushCounterResult = lambdaRecorder<Unit> {}
@ -137,6 +176,10 @@ class DefaultPushHandlerTest {
unread = 0,
clientSecret = A_SECRET,
)
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, String?, Unit> { _, _, _, _, _, _ -> }
val pushHistoryService = FakePushHistoryService(
onPushReceivedResult = onPushReceivedResult,
)
val defaultPushHandler = createDefaultPushHandler(
onNotifiableEventReceived = onNotifiableEventReceived,
notifiableEventResult = notifiableEventResult,
@ -146,9 +189,10 @@ class DefaultPushHandlerTest {
matrixAuthenticationService = FakeMatrixAuthenticationService().apply {
getLatestSessionIdLambda = { A_USER_ID }
},
incrementPushCounterResult = incrementPushCounterResult
incrementPushCounterResult = incrementPushCounterResult,
pushHistoryService = pushHistoryService,
)
defaultPushHandler.handle(aPushData)
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
incrementPushCounterResult.assertions()
.isCalledOnce()
notifiableEventResult.assertions()
@ -157,6 +201,8 @@ class DefaultPushHandlerTest {
onNotifiableEventReceived.assertions()
.isCalledOnce()
.with(value(aNotifiableMessageEvent))
onPushReceivedResult.assertions()
.isCalledOnce()
}
@Test
@ -164,8 +210,8 @@ class DefaultPushHandlerTest {
runTest {
val aNotifiableMessageEvent = aNotifiableMessageEvent()
val notifiableEventResult =
lambdaRecorder<SessionId, RoomId, EventId, ResolvedPushEvent.Event> { _, _, _ ->
ResolvedPushEvent.Event(aNotifiableMessageEvent)
lambdaRecorder<SessionId, RoomId, EventId, Result<ResolvedPushEvent.Event>> { _, _, _ ->
Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent))
}
val onNotifiableEventReceived = lambdaRecorder<NotifiableEvent, Unit> {}
val incrementPushCounterResult = lambdaRecorder<Unit> {}
@ -175,6 +221,10 @@ class DefaultPushHandlerTest {
unread = 0,
clientSecret = A_SECRET,
)
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, String?, Unit> { _, _, _, _, _, _ -> }
val pushHistoryService = FakePushHistoryService(
onPushReceivedResult = onPushReceivedResult,
)
val defaultPushHandler = createDefaultPushHandler(
onNotifiableEventReceived = onNotifiableEventReceived,
notifiableEventResult = notifiableEventResult,
@ -184,22 +234,27 @@ class DefaultPushHandlerTest {
matrixAuthenticationService = FakeMatrixAuthenticationService().apply {
getLatestSessionIdLambda = { null }
},
incrementPushCounterResult = incrementPushCounterResult
incrementPushCounterResult = incrementPushCounterResult,
pushHistoryService = pushHistoryService,
)
defaultPushHandler.handle(aPushData)
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
incrementPushCounterResult.assertions()
.isCalledOnce()
notifiableEventResult.assertions()
.isNeverCalled()
onNotifiableEventReceived.assertions()
.isNeverCalled()
onPushReceivedResult.assertions()
.isCalledOnce()
}
@Test
fun `when classical PushData is received, but not able to resolve the event, nothing happen`() =
runTest {
val notifiableEventResult =
lambdaRecorder<SessionId, RoomId, EventId, ResolvedPushEvent.Event?> { _, _, _ -> null }
lambdaRecorder<SessionId, RoomId, EventId, Result<ResolvedPushEvent.Event>> { _, _, _ ->
Result.failure(ResolvingException("Unable to resolve"))
}
val onNotifiableEventReceived = lambdaRecorder<NotifiableEvent, Unit> {}
val incrementPushCounterResult = lambdaRecorder<Unit> {}
val aPushData = PushData(
@ -208,6 +263,10 @@ class DefaultPushHandlerTest {
unread = 0,
clientSecret = A_SECRET,
)
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, String?, Unit> { _, _, _, _, _, _ -> }
val pushHistoryService = FakePushHistoryService(
onPushReceivedResult = onPushReceivedResult,
)
val defaultPushHandler = createDefaultPushHandler(
onNotifiableEventReceived = onNotifiableEventReceived,
notifiableEventResult = notifiableEventResult,
@ -218,9 +277,10 @@ class DefaultPushHandlerTest {
pushClientSecret = FakePushClientSecret(
getUserIdFromSecretResult = { A_USER_ID }
),
incrementPushCounterResult = incrementPushCounterResult
incrementPushCounterResult = incrementPushCounterResult,
pushHistoryService = pushHistoryService,
)
defaultPushHandler.handle(aPushData)
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
incrementPushCounterResult.assertions()
.isCalledOnce()
notifiableEventResult.assertions()
@ -228,6 +288,9 @@ class DefaultPushHandlerTest {
.with(value(A_USER_ID), value(A_ROOM_ID), value(AN_EVENT_ID))
onNotifiableEventReceived.assertions()
.isNeverCalled()
onPushReceivedResult.assertions()
.isCalledOnce()
.with(any(), value(AN_EVENT_ID), value(A_ROOM_ID), value(A_USER_ID), value(false), any())
}
@Test
@ -251,20 +314,28 @@ class DefaultPushHandlerTest {
> { _, _, _, _, _, _, _, _ -> }
val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda)
val onNotifiableEventReceived = lambdaRecorder<NotifiableEvent, Unit> {}
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, String?, Unit> { _, _, _, _, _, _ -> }
val pushHistoryService = FakePushHistoryService(
onPushReceivedResult = onPushReceivedResult,
)
val defaultPushHandler = createDefaultPushHandler(
elementCallEntryPoint = elementCallEntryPoint,
notifiableEventResult = { _, _, _ ->
ResolvedPushEvent.Event(aNotifiableCallEvent(callNotifyType = CallNotifyType.RING, timestamp = Instant.now().toEpochMilli()))
Result.success(
ResolvedPushEvent.Event(aNotifiableCallEvent(callNotifyType = CallNotifyType.RING, timestamp = Instant.now().toEpochMilli()))
)
},
incrementPushCounterResult = {},
pushClientSecret = FakePushClientSecret(
getUserIdFromSecretResult = { A_USER_ID }
),
onNotifiableEventReceived = onNotifiableEventReceived,
pushHistoryService = pushHistoryService,
)
defaultPushHandler.handle(aPushData)
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
handleIncomingCallLambda.assertions().isCalledOnce()
onNotifiableEventReceived.assertions().isCalledOnce()
onPushReceivedResult.assertions().isCalledOnce()
}
@Test
@ -288,21 +359,27 @@ class DefaultPushHandlerTest {
Unit,
> { _, _, _, _, _, _, _, _ -> }
val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda)
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, String?, Unit> { _, _, _, _, _, _ -> }
val pushHistoryService = FakePushHistoryService(
onPushReceivedResult = onPushReceivedResult,
)
val defaultPushHandler = createDefaultPushHandler(
elementCallEntryPoint = elementCallEntryPoint,
onNotifiableEventReceived = onNotifiableEventReceived,
notifiableEventResult = { _, _, _ ->
ResolvedPushEvent.Event(aNotifiableMessageEvent(type = EventType.CALL_NOTIFY))
Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent(type = EventType.CALL_NOTIFY)))
},
incrementPushCounterResult = {},
pushClientSecret = FakePushClientSecret(
getUserIdFromSecretResult = { A_USER_ID }
),
pushHistoryService = pushHistoryService,
)
defaultPushHandler.handle(aPushData)
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
handleIncomingCallLambda.assertions().isNeverCalled()
onNotifiableEventReceived.assertions().isCalledOnce()
onPushReceivedResult.assertions().isCalledOnce()
}
@Test
@ -326,11 +403,15 @@ class DefaultPushHandlerTest {
Unit,
> { _, _, _, _, _, _, _, _ -> }
val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda)
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, String?, Unit> { _, _, _, _, _, _ -> }
val pushHistoryService = FakePushHistoryService(
onPushReceivedResult = onPushReceivedResult,
)
val defaultPushHandler = createDefaultPushHandler(
elementCallEntryPoint = elementCallEntryPoint,
onNotifiableEventReceived = onNotifiableEventReceived,
notifiableEventResult = { _, _, _ ->
ResolvedPushEvent.Event(aNotifiableCallEvent())
Result.success(ResolvedPushEvent.Event(aNotifiableCallEvent()))
},
incrementPushCounterResult = {},
userPushStore = FakeUserPushStore().apply {
@ -339,10 +420,12 @@ class DefaultPushHandlerTest {
pushClientSecret = FakePushClientSecret(
getUserIdFromSecretResult = { A_USER_ID }
),
pushHistoryService = pushHistoryService,
)
defaultPushHandler.handle(aPushData)
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
handleIncomingCallLambda.assertions().isCalledOnce()
onNotifiableEventReceived.assertions().isCalledOnce()
onPushReceivedResult.assertions().isCalledOnce()
}
@Test
@ -361,19 +444,26 @@ class DefaultPushHandlerTest {
)
val onRedactedEventReceived = lambdaRecorder<ResolvedPushEvent.Redaction, Unit> { }
val incrementPushCounterResult = lambdaRecorder<Unit> {}
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, String?, Unit> { _, _, _, _, _, _ -> }
val pushHistoryService = FakePushHistoryService(
onPushReceivedResult = onPushReceivedResult,
)
val defaultPushHandler = createDefaultPushHandler(
onRedactedEventReceived = onRedactedEventReceived,
incrementPushCounterResult = incrementPushCounterResult,
notifiableEventResult = { _, _, _ -> aRedaction },
notifiableEventResult = { _, _, _ -> Result.success(aRedaction) },
pushClientSecret = FakePushClientSecret(
getUserIdFromSecretResult = { A_USER_ID }
),
pushHistoryService = pushHistoryService,
)
defaultPushHandler.handle(aPushData)
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
incrementPushCounterResult.assertions()
.isCalledOnce()
onRedactedEventReceived.assertions().isCalledOnce()
.with(value(aRedaction))
onPushReceivedResult.assertions()
.isCalledOnce()
}
@Test
@ -386,20 +476,27 @@ class DefaultPushHandlerTest {
clientSecret = A_SECRET,
)
val diagnosticPushHandler = DiagnosticPushHandler()
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, String?, Unit> { _, _, _, _, _, _ -> }
val pushHistoryService = FakePushHistoryService(
onPushReceivedResult = onPushReceivedResult,
)
val defaultPushHandler = createDefaultPushHandler(
diagnosticPushHandler = diagnosticPushHandler,
incrementPushCounterResult = { }
incrementPushCounterResult = { },
pushHistoryService = pushHistoryService,
)
diagnosticPushHandler.state.test {
defaultPushHandler.handle(aPushData)
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
awaitItem()
}
onPushReceivedResult.assertions()
.isCalledOnce()
}
private fun createDefaultPushHandler(
onNotifiableEventReceived: (NotifiableEvent) -> Unit = { lambdaError() },
onRedactedEventReceived: (ResolvedPushEvent.Redaction) -> Unit = { lambdaError() },
notifiableEventResult: (SessionId, RoomId, EventId) -> ResolvedPushEvent? = { _, _, _ -> lambdaError() },
notifiableEventResult: (SessionId, RoomId, EventId) -> Result<ResolvedPushEvent> = { _, _, _ -> lambdaError() },
incrementPushCounterResult: () -> Unit = { lambdaError() },
userPushStore: UserPushStore = FakeUserPushStore(),
pushClientSecret: PushClientSecret = FakePushClientSecret(),
@ -408,6 +505,7 @@ class DefaultPushHandlerTest {
diagnosticPushHandler: DiagnosticPushHandler = DiagnosticPushHandler(),
elementCallEntryPoint: FakeElementCallEntryPoint = FakeElementCallEntryPoint(),
notificationChannels: FakeNotificationChannels = FakeNotificationChannels(),
pushHistoryService: PushHistoryService = FakePushHistoryService(),
): DefaultPushHandler {
return DefaultPushHandler(
onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventReceived),
@ -425,6 +523,7 @@ class DefaultPushHandlerTest {
diagnosticPushHandler = diagnosticPushHandler,
elementCallEntryPoint = elementCallEntryPoint,
notificationChannels = notificationChannels,
pushHistoryService = pushHistoryService,
)
}
}

View file

@ -0,0 +1,33 @@
/*
* Copyright 2025 New Vector 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.store
import io.element.android.libraries.push.api.history.PushHistoryItem
import io.element.android.tests.testutils.lambda.lambdaError
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
class InMemoryPushDataStore(
initialPushCounter: Int = 0,
initialPushHistoryItems: List<PushHistoryItem> = emptyList(),
private val resetResult: () -> Unit = { lambdaError() }
) : PushDataStore {
private val mutablePushCounterFlow = MutableStateFlow(initialPushCounter)
override val pushCounterFlow: Flow<Int> = mutablePushCounterFlow.asStateFlow()
private val mutablePushHistoryItemsFlow = MutableStateFlow(initialPushHistoryItems)
override fun getPushHistoryItemsFlow(): Flow<List<PushHistoryItem>> {
return mutablePushHistoryItemsFlow.asStateFlow()
}
override suspend fun reset() {
resetResult()
}
}