Split notifications for messages in threads (#5595)

* Separate thread notifications into their own notifications when the feature flag is enabled.

Otherwise, set the `threadId` to null so it'll behave as usual. It's done this way to avoid having to inject `FeatureFlagService` in several places.

* Add permalink navigation to threads from notifications, focusing on the latest event in the list of messages of the notification tapped

* Fix redactions in threads

* Clear notifications for a thread when visiting it

* Fix opening a thread happening twice, first because of the `openThreadId` value, then because of the `focusedEventId` one

* Make opening a room through a notification also focus on the latest event

* Add helper `NotificationCreator.messageTag` function

* Remove unused `ROOM_CALL_NOTIFICATION_ID`: `FOREGROUND_SERVICE_NOTIFICATION_ID`+ `ForegroundServiceType` is used instead

* Simplify `DefaultDeepLinkCreator`

* Make sure the main timeline focuses on the thread root id too when navigating to a thread

* Handle "Mark as read" action for thread notification, using `timeline.markAsRead`

* Log failures to mark rooms as read using the notification action

---------

Co-authored-by: Benoit Marty <benoit@matrix.org>
This commit is contained in:
Jorge Martin Espinosa
2025-10-30 16:15:00 +01:00
committed by GitHub
parent e8b7db22cd
commit 7facc40771
55 changed files with 702 additions and 284 deletions

View File

@@ -14,6 +14,7 @@ import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.deeplink.api.DeepLinkCreator
import io.element.android.libraries.di.annotations.ApplicationContext
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.core.ThreadId
@@ -29,10 +30,11 @@ class DefaultIntentProvider(
sessionId: SessionId,
roomId: RoomId?,
threadId: ThreadId?,
eventId: EventId?,
): Intent {
return Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW
data = deepLinkCreator.create(sessionId, roomId, threadId).toUri()
data = deepLinkCreator.create(sessionId, roomId, threadId, eventId).toUri()
}
}
}

View File

