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 dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.deeplink.api.DeepLinkCreator import io.element.android.libraries.deeplink.api.DeepLinkCreator
import io.element.android.libraries.di.annotations.ApplicationContext 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.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
@ -29,10 +30,11 @@ class DefaultIntentProvider(
sessionId: SessionId, sessionId: SessionId,
roomId: RoomId?, roomId: RoomId?,
threadId: ThreadId?, threadId: ThreadId?,
eventId: EventId?,
): Intent { ): Intent {
return Intent(context, MainActivity::class.java).apply { return Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW 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 android.content.Intent
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.deeplink.api.DeepLinkCreator 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.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId 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_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID import io.element.android.libraries.matrix.test.A_THREAD_ID
@ -31,14 +33,15 @@ import org.robolectric.RuntimeEnvironment
class DefaultIntentProviderTest { class DefaultIntentProviderTest {
@Test @Test
fun `test getViewRoomIntent with data`() { 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( 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( val result = sut.getViewRoomIntent(
sessionId = A_SESSION_ID, sessionId = A_SESSION_ID,
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
threadId = A_THREAD_ID, threadId = A_THREAD_ID,
eventId = AN_EVENT_ID,
) )
result.commonAssertions() result.commonAssertions()
assertThat(result.data.toString()).isEqualTo("deepLinkCreatorResult") assertThat(result.data.toString()).isEqualTo("deepLinkCreatorResult")
@ -46,11 +49,12 @@ class DefaultIntentProviderTest {
value(A_SESSION_ID), value(A_SESSION_ID),
value(A_ROOM_ID), value(A_ROOM_ID),
value(A_THREAD_ID), value(A_THREAD_ID),
value(AN_EVENT_ID),
) )
} }
private fun createDefaultIntentProvider( private fun createDefaultIntentProvider(
deepLinkCreator: DeepLinkCreator = DeepLinkCreator { _, _, _ -> "" }, deepLinkCreator: DeepLinkCreator = DeepLinkCreator { _, _, _, _ -> "" },
): DefaultIntentProvider { ): DefaultIntentProvider {
return DefaultIntentProvider( return DefaultIntentProvider(
context = RuntimeEnvironment.getApplication() as Context, 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.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode 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.architecture.waitForNavTargetAttached
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.theme.ElementThemeApp import io.element.android.libraries.designsystem.theme.ElementThemeApp
@ -496,7 +497,7 @@ class LoggedInFlowNode(
trigger: JoinedRoom.Trigger? = null, trigger: JoinedRoom.Trigger? = null,
eventId: EventId? = null, eventId: EventId? = null,
clearBackstack: Boolean, clearBackstack: Boolean,
) { ): RoomFlowNode {
waitForNavTargetAttached { navTarget -> waitForNavTargetAttached { navTarget ->
navTarget is NavTarget.Home navTarget is NavTarget.Home
} }
@ -509,6 +510,15 @@ class LoggedInFlowNode(
) )
backstack.accept(AttachRoomOperation(roomNavTarget, clearBackstack)) 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) { 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.di.MatrixSessionCache
import io.element.android.appnav.intent.IntentResolver import io.element.android.appnav.intent.IntentResolver
import io.element.android.appnav.intent.ResolvedIntent 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.RootNavStateFlowFactory
import io.element.android.appnav.root.RootPresenter import io.element.android.appnav.root.RootPresenter
import io.element.android.appnav.root.RootView 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.deeplink.api.DeeplinkData
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags 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.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.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.oidc.api.OidcAction import io.element.android.libraries.oidc.api.OidcAction
@ -388,13 +392,19 @@ class RootFlowNode(
is PermalinkData.FallbackLink -> Unit is PermalinkData.FallbackLink -> Unit
is PermalinkData.RoomEmailInviteLink -> Unit is PermalinkData.RoomEmailInviteLink -> Unit
is PermalinkData.RoomLink -> { 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( attachRoom(
roomIdOrAlias = permalinkData.roomIdOrAlias, roomIdOrAlias = permalinkData.roomIdOrAlias,
trigger = JoinedRoom.Trigger.MobilePermalink, trigger = JoinedRoom.Trigger.MobilePermalink,
serverNames = permalinkData.viaParameters, serverNames = permalinkData.viaParameters,
eventId = permalinkData.eventId, eventId = focusedEventId,
clearBackstack = true clearBackstack = true
) ).maybeAttachThread(permalinkData.threadId, permalinkData.eventId)
} }
is PermalinkData.UserLink -> { is PermalinkData.UserLink -> {
attachUser(permalinkData.userId) 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) { private suspend fun navigateTo(deeplinkData: DeeplinkData) {
Timber.d("Navigating to $deeplinkData") Timber.d("Navigating to $deeplinkData")
attachSession(deeplinkData.sessionId).apply { attachSession(deeplinkData.sessionId).let { loggedInFlowNode ->
when (deeplinkData) { when (deeplinkData) {
is DeeplinkData.Root -> Unit // The room list will always be shown, observing FtueState 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.core.coroutine.withPreviousValue
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient 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.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId 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.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.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias 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 -> private fun loadingNode(buildContext: BuildContext) = node(buildContext) { modifier ->
LoadingRoomNodeView( LoadingRoomNodeView(
state = LoadingRoomState.Loading, state = LoadingRoomState.Loading,

View file

@ -13,7 +13,9 @@ import kotlinx.parcelize.Parcelize
sealed interface RoomNavigationTarget : Parcelable { sealed interface RoomNavigationTarget : Parcelable {
@Parcelize @Parcelize
data class Root(val eventId: EventId? = null) : RoomNavigationTarget data class Root(
val eventId: EventId? = null,
) : RoomNavigationTarget
@Parcelize @Parcelize
data object Details : RoomNavigationTarget 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.createNode
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope 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.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.LoadingRoomState
import io.element.android.libraries.matrix.ui.room.LoadingRoomStateFlowFactory import io.element.android.libraries.matrix.ui.room.LoadingRoomStateFlowFactory
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
@ -121,6 +123,11 @@ class JoinedRoomFlowNode(
) )
} }
suspend fun attachThread(threadId: ThreadId, focusedEventId: EventId?) {
waitForChildAttached<JoinedRoomLoadedFlowNode>()
.attachThread(threadId, focusedEventId)
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
BackstackView( 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.appnav.room.RoomNavigationTarget
import io.element.android.features.forward.api.ForwardEntryPoint import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.features.messages.api.MessagesEntryPoint 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.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.features.space.api.SpaceEntryPoint import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs 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.DependencyInjectionGraphOwner
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient 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.EventId
import io.element.android.libraries.matrix.api.core.RoomId 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.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
@ -240,7 +243,9 @@ class JoinedRoomLoadedFlowNode(
data object Space : NavTarget data object Space : NavTarget
@Parcelize @Parcelize
data class Messages(val focusedEventId: EventId? = null) : NavTarget data class Messages(
val focusedEventId: EventId? = null,
) : NavTarget
@Parcelize @Parcelize
data object RoomDetails : NavTarget data object RoomDetails : NavTarget
@ -258,6 +263,13 @@ class JoinedRoomLoadedFlowNode(
data object RoomNotificationSettings : NavTarget 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 @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
BackstackView() 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.deeplink.api.DeeplinkData
import io.element.android.libraries.matrix.api.core.UserId 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.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_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID import io.element.android.libraries.matrix.test.A_THREAD_ID
@ -67,6 +68,7 @@ class IntentResolverTest {
sessionId = A_SESSION_ID, sessionId = A_SESSION_ID,
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
threadId = null, threadId = null,
eventId = null,
) )
) )
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
@ -79,6 +81,7 @@ class IntentResolverTest {
sessionId = A_SESSION_ID, sessionId = A_SESSION_ID,
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
threadId = null, threadId = null,
eventId = null,
) )
) )
) )
@ -91,6 +94,7 @@ class IntentResolverTest {
sessionId = A_SESSION_ID, sessionId = A_SESSION_ID,
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
threadId = A_THREAD_ID, threadId = A_THREAD_ID,
eventId = null,
) )
) )
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
@ -103,6 +107,59 @@ class IntentResolverTest {
sessionId = A_SESSION_ID, sessionId = A_SESSION_ID,
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
threadId = A_THREAD_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.architecture.NodeInputs
import io.element.android.libraries.matrix.api.core.EventId 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.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.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ -22,7 +23,9 @@ import kotlinx.parcelize.Parcelize
interface MessagesEntryPoint : FeatureEntryPoint { interface MessagesEntryPoint : FeatureEntryPoint {
sealed interface InitialTarget : Parcelable { sealed interface InitialTarget : Parcelable {
@Parcelize @Parcelize
data class Messages(val focusedEventId: EventId?) : InitialTarget data class Messages(
val focusedEventId: EventId?,
) : InitialTarget
@Parcelize @Parcelize
data object PinnedMessages : InitialTarget data object PinnedMessages : InitialTarget
@ -46,3 +49,7 @@ interface MessagesEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder 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.libraries.testtags)
implementation(projects.features.networkmonitor.api) implementation(projects.features.networkmonitor.api)
implementation(projects.services.analytics.compose) implementation(projects.services.analytics.compose)
implementation(projects.services.appnavstate.api)
implementation(projects.services.toolbox.api) implementation(projects.services.toolbox.api)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.datetime) 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.SendLocationEntryPoint
import io.element.android.features.location.api.ShowLocationEntryPoint import io.element.android.features.location.api.ShowLocationEntryPoint
import io.element.android.features.messages.api.MessagesEntryPoint 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.Attachment
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode
import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider 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.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import kotlin.time.Duration.Companion.milliseconds
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
@AssistedInject @AssistedInject
@ -126,8 +129,9 @@ class MessagesFlowNode(
savedStateMap = buildContext.savedStateMap, savedStateMap = buildContext.savedStateMap,
), ),
buildContext = buildContext, buildContext = buildContext,
plugins = plugins plugins = plugins,
) { ),
MessagesEntryPointNode {
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize @Parcelize
data class Messages(val focusedEventId: EventId?) : NavTarget data class Messages(val focusedEventId: EventId?) : NavTarget
@ -175,7 +179,7 @@ class MessagesFlowNode(
data object KnockRequestsList : NavTarget data object KnockRequestsList : NavTarget
@Parcelize @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>() private val callbacks = plugins<MessagesEntryPoint.Callback>()
@ -287,7 +291,7 @@ class MessagesFlowNode(
} }
override fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) { 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) val inputs = MessagesNode.Inputs(focusedEventId = navTarget.focusedEventId)
@ -420,7 +424,7 @@ class MessagesFlowNode(
NavTarget.KnockRequestsList -> { NavTarget.KnockRequestsList -> {
knockRequestsListEntryPoint.createNode(this, buildContext) knockRequestsListEntryPoint.createNode(this, buildContext)
} }
is NavTarget.OpenThread -> { is NavTarget.Thread -> {
val inputs = ThreadedMessagesNode.Inputs( val inputs = ThreadedMessagesNode.Inputs(
threadRootEventId = navTarget.threadRootId, threadRootEventId = navTarget.threadRootId,
focusedEventId = navTarget.focusedEventId, focusedEventId = navTarget.focusedEventId,
@ -485,7 +489,7 @@ class MessagesFlowNode(
} }
override fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) { 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)) 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 @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
mentionSpanTheme.updateStyles() mentionSpanTheme.updateStyles()

View file

@ -337,12 +337,11 @@ class MessagesNode(
var focusedEventId by rememberSaveable { var focusedEventId by rememberSaveable {
mutableStateOf(inputs.focusedEventId) mutableStateOf(inputs.focusedEventId)
} }
LaunchedEffect(Unit) { LaunchedEffect(focusedEventId) {
focusedEventId?.also { eventId -> if (focusedEventId != null) {
state.timelineState.eventSink(TimelineEvents.FocusOnEvent(eventId)) 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.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.mediaplayer.api.MediaPlayer import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.services.analytics.api.AnalyticsService 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.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -85,6 +86,7 @@ class ThreadedMessagesNode(
private val timelineItemPresenterFactories: TimelineItemPresenterFactories, private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val mediaPlayer: MediaPlayer, private val mediaPlayer: MediaPlayer,
private val permalinkParser: PermalinkParser, private val permalinkParser: PermalinkParser,
private val appNavigationStateService: AppNavigationStateService,
) : Node(buildContext, plugins = plugins), MessagesNavigator { ) : Node(buildContext, plugins = plugins), MessagesNavigator {
private val callbacks = plugins<Callback>() private val callbacks = plugins<Callback>()
@ -131,6 +133,12 @@ class ThreadedMessagesNode(
onCreate = { onCreate = {
sessionCoroutineScope.launch { analyticsService.capture(room.toAnalyticsViewRoom()) } sessionCoroutineScope.launch { analyticsService.capture(room.toAnalyticsViewRoom()) }
}, },
onStart = {
appNavigationStateService.onNavigateToThread(id, inputs.threadRootEventId)
},
onStop = {
appNavigationStateService.onLeavingThread(id)
},
onDestroy = { onDestroy = {
mediaPlayer.close() mediaPlayer.close()
} }

View file

@ -86,6 +86,7 @@ class TimelinePresenter(
private val roomCallStatePresenter: Presenter<RoomCallState>, private val roomCallStatePresenter: Presenter<RoomCallState>,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
) : Presenter<TimelineState> { ) : Presenter<TimelineState> {
private val tag = "TimelinePresenter"
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create( fun create(
@ -102,14 +103,14 @@ class TimelinePresenter(
) )
private var timelineItems by mutableStateOf<ImmutableList<TimelineItem>>(persistentListOf()) private var timelineItems by mutableStateOf<ImmutableList<TimelineItem>>(persistentListOf())
private val focusRequestState: MutableState<FocusRequestState> = mutableStateOf(FocusRequestState.None)
@Composable @Composable
override fun present(): TimelineState { override fun present(): TimelineState {
val localScope = rememberCoroutineScope() val localScope = rememberCoroutineScope()
val timelineMode = remember { timelineController.mainTimelineMode() } val timelineMode = remember { timelineController.mainTimelineMode() }
var focusRequestState: FocusRequestState by remember { mutableStateOf(FocusRequestState.None) }
val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) } val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) }
val roomInfo by room.roomInfoFlow.collectAsState() val roomInfo by room.roomInfoFlow.collectAsState()
@ -155,7 +156,7 @@ class TimelinePresenter(
if (event.firstIndex == 0) { if (event.firstIndex == 0) {
newEventState.value = NewEventState.None newEventState.value = NewEventState.None
} }
Timber.d("## sendReadReceiptIfNeeded firstVisibleIndex: ${event.firstIndex}") Timber.tag(tag).d("## sendReadReceiptIfNeeded firstVisibleIndex: ${event.firstIndex}")
sessionCoroutineScope.sendReadReceiptIfNeeded( sessionCoroutineScope.sendReadReceiptIfNeeded(
firstVisibleIndex = event.firstIndex, firstVisibleIndex = event.firstIndex,
timelineItems = timelineItems, timelineItems = timelineItems,
@ -186,14 +187,17 @@ class TimelinePresenter(
is TimelineEvents.EditPoll -> { is TimelineEvents.EditPoll -> {
navigator.onEditPollClick(event.pollStartId) navigator.onEditPollClick(event.pollStartId)
} }
is TimelineEvents.FocusOnEvent -> { is TimelineEvents.FocusOnEvent -> sessionCoroutineScope.launch {
focusRequestState = FocusRequestState.Requested(event.eventId, event.debounce) 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 -> { is TimelineEvents.OnFocusEventRender -> {
focusRequestState = focusRequestState.onFocusEventRender() focusRequestState.value = focusRequestState.value.onFocusEventRender()
} }
is TimelineEvents.ClearFocusRequestState -> { is TimelineEvents.ClearFocusRequestState -> {
focusRequestState = FocusRequestState.None focusRequestState.value = FocusRequestState.None
} }
is TimelineEvents.JumpToLive -> { is TimelineEvents.JumpToLive -> {
timelineController.focusOnLive() timelineController.focusOnLive()
@ -236,69 +240,19 @@ class TimelinePresenter(
.launchIn(this) .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) { LaunchedEffect(timelineItems.size) {
computeNewItemState(timelineItems, prevMostRecentItemId, newEventState) computeNewItemState(timelineItems, prevMostRecentItemId, newEventState)
} }
LaunchedEffect(timelineItems.size, focusRequestState) { LaunchedEffect(timelineItems.size, focusRequestState.value) {
val currentFocusRequestState = focusRequestState val currentFocusRequestState = focusRequestState.value
if (currentFocusRequestState is FocusRequestState.Success && !currentFocusRequestState.rendered) { if (currentFocusRequestState is FocusRequestState.Success && !currentFocusRequestState.rendered) {
val eventId = currentFocusRequestState.eventId val eventId = currentFocusRequestState.eventId
if (timelineItemIndexer.isKnown(eventId)) { if (timelineItemIndexer.isKnown(eventId)) {
val index = timelineItemIndexer.indexOf(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( return TimelineState(
timelineItems = timelineItems, timelineItems = timelineItems,
timelineMode = timelineMode, timelineMode = timelineMode,
@ -326,7 +285,7 @@ class TimelinePresenter(
renderReadReceipts = renderReadReceipts, renderReadReceipts = renderReadReceipts,
newEventState = newEventState.value, newEventState = newEventState.value,
isLive = isLive, isLive = isLive,
focusRequestState = focusRequestState, focusRequestState = focusRequestState.value,
messageShield = messageShield.value, messageShield = messageShield.value,
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState, resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
displayThreadSummaries = displayThreadSummaries, 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. * 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. * 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 @Test
fun `test initial target to nav target mapping`() { fun `test initial target to nav target mapping`() {
assertThat(MessagesEntryPoint.InitialTarget.Messages(focusedEventId = AN_EVENT_ID).toNavTarget()) 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()) assertThat(MessagesEntryPoint.InitialTarget.PinnedMessages.toNavTarget())
.isEqualTo(MessagesFlowNode.NavTarget.PinnedMessagesList) .isEqualTo(MessagesFlowNode.NavTarget.PinnedMessagesList)
} }

View file

@ -563,9 +563,7 @@ class TimelinePresenterTest {
@Test @Test
fun `present - focus on known event retrieves the event from cache`() = runTest { fun `present - focus on known event retrieves the event from cache`() = runTest {
val timelineItemIndexer = TimelineItemIndexer().apply { val timelineItemIndexer = TimelineItemIndexer()
process(listOf(aMessageEvent(eventId = AN_EVENT_ID)))
}
val presenter = createTimelinePresenter( val presenter = createTimelinePresenter(
room = FakeJoinedRoom( room = FakeJoinedRoom(
liveTimeline = FakeTimeline( 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, timelineItemIndexer = timelineItemIndexer,
) )
@ -586,7 +587,16 @@ class TimelinePresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitFirstItem() 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)) initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
advanceUntilIdle()
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID) assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Requested(AN_EVENT_ID, Duration.ZERO)) assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Requested(AN_EVENT_ID, Duration.ZERO))

View file

@ -7,10 +7,11 @@
package io.element.android.libraries.deeplink.api 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.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
fun interface DeepLinkCreator { 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 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.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId 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]. */ /** The target is the root of the app, with the given [sessionId]. */
data class Root(override val sessionId: SessionId) : DeeplinkData data class Root(override val sessionId: SessionId) : DeeplinkData
/** The target is a room, with the given [sessionId], [roomId] and optionally a [threadId]. */ /** 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?) : DeeplinkData 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.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.deeplink.api.DeepLinkCreator 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.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultDeepLinkCreator : DeepLinkCreator { 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 { return buildString {
append("$SCHEME://$HOST/") append("$SCHEME://$HOST/")
append(sessionId.value) append(sessionId.value)
if (roomId != null) { append("/")
append("/") append(roomId?.value.orEmpty())
append(roomId.value) append("/")
if (threadId != null) { append(threadId?.value.orEmpty())
append("/") append("/")
append(threadId.value) 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 dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.deeplink.api.DeeplinkData import io.element.android.libraries.deeplink.api.DeeplinkData
import io.element.android.libraries.deeplink.api.DeeplinkParser 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.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
@ -36,8 +37,9 @@ class DefaultDeeplinkParser : DeeplinkParser {
null -> DeeplinkData.Root(sessionId) null -> DeeplinkData.Root(sessionId)
else -> { else -> {
val roomId = screenPathComponent.let(::RoomId) val roomId = screenPathComponent.let(::RoomId)
val threadId = pathBits.elementAtOrNull(2)?.let(::ThreadId) val threadId = pathBits.elementAtOrNull(2)?.takeIf { it.isNotBlank() }?.let(::ThreadId)
DeeplinkData.Room(sessionId, roomId, 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 package io.element.android.libraries.deeplink.impl
import com.google.common.truth.Truth.assertThat 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_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID import io.element.android.libraries.matrix.test.A_THREAD_ID
@ -17,11 +18,15 @@ class DefaultDeepLinkCreatorTest {
@Test @Test
fun create() { fun create() {
val sut = DefaultDeepLinkCreator() 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") .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") .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") .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 androidx.core.net.toUri
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.deeplink.api.DeeplinkData 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_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID import io.element.android.libraries.matrix.test.A_THREAD_ID
@ -28,6 +29,10 @@ class DefaultDeeplinkParserTest {
"elementx://open/@alice:server.org/!aRoomId:domain" "elementx://open/@alice:server.org/!aRoomId:domain"
const val A_URI_WITH_ROOM_WITH_THREAD = const val A_URI_WITH_ROOM_WITH_THREAD =
"elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId" "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 @Test
@ -36,9 +41,13 @@ class DefaultDeeplinkParserTest {
assertThat(sut.getFromIntent(createIntent(A_URI))) assertThat(sut.getFromIntent(createIntent(A_URI)))
.isEqualTo(DeeplinkData.Root(A_SESSION_ID)) .isEqualTo(DeeplinkData.Root(A_SESSION_ID))
assertThat(sut.getFromIntent(createIntent(A_URI_WITH_ROOM))) 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))) 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 @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.EventId
import io.element.android.libraries.matrix.api.core.RoomId 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.RoomIdOrAlias
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.core.UserId
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -28,6 +29,7 @@ sealed interface PermalinkData : Parcelable {
data class RoomLink( data class RoomLink(
val roomIdOrAlias: RoomIdOrAlias, val roomIdOrAlias: RoomIdOrAlias,
val eventId: EventId? = null, val eventId: EventId? = null,
val threadId: ThreadId? = null,
val viaParameters: ImmutableList<String> = persistentListOf() val viaParameters: ImmutableList<String> = persistentListOf()
) : PermalinkData ) : 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.EventId
import io.element.android.libraries.matrix.api.core.RoomId 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.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.core.UserId
import io.element.android.libraries.matrix.api.notification.NotificationContent import io.element.android.libraries.matrix.api.notification.NotificationContent
import io.element.android.libraries.matrix.api.notification.NotificationData import io.element.android.libraries.matrix.api.notification.NotificationData
@ -41,8 +42,7 @@ class NotificationMapper(
NotificationData( NotificationData(
sessionId = sessionId, sessionId = sessionId,
eventId = eventId, eventId = eventId,
// FIXME once the `NotificationItem` in the SDK returns the thread id threadId = item.threadId?.let(::ThreadId),
threadId = null,
roomId = roomId, roomId = roomId,
senderAvatarUrl = item.senderInfo.avatarUrl, senderAvatarUrl = item.senderInfo.avatarUrl,
senderDisplayName = item.senderInfo.displayName, 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.EventId
import io.element.android.libraries.matrix.api.core.RoomId 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.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
interface NotificationCleaner { interface NotificationCleaner {
fun clearAllMessagesEvents(sessionId: SessionId) fun clearAllMessagesEvents(sessionId: SessionId)
fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId) fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId)
fun clearMessagesForThread(sessionId: SessionId, roomId: RoomId, threadId: ThreadId)
fun clearEvent(sessionId: SessionId, eventId: EventId) fun clearEvent(sessionId: SessionId, eventId: EventId)
fun clearMembershipNotificationForSession(sessionId: SessionId) fun clearMembershipNotificationForSession(sessionId: SessionId)

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.push.impl.intent package io.element.android.libraries.push.impl.intent
import android.content.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.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
@ -20,5 +21,6 @@ interface IntentProvider {
sessionId: SessionId, sessionId: SessionId,
roomId: RoomId?, roomId: RoomId?,
threadId: ThreadId?, threadId: ThreadId?,
eventId: EventId?,
): Intent ): 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.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.core.RoomId 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.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.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
import timber.log.Timber import timber.log.Timber
interface ActiveNotificationsProvider { 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 getNotificationsForSession(sessionId: SessionId): List<StatusBarNotification>
fun getMembershipNotificationForSession(sessionId: SessionId): List<StatusBarNotification> fun getMembershipNotificationForSession(sessionId: SessionId): List<StatusBarNotification>
fun getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification> fun getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification>
@ -44,9 +54,15 @@ class DefaultActiveNotificationsProvider(
return getNotificationsForSession(sessionId).filter { it.id == notificationId } 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) 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> { 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.extensions.runCatchingExceptions
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.di.annotations.ApplicationContext 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.MatrixClient
import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
@ -85,6 +87,7 @@ class DefaultNotifiableEventResolver(
private val permalinkParser: PermalinkParser, private val permalinkParser: PermalinkParser,
private val callNotificationEventResolver: CallNotificationEventResolver, private val callNotificationEventResolver: CallNotificationEventResolver,
private val fallbackNotificationFactory: FallbackNotificationFactory, private val fallbackNotificationFactory: FallbackNotificationFactory,
private val featureFlagService: FeatureFlagService,
) : NotifiableEventResolver { ) : NotifiableEventResolver {
override suspend fun resolveEvents( override suspend fun resolveEvents(
sessionId: SessionId, sessionId: SessionId,
@ -141,7 +144,7 @@ class DefaultNotifiableEventResolver(
senderId = content.senderId, senderId = content.senderId,
roomId = roomId, roomId = roomId,
eventId = eventId, eventId = eventId,
threadId = threadId, threadId = threadId.takeIf { featureFlagService.isFeatureEnabled(FeatureFlags.Threads) },
noisy = isNoisy, noisy = isNoisy,
timestamp = this.timestamp, timestamp = this.timestamp,
senderDisambiguatedDisplayName = senderDisambiguatedDisplayName, 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.matrix.ui.media.ImageLoaderHolder
import io.element.android.libraries.push.api.notifications.NotificationCleaner import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.libraries.push.api.notifications.NotificationIdProvider 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.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
@ -93,10 +94,10 @@ class DefaultNotificationDrawerManager(
) )
} }
is NavigationState.Thread -> { is NavigationState.Thread -> {
onEnteringThread( clearMessagesForThread(
navigationState.parentRoom.parentSpace.parentSession.sessionId, sessionId = navigationState.parentRoom.parentSpace.parentSession.sessionId,
navigationState.parentRoom.roomId, roomId = navigationState.parentRoom.roomId,
navigationState.threadId threadId = navigationState.threadId,
) )
} }
} }
@ -145,6 +146,16 @@ class DefaultNotificationDrawerManager(
clearSummaryNotificationIfNeeded(sessionId) 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) { override fun clearMembershipNotificationForSession(sessionId: SessionId) {
activeNotificationsProvider.getMembershipNotificationForSession(sessionId) activeNotificationsProvider.getMembershipNotificationForSession(sessionId)
.forEach { notificationManager.cancel(it.tag, it.id) } .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>) { private suspend fun renderEvents(eventsToRender: List<NotifiableEvent>) {
// Group by sessionId // Group by sessionId
val eventsForSessions = eventsToRender.groupBy { 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.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId 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.JoinedRoom
import io.element.android.libraries.matrix.api.room.isDm 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.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory
import io.element.android.libraries.push.api.notifications.NotificationCleaner import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.libraries.push.impl.R import io.element.android.libraries.push.impl.R
@ -72,8 +74,12 @@ class NotificationBroadcastReceiverHandler(
notificationCleaner.clearEvent(sessionId, eventId) notificationCleaner.clearEvent(sessionId, eventId)
} }
actionIds.markRoomRead -> if (roomId != null) { actionIds.markRoomRead -> if (roomId != null) {
notificationCleaner.clearMessagesForRoom(sessionId, roomId) if (threadId == null) {
handleMarkAsRead(sessionId, roomId) notificationCleaner.clearMessagesForRoom(sessionId, roomId)
} else {
notificationCleaner.clearMessagesForThread(sessionId, roomId, threadId)
}
handleMarkAsRead(sessionId, roomId, threadId)
} }
actionIds.join -> if (roomId != null) { actionIds.join -> if (roomId != null) {
notificationCleaner.clearMembershipNotificationForRoom(sessionId, roomId) notificationCleaner.clearMembershipNotificationForRoom(sessionId, roomId)
@ -96,7 +102,8 @@ class NotificationBroadcastReceiverHandler(
client.getRoom(roomId)?.leave() 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 client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch
val isSendPublicReadReceiptsEnabled = sessionPreferencesStore.get(sessionId, this).isSendPublicReadReceiptsEnabled().first() val isSendPublicReadReceiptsEnabled = sessionPreferencesStore.get(sessionId, this).isSendPublicReadReceiptsEnabled().first()
val receiptType = if (isSendPublicReadReceiptsEnabled) { val receiptType = if (isSendPublicReadReceiptsEnabled) {
@ -104,7 +111,26 @@ class NotificationBroadcastReceiverHandler(
} else { } else {
ReceiptType.READ_PRIVATE 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( private fun handleSmartReply(

View file

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

View file

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

View file

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

View file

@ -10,6 +10,7 @@ package io.element.android.libraries.push.impl.notifications.factories
import android.app.Notification import android.app.Notification
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.drawable.Icon
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.MessagingStyle import androidx.core.app.NotificationCompat.MessagingStyle
@ -20,7 +21,7 @@ import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.di.annotations.ApplicationContext 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.core.ThreadId
import io.element.android.libraries.matrix.api.timeline.item.event.EventType import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.libraries.matrix.api.user.MatrixUser 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.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent 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.push.impl.notifications.shortcut.createShortcutId
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.toolbox.api.strings.StringProvider import io.element.android.services.toolbox.api.strings.StringProvider
interface NotificationCreator { interface NotificationCreator {
@ -86,6 +88,17 @@ interface NotificationCreator {
fun createDiagnosticNotification( fun createDiagnosticNotification(
@ColorInt color: Int, @ColorInt color: Int,
): Notification ): 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) @ContributesBinding(AppScope::class)
@ -99,7 +112,7 @@ class DefaultNotificationCreator(
private val quickReplyActionFactory: QuickReplyActionFactory, private val quickReplyActionFactory: QuickReplyActionFactory,
private val bitmapLoader: NotificationBitmapLoader, private val bitmapLoader: NotificationBitmapLoader,
private val acceptInvitationActionFactory: AcceptInvitationActionFactory, private val acceptInvitationActionFactory: AcceptInvitationActionFactory,
private val rejectInvitationActionFactory: RejectInvitationActionFactory private val rejectInvitationActionFactory: RejectInvitationActionFactory,
) : NotificationCreator { ) : NotificationCreator {
/** /**
* Create a notification for a Room. * Create a notification for a Room.
@ -117,9 +130,10 @@ class DefaultNotificationCreator(
@ColorInt color: Int, @ColorInt color: Int,
): Notification { ): Notification {
// Build the pending intent for when the notification is clicked // Build the pending intent for when the notification is clicked
val eventId = events.firstOrNull()?.eventId
val openIntent = when { val openIntent = when {
threadId != null -> pendingIntentFactory.createOpenThreadPendingIntent(roomInfo, threadId) threadId != null -> pendingIntentFactory.createOpenThreadPendingIntent(roomInfo.sessionId, roomInfo.roomId, eventId, threadId)
else -> pendingIntentFactory.createOpenRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId) else -> pendingIntentFactory.createOpenRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId, eventId)
} }
val smallIcon = CommonDrawables.ic_notification val smallIcon = CommonDrawables.ic_notification
val containsMissedCall = events.any { it.type == EventType.RTC_NOTIFICATION } val containsMissedCall = events.any { it.type == EventType.RTC_NOTIFICATION }
@ -140,19 +154,30 @@ class DefaultNotificationCreator(
// Must match those created in the ShortcutInfoCompat.Builder() // Must match those created in the ShortcutInfoCompat.Builder()
// for the notification to appear as a "Conversation": // for the notification to appear as a "Conversation":
// https://developer.android.com/develop/ui/views/notifications/conversations // 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+) // 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 // devices and all Wear devices. But we want a custom grouping, so we specify the groupID
.setGroup(roomInfo.sessionId.value) .setGroup(roomInfo.sessionId.value)
.setGroupSummary(false)
// In order to avoid notification making sound twice (due to the summary notification) // 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 // Remove notification after opening it or using an action
.setAutoCancel(true) .setAutoCancel(true)
} }
val messagingStyle = existingNotification?.let { val messagingStyle = existingNotification?.let {
MessagingStyle.extractMessagingStyleFromNotification(it) MessagingStyle.extractMessagingStyleFromNotification(it)
} ?: 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) messagingStyle.addMessagesFromEvents(events, imageLoader)
@ -162,19 +187,6 @@ class DefaultNotificationCreator(
.setWhen(lastMessageTimestamp) .setWhen(lastMessageTimestamp)
// MESSAGING_STYLE sets title and content for API 16 and above devices. // MESSAGING_STYLE sets title and content for API 16 and above devices.
.setStyle(messagingStyle) .setStyle(messagingStyle)
// 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) .setSmallIcon(smallIcon)
// Set primary color (important for Wear 2.0 Notifications). // Set primary color (important for Wear 2.0 Notifications).
.setColor(color) .setColor(color)
@ -197,8 +209,8 @@ class DefaultNotificationCreator(
// Clear existing actions since we might be updating an existing notification // Clear existing actions since we might be updating an existing notification
clearActions() clearActions()
// Add actions and notification intents // Add actions and notification intents
// Mark room as read // Mark room/thread as read
addAction(markAsReadActionFactory.create(roomInfo)) addAction(markAsReadActionFactory.create(roomInfo, threadId))
// Quick reply // Quick reply
if (!roomInfo.hasSmartReplyError) { if (!roomInfo.hasSmartReplyError) {
val latestEventId = events.lastOrNull()?.eventId val latestEventId = events.lastOrNull()?.eventId
@ -208,7 +220,7 @@ class DefaultNotificationCreator(
setContentIntent(openIntent) setContentIntent(openIntent)
} }
if (largeIcon != null) { if (largeIcon != null) {
setLargeIcon(largeIcon) setLargeIcon(Icon.createWithBitmap(largeIcon))
} }
setDeleteIntent(pendingIntentFactory.createDismissRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId)) setDeleteIntent(pendingIntentFactory.createDismissRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId))
@ -239,7 +251,7 @@ class DefaultNotificationCreator(
addAction(rejectInvitationActionFactory.create(inviteNotifiableEvent)) addAction(rejectInvitationActionFactory.create(inviteNotifiableEvent))
addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent)) addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent))
// Build the pending intent for when the notification is clicked // 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) { if (inviteNotifiableEvent.noisy) {
// Compat // Compat
@ -279,7 +291,7 @@ class DefaultNotificationCreator(
.setSmallIcon(smallIcon) .setSmallIcon(smallIcon)
.setColor(color) .setColor(color)
.setAutoCancel(true) .setAutoCancel(true)
.setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent(simpleNotifiableEvent.sessionId, simpleNotifiableEvent.roomId)) .setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent(simpleNotifiableEvent.sessionId, simpleNotifiableEvent.roomId, null))
.apply { .apply {
if (simpleNotifiableEvent.noisy) { if (simpleNotifiableEvent.noisy) {
// Compat // Compat
@ -447,21 +459,26 @@ class DefaultNotificationCreator(
} }
private suspend fun messagingStyleFromCurrentUser( private suspend fun messagingStyleFromCurrentUser(
sessionId: SessionId,
user: MatrixUser, user: MatrixUser,
imageLoader: ImageLoader, imageLoader: ImageLoader,
roomName: String, roomName: String,
isThread: Boolean,
roomIsGroup: Boolean roomIsGroup: Boolean
): MessagingStyle { ): MessagingStyle {
return MessagingStyle( return MessagingStyle(
Person.Builder() Person.Builder()
.setName(user.displayName?.annotateForDebug(50)) .setName(user.displayName?.annotateForDebug(50))
.setIcon(bitmapLoader.getUserIcon(user.avatarUrl, imageLoader)) .setIcon(bitmapLoader.getUserIcon(user.avatarUrl, imageLoader))
.setKey(sessionId.value) .setKey(user.userId.value)
.build() .build()
).also { ).also {
it.conversationTitle = roomName.takeIf { roomIsGroup } it.conversationTitle = if (isThread) {
it.isGroupConversation = roomIsGroup 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.intent.IntentProvider
import io.element.android.libraries.push.impl.notifications.NotificationActionIds 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.NotificationBroadcastReceiver
import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo
import io.element.android.libraries.push.impl.notifications.TestNotificationReceiver import io.element.android.libraries.push.impl.notifications.TestNotificationReceiver
import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.services.toolbox.api.systemclock.SystemClock
@ -32,19 +31,19 @@ class PendingIntentFactory(
private val actionIds: NotificationActionIds, private val actionIds: NotificationActionIds,
) { ) {
fun createOpenSessionPendingIntent(sessionId: SessionId): PendingIntent? { 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? { fun createOpenRoomPendingIntent(sessionId: SessionId, roomId: RoomId, eventId: EventId?): PendingIntent? {
return createRoomPendingIntent(sessionId = sessionId, roomId = roomId, threadId = null) return createRoomPendingIntent(sessionId = sessionId, roomId = roomId, eventId = eventId, threadId = null)
} }
fun createOpenThreadPendingIntent(roomInfo: RoomEventGroupInfo, threadId: ThreadId?): PendingIntent? { fun createOpenThreadPendingIntent(sessionId: SessionId, roomId: RoomId, eventId: EventId?, threadId: ThreadId): PendingIntent? {
return createRoomPendingIntent(sessionId = roomInfo.sessionId, roomId = roomInfo.roomId, threadId = threadId) return createRoomPendingIntent(sessionId = sessionId, roomId = roomId, eventId = eventId, threadId = threadId)
} }
private fun createRoomPendingIntent(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?): PendingIntent? { private fun createRoomPendingIntent(sessionId: SessionId, roomId: RoomId?, eventId: EventId?, threadId: ThreadId?): PendingIntent? {
val intent = intentProvider.getViewRoomIntent(sessionId = sessionId, roomId = roomId, threadId = threadId) val intent = intentProvider.getViewRoomIntent(sessionId = sessionId, roomId = roomId, eventId = eventId, threadId = threadId)
return PendingIntent.getActivity( return PendingIntent.getActivity(
context, context,
clock.epochMillis().toInt(), 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.androidutils.uri.createIgnoredUri
import io.element.android.libraries.designsystem.icons.CompoundDrawables import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.di.annotations.ApplicationContext 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.R
import io.element.android.libraries.push.impl.notifications.NotificationActionIds 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.NotificationBroadcastReceiver
@ -30,15 +31,16 @@ class MarkAsReadActionFactory(
private val stringProvider: StringProvider, private val stringProvider: StringProvider,
private val clock: SystemClock, 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 if (!NotificationConfig.SHOW_MARK_AS_READ_ACTION) return null
val sessionId = roomInfo.sessionId.value val sessionId = roomInfo.sessionId.value
val roomId = roomInfo.roomId.value val roomId = roomInfo.roomId.value
val intent = Intent(context, NotificationBroadcastReceiver::class.java) val intent = Intent(context, NotificationBroadcastReceiver::class.java)
intent.action = actionIds.markRoomRead 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_SESSION_ID, sessionId)
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId) intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId)
threadId?.let { intent.putExtra(NotificationBroadcastReceiver.KEY_THREAD_ID, threadId.value) }
val pendingIntent = PendingIntent.getBroadcast( val pendingIntent = PendingIntent.getBroadcast(
context, context,
clock.epochMillis().toInt(), 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()) { if (redactions.isNotEmpty()) {
onRedactedEventReceived.onRedactedEventsReceived(redactions) appCoroutineScope.launch { onRedactedEventReceived.onRedactedEventsReceived(redactions) }
} }
// Find and process ringing call notifications separately // Find and process ringing call notifications separately

View file

@ -16,7 +16,6 @@ import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans import androidx.core.text.inSpans
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding 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.di.annotations.ApplicationContext
import io.element.android.libraries.push.impl.notifications.ActiveNotificationsProvider import io.element.android.libraries.push.impl.notifications.ActiveNotificationsProvider
import io.element.android.libraries.push.impl.notifications.NotificationDisplayer 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.push.impl.notifications.model.ResolvedPushEvent
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.toolbox.api.strings.StringProvider import io.element.android.services.toolbox.api.strings.StringProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
interface OnRedactedEventReceived { interface OnRedactedEventReceived {
fun onRedactedEventsReceived(redactions: List<ResolvedPushEvent.Redaction>) suspend fun onRedactedEventsReceived(redactions: List<ResolvedPushEvent.Redaction>)
} }
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultOnRedactedEventReceived( class DefaultOnRedactedEventReceived(
private val activeNotificationsProvider: ActiveNotificationsProvider, private val activeNotificationsProvider: ActiveNotificationsProvider,
private val notificationDisplayer: NotificationDisplayer, private val notificationDisplayer: NotificationDisplayer,
@AppCoroutineScope
private val coroutineScope: CoroutineScope,
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val stringProvider: StringProvider, private val stringProvider: StringProvider,
) : OnRedactedEventReceived { ) : OnRedactedEventReceived {
override fun onRedactedEventsReceived(redactions: List<ResolvedPushEvent.Redaction>) { override suspend fun onRedactedEventsReceived(redactions: List<ResolvedPushEvent.Redaction>) {
coroutineScope.launch { val redactionsBySessionIdAndRoom = redactions.groupBy { redaction ->
val redactionsBySessionIdAndRoom = redactions.groupBy { redaction -> redaction.sessionId to redaction.roomId
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) { notifications.forEach { statusBarNotification ->
val (sessionId, roomId) = keys val notification = statusBarNotification.notification
val notifications = activeNotificationsProvider.getMessageNotificationsForRoom( val messagingStyle = MessagingStyle.extractMessagingStyleFromNotification(notification)
sessionId, if (messagingStyle == null) {
roomId, 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_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2 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.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
@ -80,8 +81,35 @@ class DefaultActiveNotificationsProviderTest {
) )
val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications) val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications)
assertThat(activeNotificationsProvider.getMessageNotificationsForRoom(A_SESSION_ID, A_ROOM_ID)).hasSize(1) assertThat(activeNotificationsProvider.getMessageNotificationsForRoom(A_SESSION_ID, A_ROOM_ID, null)).hasSize(1)
assertThat(activeNotificationsProvider.getMessageNotificationsForRoom(A_SESSION_ID_2, A_ROOM_ID_2)).isEmpty() 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 @Test

View file

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

View file

@ -9,6 +9,7 @@ package io.element.android.libraries.push.impl.notifications
import android.content.Context import android.content.Context
import com.google.common.truth.Truth.assertThat 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.core.EventId
import io.element.android.libraries.matrix.api.exception.NotificationResolverException import io.element.android.libraries.matrix.api.exception.NotificationResolverException
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
@ -854,7 +855,8 @@ class DefaultNotifiableEventResolverTest {
fallbackNotificationFactory = FallbackNotificationFactory( fallbackNotificationFactory = FallbackNotificationFactory(
clock = FakeSystemClock(), clock = FakeSystemClock(),
stringProvider = FakeStringProvider(defaultResult = "You have new messages.") 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. // 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 matrixUser = aMatrixUser(id = A_SESSION_ID.value, displayName = "alice", avatarUrl = "mxc://data")
val mockRoomGroupMessageCreator = FakeRoomGroupMessageCreator( val mockRoomGroupMessageCreator = FakeRoomGroupMessageCreator(
createRoomMessageResult = lambdaRecorder { user, _, roomId, _, existingNotification -> createRoomMessageResult = lambdaRecorder { user, _, roomId, _, _, existingNotification ->
assertThat(user).isEqualTo(matrixUser) assertThat(user).isEqualTo(matrixUser)
assertThat(roomId).isEqualTo(A_ROOM_ID) assertThat(roomId).isEqualTo(A_ROOM_ID)
assertThat(existingNotification).isNull() assertThat(existingNotification).isNull()
@ -144,9 +144,16 @@ class DefaultNotificationDrawerManagerTest {
messageCreator.createRoomMessageResult.assertions() messageCreator.createRoomMessageResult.assertions()
.isCalledExactly(3) .isCalledExactly(3)
.withSequence( .withSequence(
listOf(value(aMatrixUser(id = A_SESSION_ID.value, displayName = "alice")), 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()), 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()), listOf(
value(aMatrixUser(id = A_SESSION_ID.value, displayName = A_SESSION_ID.value, avatarUrl = AN_AVATAR_URL)),
any(),
any(),
any(),
any(),
any()
),
) )
defaultNotificationDrawerManager.destroy() defaultNotificationDrawerManager.destroy()

View file

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

View file

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

View file

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

View file

@ -71,7 +71,7 @@ class NotificationRendererTest {
@Test @Test
fun `given a room message group notification is added when rendering then show the message notification and update summary`() = runTest { 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())) 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.MarkAsReadActionFactory
import io.element.android.libraries.push.impl.notifications.factories.action.QuickReplyActionFactory 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.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.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
@ -249,7 +250,7 @@ class DefaultNotificationCreatorTest {
currentUser = aMatrixUser(), currentUser = aMatrixUser(),
existingNotification = null, existingNotification = null,
imageLoader = FakeImageLoader().getImageLoader(), imageLoader = FakeImageLoader().getImageLoader(),
events = emptyList(), events = listOf(aNotifiableMessageEvent()),
color = A_COLOR_INT, color = A_COLOR_INT,
) )
result.commonAssertions() result.commonAssertions()
@ -276,7 +277,7 @@ class DefaultNotificationCreatorTest {
currentUser = aMatrixUser(), currentUser = aMatrixUser(),
existingNotification = null, existingNotification = null,
imageLoader = FakeImageLoader().getImageLoader(), imageLoader = FakeImageLoader().getImageLoader(),
events = emptyList(), events = listOf(aNotifiableMessageEvent()),
color = A_COLOR_INT, color = A_COLOR_INT,
) )
result.commonAssertions() result.commonAssertions()

View file

@ -8,11 +8,12 @@
package io.element.android.libraries.push.impl.notifications.factories package io.element.android.libraries.push.impl.notifications.factories
import android.content.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.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId 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.intent.IntentProvider
class FakeIntentProvider : 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 android.service.notification.StatusBarNotification
import io.element.android.libraries.matrix.api.core.RoomId 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.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.push.impl.notifications.ActiveNotificationsProvider import io.element.android.libraries.push.impl.notifications.ActiveNotificationsProvider
class FakeActiveNotificationsProvider( 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 getNotificationsForSessionResult: (SessionId) -> List<StatusBarNotification> = { emptyList() },
private val getMembershipNotificationForSessionResult: (SessionId) -> List<StatusBarNotification> = { emptyList() }, private val getMembershipNotificationForSessionResult: (SessionId) -> List<StatusBarNotification> = { emptyList() },
private val getMembershipNotificationForRoomResult: (SessionId, RoomId) -> List<StatusBarNotification> = { _, _ -> emptyList() }, private val getMembershipNotificationForRoomResult: (SessionId, RoomId) -> List<StatusBarNotification> = { _, _ -> emptyList() },
private val getSummaryNotificationResult: (SessionId) -> StatusBarNotification? = { null }, private val getSummaryNotificationResult: (SessionId) -> StatusBarNotification? = { null },
private val countResult: (SessionId) -> Int = { 0 }, private val countResult: (SessionId) -> Int = { 0 },
) : ActiveNotificationsProvider { ) : ActiveNotificationsProvider {
override fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification> { override fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId, threadId: ThreadId?): List<StatusBarNotification> {
return getMessageNotificationsForRoomResult(sessionId, roomId) 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> { override fun getNotificationsForSession(sessionId: SessionId): List<StatusBarNotification> {

View file

@ -11,25 +11,29 @@ import android.app.Notification
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import coil3.ImageLoader import coil3.ImageLoader
import io.element.android.libraries.matrix.api.core.RoomId 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.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.notifications.RoomGroupMessageCreator 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.fixtures.A_NOTIFICATION
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent 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 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( class FakeRoomGroupMessageCreator(
var createRoomMessageResult: LambdaFiveParamsRecorder<MatrixUser, List<NotifiableMessageEvent>, RoomId, ImageLoader, Notification?, Notification> = var createRoomMessageResult: LambdaSixParamsRecorder<MatrixUser, List<NotifiableMessageEvent>, RoomId, ThreadId?, ImageLoader, Notification?, Notification> =
lambdaRecorder { _, _, _, _, _ -> A_NOTIFICATION } lambdaRecorder { _, _, _, _, _, _ -> A_NOTIFICATION }
) : RoomGroupMessageCreator { ) : RoomGroupMessageCreator {
override suspend fun createRoomMessage( override suspend fun createRoomMessage(
currentUser: MatrixUser, currentUser: MatrixUser,
events: List<NotifiableMessageEvent>, events: List<NotifiableMessageEvent>,
roomId: RoomId, roomId: RoomId,
threadId: ThreadId?,
imageLoader: ImageLoader, imageLoader: ImageLoader,
existingNotification: Notification?, existingNotification: Notification?,
@ColorInt color: Int, @ColorInt color: Int,
): Notification { ): 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 package io.element.android.libraries.push.impl.push
import android.app.Notification
import android.service.notification.StatusBarNotification import android.service.notification.StatusBarNotification
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.test.platform.app.InstrumentationRegistry 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.RoomId
import io.element.android.libraries.matrix.api.core.SessionId 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
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_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.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.FakeActiveNotificationsProvider
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -29,43 +38,113 @@ import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
class DefaultOnRedactedEventReceivedTest { 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 @Test
fun `when no notifications are found, nothing happen`() = runTest { fun `when no notifications are found, nothing happen`() = runTest {
val showNotificationLambda = lambdaRecorder<String?, Int, Notification, Boolean> { _, _, _ -> true }
val sut = createDefaultOnRedactedEventReceived( 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))) sut.onRedactedEventsReceived(listOf(ResolvedPushEvent.Redaction(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, null)))
showNotificationLambda.assertions().isNeverCalled()
} }
@Test @Test
fun `when a notification is found, try to retrieve the message`() = runTest { 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( val sut = createDefaultOnRedactedEventReceived(
getMessageNotificationsForRoomResult = { _, _ -> getAllMessageNotificationsForRoomResult = { _, _ ->
listOf( listOf(
mockk { 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))) sut.onRedactedEventsReceived(listOf(ResolvedPushEvent.Redaction(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, null)))
showNotificationLambda.assertions().isCalledOnce()
} }
private fun TestScope.createDefaultOnRedactedEventReceived( @Test
getMessageNotificationsForRoomResult: (SessionId, RoomId) -> List<StatusBarNotification> = { _, _ -> lambdaError() }, 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 { ): DefaultOnRedactedEventReceived {
val context = InstrumentationRegistry.getInstrumentation().context val context = InstrumentationRegistry.getInstrumentation().context
return DefaultOnRedactedEventReceived( return DefaultOnRedactedEventReceived(
activeNotificationsProvider = FakeActiveNotificationsProvider( activeNotificationsProvider = FakeActiveNotificationsProvider(
getMessageNotificationsForRoomResult = getMessageNotificationsForRoomResult, getMessageNotificationsForRoomResult = { _, _, _ -> lambdaError() },
getAllMessageNotificationsForRoomResult = getAllMessageNotificationsForRoomResult,
getNotificationsForSessionResult = { lambdaError() }, getNotificationsForSessionResult = { lambdaError() },
getMembershipNotificationForSessionResult = { lambdaError() }, getMembershipNotificationForSessionResult = { lambdaError() },
getMembershipNotificationForRoomResult = { _, _ -> lambdaError() }, getMembershipNotificationForRoomResult = { _, _ -> lambdaError() },
getSummaryNotificationResult = { lambdaError() }, getSummaryNotificationResult = { lambdaError() },
countResult = { lambdaError() }, countResult = { lambdaError() },
), ),
notificationDisplayer = FakeNotificationDisplayer(), notificationDisplayer = displayer,
coroutineScope = this,
context = context, context = context,
stringProvider = FakeStringProvider(), stringProvider = FakeStringProvider(),
) )

View file

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