@@ -13,9 +13,11 @@ import android.content.Context
import android.content.Intent
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.deeplink.api.DeepLinkCreator
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.core.ThreadId
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID
@@ -31,14 +33,15 @@ import org.robolectric.RuntimeEnvironment
class DefaultIntentProviderTest {
@Test
fun `test getViewRoomIntent with data`() {
val deepLinkCreator = lambdaRecorder<SessionId, RoomId?, ThreadId?, String> { _, _, _ -> "deepLinkCreatorResult" }
val deepLinkCreator = lambdaRecorder<SessionId, RoomId?, ThreadId?, EventId?, String> { _, _, _, _ -> "deepLinkCreatorResult" }
val sut = createDefaultIntentProvider(
deepLinkCreator = { sessionId, roomId, threadId -> deepLinkCreator.invoke(sessionId, roomId, threadId) },
deepLinkCreator = { sessionId, roomId, threadId, eventId -> deepLinkCreator.invoke(sessionId, roomId, threadId, eventId) },
)
val result = sut.getViewRoomIntent(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
threadId = A_THREAD_ID,
eventId = AN_EVENT_ID,
)
result.commonAssertions()
assertThat(result.data.toString()).isEqualTo("deepLinkCreatorResult")
@@ -46,11 +49,12 @@ class DefaultIntentProviderTest {
value(A_SESSION_ID),
value(A_ROOM_ID),
value(A_THREAD_ID),
value(AN_EVENT_ID),
)
}
private fun createDefaultIntentProvider(
deepLinkCreator: DeepLinkCreator = DeepLinkCreator { _, _, _ -> "" },
deepLinkCreator: DeepLinkCreator = DeepLinkCreator { _, _, _, _ -> "" },
): DefaultIntentProvider {
return DefaultIntentProvider(
context = RuntimeEnvironment.getApplication() as Context,

View File

@@ -68,6 +68,7 @@ import io.element.android.features.verifysession.api.IncomingVerificationEntryPo
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.waitForChildAttached
import io.element.android.libraries.architecture.waitForNavTargetAttached
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.theme.ElementThemeApp
@@ -496,7 +497,7 @@ class LoggedInFlowNode(
trigger: JoinedRoom.Trigger? = null,
eventId: EventId? = null,
clearBackstack: Boolean,
) {
): RoomFlowNode {
waitForNavTargetAttached { navTarget ->
navTarget is NavTarget.Home
}
@@ -509,6 +510,15 @@ class LoggedInFlowNode(
)
backstack.accept(AttachRoomOperation(roomNavTarget, clearBackstack))
}
// If we don't do this check, we might be returning while a previous node with the same type is still displayed
// This means we may attach some new nodes to that one, which will be quickly replaced by the one instantiated above
return waitForChildAttached<RoomFlowNode, NavTarget> {
it is NavTarget.Room &&
it.roomIdOrAlias == roomIdOrAlias &&
it.initialElement is RoomNavigationTarget.Root &&
it.initialElement.eventId == eventId
}
}
suspend fun attachUser(userId: UserId) {

View File

@@ -31,6 +31,7 @@ import io.element.android.annotations.ContributesNode
import io.element.android.appnav.di.MatrixSessionCache
import io.element.android.appnav.intent.IntentResolver
import io.element.android.appnav.intent.ResolvedIntent
import io.element.android.appnav.room.RoomFlowNode
import io.element.android.appnav.root.RootNavStateFlowFactory
import io.element.android.appnav.root.RootPresenter
import io.element.android.appnav.root.RootView
@@ -49,7 +50,10 @@ import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.deeplink.api.DeeplinkData
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.asEventId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.oidc.api.OidcAction
@@ -388,13 +392,19 @@ class RootFlowNode(
is PermalinkData.FallbackLink -> Unit
is PermalinkData.RoomEmailInviteLink -> Unit
is PermalinkData.RoomLink -> {
// If there is a thread id, focus on it in the main timeline
val focusedEventId = if (permalinkData.threadId != null) {
permalinkData.threadId?.asEventId()
} else {
permalinkData.eventId
}
attachRoom(
roomIdOrAlias = permalinkData.roomIdOrAlias,
trigger = JoinedRoom.Trigger.MobilePermalink,
serverNames = permalinkData.viaParameters,
eventId = permalinkData.eventId,
eventId = focusedEventId,
clearBackstack = true
)
).maybeAttachThread(permalinkData.threadId, permalinkData.eventId)
}
is PermalinkData.UserLink -> {
attachUser(permalinkData.userId)
@@ -402,12 +412,24 @@ class RootFlowNode(
}
}
private suspend fun RoomFlowNode.maybeAttachThread(threadId: ThreadId?, focusedEventId: EventId?) {
if (threadId != null) {
attachThread(threadId, focusedEventId)
}
}
private suspend fun navigateTo(deeplinkData: DeeplinkData) {
Timber.d("Navigating to $deeplinkData")
attachSession(deeplinkData.sessionId).apply {
attachSession(deeplinkData.sessionId).let { loggedInFlowNode ->
when (deeplinkData) {
is DeeplinkData.Root -> Unit // The room list will always be shown, observing FtueState
is DeeplinkData.Room -> attachRoom(deeplinkData.roomId.toRoomIdOrAlias(), clearBackstack = true)
is DeeplinkData.Room -> {
loggedInFlowNode.attachRoom(
roomIdOrAlias = deeplinkData.roomId.toRoomIdOrAlias(),
eventId = if (deeplinkData.threadId != null) deeplinkData.threadId?.asEventId() else deeplinkData.eventId,
clearBackstack = true,
).maybeAttachThread(deeplinkData.threadId, deeplinkData.eventId)
}
}
}
}

View File

@@ -39,9 +39,11 @@ import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.core.coroutine.withPreviousValue
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
@@ -211,6 +213,11 @@ class RoomFlowNode(
}
}
suspend fun attachThread(threadId: ThreadId, focusedEventId: EventId?) {
waitForChildAttached<JoinedRoomFlowNode>()
.attachThread(threadId, focusedEventId)
}
private fun loadingNode(buildContext: BuildContext) = node(buildContext) { modifier ->
LoadingRoomNodeView(
state = LoadingRoomState.Loading,

View File

@@ -13,7 +13,9 @@ import kotlinx.parcelize.Parcelize
sealed interface RoomNavigationTarget : Parcelable {
@Parcelize
data class Root(val eventId: EventId? = null) : RoomNavigationTarget
data class Root(
val eventId: EventId? = null,
) : RoomNavigationTarget
@Parcelize
data object Details : RoomNavigationTarget

View File

@@ -34,7 +34,9 @@ import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
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.ThreadId
import io.element.android.libraries.matrix.ui.room.LoadingRoomState
import io.element.android.libraries.matrix.ui.room.LoadingRoomStateFlowFactory
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -121,6 +123,11 @@ class JoinedRoomFlowNode(
)
}
suspend fun attachThread(threadId: ThreadId, focusedEventId: EventId?) {
waitForChildAttached<JoinedRoomLoadedFlowNode>()
.attachThread(threadId, focusedEventId)
}
@Composable
override fun View(modifier: Modifier) {
BackstackView(

View File

@@ -25,18 +25,21 @@ import io.element.android.appnav.di.RoomGraphFactory
import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.messages.api.MessagesEntryPointNode
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.architecture.waitForChildAttached
import io.element.android.libraries.di.DependencyInjectionGraphOwner
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient
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.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.JoinedRoom
@@ -240,7 +243,9 @@ class JoinedRoomLoadedFlowNode(
data object Space : NavTarget
@Parcelize
data class Messages(val focusedEventId: EventId? = null) : NavTarget
data class Messages(
val focusedEventId: EventId? = null,
) : NavTarget
@Parcelize
data object RoomDetails : NavTarget
@@ -258,6 +263,13 @@ class JoinedRoomLoadedFlowNode(
data object RoomNotificationSettings : NavTarget
}
suspend fun attachThread(threadId: ThreadId, focusedEventId: EventId?) {
val messageNode = waitForChildAttached<Node, NavTarget> { navTarget ->
navTarget is NavTarget.Messages
}
(messageNode as? MessagesEntryPointNode)?.attachThread(threadId, focusedEventId)
}
@Composable
override fun View(modifier: Modifier) {
BackstackView()

View File

@@ -17,6 +17,7 @@ import io.element.android.features.login.test.FakeLoginIntentResolver
import io.element.android.libraries.deeplink.api.DeeplinkData
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID
@@ -67,6 +68,7 @@ class IntentResolverTest {
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
threadId = null,
eventId = null,
)
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
@@ -79,6 +81,7 @@ class IntentResolverTest {
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
threadId = null,
eventId = null,
)
)
)
@@ -91,6 +94,7 @@ class IntentResolverTest {
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
threadId = A_THREAD_ID,
eventId = null,
)
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
@@ -103,6 +107,59 @@ class IntentResolverTest {
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
threadId = A_THREAD_ID,
eventId = null,
)
)
)
}
@Test
fun `test resolve navigation intent event`() {
val sut = createIntentResolver(
deeplinkParserResult = DeeplinkData.Room(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
threadId = null,
eventId = AN_EVENT_ID,
)
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
}
val result = sut.resolve(intent)
assertThat(result).isEqualTo(
ResolvedIntent.Navigation(
deeplinkData = DeeplinkData.Room(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
threadId = null,
eventId = AN_EVENT_ID,
)
)
)
}
@Test
fun `test resolve navigation intent thread and event`() {
val sut = createIntentResolver(
deeplinkParserResult = DeeplinkData.Room(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
threadId = A_THREAD_ID,
eventId = AN_EVENT_ID,
)
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
}
val result = sut.resolve(intent)
assertThat(result).isEqualTo(
ResolvedIntent.Navigation(
deeplinkData = DeeplinkData.Room(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
threadId = A_THREAD_ID,
eventId = AN_EVENT_ID,
)
)
)

View File

@@ -15,6 +15,7 @@ import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs
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.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import kotlinx.parcelize.Parcelize
@@ -22,7 +23,9 @@ import kotlinx.parcelize.Parcelize
interface MessagesEntryPoint : FeatureEntryPoint {
sealed interface InitialTarget : Parcelable {
@Parcelize
data class Messages(val focusedEventId: EventId?) : InitialTarget
data class Messages(
val focusedEventId: EventId?,
) : InitialTarget
@Parcelize
data object PinnedMessages : InitialTarget
@@ -46,3 +49,7 @@ interface MessagesEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
}
interface MessagesEntryPointNode {
suspend fun attachThread(threadId: ThreadId, focusedEventId: EventId?)
}

View File

@@ -58,6 +58,7 @@ dependencies {
implementation(projects.libraries.testtags)
implementation(projects.features.networkmonitor.api)
implementation(projects.services.analytics.compose)
implementation(projects.services.appnavstate.api)
implementation(projects.services.toolbox.api)
implementation(libs.coil.compose)
implementation(libs.datetime)

View File

@@ -33,6 +33,7 @@ import io.element.android.features.location.api.LocationService
import io.element.android.features.location.api.SendLocationEntryPoint
import io.element.android.features.location.api.ShowLocationEntryPoint
import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.messages.api.MessagesEntryPointNode
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode
import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider
@@ -87,10 +88,12 @@ import io.element.android.libraries.textcomposer.mentions.MentionSpanUpdater
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
import kotlin.time.Duration.Companion.milliseconds
@ContributesNode(RoomScope::class)
@AssistedInject
@@ -126,8 +129,9 @@ class MessagesFlowNode(
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins
) {
plugins = plugins,
),
MessagesEntryPointNode {
sealed interface NavTarget : Parcelable {
@Parcelize
data class Messages(val focusedEventId: EventId?) : NavTarget
@@ -175,7 +179,7 @@ class MessagesFlowNode(
data object KnockRequestsList : NavTarget
@Parcelize
data class OpenThread(val threadRootId: ThreadId, val focusedEventId: EventId?) : NavTarget
data class Thread(val threadRootId: ThreadId, val focusedEventId: EventId?) : NavTarget
}
private val callbacks = plugins<MessagesEntryPoint.Callback>()
@@ -287,7 +291,7 @@ class MessagesFlowNode(
}
override fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) {
backstack.push(NavTarget.OpenThread(threadRootId, focusedEventId))
backstack.push(NavTarget.Thread(threadRootId, focusedEventId))
}
}
val inputs = MessagesNode.Inputs(focusedEventId = navTarget.focusedEventId)
@@ -420,7 +424,7 @@ class MessagesFlowNode(
NavTarget.KnockRequestsList -> {
knockRequestsListEntryPoint.createNode(this, buildContext)
}
is NavTarget.OpenThread -> {
is NavTarget.Thread -> {
val inputs = ThreadedMessagesNode.Inputs(
threadRootEventId = navTarget.threadRootId,
focusedEventId = navTarget.focusedEventId,
@@ -485,7 +489,7 @@ class MessagesFlowNode(
}
override fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) {
backstack.push(NavTarget.OpenThread(threadRootId, focusedEventId))
backstack.push(NavTarget.Thread(threadRootId, focusedEventId))
}
}
createNode<ThreadedMessagesNode>(buildContext, listOf(inputs, callback))
@@ -603,6 +607,16 @@ class MessagesFlowNode(
)
}
override suspend fun attachThread(threadId: ThreadId, focusedEventId: EventId?) {
// Wait until we have the UI for the main timeline attached
waitForChildAttached<MessagesNode>()
// Give some time for the items in the main timeline to be received, otherwise loading the focused thread root id won't work
// (look at TimelineItemIndexer and firstProcessLatch for more info)
delay(10.milliseconds)
// Then push the new threads screen on top
backstack.push(NavTarget.Thread(threadId, focusedEventId))
}
@Composable
override fun View(modifier: Modifier) {
mentionSpanTheme.updateStyles()

View File

@@ -337,12 +337,11 @@ class MessagesNode(
var focusedEventId by rememberSaveable {
mutableStateOf(inputs.focusedEventId)
}
LaunchedEffect(Unit) {
focusedEventId?.also { eventId ->
state.timelineState.eventSink(TimelineEvents.FocusOnEvent(eventId))
LaunchedEffect(focusedEventId) {
if (focusedEventId != null) {
state.timelineState.eventSink(TimelineEvents.FocusOnEvent(focusedEventId!!))
focusedEventId = null
}
// Reset the focused event id to null to avoid refocusing when restoring node.
focusedEventId = null
}
}
}

View File

@@ -64,6 +64,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
@@ -85,6 +86,7 @@ class ThreadedMessagesNode(
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val mediaPlayer: MediaPlayer,
private val permalinkParser: PermalinkParser,
private val appNavigationStateService: AppNavigationStateService,
) : Node(buildContext, plugins = plugins), MessagesNavigator {
private val callbacks = plugins<Callback>()
@@ -131,6 +133,12 @@ class ThreadedMessagesNode(
onCreate = {
sessionCoroutineScope.launch { analyticsService.capture(room.toAnalyticsViewRoom()) }
},
onStart = {
appNavigationStateService.onNavigateToThread(id, inputs.threadRootEventId)
},
onStop = {
appNavigationStateService.onLeavingThread(id)
},
onDestroy = {
mediaPlayer.close()
}

View File

@@ -86,6 +86,7 @@ class TimelinePresenter(
private val roomCallStatePresenter: Presenter<RoomCallState>,
private val featureFlagService: FeatureFlagService,
) : Presenter<TimelineState> {
private val tag = "TimelinePresenter"
@AssistedFactory
interface Factory {
fun create(
@@ -102,14 +103,14 @@ class TimelinePresenter(
)
private var timelineItems by mutableStateOf<ImmutableList<TimelineItem>>(persistentListOf())
private val focusRequestState: MutableState<FocusRequestState> = mutableStateOf(FocusRequestState.None)
@Composable
override fun present(): TimelineState {
val localScope = rememberCoroutineScope()
val timelineMode = remember { timelineController.mainTimelineMode() }
var focusRequestState: FocusRequestState by remember { mutableStateOf(FocusRequestState.None) }
val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) }
val roomInfo by room.roomInfoFlow.collectAsState()
@@ -155,7 +156,7 @@ class TimelinePresenter(
if (event.firstIndex == 0) {
newEventState.value = NewEventState.None
}
Timber.d("## sendReadReceiptIfNeeded firstVisibleIndex: ${event.firstIndex}")
Timber.tag(tag).d("## sendReadReceiptIfNeeded firstVisibleIndex: ${event.firstIndex}")
sessionCoroutineScope.sendReadReceiptIfNeeded(
firstVisibleIndex = event.firstIndex,
timelineItems = timelineItems,
@@ -186,14 +187,17 @@ class TimelinePresenter(
is TimelineEvents.EditPoll -> {
navigator.onEditPollClick(event.pollStartId)
}
is TimelineEvents.FocusOnEvent -> {
focusRequestState = FocusRequestState.Requested(event.eventId, event.debounce)
}
is TimelineEvents.FocusOnEvent -> sessionCoroutineScope.launch {
focusRequestState.value = FocusRequestState.Requested(event.eventId, event.debounce)
delay(event.debounce)
Timber.tag(tag).d("Started focus on ${event.eventId}")
focusOnEvent(event.eventId, focusRequestState)
}.start()
is TimelineEvents.OnFocusEventRender -> {
focusRequestState = focusRequestState.onFocusEventRender()
focusRequestState.value = focusRequestState.value.onFocusEventRender()
}
is TimelineEvents.ClearFocusRequestState -> {
focusRequestState = FocusRequestState.None
focusRequestState.value = FocusRequestState.None
}
is TimelineEvents.JumpToLive -> {
timelineController.focusOnLive()
@@ -236,69 +240,19 @@ class TimelinePresenter(
.launchIn(this)
}
LaunchedEffect(focusRequestState) {
Timber.d("## focusRequestState: $focusRequestState")
when (val currentFocusRequestState = focusRequestState) {
is FocusRequestState.Requested -> {
delay(currentFocusRequestState.debounce)
if (timelineItemIndexer.isKnown(currentFocusRequestState.eventId)) {
val index = timelineItemIndexer.indexOf(currentFocusRequestState.eventId)
focusRequestState = FocusRequestState.Success(eventId = currentFocusRequestState.eventId, index = index)
} else {
focusRequestState = FocusRequestState.Loading(eventId = currentFocusRequestState.eventId)
}
}
is FocusRequestState.Loading -> {
val eventId = currentFocusRequestState.eventId
val threadId = room.threadRootIdForEvent(eventId).getOrElse {
focusRequestState = FocusRequestState.Failure(it)
return@LaunchedEffect
}
if (timelineController.mainTimelineMode() is Timeline.Mode.Thread && threadId == null) {
// We are in a thread timeline, and the event isn't part of a thread, we need to navigate back to the room
focusRequestState = FocusRequestState.None
navigator.onNavigateToRoom(room.roomId, eventId, calculateServerNamesForRoom(room))
} else {
timelineController.focusOnEvent(eventId, threadId)
.onSuccess { result ->
when (result) {
is EventFocusResult.FocusedOnLive -> {
focusRequestState = FocusRequestState.Success(eventId = eventId)
}
is EventFocusResult.IsInThread -> {
val currentThreadId = (timelineController.mainTimelineMode() as? Timeline.Mode.Thread)?.threadRootId
if (currentThreadId == result.threadId) {
// It's the same thread, we just focus on the event
focusRequestState = FocusRequestState.Success(eventId = eventId)
} else {
focusRequestState = FocusRequestState.Success(eventId = result.threadId.asEventId())
// It's part of a thread we're not in, let's open it in another timeline
navigator.onOpenThread(result.threadId, eventId)
}
}
}
}
.onFailure {
focusRequestState = FocusRequestState.Failure(it)
}
}
}
else -> Unit
}
}
LaunchedEffect(timelineItems.size) {
computeNewItemState(timelineItems, prevMostRecentItemId, newEventState)
}
LaunchedEffect(timelineItems.size, focusRequestState) {
val currentFocusRequestState = focusRequestState
LaunchedEffect(timelineItems.size, focusRequestState.value) {
val currentFocusRequestState = focusRequestState.value
if (currentFocusRequestState is FocusRequestState.Success && !currentFocusRequestState.rendered) {
val eventId = currentFocusRequestState.eventId
if (timelineItemIndexer.isKnown(eventId)) {
val index = timelineItemIndexer.indexOf(eventId)
focusRequestState = FocusRequestState.Success(eventId = eventId, index = index)
focusRequestState.value = FocusRequestState.Success(eventId = eventId, index = index)
} else {
Timber.w("Unknown timeline item for focused item, can't render focus")
}
}
}
@@ -319,6 +273,11 @@ class TimelinePresenter(
)
}
}
LaunchedEffect(focusRequestState.value) {
Timber.tag(tag).d("Timeline: $timelineMode | focus state: ${focusRequestState.value}")
}
return TimelineState(
timelineItems = timelineItems,
timelineMode = timelineMode,
@@ -326,7 +285,7 @@ class TimelinePresenter(
renderReadReceipts = renderReadReceipts,
newEventState = newEventState.value,
isLive = isLive,
focusRequestState = focusRequestState,
focusRequestState = focusRequestState.value,
messageShield = messageShield.value,
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
displayThreadSummaries = displayThreadSummaries,
@@ -334,6 +293,55 @@ class TimelinePresenter(
)
}
private suspend fun focusOnEvent(
eventId: EventId,
focusRequestState: MutableState<FocusRequestState>,
) {
if (timelineItemIndexer.isKnown(eventId)) {
val index = timelineItemIndexer.indexOf(eventId)
focusRequestState.value = FocusRequestState.Success(eventId = eventId, index = index)
return
}
Timber.tag(tag).d("Event $eventId not found in the loaded timeline, loading a focused timeline")
focusRequestState.value = FocusRequestState.Loading(eventId = eventId)
val threadId = room.threadRootIdForEvent(eventId).getOrElse {
focusRequestState.value = FocusRequestState.Failure(it)
return
}
if (timelineController.mainTimelineMode() is Timeline.Mode.Thread && threadId == null) {
// We are in a thread timeline, and the event isn't part of a thread, we need to navigate back to the room
focusRequestState.value = FocusRequestState.None
navigator.onNavigateToRoom(room.roomId, eventId, calculateServerNamesForRoom(room))
} else {
Timber.tag(tag).d("Focusing on event $eventId - thread $threadId")
timelineController.focusOnEvent(eventId, threadId)
.onSuccess { result ->
when (result) {
is EventFocusResult.FocusedOnLive -> {
focusRequestState.value = FocusRequestState.Success(eventId = eventId)
}
is EventFocusResult.IsInThread -> {
val currentThreadId = (timelineController.mainTimelineMode() as? Timeline.Mode.Thread)?.threadRootId
if (currentThreadId == result.threadId) {
// It's the same thread, we just focus on the event
focusRequestState.value = FocusRequestState.Success(eventId = eventId)
} else {
focusRequestState.value = FocusRequestState.Success(eventId = result.threadId.asEventId())
// It's part of a thread we're not in, let's open it in another timeline
navigator.onOpenThread(result.threadId, eventId)
}
}
}
}
.onFailure {
focusRequestState.value = FocusRequestState.Failure(it)
}
}
}
/**
* This method compute the hasNewItem state passed as a [MutableState] each time the timeline items size changes.
* Basically, if we got new timeline event from sync or local, either from us or another user, we update the state so we tell we have new items.

View File

@@ -136,7 +136,7 @@ class DefaultMessagesEntryPointTest {
@Test
fun `test initial target to nav target mapping`() {
assertThat(MessagesEntryPoint.InitialTarget.Messages(focusedEventId = AN_EVENT_ID).toNavTarget())
.isEqualTo(MessagesFlowNode.NavTarget.Messages(AN_EVENT_ID))
.isEqualTo(MessagesFlowNode.NavTarget.Messages(focusedEventId = AN_EVENT_ID))
assertThat(MessagesEntryPoint.InitialTarget.PinnedMessages.toNavTarget())
.isEqualTo(MessagesFlowNode.NavTarget.PinnedMessagesList)
}

View File

@@ -563,9 +563,7 @@ class TimelinePresenterTest {
@Test
fun `present - focus on known event retrieves the event from cache`() = runTest {
val timelineItemIndexer = TimelineItemIndexer().apply {
process(listOf(aMessageEvent(eventId = AN_EVENT_ID)))
}
val timelineItemIndexer = TimelineItemIndexer()
val presenter = createTimelinePresenter(
room = FakeJoinedRoom(
liveTimeline = FakeTimeline(
@@ -578,7 +576,10 @@ class TimelinePresenterTest {
)
)
),
baseRoom = FakeBaseRoom(canUserSendMessageResult = { _, _ -> Result.success(true) }),
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
threadRootIdForEventResult = { Result.success(null) },
),
),
timelineItemIndexer = timelineItemIndexer,
)
@@ -586,7 +587,16 @@ class TimelinePresenterTest {
presenter.present()
}.test {
val initialState = awaitFirstItem()
advanceUntilIdle()
// Pre-populate the indexer after the first items have been retrieved
timelineItemIndexer.process(listOf(aMessageEvent(eventId = AN_EVENT_ID)))
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
advanceUntilIdle()
awaitItem().also { state ->
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Requested(AN_EVENT_ID, Duration.ZERO))

View File

@@ -7,10 +7,11 @@
package io.element.android.libraries.deeplink.api
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.core.ThreadId
fun interface DeepLinkCreator {
fun create(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?): String
fun create(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?, eventId: EventId?): String
}

View File

@@ -7,6 +7,7 @@
package io.element.android.libraries.deeplink.api
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.core.ThreadId
@@ -18,6 +19,6 @@ sealed interface DeeplinkData {
/** The target is the root of the app, with the given [sessionId]. */
data class Root(override val sessionId: SessionId) : DeeplinkData
/** The target is a room, with the given [sessionId], [roomId] and optionally a [threadId]. */
data class Room(override val sessionId: SessionId, val roomId: RoomId, val threadId: ThreadId?) : DeeplinkData
/** The target is a room, with the given [sessionId], [roomId] and optionally a [threadId] and [eventId]. */
data class Room(override val sessionId: SessionId, val roomId: RoomId, val threadId: ThreadId?, val eventId: EventId?) : DeeplinkData
}

View File

@@ -10,24 +10,30 @@ package io.element.android.libraries.deeplink.impl
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.deeplink.api.DeepLinkCreator
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.core.ThreadId
@ContributesBinding(AppScope::class)
class DefaultDeepLinkCreator : DeepLinkCreator {
override fun create(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?): String {
override fun create(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?, eventId: EventId?): String {
return buildString {
append("$SCHEME://$HOST/")
append(sessionId.value)
if (roomId != null) {
append("/")
append(roomId.value)
if (threadId != null) {
append("/")
append(threadId.value)
}
}
append("/")
append(roomId?.value.orEmpty())
append("/")
append(threadId?.value.orEmpty())
append("/")
append(eventId?.value.orEmpty())
}
// Remove all possible trailing '/' characters:
// No event id
.removeSuffix("/")
// No thread id
.removeSuffix("/")
// No room id
.removeSuffix("/")
}
}

View File

@@ -13,6 +13,7 @@ import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.deeplink.api.DeeplinkData
import io.element.android.libraries.deeplink.api.DeeplinkParser
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.core.ThreadId
@@ -36,8 +37,9 @@ class DefaultDeeplinkParser : DeeplinkParser {
null -> DeeplinkData.Root(sessionId)
else -> {
val roomId = screenPathComponent.let(::RoomId)
val threadId = pathBits.elementAtOrNull(2)?.let(::ThreadId)
DeeplinkData.Room(sessionId, roomId, threadId)
val threadId = pathBits.elementAtOrNull(2)?.takeIf { it.isNotBlank() }?.let(::ThreadId)
val eventId = pathBits.elementAtOrNull(3)?.takeIf { it.isNotBlank() }?.let(::EventId)
DeeplinkData.Room(sessionId, roomId, threadId, eventId)
}
}
}

View File

@@ -8,6 +8,7 @@
package io.element.android.libraries.deeplink.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID
@@ -17,11 +18,15 @@ class DefaultDeepLinkCreatorTest {
@Test
fun create() {
val sut = DefaultDeepLinkCreator()
assertThat(sut.create(A_SESSION_ID, null, null))
assertThat(sut.create(A_SESSION_ID, null, null, null))
.isEqualTo("elementx://open/@alice:server.org")
assertThat(sut.create(A_SESSION_ID, A_ROOM_ID, null))
assertThat(sut.create(A_SESSION_ID, A_ROOM_ID, null, null))
.isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain")
assertThat(sut.create(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID))
assertThat(sut.create(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID, null))
.isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId")
assertThat(sut.create(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID, AN_EVENT_ID))
.isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId/\$anEventId")
assertThat(sut.create(A_SESSION_ID, A_ROOM_ID, null, AN_EVENT_ID))
.isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain//\$anEventId")
}
}

View File

@@ -11,6 +11,7 @@ import android.content.Intent
import androidx.core.net.toUri
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.deeplink.api.DeeplinkData
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID
@@ -28,6 +29,10 @@ class DefaultDeeplinkParserTest {
"elementx://open/@alice:server.org/!aRoomId:domain"
const val A_URI_WITH_ROOM_WITH_THREAD =
"elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId"
const val A_URI_WITH_ROOM_WITH_THREAD_AND_EVENT =
"elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId/\$anEventId"
const val A_URI_WITH_ROOM_WITH_EVENT_AND_NO_THREAD =
"elementx://open/@alice:server.org/!aRoomId:domain//\$anEventId"
}
@Test
@@ -36,9 +41,13 @@ class DefaultDeeplinkParserTest {
assertThat(sut.getFromIntent(createIntent(A_URI)))
.isEqualTo(DeeplinkData.Root(A_SESSION_ID))
assertThat(sut.getFromIntent(createIntent(A_URI_WITH_ROOM)))
.isEqualTo(DeeplinkData.Room(A_SESSION_ID, A_ROOM_ID, null))
.isEqualTo(DeeplinkData.Room(A_SESSION_ID, A_ROOM_ID, null, null))
assertThat(sut.getFromIntent(createIntent(A_URI_WITH_ROOM_WITH_THREAD)))
.isEqualTo(DeeplinkData.Room(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID))
.isEqualTo(DeeplinkData.Room(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID, null))
assertThat(sut.getFromIntent(createIntent(A_URI_WITH_ROOM_WITH_THREAD_AND_EVENT)))
.isEqualTo(DeeplinkData.Room(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID, AN_EVENT_ID))
assertThat(sut.getFromIntent(createIntent(A_URI_WITH_ROOM_WITH_EVENT_AND_NO_THREAD)))
.isEqualTo(DeeplinkData.Room(A_SESSION_ID, A_ROOM_ID, null, AN_EVENT_ID))
}
@Test

View File

@@ -13,6 +13,7 @@ import androidx.compose.runtime.Immutable
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.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -28,6 +29,7 @@ sealed interface PermalinkData : Parcelable {
data class RoomLink(
val roomIdOrAlias: RoomIdOrAlias,
val eventId: EventId? = null,
val threadId: ThreadId? = null,
val viaParameters: ImmutableList<String> = persistentListOf()
) : PermalinkData

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.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.notification.NotificationContent
import io.element.android.libraries.matrix.api.notification.NotificationData
@@ -41,8 +42,7 @@ class NotificationMapper(
NotificationData(
sessionId = sessionId,
eventId = eventId,
// FIXME once the `NotificationItem` in the SDK returns the thread id
threadId = null,
threadId = item.threadId?.let(::ThreadId),
roomId = roomId,
senderAvatarUrl = item.senderInfo.avatarUrl,
senderDisplayName = item.senderInfo.displayName,

View File

@@ -10,10 +10,12 @@ package io.element.android.libraries.push.api.notifications
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.core.ThreadId
interface NotificationCleaner {
fun clearAllMessagesEvents(sessionId: SessionId)
fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId)
fun clearMessagesForThread(sessionId: SessionId, roomId: RoomId, threadId: ThreadId)
fun clearEvent(sessionId: SessionId, eventId: EventId)
fun clearMembershipNotificationForSession(sessionId: SessionId)

View File

@@ -8,6 +8,7 @@
package io.element.android.libraries.push.impl.intent
import android.content.Intent
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.core.ThreadId
@@ -20,5 +21,6 @@ interface IntentProvider {
sessionId: SessionId,
roomId: RoomId?,
threadId: ThreadId?,
eventId: EventId?,
): Intent
}

View File

@@ -14,11 +14,21 @@ import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.extensions.runCatchingExceptions
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.core.ThreadId
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
import timber.log.Timber
interface ActiveNotificationsProvider {
fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification>
/**
* Gets the displayed notifications for the combination of [sessionId], [roomId] and [threadId].
*/
fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId, threadId: ThreadId?): List<StatusBarNotification>
/**
* Gets all displayed notifications associated to [sessionId] and [roomId]. These will include all thread notifications as well.
*/
fun getAllMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification>
fun getNotificationsForSession(sessionId: SessionId): List<StatusBarNotification>
fun getMembershipNotificationForSession(sessionId: SessionId): List<StatusBarNotification>
fun getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification>
@@ -44,9 +54,15 @@ class DefaultActiveNotificationsProvider(
return getNotificationsForSession(sessionId).filter { it.id == notificationId }
}
override fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification> {
override fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId, threadId: ThreadId?): List<StatusBarNotification> {
val notificationId = NotificationIdProvider.getRoomMessagesNotificationId(sessionId)
return getNotificationsForSession(sessionId).filter { it.id == notificationId && it.tag == roomId.value }
val expectedTag = NotificationCreator.messageTag(roomId, threadId)
return getNotificationsForSession(sessionId).filter { it.id == notificationId && it.tag == expectedTag }
}
override fun getAllMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification> {
val notificationId = NotificationIdProvider.getRoomMessagesNotificationId(sessionId)
return getNotificationsForSession(sessionId).filter { it.id == notificationId && it.tag.startsWith(roomId.value) }
}
override fun getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification> {

View File

@@ -17,6 +17,8 @@ import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.EventId
@@ -85,6 +87,7 @@ class DefaultNotifiableEventResolver(
private val permalinkParser: PermalinkParser,
private val callNotificationEventResolver: CallNotificationEventResolver,
private val fallbackNotificationFactory: FallbackNotificationFactory,
private val featureFlagService: FeatureFlagService,
) : NotifiableEventResolver {
override suspend fun resolveEvents(
sessionId: SessionId,
@@ -141,7 +144,7 @@ class DefaultNotifiableEventResolver(
senderId = content.senderId,
roomId = roomId,
eventId = eventId,
threadId = threadId,
threadId = threadId.takeIf { featureFlagService.isFeatureEnabled(FeatureFlags.Threads) },
noisy = isNoisy,
timestamp = this.timestamp,
senderDisambiguatedDisplayName = senderDisambiguatedDisplayName,

View File

@@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom
import io.element.android.services.appnavstate.api.AppNavigationStateService
@@ -93,10 +94,10 @@ class DefaultNotificationDrawerManager(
)
}
is NavigationState.Thread -> {
onEnteringThread(
navigationState.parentRoom.parentSpace.parentSession.sessionId,
navigationState.parentRoom.roomId,
navigationState.threadId
clearMessagesForThread(
sessionId = navigationState.parentRoom.parentSpace.parentSession.sessionId,
roomId = navigationState.parentRoom.roomId,
threadId = navigationState.threadId,
)
}
}
@@ -145,6 +146,16 @@ class DefaultNotificationDrawerManager(
clearSummaryNotificationIfNeeded(sessionId)
}
/**
* Should be called when the application is currently opened and showing timeline for the given threadId.
* Used to ignore events related to that thread (no need to display notification) and clean any existing notification on this room.
*/
override fun clearMessagesForThread(sessionId: SessionId, roomId: RoomId, threadId: ThreadId) {
val tag = NotificationCreator.messageTag(roomId, threadId)
notificationManager.cancel(tag, NotificationIdProvider.getRoomMessagesNotificationId(sessionId))
clearSummaryNotificationIfNeeded(sessionId)
}
override fun clearMembershipNotificationForSession(sessionId: SessionId) {
activeNotificationsProvider.getMembershipNotificationForSession(sessionId)
.forEach { notificationManager.cancel(it.tag, it.id) }
@@ -176,16 +187,6 @@ class DefaultNotificationDrawerManager(
}
}
/**
* Should be called when the application is currently opened and showing timeline for the given threadId.
* Used to ignore events related to that thread (no need to display notification) and clean any existing notification on this room.
*/
@Suppress("UNUSED_PARAMETER")
private fun onEnteringThread(sessionId: SessionId, roomId: RoomId, threadId: ThreadId) {
// TODO maybe we'll have to embed more data in the tag to get a threadId
// Do nothing for now
}
private suspend fun renderEvents(eventsToRender: List<NotifiableEvent>) {
// Group by sessionId
val eventsForSessions = eventsToRender.groupBy {

View File

@@ -16,9 +16,11 @@ 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.core.ThreadId
import io.element.android.libraries.matrix.api.room.CreateTimelineParams
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory
import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.libraries.push.impl.R
@@ -72,8 +74,12 @@ class NotificationBroadcastReceiverHandler(
notificationCleaner.clearEvent(sessionId, eventId)
}
actionIds.markRoomRead -> if (roomId != null) {
notificationCleaner.clearMessagesForRoom(sessionId, roomId)
handleMarkAsRead(sessionId, roomId)
if (threadId == null) {
notificationCleaner.clearMessagesForRoom(sessionId, roomId)
} else {
notificationCleaner.clearMessagesForThread(sessionId, roomId, threadId)
}
handleMarkAsRead(sessionId, roomId, threadId)
}
actionIds.join -> if (roomId != null) {
notificationCleaner.clearMembershipNotificationForRoom(sessionId, roomId)
@@ -96,7 +102,8 @@ class NotificationBroadcastReceiverHandler(
client.getRoom(roomId)?.leave()
}
private fun handleMarkAsRead(sessionId: SessionId, roomId: RoomId) = appCoroutineScope.launch {
@Suppress("unused")
private fun handleMarkAsRead(sessionId: SessionId, roomId: RoomId, threadId: ThreadId?) = appCoroutineScope.launch {
val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch
val isSendPublicReadReceiptsEnabled = sessionPreferencesStore.get(sessionId, this).isSendPublicReadReceiptsEnabled().first()
val receiptType = if (isSendPublicReadReceiptsEnabled) {
@@ -104,7 +111,26 @@ class NotificationBroadcastReceiverHandler(
} else {
ReceiptType.READ_PRIVATE
}
client.getRoom(roomId)?.markAsRead(receiptType = receiptType)
val room = client.getJoinedRoom(roomId) ?: return@launch
val timeline = if (threadId != null) {
room.createTimeline(CreateTimelineParams.Threaded(threadId)).getOrNull()
} else {
room.liveTimeline
}
timeline?.markAsRead(receiptType)
?.onSuccess {
if (threadId != null) {
Timber.d("Marked thread $threadId in room $roomId as read with receipt type $receiptType")
} else {
Timber.d("Marked room $roomId as read with receipt type $receiptType")
}
}
?.onFailure {
Timber.e(it, "Fails to mark as read with receipt type $receiptType")
}
if (timeline?.mode != Timeline.Mode.Live) {
timeline?.close()
}
}
private fun handleSmartReply(

View File

@@ -18,6 +18,7 @@ import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
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.core.ThreadId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
@@ -82,32 +83,38 @@ class DefaultNotificationDataFactory(
): List<RoomNotification> {
val messagesToDisplay = messages.filterNot { it.canNotBeDisplayed() }
.groupBy { it.roomId }
return messagesToDisplay.map { (roomId, events) ->
return messagesToDisplay.flatMap { (roomId, events) ->
val roomName = events.lastOrNull()?.roomName ?: roomId.value
val isDm = events.lastOrNull()?.roomIsDm ?: false
val notification = roomGroupMessageCreator.createRoomMessage(
currentUser = currentUser,
events = events,
roomId = roomId,
imageLoader = imageLoader,
existingNotification = getExistingNotificationForMessages(currentUser.userId, roomId),
color = color,
)
RoomNotification(
notification = notification,
roomId = roomId,
summaryLine = createRoomMessagesGroupSummaryLine(events, roomName, isDm),
messageCount = events.size,
latestTimestamp = events.maxOf { it.timestamp },
shouldBing = events.any { it.noisy }
)
val eventsByThreadId = events.groupBy { it.threadId }
eventsByThreadId.map { (threadId, events) ->
val notification = roomGroupMessageCreator.createRoomMessage(
currentUser = currentUser,
events = events,
roomId = roomId,
threadId = threadId,
imageLoader = imageLoader,
existingNotification = getExistingNotificationForMessages(currentUser.userId, roomId, threadId),
color = color,
)
RoomNotification(
notification = notification,
roomId = roomId,
threadId = threadId,
summaryLine = createRoomMessagesGroupSummaryLine(events, roomName, isDm),
messageCount = events.size,
latestTimestamp = events.maxOf { it.timestamp },
shouldBing = events.any { it.noisy }
)
}
}
}
private fun NotifiableMessageEvent.canNotBeDisplayed() = isRedacted
private fun getExistingNotificationForMessages(sessionId: SessionId, roomId: RoomId): Notification? {
return activeNotificationsProvider.getMessageNotificationsForRoom(sessionId, roomId).firstOrNull()?.notification
private fun getExistingNotificationForMessages(sessionId: SessionId, roomId: RoomId, threadId: ThreadId?): Notification? {
return activeNotificationsProvider.getMessageNotificationsForRoom(sessionId, roomId, threadId).firstOrNull()?.notification
}
@JvmName("toNotificationInvites")
@@ -228,6 +235,7 @@ class DefaultNotificationDataFactory(
data class RoomNotification(
val notification: Notification,
val roomId: RoomId,
val threadId: ThreadId?,
val summaryLine: CharSequence,
val messageCount: Int,
val latestTimestamp: Long,
@@ -236,6 +244,7 @@ data class RoomNotification(
fun isDataEqualTo(other: RoomNotification): Boolean {
return notification == other.notification &&
roomId == other.roomId &&
threadId == other.threadId &&
summaryLine.toString() == other.summaryLine.toString() &&
messageCount == other.messageCount &&
latestTimestamp == other.latestTimestamp &&

View File

@@ -15,6 +15,7 @@ import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
@@ -64,8 +65,12 @@ class NotificationRenderer(
}
roomNotifications.forEach { notificationData ->
val tag = NotificationCreator.messageTag(
roomId = notificationData.roomId,
threadId = notificationData.threadId
)
notificationDisplayer.showNotificationMessage(
tag = notificationData.roomId.value,
tag = tag,
id = NotificationIdProvider.getRoomMessagesNotificationId(currentUser.userId),
notification = notificationData.notification
)

View File

@@ -14,6 +14,7 @@ import coil3.ImageLoader
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
import io.element.android.libraries.push.impl.R
@@ -27,6 +28,7 @@ interface RoomGroupMessageCreator {
currentUser: MatrixUser,
events: List<NotifiableMessageEvent>,
roomId: RoomId,
threadId: ThreadId?,
imageLoader: ImageLoader,
existingNotification: Notification?,
@ColorInt color: Int,
@@ -43,6 +45,7 @@ class DefaultRoomGroupMessageCreator(
currentUser: MatrixUser,
events: List<NotifiableMessageEvent>,
roomId: RoomId,
threadId: ThreadId?,
imageLoader: ImageLoader,
existingNotification: Notification?,
@ColorInt color: Int,
@@ -73,7 +76,7 @@ class DefaultRoomGroupMessageCreator(
customSound = events.last().soundName,
isUpdated = events.last().isUpdated,
),
threadId = lastKnownRoomEvent.threadId,
threadId = threadId,
largeIcon = largeBitmap,
lastMessageTimestamp = lastMessageTimestamp,
tickerText = tickerText,

View File

@@ -107,7 +107,7 @@ class DefaultNotificationConversationService(
val shortcutInfo = ShortcutInfoCompat.Builder(context, createShortcutId(sessionId, roomId))
.setShortLabel(roomName)
.setIcon(icon)
.setIntent(intentProvider.getViewRoomIntent(sessionId, roomId, threadId = null))
.setIntent(intentProvider.getViewRoomIntent(sessionId, roomId, threadId = null, eventId = null))
.setCategories(categories)
.setLongLived(true)
.let {

View File

@@ -10,6 +10,7 @@ package io.element.android.libraries.push.impl.notifications.factories
import android.app.Notification
import android.content.Context
import android.graphics.Bitmap
import android.graphics.drawable.Icon
import androidx.annotation.ColorInt
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.MessagingStyle
@@ -20,7 +21,7 @@ import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.libraries.matrix.api.user.MatrixUser
@@ -38,6 +39,7 @@ import io.element.android.libraries.push.impl.notifications.model.InviteNotifiab
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import io.element.android.libraries.push.impl.notifications.shortcut.createShortcutId
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.toolbox.api.strings.StringProvider
interface NotificationCreator {
@@ -86,6 +88,17 @@ interface NotificationCreator {
fun createDiagnosticNotification(
@ColorInt color: Int,
): Notification
companion object {
/**
* Creates a tag for a message notification given its [roomId] and optional [threadId].
*/
fun messageTag(roomId: RoomId, threadId: ThreadId?): String = if (threadId != null) {
"$roomId|$threadId"
} else {
roomId.value
}
}
}
@ContributesBinding(AppScope::class)
@@ -99,7 +112,7 @@ class DefaultNotificationCreator(
private val quickReplyActionFactory: QuickReplyActionFactory,
private val bitmapLoader: NotificationBitmapLoader,
private val acceptInvitationActionFactory: AcceptInvitationActionFactory,
private val rejectInvitationActionFactory: RejectInvitationActionFactory
private val rejectInvitationActionFactory: RejectInvitationActionFactory,
) : NotificationCreator {
/**
* Create a notification for a Room.
@@ -117,9 +130,10 @@ class DefaultNotificationCreator(
@ColorInt color: Int,
): Notification {
// Build the pending intent for when the notification is clicked
val eventId = events.firstOrNull()?.eventId
val openIntent = when {
threadId != null -> pendingIntentFactory.createOpenThreadPendingIntent(roomInfo, threadId)
else -> pendingIntentFactory.createOpenRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId)
threadId != null -> pendingIntentFactory.createOpenThreadPendingIntent(roomInfo.sessionId, roomInfo.roomId, eventId, threadId)
else -> pendingIntentFactory.createOpenRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId, eventId)
}
val smallIcon = CommonDrawables.ic_notification
val containsMissedCall = events.any { it.type == EventType.RTC_NOTIFICATION }
@@ -140,19 +154,30 @@ class DefaultNotificationCreator(
// Must match those created in the ShortcutInfoCompat.Builder()
// for the notification to appear as a "Conversation":
// https://developer.android.com/develop/ui/views/notifications/conversations
.setShortcutId(createShortcutId(roomInfo.sessionId, roomInfo.roomId))
.apply {
if (threadId == null) {
setShortcutId(createShortcutId(roomInfo.sessionId, roomInfo.roomId))
}
}
// Auto-bundling is enabled for 4 or more notifications on API 24+ (N+)
// devices and all Wear devices. But we want a custom grouping, so we specify the groupID
.setGroup(roomInfo.sessionId.value)
.setGroupSummary(false)
// In order to avoid notification making sound twice (due to the summary notification)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
// Remove notification after opening it or using an action
.setAutoCancel(true)
}
val messagingStyle = existingNotification?.let {
MessagingStyle.extractMessagingStyleFromNotification(it)
} ?: messagingStyleFromCurrentUser(roomInfo.sessionId, currentUser, imageLoader, roomInfo.roomDisplayName, !roomInfo.isDm)
} ?: messagingStyleFromCurrentUser(
user = currentUser,
imageLoader = imageLoader,
roomName = roomInfo.roomDisplayName,
isThread = threadId != null,
roomIsGroup = !roomInfo.isDm,
)
messagingStyle.addMessagesFromEvents(events, imageLoader)
@@ -162,19 +187,6 @@ class DefaultNotificationCreator(
.setWhen(lastMessageTimestamp)
// MESSAGING_STYLE sets title and content for API 16 and above devices.
.setStyle(messagingStyle)
// Not needed anymore?
// Title for API < 16 devices.
.setContentTitle(roomInfo.roomDisplayName.annotateForDebug(1))
// Content for API < 16 devices.
.setContentText(stringProvider.getString(R.string.notification_new_messages).annotateForDebug(2))
// Number of new notifications for API <24 (M and below) devices.
.setSubText(
stringProvider.getQuantityString(
R.plurals.notification_new_messages_for_room,
messagingStyle.messages.size,
messagingStyle.messages.size
).annotateForDebug(3)
)
.setSmallIcon(smallIcon)
// Set primary color (important for Wear 2.0 Notifications).
.setColor(color)
@@ -197,8 +209,8 @@ class DefaultNotificationCreator(
// Clear existing actions since we might be updating an existing notification
clearActions()
// Add actions and notification intents
// Mark room as read
addAction(markAsReadActionFactory.create(roomInfo))
// Mark room/thread as read
addAction(markAsReadActionFactory.create(roomInfo, threadId))
// Quick reply
if (!roomInfo.hasSmartReplyError) {
val latestEventId = events.lastOrNull()?.eventId
@@ -208,7 +220,7 @@ class DefaultNotificationCreator(
setContentIntent(openIntent)
}
if (largeIcon != null) {
setLargeIcon(largeIcon)
setLargeIcon(Icon.createWithBitmap(largeIcon))
}
setDeleteIntent(pendingIntentFactory.createDismissRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId))
@@ -239,7 +251,7 @@ class DefaultNotificationCreator(
addAction(rejectInvitationActionFactory.create(inviteNotifiableEvent))
addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent))
// Build the pending intent for when the notification is clicked
setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent(inviteNotifiableEvent.sessionId, inviteNotifiableEvent.roomId))
setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent(inviteNotifiableEvent.sessionId, inviteNotifiableEvent.roomId, null))
if (inviteNotifiableEvent.noisy) {
// Compat
@@ -279,7 +291,7 @@ class DefaultNotificationCreator(
.setSmallIcon(smallIcon)
.setColor(color)
.setAutoCancel(true)
.setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent(simpleNotifiableEvent.sessionId, simpleNotifiableEvent.roomId))
.setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent(simpleNotifiableEvent.sessionId, simpleNotifiableEvent.roomId, null))
.apply {
if (simpleNotifiableEvent.noisy) {
// Compat
@@ -447,21 +459,26 @@ class DefaultNotificationCreator(
}
private suspend fun messagingStyleFromCurrentUser(
sessionId: SessionId,
user: MatrixUser,
imageLoader: ImageLoader,
roomName: String,
isThread: Boolean,
roomIsGroup: Boolean
): MessagingStyle {
return MessagingStyle(
Person.Builder()
.setName(user.displayName?.annotateForDebug(50))
.setIcon(bitmapLoader.getUserIcon(user.avatarUrl, imageLoader))
.setKey(sessionId.value)
.setKey(user.userId.value)
.build()
).also {
it.conversationTitle = roomName.takeIf { roomIsGroup }
it.isGroupConversation = roomIsGroup
it.conversationTitle = if (isThread) {
stringProvider.getString(CommonStrings.notification_thread_in_room, roomName)
} else {
roomName
}
// So the avatar is displayed even if they're part of a conversation
it.isGroupConversation = roomIsGroup || isThread
}
}

View File

@@ -20,7 +20,6 @@ import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.push.impl.intent.IntentProvider
import io.element.android.libraries.push.impl.notifications.NotificationActionIds
import io.element.android.libraries.push.impl.notifications.NotificationBroadcastReceiver
import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo
import io.element.android.libraries.push.impl.notifications.TestNotificationReceiver
import io.element.android.services.toolbox.api.systemclock.SystemClock
@@ -32,19 +31,19 @@ class PendingIntentFactory(
private val actionIds: NotificationActionIds,
) {
fun createOpenSessionPendingIntent(sessionId: SessionId): PendingIntent? {
return createRoomPendingIntent(sessionId = sessionId, roomId = null, threadId = null)
return createRoomPendingIntent(sessionId = sessionId, roomId = null, eventId = null, threadId = null)
}
fun createOpenRoomPendingIntent(sessionId: SessionId, roomId: RoomId): PendingIntent? {
return createRoomPendingIntent(sessionId = sessionId, roomId = roomId, threadId = null)
fun createOpenRoomPendingIntent(sessionId: SessionId, roomId: RoomId, eventId: EventId?): PendingIntent? {
return createRoomPendingIntent(sessionId = sessionId, roomId = roomId, eventId = eventId, threadId = null)
}
fun createOpenThreadPendingIntent(roomInfo: RoomEventGroupInfo, threadId: ThreadId?): PendingIntent? {
return createRoomPendingIntent(sessionId = roomInfo.sessionId, roomId = roomInfo.roomId, threadId = threadId)
fun createOpenThreadPendingIntent(sessionId: SessionId, roomId: RoomId, eventId: EventId?, threadId: ThreadId): PendingIntent? {
return createRoomPendingIntent(sessionId = sessionId, roomId = roomId, eventId = eventId, threadId = threadId)
}
private fun createRoomPendingIntent(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?): PendingIntent? {
val intent = intentProvider.getViewRoomIntent(sessionId = sessionId, roomId = roomId, threadId = threadId)
private fun createRoomPendingIntent(sessionId: SessionId, roomId: RoomId?, eventId: EventId?, threadId: ThreadId?): PendingIntent? {
val intent = intentProvider.getViewRoomIntent(sessionId = sessionId, roomId = roomId, eventId = eventId, threadId = threadId)
return PendingIntent.getActivity(
context,
clock.epochMillis().toInt(),

View File

@@ -16,6 +16,7 @@ import io.element.android.appconfig.NotificationConfig
import io.element.android.libraries.androidutils.uri.createIgnoredUri
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.NotificationActionIds
import io.element.android.libraries.push.impl.notifications.NotificationBroadcastReceiver
@@ -30,15 +31,16 @@ class MarkAsReadActionFactory(
private val stringProvider: StringProvider,
private val clock: SystemClock,
) {
fun create(roomInfo: RoomEventGroupInfo): NotificationCompat.Action? {
fun create(roomInfo: RoomEventGroupInfo, threadId: ThreadId?): NotificationCompat.Action? {
if (!NotificationConfig.SHOW_MARK_AS_READ_ACTION) return null
val sessionId = roomInfo.sessionId.value
val roomId = roomInfo.roomId.value
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
intent.action = actionIds.markRoomRead
intent.data = createIgnoredUri("markRead/$sessionId/$roomId")
intent.data = createIgnoredUri("markRead/$sessionId/$roomId" + threadId?.let { "/$it" }.orEmpty())
intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId)
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId)
threadId?.let { intent.putExtra(NotificationBroadcastReceiver.KEY_THREAD_ID, threadId.value) }
val pendingIntent = PendingIntent.getBroadcast(
context,
clock.epochMillis().toInt(),

View File

@@ -183,9 +183,9 @@ class DefaultPushHandler(
}
}
// Process redactions of messages
// Process redactions of messages in background to not block operations with higher priority
if (redactions.isNotEmpty()) {
onRedactedEventReceived.onRedactedEventsReceived(redactions)
appCoroutineScope.launch { onRedactedEventReceived.onRedactedEventsReceived(redactions) }
}
// Find and process ringing call notifications separately

View File

@@ -16,7 +16,6 @@ import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.push.impl.notifications.ActiveNotificationsProvider
import io.element.android.libraries.push.impl.notifications.NotificationDisplayer
@@ -24,71 +23,63 @@ import io.element.android.libraries.push.impl.notifications.factories.DefaultNot
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.toolbox.api.strings.StringProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
interface OnRedactedEventReceived {
fun onRedactedEventsReceived(redactions: List<ResolvedPushEvent.Redaction>)
suspend fun onRedactedEventsReceived(redactions: List<ResolvedPushEvent.Redaction>)
}
@ContributesBinding(AppScope::class)
class DefaultOnRedactedEventReceived(
private val activeNotificationsProvider: ActiveNotificationsProvider,
private val notificationDisplayer: NotificationDisplayer,
@AppCoroutineScope
private val coroutineScope: CoroutineScope,
@ApplicationContext private val context: Context,
private val stringProvider: StringProvider,
) : OnRedactedEventReceived {
override fun onRedactedEventsReceived(redactions: List<ResolvedPushEvent.Redaction>) {
coroutineScope.launch {
val redactionsBySessionIdAndRoom = redactions.groupBy { redaction ->
redaction.sessionId to redaction.roomId
override suspend fun onRedactedEventsReceived(redactions: List<ResolvedPushEvent.Redaction>) {
val redactionsBySessionIdAndRoom = redactions.groupBy { redaction ->
redaction.sessionId to redaction.roomId
}
for ((keys, roomRedactions) in redactionsBySessionIdAndRoom) {
val (sessionId, roomId) = keys
// Get all notifications for the room, including those for threads
val notifications = activeNotificationsProvider.getAllMessageNotificationsForRoom(sessionId, roomId)
if (notifications.isEmpty()) {
Timber.d("No notifications found for redacted event")
}
for ((keys, roomRedactions) in redactionsBySessionIdAndRoom) {
val (sessionId, roomId) = keys
val notifications = activeNotificationsProvider.getMessageNotificationsForRoom(
sessionId,
roomId,
notifications.forEach { statusBarNotification ->
val notification = statusBarNotification.notification
val messagingStyle = MessagingStyle.extractMessagingStyleFromNotification(notification)
if (messagingStyle == null) {
Timber.w("Unable to retrieve messaging style from notification")
return@forEach
}
val messageToRedactIndex = messagingStyle.messages.indexOfFirst { message ->
roomRedactions.any { it.redactedEventId.value == message.extras.getString(DefaultNotificationCreator.MESSAGE_EVENT_ID) }
}
if (messageToRedactIndex == -1) {
Timber.d("Unable to find the message to remove from notification")
return@forEach
}
val oldMessage = messagingStyle.messages[messageToRedactIndex]
val content = buildSpannedString {
inSpans(StyleSpan(Typeface.ITALIC)) {
append(stringProvider.getString(CommonStrings.common_message_removed))
}
}
val newMessage = MessagingStyle.Message(
content,
oldMessage.timestamp,
oldMessage.person
)
messagingStyle.messages[messageToRedactIndex] = newMessage
notificationDisplayer.showNotificationMessage(
statusBarNotification.tag,
statusBarNotification.id,
NotificationCompat.Builder(context, notification)
.setStyle(messagingStyle)
.build()
)
if (notifications.isEmpty()) {
Timber.d("No notifications found for redacted event")
}
notifications.forEach { statusBarNotification ->
val notification = statusBarNotification.notification
val messagingStyle = MessagingStyle.extractMessagingStyleFromNotification(notification)
if (messagingStyle == null) {
Timber.w("Unable to retrieve messaging style from notification")
return@forEach
}
val messageToRedactIndex = messagingStyle.messages.indexOfFirst { message ->
roomRedactions.any { it.redactedEventId.value == message.extras.getString(DefaultNotificationCreator.MESSAGE_EVENT_ID) }
}
if (messageToRedactIndex == -1) {
Timber.d("Unable to find the message to remove from notification")
return@forEach
}
val oldMessage = messagingStyle.messages[messageToRedactIndex]
val content = buildSpannedString {
inSpans(StyleSpan(Typeface.ITALIC)) {
append(stringProvider.getString(CommonStrings.common_message_removed))
}
}
val newMessage = MessagingStyle.Message(
content,
oldMessage.timestamp,
oldMessage.person
)
messagingStyle.messages[messageToRedactIndex] = newMessage
notificationDisplayer.showNotificationMessage(
statusBarNotification.tag,
statusBarNotification.id,
NotificationCompat.Builder(context, notification)
.setStyle(messagingStyle)
.build()
)
}
}
}
}

View File

@@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.mockk.every
import io.mockk.mockk
@@ -80,8 +81,35 @@ class DefaultActiveNotificationsProviderTest {
)
val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications)
assertThat(activeNotificationsProvider.getMessageNotificationsForRoom(A_SESSION_ID, A_ROOM_ID)).hasSize(1)
assertThat(activeNotificationsProvider.getMessageNotificationsForRoom(A_SESSION_ID_2, A_ROOM_ID_2)).isEmpty()
assertThat(activeNotificationsProvider.getMessageNotificationsForRoom(A_SESSION_ID, A_ROOM_ID, null)).hasSize(1)
assertThat(activeNotificationsProvider.getMessageNotificationsForRoom(A_SESSION_ID_2, A_ROOM_ID_2, null)).isEmpty()
}
@Test
fun `getMessageNotificationsForRoom with thread id returns only message notifications for a thread using those session and room ids`() {
val activeNotifications = listOf(
aStatusBarNotification(
id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID),
groupId = A_SESSION_ID.value,
tag = "$A_ROOM_ID|$A_THREAD_ID",
),
aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value, tag = A_ROOM_ID.value),
aStatusBarNotification(
id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID_2),
groupId = A_SESSION_ID_2.value,
tag = "$A_ROOM_ID|$A_THREAD_ID",
),
aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID_2), groupId = A_SESSION_ID_2.value, tag = A_ROOM_ID.value),
aStatusBarNotification(
id = notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID_2),
groupId = A_SESSION_ID_2.value,
tag = "$A_ROOM_ID|$A_THREAD_ID",
),
)
val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications)
assertThat(activeNotificationsProvider.getMessageNotificationsForRoom(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID)).hasSize(1)
assertThat(activeNotificationsProvider.getMessageNotificationsForRoom(A_SESSION_ID_2, A_ROOM_ID_2, A_THREAD_ID)).isEmpty()
}
@Test

View File

@@ -53,6 +53,7 @@ class DefaultBaseRoomGroupMessageCreatorTest {
roomId = A_ROOM_ID,
imageLoader = fakeImageLoader.getImageLoader(),
existingNotification = null,
threadId = null,
color = A_COLOR_INT,
)
assertThat(result.number).isEqualTo(1)
@@ -76,6 +77,7 @@ class DefaultBaseRoomGroupMessageCreatorTest {
roomId = A_ROOM_ID,
imageLoader = fakeImageLoader.getImageLoader(),
existingNotification = null,
threadId = null,
color = A_COLOR_INT,
)
@Suppress("DEPRECATION")
@@ -141,6 +143,7 @@ class DefaultBaseRoomGroupMessageCreatorTest {
roomId = A_ROOM_ID,
imageLoader = fakeImageLoader.getImageLoader(),
existingNotification = null,
threadId = null,
color = A_COLOR_INT,
)
assertThat(result.number).isEqualTo(1)
@@ -160,6 +163,7 @@ class DefaultBaseRoomGroupMessageCreatorTest {
roomId = A_ROOM_ID,
imageLoader = fakeImageLoader.getImageLoader(),
existingNotification = null,
threadId = null,
color = A_COLOR_INT,
)
assertThat(result.number).isEqualTo(2)
@@ -189,6 +193,7 @@ class DefaultBaseRoomGroupMessageCreatorTest {
roomId = A_ROOM_ID,
imageLoader = fakeImageLoader.getImageLoader(),
existingNotification = null,
threadId = null,
color = A_COLOR_INT,
)
val actionTitles = result.actions?.map { it.title }
@@ -214,6 +219,7 @@ class DefaultBaseRoomGroupMessageCreatorTest {
roomId = A_ROOM_ID,
imageLoader = fakeImageLoader.getImageLoader(),
existingNotification = null,
threadId = null,
color = A_COLOR_INT,
)
assertThat(result.number).isEqualTo(1)

View File

@@ -9,6 +9,7 @@ package io.element.android.libraries.push.impl.notifications
import android.content.Context
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.exception.NotificationResolverException
import io.element.android.libraries.matrix.api.media.MediaSource
@@ -854,7 +855,8 @@ class DefaultNotifiableEventResolverTest {
fallbackNotificationFactory = FallbackNotificationFactory(
clock = FakeSystemClock(),
stringProvider = FakeStringProvider(defaultResult = "You have new messages.")
)
),
featureFlagService = FakeFeatureFlagService(),
)
}
}

View File

@@ -64,7 +64,7 @@ class DefaultNotificationDrawerManagerTest {
// For now just call all the API. Later, add more valuable tests.
val matrixUser = aMatrixUser(id = A_SESSION_ID.value, displayName = "alice", avatarUrl = "mxc://data")
val mockRoomGroupMessageCreator = FakeRoomGroupMessageCreator(
createRoomMessageResult = lambdaRecorder { user, _, roomId, _, existingNotification ->
createRoomMessageResult = lambdaRecorder { user, _, roomId, _, _, existingNotification ->
assertThat(user).isEqualTo(matrixUser)
assertThat(roomId).isEqualTo(A_ROOM_ID)
assertThat(existingNotification).isNull()
@@ -144,9 +144,16 @@ class DefaultNotificationDrawerManagerTest {
messageCreator.createRoomMessageResult.assertions()
.isCalledExactly(3)
.withSequence(
listOf(value(aMatrixUser(id = A_SESSION_ID.value, displayName = "alice")), any(), any(), any(), any()),
listOf(value(aMatrixUser(id = A_SESSION_ID.value, displayName = A_SESSION_ID.value)), any(), any(), any(), any()),
listOf(value(aMatrixUser(id = A_SESSION_ID.value, displayName = A_SESSION_ID.value, avatarUrl = AN_AVATAR_URL)), any(), any(), any(), any()),
listOf(value(aMatrixUser(id = A_SESSION_ID.value, displayName = "alice")), any(), any(), any(), any(), any()),
listOf(value(aMatrixUser(id = A_SESSION_ID.value, displayName = A_SESSION_ID.value)), any(), any(), any(), any(), any()),
listOf(
value(aMatrixUser(id = A_SESSION_ID.value, displayName = A_SESSION_ID.value, avatarUrl = AN_AVATAR_URL)),
any(),
any(),
any(),
any(),
any()
),
)
defaultNotificationDrawerManager.destroy()

View File

@@ -43,6 +43,7 @@ class DefaultSummaryGroupMessageCreatorTest {
messageCount = 1,
latestTimestamp = A_FAKE_TIMESTAMP + 10,
shouldBing = true,
threadId = null,
)
),
invitationNotifications = emptyList(),

View File

@@ -222,10 +222,11 @@ class NotificationBroadcastReceiverHandlerTest {
)
val clearMessagesForRoomLambda = lambdaRecorder<SessionId, RoomId, Unit> { _, _ -> }
val markAsReadResult = lambdaRecorder<ReceiptType, Result<Unit>> { Result.success(Unit) }
val timeline = FakeTimeline(markAsReadResult = markAsReadResult)
val joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
markAsReadResult = markAsReadResult,
),
baseRoom = FakeBaseRoom(),
liveTimeline = timeline,
createTimelineResult = { Result.success(timeline) },
)
val fakeNotificationCleaner = FakeNotificationCleaner(
clearMessagesForRoomLambda = clearMessagesForRoomLambda,

View File

@@ -97,6 +97,7 @@ class NotificationDataFactoryTest {
currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL),
events = events,
roomId = A_ROOM_ID,
threadId = null,
imageLoader = FakeImageLoader().getImageLoader(),
existingNotification = null,
color = A_COLOR_INT,
@@ -105,7 +106,8 @@ class NotificationDataFactoryTest {
summaryLine = "A room name: Bob Hello world!",
messageCount = events.size,
latestTimestamp = events.maxOf { it.timestamp },
shouldBing = events.any { it.noisy }
shouldBing = events.any { it.noisy },
threadId = null,
)
val roomWithMessage = listOf(A_MESSAGE_EVENT)
@@ -152,6 +154,7 @@ class NotificationDataFactoryTest {
currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL),
events = withRedactedRemoved,
roomId = A_ROOM_ID,
threadId = null,
imageLoader = FakeImageLoader().getImageLoader(),
existingNotification = null,
color = A_COLOR_INT,
@@ -160,7 +163,8 @@ class NotificationDataFactoryTest {
summaryLine = "A room name: Bob Hello world!",
messageCount = withRedactedRemoved.size,
latestTimestamp = withRedactedRemoved.maxOf { it.timestamp },
shouldBing = withRedactedRemoved.any { it.noisy }
shouldBing = withRedactedRemoved.any { it.noisy },
threadId = null,
)
val fakeImageLoader = FakeImageLoader()

View File

@@ -71,7 +71,7 @@ class NotificationRendererTest {
@Test
fun `given a room message group notification is added when rendering then show the message notification and update summary`() = runTest {
roomGroupMessageCreator.createRoomMessageResult = lambdaRecorder { _, _, _, _, _ -> A_NOTIFICATION }
roomGroupMessageCreator.createRoomMessageResult = lambdaRecorder { _, _, _, _, _, _ -> A_NOTIFICATION }
renderEventsAsNotifications(listOf(aNotifiableMessageEvent()))

View File

@@ -32,6 +32,7 @@ import io.element.android.libraries.push.impl.notifications.factories.action.Acc
import io.element.android.libraries.push.impl.notifications.factories.action.MarkAsReadActionFactory
import io.element.android.libraries.push.impl.notifications.factories.action.QuickReplyActionFactory
import io.element.android.libraries.push.impl.notifications.factories.action.RejectInvitationActionFactory
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
@@ -249,7 +250,7 @@ class DefaultNotificationCreatorTest {
currentUser = aMatrixUser(),
existingNotification = null,
imageLoader = FakeImageLoader().getImageLoader(),
events = emptyList(),
events = listOf(aNotifiableMessageEvent()),
color = A_COLOR_INT,
)
result.commonAssertions()
@@ -276,7 +277,7 @@ class DefaultNotificationCreatorTest {
currentUser = aMatrixUser(),
existingNotification = null,
imageLoader = FakeImageLoader().getImageLoader(),
events = emptyList(),
events = listOf(aNotifiableMessageEvent()),
color = A_COLOR_INT,
)
result.commonAssertions()

View File

@@ -8,11 +8,12 @@
package io.element.android.libraries.push.impl.notifications.factories
import android.content.Intent
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.core.ThreadId
import io.element.android.libraries.push.impl.intent.IntentProvider
class FakeIntentProvider : IntentProvider {
override fun getViewRoomIntent(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?) = Intent(Intent.ACTION_VIEW)
override fun getViewRoomIntent(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?, eventId: EventId?) = Intent(Intent.ACTION_VIEW)
}

View File

@@ -10,18 +10,24 @@ package io.element.android.libraries.push.impl.notifications.fake
import android.service.notification.StatusBarNotification
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.core.ThreadId
import io.element.android.libraries.push.impl.notifications.ActiveNotificationsProvider
class FakeActiveNotificationsProvider(
private val getMessageNotificationsForRoomResult: (SessionId, RoomId) -> List<StatusBarNotification> = { _, _ -> emptyList() },
private val getMessageNotificationsForRoomResult: (SessionId, RoomId, ThreadId?) -> List<StatusBarNotification> = { _, _, _ -> emptyList() },
private val getAllMessageNotificationsForRoomResult: (SessionId, RoomId) -> List<StatusBarNotification> = { _, _ -> emptyList() },
private val getNotificationsForSessionResult: (SessionId) -> List<StatusBarNotification> = { emptyList() },
private val getMembershipNotificationForSessionResult: (SessionId) -> List<StatusBarNotification> = { emptyList() },
private val getMembershipNotificationForRoomResult: (SessionId, RoomId) -> List<StatusBarNotification> = { _, _ -> emptyList() },
private val getSummaryNotificationResult: (SessionId) -> StatusBarNotification? = { null },
private val countResult: (SessionId) -> Int = { 0 },
) : ActiveNotificationsProvider {
override fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification> {
return getMessageNotificationsForRoomResult(sessionId, roomId)
override fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId, threadId: ThreadId?): List<StatusBarNotification> {
return getMessageNotificationsForRoomResult(sessionId, roomId, threadId)
}
override fun getAllMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification> {
return getAllMessageNotificationsForRoomResult(sessionId, roomId)
}
override fun getNotificationsForSession(sessionId: SessionId): List<StatusBarNotification> {

View File

@@ -11,25 +11,29 @@ import android.app.Notification
import androidx.annotation.ColorInt
import coil3.ImageLoader
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.notifications.RoomGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.tests.testutils.lambda.LambdaFiveParamsRecorder
import io.element.android.tests.testutils.lambda.LambdaSixParamsRecorder
import io.element.android.tests.testutils.lambda.lambdaRecorder
// We just can't make the param types fit
@Suppress("MaxLineLength", "ktlint:standard:max-line-length", "ktlint:standard:parameter-wrapping")
class FakeRoomGroupMessageCreator(
var createRoomMessageResult: LambdaFiveParamsRecorder<MatrixUser, List<NotifiableMessageEvent>, RoomId, ImageLoader, Notification?, Notification> =
lambdaRecorder { _, _, _, _, _ -> A_NOTIFICATION }
var createRoomMessageResult: LambdaSixParamsRecorder<MatrixUser, List<NotifiableMessageEvent>, RoomId, ThreadId?, ImageLoader, Notification?, Notification> =
lambdaRecorder { _, _, _, _, _, _ -> A_NOTIFICATION }
) : RoomGroupMessageCreator {
override suspend fun createRoomMessage(
currentUser: MatrixUser,
events: List<NotifiableMessageEvent>,
roomId: RoomId,
threadId: ThreadId?,
imageLoader: ImageLoader,
existingNotification: Notification?,
@ColorInt color: Int,
): Notification {
return createRoomMessageResult(currentUser, events, roomId, imageLoader, existingNotification)
return createRoomMessageResult(currentUser, events, roomId, threadId, imageLoader, existingNotification)
}
}

View File

@@ -7,21 +7,30 @@
package io.element.android.libraries.push.impl.push
import android.app.Notification
import android.service.notification.StatusBarNotification
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.RoomId
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_EVENT_ID_2
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.push.impl.notifications.factories.DefaultNotificationCreator
import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
@@ -29,43 +38,113 @@ import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class DefaultOnRedactedEventReceivedTest {
private val fakePerson = Person.Builder().setName(A_USER_NAME).setKey(A_USER_ID.value).build()
private val fakeMessage = NotificationCompat.MessagingStyle.Message("A message", 0L, fakePerson).also {
it.extras.putString(DefaultNotificationCreator.MESSAGE_EVENT_ID, AN_EVENT_ID.value)
}
private val fakeNotification = NotificationCompat.Builder(InstrumentationRegistry.getInstrumentation().targetContext, "aChannel")
.setStyle(
NotificationCompat.MessagingStyle(fakePerson)
.addMessage(fakeMessage)
)
.setGroup(A_SESSION_ID.value)
.build()
private val fakeIncorrectMessage = NotificationCompat.MessagingStyle.Message("The wrong message", 0L, fakePerson).also {
it.extras.putString(DefaultNotificationCreator.MESSAGE_EVENT_ID, AN_EVENT_ID_2.value)
}
private val fakeIncorrectNotification = NotificationCompat.Builder(InstrumentationRegistry.getInstrumentation().targetContext, "aChannel")
.setGroup(A_SESSION_ID.value)
.setStyle(
NotificationCompat.MessagingStyle(fakePerson)
.addMessage(fakeIncorrectMessage)
)
.build()
@Test
fun `when no notifications are found, nothing happen`() = runTest {
val showNotificationLambda = lambdaRecorder<String?, Int, Notification, Boolean> { _, _, _ -> true }
val sut = createDefaultOnRedactedEventReceived(
getMessageNotificationsForRoomResult = { _, _ -> emptyList() }
getAllMessageNotificationsForRoomResult = { _, _ -> emptyList() },
displayer = FakeNotificationDisplayer(showNotificationLambda),
)
sut.onRedactedEventsReceived(listOf(ResolvedPushEvent.Redaction(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, null)))
showNotificationLambda.assertions().isNeverCalled()
}
@Test
fun `when a notification is found, try to retrieve the message`() = runTest {
val showNotificationLambda = lambdaRecorder<String?, Int, Notification, Boolean> { tag, id, _ ->
assertThat(tag).isEqualTo(A_ROOM_ID.value)
assertThat(id).isEqualTo(1)
true
}
val sut = createDefaultOnRedactedEventReceived(
getMessageNotificationsForRoomResult = { _, _ ->
getAllMessageNotificationsForRoomResult = { _, _ ->
listOf(
mockk {
every { notification } returns mockk {}
every { id } returns 1
every { notification } returns fakeNotification
every { tag } returns A_ROOM_ID.value
},
mockk {
every { id } returns 2
every { notification } returns fakeIncorrectNotification
every { tag } returns A_ROOM_ID.value
}
)
}
},
displayer = FakeNotificationDisplayer(showNotificationLambda),
)
sut.onRedactedEventsReceived(listOf(ResolvedPushEvent.Redaction(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, null)))
showNotificationLambda.assertions().isCalledOnce()
}
private fun TestScope.createDefaultOnRedactedEventReceived(
getMessageNotificationsForRoomResult: (SessionId, RoomId) -> List<StatusBarNotification> = { _, _ -> lambdaError() },
@Test
fun `when thread notifications are found, try to retrieve the message`() = runTest {
val showNotificationLambda = lambdaRecorder<String?, Int, Notification, Boolean> { tag, id, _ ->
assertThat(tag).isEqualTo("$A_ROOM_ID|$A_THREAD_ID")
assertThat(id).isEqualTo(1)
true
}
val sut = createDefaultOnRedactedEventReceived(
getAllMessageNotificationsForRoomResult = { _, _ ->
listOf(
mockk {
every { id } returns 1
every { notification } returns fakeNotification
every { tag } returns "$A_ROOM_ID|$A_THREAD_ID"
},
mockk {
every { id } returns 2
every { notification } returns fakeIncorrectNotification
every { tag } returns A_ROOM_ID.value
}
)
},
displayer = FakeNotificationDisplayer(showNotificationMessageResult = showNotificationLambda),
)
sut.onRedactedEventsReceived(listOf(ResolvedPushEvent.Redaction(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, null)))
showNotificationLambda.assertions().isCalledOnce()
}
private fun createDefaultOnRedactedEventReceived(
getAllMessageNotificationsForRoomResult: (SessionId, RoomId) -> List<StatusBarNotification> = { _, _ -> lambdaError() },
displayer: FakeNotificationDisplayer = FakeNotificationDisplayer(),
): DefaultOnRedactedEventReceived {
val context = InstrumentationRegistry.getInstrumentation().context
return DefaultOnRedactedEventReceived(
activeNotificationsProvider = FakeActiveNotificationsProvider(
getMessageNotificationsForRoomResult = getMessageNotificationsForRoomResult,
getMessageNotificationsForRoomResult = { _, _, _ -> lambdaError() },
getAllMessageNotificationsForRoomResult = getAllMessageNotificationsForRoomResult,
getNotificationsForSessionResult = { lambdaError() },
getMembershipNotificationForSessionResult = { lambdaError() },
getMembershipNotificationForRoomResult = { _, _ -> lambdaError() },
getSummaryNotificationResult = { lambdaError() },
countResult = { lambdaError() },
),
notificationDisplayer = FakeNotificationDisplayer(),
coroutineScope = this,
notificationDisplayer = displayer,
context = context,
stringProvider = FakeStringProvider(),
)

View File

@@ -13,7 +13,7 @@ import io.element.android.tests.testutils.lambda.lambdaError
class FakeOnRedactedEventReceived(
private val onRedactedEventsReceivedResult: (List<ResolvedPushEvent.Redaction>) -> Unit = { lambdaError() },
) : OnRedactedEventReceived {
override fun onRedactedEventsReceived(redactions: List<ResolvedPushEvent.Redaction>) {
override suspend fun onRedactedEventsReceived(redactions: List<ResolvedPushEvent.Redaction>) {
onRedactedEventsReceivedResult(redactions)
}
}

View File

@@ -10,12 +10,14 @@ package io.element.android.libraries.push.test.notifications
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.core.ThreadId
import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.tests.testutils.lambda.lambdaError
class FakeNotificationCleaner(
private val clearAllMessagesEventsLambda: (SessionId) -> Unit = { lambdaError() },
private val clearMessagesForRoomLambda: (SessionId, RoomId) -> Unit = { _, _ -> lambdaError() },
private val clearMessagesForThreadLambda: (SessionId, RoomId, ThreadId) -> Unit = { _, _, _ -> lambdaError() },
private val clearEventLambda: (SessionId, EventId) -> Unit = { _, _ -> lambdaError() },
private val clearMembershipNotificationForSessionLambda: (SessionId) -> Unit = { lambdaError() },
private val clearMembershipNotificationForRoomLambda: (SessionId, RoomId) -> Unit = { _, _ -> lambdaError() }
@@ -28,6 +30,10 @@ class FakeNotificationCleaner(
clearMessagesForRoomLambda(sessionId, roomId)
}
override fun clearMessagesForThread(sessionId: SessionId, roomId: RoomId, threadId: ThreadId) {
clearMessagesForThreadLambda(sessionId, roomId, threadId)
}
override fun clearEvent(sessionId: SessionId, eventId: EventId) {
clearEventLambda(sessionId, eventId)
}