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:
committed by
GitHub
parent
e8b7db22cd
commit
7facc40771
@@ -14,6 +14,7 @@ import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.deeplink.api.DeepLinkCreator
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
@@ -29,10 +30,11 @@ class DefaultIntentProvider(
|
||||
sessionId: SessionId,
|
||||
roomId: RoomId?,
|
||||
threadId: ThreadId?,
|
||||
eventId: EventId?,
|
||||
): Intent {
|
||||
return Intent(context, MainActivity::class.java).apply {
|
||||
action = Intent.ACTION_VIEW
|
||||
data = deepLinkCreator.create(sessionId, roomId, threadId).toUri()
|
||||
data = deepLinkCreator.create(sessionId, roomId, threadId, eventId).toUri()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,11 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.deeplink.api.DeepLinkCreator
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_THREAD_ID
|
||||
@@ -31,14 +33,15 @@ import org.robolectric.RuntimeEnvironment
|
||||
class DefaultIntentProviderTest {
|
||||
@Test
|
||||
fun `test getViewRoomIntent with data`() {
|
||||
val deepLinkCreator = lambdaRecorder<SessionId, RoomId?, ThreadId?, String> { _, _, _ -> "deepLinkCreatorResult" }
|
||||
val deepLinkCreator = lambdaRecorder<SessionId, RoomId?, ThreadId?, EventId?, String> { _, _, _, _ -> "deepLinkCreatorResult" }
|
||||
val sut = createDefaultIntentProvider(
|
||||
deepLinkCreator = { sessionId, roomId, threadId -> deepLinkCreator.invoke(sessionId, roomId, threadId) },
|
||||
deepLinkCreator = { sessionId, roomId, threadId, eventId -> deepLinkCreator.invoke(sessionId, roomId, threadId, eventId) },
|
||||
)
|
||||
val result = sut.getViewRoomIntent(
|
||||
sessionId = A_SESSION_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
threadId = A_THREAD_ID,
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
result.commonAssertions()
|
||||
assertThat(result.data.toString()).isEqualTo("deepLinkCreatorResult")
|
||||
@@ -46,11 +49,12 @@ class DefaultIntentProviderTest {
|
||||
value(A_SESSION_ID),
|
||||
value(A_ROOM_ID),
|
||||
value(A_THREAD_ID),
|
||||
value(AN_EVENT_ID),
|
||||
)
|
||||
}
|
||||
|
||||
private fun createDefaultIntentProvider(
|
||||
deepLinkCreator: DeepLinkCreator = DeepLinkCreator { _, _, _ -> "" },
|
||||
deepLinkCreator: DeepLinkCreator = DeepLinkCreator { _, _, _, _ -> "" },
|
||||
): DefaultIntentProvider {
|
||||
return DefaultIntentProvider(
|
||||
context = RuntimeEnvironment.getApplication() as Context,
|
||||
|
||||
@@ -68,6 +68,7 @@ import io.element.android.features.verifysession.api.IncomingVerificationEntryPo
|
||||
import io.element.android.libraries.architecture.BackstackView
|
||||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.architecture.waitForChildAttached
|
||||
import io.element.android.libraries.architecture.waitForNavTargetAttached
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.designsystem.theme.ElementThemeApp
|
||||
@@ -496,7 +497,7 @@ class LoggedInFlowNode(
|
||||
trigger: JoinedRoom.Trigger? = null,
|
||||
eventId: EventId? = null,
|
||||
clearBackstack: Boolean,
|
||||
) {
|
||||
): RoomFlowNode {
|
||||
waitForNavTargetAttached { navTarget ->
|
||||
navTarget is NavTarget.Home
|
||||
}
|
||||
@@ -509,6 +510,15 @@ class LoggedInFlowNode(
|
||||
)
|
||||
backstack.accept(AttachRoomOperation(roomNavTarget, clearBackstack))
|
||||
}
|
||||
|
||||
// If we don't do this check, we might be returning while a previous node with the same type is still displayed
|
||||
// This means we may attach some new nodes to that one, which will be quickly replaced by the one instantiated above
|
||||
return waitForChildAttached<RoomFlowNode, NavTarget> {
|
||||
it is NavTarget.Room &&
|
||||
it.roomIdOrAlias == roomIdOrAlias &&
|
||||
it.initialElement is RoomNavigationTarget.Root &&
|
||||
it.initialElement.eventId == eventId
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun attachUser(userId: UserId) {
|
||||
|
||||
@@ -31,6 +31,7 @@ import io.element.android.annotations.ContributesNode
|
||||
import io.element.android.appnav.di.MatrixSessionCache
|
||||
import io.element.android.appnav.intent.IntentResolver
|
||||
import io.element.android.appnav.intent.ResolvedIntent
|
||||
import io.element.android.appnav.room.RoomFlowNode
|
||||
import io.element.android.appnav.root.RootNavStateFlowFactory
|
||||
import io.element.android.appnav.root.RootPresenter
|
||||
import io.element.android.appnav.root.RootView
|
||||
@@ -49,7 +50,10 @@ import io.element.android.libraries.core.uri.ensureProtocol
|
||||
import io.element.android.libraries.deeplink.api.DeeplinkData
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.core.asEventId
|
||||
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.libraries.oidc.api.OidcAction
|
||||
@@ -388,13 +392,19 @@ class RootFlowNode(
|
||||
is PermalinkData.FallbackLink -> Unit
|
||||
is PermalinkData.RoomEmailInviteLink -> Unit
|
||||
is PermalinkData.RoomLink -> {
|
||||
// If there is a thread id, focus on it in the main timeline
|
||||
val focusedEventId = if (permalinkData.threadId != null) {
|
||||
permalinkData.threadId?.asEventId()
|
||||
} else {
|
||||
permalinkData.eventId
|
||||
}
|
||||
attachRoom(
|
||||
roomIdOrAlias = permalinkData.roomIdOrAlias,
|
||||
trigger = JoinedRoom.Trigger.MobilePermalink,
|
||||
serverNames = permalinkData.viaParameters,
|
||||
eventId = permalinkData.eventId,
|
||||
eventId = focusedEventId,
|
||||
clearBackstack = true
|
||||
)
|
||||
).maybeAttachThread(permalinkData.threadId, permalinkData.eventId)
|
||||
}
|
||||
is PermalinkData.UserLink -> {
|
||||
attachUser(permalinkData.userId)
|
||||
@@ -402,12 +412,24 @@ class RootFlowNode(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun RoomFlowNode.maybeAttachThread(threadId: ThreadId?, focusedEventId: EventId?) {
|
||||
if (threadId != null) {
|
||||
attachThread(threadId, focusedEventId)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun navigateTo(deeplinkData: DeeplinkData) {
|
||||
Timber.d("Navigating to $deeplinkData")
|
||||
attachSession(deeplinkData.sessionId).apply {
|
||||
attachSession(deeplinkData.sessionId).let { loggedInFlowNode ->
|
||||
when (deeplinkData) {
|
||||
is DeeplinkData.Root -> Unit // The room list will always be shown, observing FtueState
|
||||
is DeeplinkData.Room -> attachRoom(deeplinkData.roomId.toRoomIdOrAlias(), clearBackstack = true)
|
||||
is DeeplinkData.Room -> {
|
||||
loggedInFlowNode.attachRoom(
|
||||
roomIdOrAlias = deeplinkData.roomId.toRoomIdOrAlias(),
|
||||
eventId = if (deeplinkData.threadId != null) deeplinkData.threadId?.asEventId() else deeplinkData.eventId,
|
||||
clearBackstack = true,
|
||||
).maybeAttachThread(deeplinkData.threadId, deeplinkData.eventId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,9 +39,11 @@ import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.core.coroutine.withPreviousValue
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
|
||||
@@ -211,6 +213,11 @@ class RoomFlowNode(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun attachThread(threadId: ThreadId, focusedEventId: EventId?) {
|
||||
waitForChildAttached<JoinedRoomFlowNode>()
|
||||
.attachThread(threadId, focusedEventId)
|
||||
}
|
||||
|
||||
private fun loadingNode(buildContext: BuildContext) = node(buildContext) { modifier ->
|
||||
LoadingRoomNodeView(
|
||||
state = LoadingRoomState.Loading,
|
||||
|
||||
@@ -13,7 +13,9 @@ import kotlinx.parcelize.Parcelize
|
||||
|
||||
sealed interface RoomNavigationTarget : Parcelable {
|
||||
@Parcelize
|
||||
data class Root(val eventId: EventId? = null) : RoomNavigationTarget
|
||||
data class Root(
|
||||
val eventId: EventId? = null,
|
||||
) : RoomNavigationTarget
|
||||
|
||||
@Parcelize
|
||||
data object Details : RoomNavigationTarget
|
||||
|
||||
@@ -34,7 +34,9 @@ import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.ui.room.LoadingRoomState
|
||||
import io.element.android.libraries.matrix.ui.room.LoadingRoomStateFlowFactory
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
@@ -121,6 +123,11 @@ class JoinedRoomFlowNode(
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun attachThread(threadId: ThreadId, focusedEventId: EventId?) {
|
||||
waitForChildAttached<JoinedRoomLoadedFlowNode>()
|
||||
.attachThread(threadId, focusedEventId)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
BackstackView(
|
||||
|
||||
@@ -25,18 +25,21 @@ import io.element.android.appnav.di.RoomGraphFactory
|
||||
import io.element.android.appnav.room.RoomNavigationTarget
|
||||
import io.element.android.features.forward.api.ForwardEntryPoint
|
||||
import io.element.android.features.messages.api.MessagesEntryPoint
|
||||
import io.element.android.features.messages.api.MessagesEntryPointNode
|
||||
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
|
||||
import io.element.android.features.space.api.SpaceEntryPoint
|
||||
import io.element.android.libraries.architecture.BackstackView
|
||||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.architecture.waitForChildAttached
|
||||
import io.element.android.libraries.di.DependencyInjectionGraphOwner
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
@@ -240,7 +243,9 @@ class JoinedRoomLoadedFlowNode(
|
||||
data object Space : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class Messages(val focusedEventId: EventId? = null) : NavTarget
|
||||
data class Messages(
|
||||
val focusedEventId: EventId? = null,
|
||||
) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object RoomDetails : NavTarget
|
||||
@@ -258,6 +263,13 @@ class JoinedRoomLoadedFlowNode(
|
||||
data object RoomNotificationSettings : NavTarget
|
||||
}
|
||||
|
||||
suspend fun attachThread(threadId: ThreadId, focusedEventId: EventId?) {
|
||||
val messageNode = waitForChildAttached<Node, NavTarget> { navTarget ->
|
||||
navTarget is NavTarget.Messages
|
||||
}
|
||||
(messageNode as? MessagesEntryPointNode)?.attachThread(threadId, focusedEventId)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
BackstackView()
|
||||
|
||||
@@ -17,6 +17,7 @@ import io.element.android.features.login.test.FakeLoginIntentResolver
|
||||
import io.element.android.libraries.deeplink.api.DeeplinkData
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_THREAD_ID
|
||||
@@ -67,6 +68,7 @@ class IntentResolverTest {
|
||||
sessionId = A_SESSION_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
threadId = null,
|
||||
eventId = null,
|
||||
)
|
||||
)
|
||||
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
|
||||
@@ -79,6 +81,7 @@ class IntentResolverTest {
|
||||
sessionId = A_SESSION_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
threadId = null,
|
||||
eventId = null,
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -91,6 +94,7 @@ class IntentResolverTest {
|
||||
sessionId = A_SESSION_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
threadId = A_THREAD_ID,
|
||||
eventId = null,
|
||||
)
|
||||
)
|
||||
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
|
||||
@@ -103,6 +107,59 @@ class IntentResolverTest {
|
||||
sessionId = A_SESSION_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
threadId = A_THREAD_ID,
|
||||
eventId = null,
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test resolve navigation intent event`() {
|
||||
val sut = createIntentResolver(
|
||||
deeplinkParserResult = DeeplinkData.Room(
|
||||
sessionId = A_SESSION_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
threadId = null,
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
)
|
||||
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
|
||||
action = Intent.ACTION_VIEW
|
||||
}
|
||||
val result = sut.resolve(intent)
|
||||
assertThat(result).isEqualTo(
|
||||
ResolvedIntent.Navigation(
|
||||
deeplinkData = DeeplinkData.Room(
|
||||
sessionId = A_SESSION_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
threadId = null,
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test resolve navigation intent thread and event`() {
|
||||
val sut = createIntentResolver(
|
||||
deeplinkParserResult = DeeplinkData.Room(
|
||||
sessionId = A_SESSION_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
threadId = A_THREAD_ID,
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
)
|
||||
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
|
||||
action = Intent.ACTION_VIEW
|
||||
}
|
||||
val result = sut.resolve(intent)
|
||||
assertThat(result).isEqualTo(
|
||||
ResolvedIntent.Navigation(
|
||||
deeplinkData = DeeplinkData.Room(
|
||||
sessionId = A_SESSION_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
threadId = A_THREAD_ID,
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import kotlinx.parcelize.Parcelize
|
||||
@@ -22,7 +23,9 @@ import kotlinx.parcelize.Parcelize
|
||||
interface MessagesEntryPoint : FeatureEntryPoint {
|
||||
sealed interface InitialTarget : Parcelable {
|
||||
@Parcelize
|
||||
data class Messages(val focusedEventId: EventId?) : InitialTarget
|
||||
data class Messages(
|
||||
val focusedEventId: EventId?,
|
||||
) : InitialTarget
|
||||
|
||||
@Parcelize
|
||||
data object PinnedMessages : InitialTarget
|
||||
@@ -46,3 +49,7 @@ interface MessagesEntryPoint : FeatureEntryPoint {
|
||||
|
||||
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
|
||||
}
|
||||
|
||||
interface MessagesEntryPointNode {
|
||||
suspend fun attachThread(threadId: ThreadId, focusedEventId: EventId?)
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ dependencies {
|
||||
implementation(projects.libraries.testtags)
|
||||
implementation(projects.features.networkmonitor.api)
|
||||
implementation(projects.services.analytics.compose)
|
||||
implementation(projects.services.appnavstate.api)
|
||||
implementation(projects.services.toolbox.api)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.datetime)
|
||||
|
||||
@@ -33,6 +33,7 @@ import io.element.android.features.location.api.LocationService
|
||||
import io.element.android.features.location.api.SendLocationEntryPoint
|
||||
import io.element.android.features.location.api.ShowLocationEntryPoint
|
||||
import io.element.android.features.messages.api.MessagesEntryPoint
|
||||
import io.element.android.features.messages.api.MessagesEntryPointNode
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode
|
||||
import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider
|
||||
@@ -87,10 +88,12 @@ import io.element.android.libraries.textcomposer.mentions.MentionSpanUpdater
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@ContributesNode(RoomScope::class)
|
||||
@AssistedInject
|
||||
@@ -126,8 +129,9 @@ class MessagesFlowNode(
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
buildContext = buildContext,
|
||||
plugins = plugins
|
||||
) {
|
||||
plugins = plugins,
|
||||
),
|
||||
MessagesEntryPointNode {
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
data class Messages(val focusedEventId: EventId?) : NavTarget
|
||||
@@ -175,7 +179,7 @@ class MessagesFlowNode(
|
||||
data object KnockRequestsList : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class OpenThread(val threadRootId: ThreadId, val focusedEventId: EventId?) : NavTarget
|
||||
data class Thread(val threadRootId: ThreadId, val focusedEventId: EventId?) : NavTarget
|
||||
}
|
||||
|
||||
private val callbacks = plugins<MessagesEntryPoint.Callback>()
|
||||
@@ -287,7 +291,7 @@ class MessagesFlowNode(
|
||||
}
|
||||
|
||||
override fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) {
|
||||
backstack.push(NavTarget.OpenThread(threadRootId, focusedEventId))
|
||||
backstack.push(NavTarget.Thread(threadRootId, focusedEventId))
|
||||
}
|
||||
}
|
||||
val inputs = MessagesNode.Inputs(focusedEventId = navTarget.focusedEventId)
|
||||
@@ -420,7 +424,7 @@ class MessagesFlowNode(
|
||||
NavTarget.KnockRequestsList -> {
|
||||
knockRequestsListEntryPoint.createNode(this, buildContext)
|
||||
}
|
||||
is NavTarget.OpenThread -> {
|
||||
is NavTarget.Thread -> {
|
||||
val inputs = ThreadedMessagesNode.Inputs(
|
||||
threadRootEventId = navTarget.threadRootId,
|
||||
focusedEventId = navTarget.focusedEventId,
|
||||
@@ -485,7 +489,7 @@ class MessagesFlowNode(
|
||||
}
|
||||
|
||||
override fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) {
|
||||
backstack.push(NavTarget.OpenThread(threadRootId, focusedEventId))
|
||||
backstack.push(NavTarget.Thread(threadRootId, focusedEventId))
|
||||
}
|
||||
}
|
||||
createNode<ThreadedMessagesNode>(buildContext, listOf(inputs, callback))
|
||||
@@ -603,6 +607,16 @@ class MessagesFlowNode(
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun attachThread(threadId: ThreadId, focusedEventId: EventId?) {
|
||||
// Wait until we have the UI for the main timeline attached
|
||||
waitForChildAttached<MessagesNode>()
|
||||
// Give some time for the items in the main timeline to be received, otherwise loading the focused thread root id won't work
|
||||
// (look at TimelineItemIndexer and firstProcessLatch for more info)
|
||||
delay(10.milliseconds)
|
||||
// Then push the new threads screen on top
|
||||
backstack.push(NavTarget.Thread(threadId, focusedEventId))
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
mentionSpanTheme.updateStyles()
|
||||
|
||||
@@ -337,12 +337,11 @@ class MessagesNode(
|
||||
var focusedEventId by rememberSaveable {
|
||||
mutableStateOf(inputs.focusedEventId)
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
focusedEventId?.also { eventId ->
|
||||
state.timelineState.eventSink(TimelineEvents.FocusOnEvent(eventId))
|
||||
LaunchedEffect(focusedEventId) {
|
||||
if (focusedEventId != null) {
|
||||
state.timelineState.eventSink(TimelineEvents.FocusOnEvent(focusedEventId!!))
|
||||
focusedEventId = null
|
||||
}
|
||||
// Reset the focused event id to null to avoid refocusing when restoring node.
|
||||
focusedEventId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.mediaplayer.api.MediaPlayer
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.appnavstate.api.AppNavigationStateService
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -85,6 +86,7 @@ class ThreadedMessagesNode(
|
||||
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
|
||||
private val mediaPlayer: MediaPlayer,
|
||||
private val permalinkParser: PermalinkParser,
|
||||
private val appNavigationStateService: AppNavigationStateService,
|
||||
) : Node(buildContext, plugins = plugins), MessagesNavigator {
|
||||
private val callbacks = plugins<Callback>()
|
||||
|
||||
@@ -131,6 +133,12 @@ class ThreadedMessagesNode(
|
||||
onCreate = {
|
||||
sessionCoroutineScope.launch { analyticsService.capture(room.toAnalyticsViewRoom()) }
|
||||
},
|
||||
onStart = {
|
||||
appNavigationStateService.onNavigateToThread(id, inputs.threadRootEventId)
|
||||
},
|
||||
onStop = {
|
||||
appNavigationStateService.onLeavingThread(id)
|
||||
},
|
||||
onDestroy = {
|
||||
mediaPlayer.close()
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ class TimelinePresenter(
|
||||
private val roomCallStatePresenter: Presenter<RoomCallState>,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
) : Presenter<TimelineState> {
|
||||
private val tag = "TimelinePresenter"
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(
|
||||
@@ -102,14 +103,14 @@ class TimelinePresenter(
|
||||
)
|
||||
private var timelineItems by mutableStateOf<ImmutableList<TimelineItem>>(persistentListOf())
|
||||
|
||||
private val focusRequestState: MutableState<FocusRequestState> = mutableStateOf(FocusRequestState.None)
|
||||
|
||||
@Composable
|
||||
override fun present(): TimelineState {
|
||||
val localScope = rememberCoroutineScope()
|
||||
|
||||
val timelineMode = remember { timelineController.mainTimelineMode() }
|
||||
|
||||
var focusRequestState: FocusRequestState by remember { mutableStateOf(FocusRequestState.None) }
|
||||
|
||||
val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) }
|
||||
|
||||
val roomInfo by room.roomInfoFlow.collectAsState()
|
||||
@@ -155,7 +156,7 @@ class TimelinePresenter(
|
||||
if (event.firstIndex == 0) {
|
||||
newEventState.value = NewEventState.None
|
||||
}
|
||||
Timber.d("## sendReadReceiptIfNeeded firstVisibleIndex: ${event.firstIndex}")
|
||||
Timber.tag(tag).d("## sendReadReceiptIfNeeded firstVisibleIndex: ${event.firstIndex}")
|
||||
sessionCoroutineScope.sendReadReceiptIfNeeded(
|
||||
firstVisibleIndex = event.firstIndex,
|
||||
timelineItems = timelineItems,
|
||||
@@ -186,14 +187,17 @@ class TimelinePresenter(
|
||||
is TimelineEvents.EditPoll -> {
|
||||
navigator.onEditPollClick(event.pollStartId)
|
||||
}
|
||||
is TimelineEvents.FocusOnEvent -> {
|
||||
focusRequestState = FocusRequestState.Requested(event.eventId, event.debounce)
|
||||
}
|
||||
is TimelineEvents.FocusOnEvent -> sessionCoroutineScope.launch {
|
||||
focusRequestState.value = FocusRequestState.Requested(event.eventId, event.debounce)
|
||||
delay(event.debounce)
|
||||
Timber.tag(tag).d("Started focus on ${event.eventId}")
|
||||
focusOnEvent(event.eventId, focusRequestState)
|
||||
}.start()
|
||||
is TimelineEvents.OnFocusEventRender -> {
|
||||
focusRequestState = focusRequestState.onFocusEventRender()
|
||||
focusRequestState.value = focusRequestState.value.onFocusEventRender()
|
||||
}
|
||||
is TimelineEvents.ClearFocusRequestState -> {
|
||||
focusRequestState = FocusRequestState.None
|
||||
focusRequestState.value = FocusRequestState.None
|
||||
}
|
||||
is TimelineEvents.JumpToLive -> {
|
||||
timelineController.focusOnLive()
|
||||
@@ -236,69 +240,19 @@ class TimelinePresenter(
|
||||
.launchIn(this)
|
||||
}
|
||||
|
||||
LaunchedEffect(focusRequestState) {
|
||||
Timber.d("## focusRequestState: $focusRequestState")
|
||||
when (val currentFocusRequestState = focusRequestState) {
|
||||
is FocusRequestState.Requested -> {
|
||||
delay(currentFocusRequestState.debounce)
|
||||
if (timelineItemIndexer.isKnown(currentFocusRequestState.eventId)) {
|
||||
val index = timelineItemIndexer.indexOf(currentFocusRequestState.eventId)
|
||||
focusRequestState = FocusRequestState.Success(eventId = currentFocusRequestState.eventId, index = index)
|
||||
} else {
|
||||
focusRequestState = FocusRequestState.Loading(eventId = currentFocusRequestState.eventId)
|
||||
}
|
||||
}
|
||||
is FocusRequestState.Loading -> {
|
||||
val eventId = currentFocusRequestState.eventId
|
||||
val threadId = room.threadRootIdForEvent(eventId).getOrElse {
|
||||
focusRequestState = FocusRequestState.Failure(it)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
if (timelineController.mainTimelineMode() is Timeline.Mode.Thread && threadId == null) {
|
||||
// We are in a thread timeline, and the event isn't part of a thread, we need to navigate back to the room
|
||||
focusRequestState = FocusRequestState.None
|
||||
navigator.onNavigateToRoom(room.roomId, eventId, calculateServerNamesForRoom(room))
|
||||
} else {
|
||||
timelineController.focusOnEvent(eventId, threadId)
|
||||
.onSuccess { result ->
|
||||
when (result) {
|
||||
is EventFocusResult.FocusedOnLive -> {
|
||||
focusRequestState = FocusRequestState.Success(eventId = eventId)
|
||||
}
|
||||
is EventFocusResult.IsInThread -> {
|
||||
val currentThreadId = (timelineController.mainTimelineMode() as? Timeline.Mode.Thread)?.threadRootId
|
||||
if (currentThreadId == result.threadId) {
|
||||
// It's the same thread, we just focus on the event
|
||||
focusRequestState = FocusRequestState.Success(eventId = eventId)
|
||||
} else {
|
||||
focusRequestState = FocusRequestState.Success(eventId = result.threadId.asEventId())
|
||||
// It's part of a thread we're not in, let's open it in another timeline
|
||||
navigator.onOpenThread(result.threadId, eventId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onFailure {
|
||||
focusRequestState = FocusRequestState.Failure(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(timelineItems.size) {
|
||||
computeNewItemState(timelineItems, prevMostRecentItemId, newEventState)
|
||||
}
|
||||
|
||||
LaunchedEffect(timelineItems.size, focusRequestState) {
|
||||
val currentFocusRequestState = focusRequestState
|
||||
LaunchedEffect(timelineItems.size, focusRequestState.value) {
|
||||
val currentFocusRequestState = focusRequestState.value
|
||||
if (currentFocusRequestState is FocusRequestState.Success && !currentFocusRequestState.rendered) {
|
||||
val eventId = currentFocusRequestState.eventId
|
||||
if (timelineItemIndexer.isKnown(eventId)) {
|
||||
val index = timelineItemIndexer.indexOf(eventId)
|
||||
focusRequestState = FocusRequestState.Success(eventId = eventId, index = index)
|
||||
focusRequestState.value = FocusRequestState.Success(eventId = eventId, index = index)
|
||||
} else {
|
||||
Timber.w("Unknown timeline item for focused item, can't render focus")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,6 +273,11 @@ class TimelinePresenter(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(focusRequestState.value) {
|
||||
Timber.tag(tag).d("Timeline: $timelineMode | focus state: ${focusRequestState.value}")
|
||||
}
|
||||
|
||||
return TimelineState(
|
||||
timelineItems = timelineItems,
|
||||
timelineMode = timelineMode,
|
||||
@@ -326,7 +285,7 @@ class TimelinePresenter(
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
newEventState = newEventState.value,
|
||||
isLive = isLive,
|
||||
focusRequestState = focusRequestState,
|
||||
focusRequestState = focusRequestState.value,
|
||||
messageShield = messageShield.value,
|
||||
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
|
||||
displayThreadSummaries = displayThreadSummaries,
|
||||
@@ -334,6 +293,55 @@ class TimelinePresenter(
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun focusOnEvent(
|
||||
eventId: EventId,
|
||||
focusRequestState: MutableState<FocusRequestState>,
|
||||
) {
|
||||
if (timelineItemIndexer.isKnown(eventId)) {
|
||||
val index = timelineItemIndexer.indexOf(eventId)
|
||||
focusRequestState.value = FocusRequestState.Success(eventId = eventId, index = index)
|
||||
return
|
||||
}
|
||||
|
||||
Timber.tag(tag).d("Event $eventId not found in the loaded timeline, loading a focused timeline")
|
||||
focusRequestState.value = FocusRequestState.Loading(eventId = eventId)
|
||||
|
||||
val threadId = room.threadRootIdForEvent(eventId).getOrElse {
|
||||
focusRequestState.value = FocusRequestState.Failure(it)
|
||||
return
|
||||
}
|
||||
|
||||
if (timelineController.mainTimelineMode() is Timeline.Mode.Thread && threadId == null) {
|
||||
// We are in a thread timeline, and the event isn't part of a thread, we need to navigate back to the room
|
||||
focusRequestState.value = FocusRequestState.None
|
||||
navigator.onNavigateToRoom(room.roomId, eventId, calculateServerNamesForRoom(room))
|
||||
} else {
|
||||
Timber.tag(tag).d("Focusing on event $eventId - thread $threadId")
|
||||
timelineController.focusOnEvent(eventId, threadId)
|
||||
.onSuccess { result ->
|
||||
when (result) {
|
||||
is EventFocusResult.FocusedOnLive -> {
|
||||
focusRequestState.value = FocusRequestState.Success(eventId = eventId)
|
||||
}
|
||||
is EventFocusResult.IsInThread -> {
|
||||
val currentThreadId = (timelineController.mainTimelineMode() as? Timeline.Mode.Thread)?.threadRootId
|
||||
if (currentThreadId == result.threadId) {
|
||||
// It's the same thread, we just focus on the event
|
||||
focusRequestState.value = FocusRequestState.Success(eventId = eventId)
|
||||
} else {
|
||||
focusRequestState.value = FocusRequestState.Success(eventId = result.threadId.asEventId())
|
||||
// It's part of a thread we're not in, let's open it in another timeline
|
||||
navigator.onOpenThread(result.threadId, eventId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onFailure {
|
||||
focusRequestState.value = FocusRequestState.Failure(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method compute the hasNewItem state passed as a [MutableState] each time the timeline items size changes.
|
||||
* Basically, if we got new timeline event from sync or local, either from us or another user, we update the state so we tell we have new items.
|
||||
|
||||
@@ -136,7 +136,7 @@ class DefaultMessagesEntryPointTest {
|
||||
@Test
|
||||
fun `test initial target to nav target mapping`() {
|
||||
assertThat(MessagesEntryPoint.InitialTarget.Messages(focusedEventId = AN_EVENT_ID).toNavTarget())
|
||||
.isEqualTo(MessagesFlowNode.NavTarget.Messages(AN_EVENT_ID))
|
||||
.isEqualTo(MessagesFlowNode.NavTarget.Messages(focusedEventId = AN_EVENT_ID))
|
||||
assertThat(MessagesEntryPoint.InitialTarget.PinnedMessages.toNavTarget())
|
||||
.isEqualTo(MessagesFlowNode.NavTarget.PinnedMessagesList)
|
||||
}
|
||||
|
||||
@@ -563,9 +563,7 @@ class TimelinePresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - focus on known event retrieves the event from cache`() = runTest {
|
||||
val timelineItemIndexer = TimelineItemIndexer().apply {
|
||||
process(listOf(aMessageEvent(eventId = AN_EVENT_ID)))
|
||||
}
|
||||
val timelineItemIndexer = TimelineItemIndexer()
|
||||
val presenter = createTimelinePresenter(
|
||||
room = FakeJoinedRoom(
|
||||
liveTimeline = FakeTimeline(
|
||||
@@ -578,7 +576,10 @@ class TimelinePresenterTest {
|
||||
)
|
||||
)
|
||||
),
|
||||
baseRoom = FakeBaseRoom(canUserSendMessageResult = { _, _ -> Result.success(true) }),
|
||||
baseRoom = FakeBaseRoom(
|
||||
canUserSendMessageResult = { _, _ -> Result.success(true) },
|
||||
threadRootIdForEventResult = { Result.success(null) },
|
||||
),
|
||||
),
|
||||
timelineItemIndexer = timelineItemIndexer,
|
||||
)
|
||||
@@ -586,7 +587,16 @@ class TimelinePresenterTest {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitFirstItem()
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
// Pre-populate the indexer after the first items have been retrieved
|
||||
timelineItemIndexer.process(listOf(aMessageEvent(eventId = AN_EVENT_ID)))
|
||||
|
||||
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
|
||||
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Requested(AN_EVENT_ID, Duration.ZERO))
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
|
||||
package io.element.android.libraries.deeplink.api
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
|
||||
fun interface DeepLinkCreator {
|
||||
fun create(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?): String
|
||||
fun create(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?, eventId: EventId?): String
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
package io.element.android.libraries.deeplink.api
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
@@ -18,6 +19,6 @@ sealed interface DeeplinkData {
|
||||
/** The target is the root of the app, with the given [sessionId]. */
|
||||
data class Root(override val sessionId: SessionId) : DeeplinkData
|
||||
|
||||
/** The target is a room, with the given [sessionId], [roomId] and optionally a [threadId]. */
|
||||
data class Room(override val sessionId: SessionId, val roomId: RoomId, val threadId: ThreadId?) : DeeplinkData
|
||||
/** The target is a room, with the given [sessionId], [roomId] and optionally a [threadId] and [eventId]. */
|
||||
data class Room(override val sessionId: SessionId, val roomId: RoomId, val threadId: ThreadId?, val eventId: EventId?) : DeeplinkData
|
||||
}
|
||||
|
||||
@@ -10,24 +10,30 @@ package io.element.android.libraries.deeplink.impl
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.deeplink.api.DeepLinkCreator
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultDeepLinkCreator : DeepLinkCreator {
|
||||
override fun create(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?): String {
|
||||
override fun create(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?, eventId: EventId?): String {
|
||||
return buildString {
|
||||
append("$SCHEME://$HOST/")
|
||||
append(sessionId.value)
|
||||
if (roomId != null) {
|
||||
append("/")
|
||||
append(roomId.value)
|
||||
if (threadId != null) {
|
||||
append("/")
|
||||
append(threadId.value)
|
||||
}
|
||||
}
|
||||
append("/")
|
||||
append(roomId?.value.orEmpty())
|
||||
append("/")
|
||||
append(threadId?.value.orEmpty())
|
||||
append("/")
|
||||
append(eventId?.value.orEmpty())
|
||||
}
|
||||
// Remove all possible trailing '/' characters:
|
||||
// No event id
|
||||
.removeSuffix("/")
|
||||
// No thread id
|
||||
.removeSuffix("/")
|
||||
// No room id
|
||||
.removeSuffix("/")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.deeplink.api.DeeplinkData
|
||||
import io.element.android.libraries.deeplink.api.DeeplinkParser
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
@@ -36,8 +37,9 @@ class DefaultDeeplinkParser : DeeplinkParser {
|
||||
null -> DeeplinkData.Root(sessionId)
|
||||
else -> {
|
||||
val roomId = screenPathComponent.let(::RoomId)
|
||||
val threadId = pathBits.elementAtOrNull(2)?.let(::ThreadId)
|
||||
DeeplinkData.Room(sessionId, roomId, threadId)
|
||||
val threadId = pathBits.elementAtOrNull(2)?.takeIf { it.isNotBlank() }?.let(::ThreadId)
|
||||
val eventId = pathBits.elementAtOrNull(3)?.takeIf { it.isNotBlank() }?.let(::EventId)
|
||||
DeeplinkData.Room(sessionId, roomId, threadId, eventId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
package io.element.android.libraries.deeplink.impl
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_THREAD_ID
|
||||
@@ -17,11 +18,15 @@ class DefaultDeepLinkCreatorTest {
|
||||
@Test
|
||||
fun create() {
|
||||
val sut = DefaultDeepLinkCreator()
|
||||
assertThat(sut.create(A_SESSION_ID, null, null))
|
||||
assertThat(sut.create(A_SESSION_ID, null, null, null))
|
||||
.isEqualTo("elementx://open/@alice:server.org")
|
||||
assertThat(sut.create(A_SESSION_ID, A_ROOM_ID, null))
|
||||
assertThat(sut.create(A_SESSION_ID, A_ROOM_ID, null, null))
|
||||
.isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain")
|
||||
assertThat(sut.create(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID))
|
||||
assertThat(sut.create(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID, null))
|
||||
.isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId")
|
||||
assertThat(sut.create(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID, AN_EVENT_ID))
|
||||
.isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId/\$anEventId")
|
||||
assertThat(sut.create(A_SESSION_ID, A_ROOM_ID, null, AN_EVENT_ID))
|
||||
.isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain//\$anEventId")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import android.content.Intent
|
||||
import androidx.core.net.toUri
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.deeplink.api.DeeplinkData
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_THREAD_ID
|
||||
@@ -28,6 +29,10 @@ class DefaultDeeplinkParserTest {
|
||||
"elementx://open/@alice:server.org/!aRoomId:domain"
|
||||
const val A_URI_WITH_ROOM_WITH_THREAD =
|
||||
"elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId"
|
||||
const val A_URI_WITH_ROOM_WITH_THREAD_AND_EVENT =
|
||||
"elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId/\$anEventId"
|
||||
const val A_URI_WITH_ROOM_WITH_EVENT_AND_NO_THREAD =
|
||||
"elementx://open/@alice:server.org/!aRoomId:domain//\$anEventId"
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -36,9 +41,13 @@ class DefaultDeeplinkParserTest {
|
||||
assertThat(sut.getFromIntent(createIntent(A_URI)))
|
||||
.isEqualTo(DeeplinkData.Root(A_SESSION_ID))
|
||||
assertThat(sut.getFromIntent(createIntent(A_URI_WITH_ROOM)))
|
||||
.isEqualTo(DeeplinkData.Room(A_SESSION_ID, A_ROOM_ID, null))
|
||||
.isEqualTo(DeeplinkData.Room(A_SESSION_ID, A_ROOM_ID, null, null))
|
||||
assertThat(sut.getFromIntent(createIntent(A_URI_WITH_ROOM_WITH_THREAD)))
|
||||
.isEqualTo(DeeplinkData.Room(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID))
|
||||
.isEqualTo(DeeplinkData.Room(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID, null))
|
||||
assertThat(sut.getFromIntent(createIntent(A_URI_WITH_ROOM_WITH_THREAD_AND_EVENT)))
|
||||
.isEqualTo(DeeplinkData.Room(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID, AN_EVENT_ID))
|
||||
assertThat(sut.getFromIntent(createIntent(A_URI_WITH_ROOM_WITH_EVENT_AND_NO_THREAD)))
|
||||
.isEqualTo(DeeplinkData.Room(A_SESSION_ID, A_ROOM_ID, null, AN_EVENT_ID))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -13,6 +13,7 @@ import androidx.compose.runtime.Immutable
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
@@ -28,6 +29,7 @@ sealed interface PermalinkData : Parcelable {
|
||||
data class RoomLink(
|
||||
val roomIdOrAlias: RoomIdOrAlias,
|
||||
val eventId: EventId? = null,
|
||||
val threadId: ThreadId? = null,
|
||||
val viaParameters: ImmutableList<String> = persistentListOf()
|
||||
) : PermalinkData
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.notification.NotificationContent
|
||||
import io.element.android.libraries.matrix.api.notification.NotificationData
|
||||
@@ -41,8 +42,7 @@ class NotificationMapper(
|
||||
NotificationData(
|
||||
sessionId = sessionId,
|
||||
eventId = eventId,
|
||||
// FIXME once the `NotificationItem` in the SDK returns the thread id
|
||||
threadId = null,
|
||||
threadId = item.threadId?.let(::ThreadId),
|
||||
roomId = roomId,
|
||||
senderAvatarUrl = item.senderInfo.avatarUrl,
|
||||
senderDisplayName = item.senderInfo.displayName,
|
||||
|
||||
@@ -10,10 +10,12 @@ package io.element.android.libraries.push.api.notifications
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
|
||||
interface NotificationCleaner {
|
||||
fun clearAllMessagesEvents(sessionId: SessionId)
|
||||
fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId)
|
||||
fun clearMessagesForThread(sessionId: SessionId, roomId: RoomId, threadId: ThreadId)
|
||||
fun clearEvent(sessionId: SessionId, eventId: EventId)
|
||||
|
||||
fun clearMembershipNotificationForSession(sessionId: SessionId)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
package io.element.android.libraries.push.impl.intent
|
||||
|
||||
import android.content.Intent
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
@@ -20,5 +21,6 @@ interface IntentProvider {
|
||||
sessionId: SessionId,
|
||||
roomId: RoomId?,
|
||||
threadId: ThreadId?,
|
||||
eventId: EventId?,
|
||||
): Intent
|
||||
}
|
||||
|
||||
@@ -14,11 +14,21 @@ import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
|
||||
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
|
||||
import timber.log.Timber
|
||||
|
||||
interface ActiveNotificationsProvider {
|
||||
fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification>
|
||||
/**
|
||||
* Gets the displayed notifications for the combination of [sessionId], [roomId] and [threadId].
|
||||
*/
|
||||
fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId, threadId: ThreadId?): List<StatusBarNotification>
|
||||
|
||||
/**
|
||||
* Gets all displayed notifications associated to [sessionId] and [roomId]. These will include all thread notifications as well.
|
||||
*/
|
||||
fun getAllMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification>
|
||||
fun getNotificationsForSession(sessionId: SessionId): List<StatusBarNotification>
|
||||
fun getMembershipNotificationForSession(sessionId: SessionId): List<StatusBarNotification>
|
||||
fun getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification>
|
||||
@@ -44,9 +54,15 @@ class DefaultActiveNotificationsProvider(
|
||||
return getNotificationsForSession(sessionId).filter { it.id == notificationId }
|
||||
}
|
||||
|
||||
override fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification> {
|
||||
override fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId, threadId: ThreadId?): List<StatusBarNotification> {
|
||||
val notificationId = NotificationIdProvider.getRoomMessagesNotificationId(sessionId)
|
||||
return getNotificationsForSession(sessionId).filter { it.id == notificationId && it.tag == roomId.value }
|
||||
val expectedTag = NotificationCreator.messageTag(roomId, threadId)
|
||||
return getNotificationsForSession(sessionId).filter { it.id == notificationId && it.tag == expectedTag }
|
||||
}
|
||||
|
||||
override fun getAllMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification> {
|
||||
val notificationId = NotificationIdProvider.getRoomMessagesNotificationId(sessionId)
|
||||
return getNotificationsForSession(sessionId).filter { it.id == notificationId && it.tag.startsWith(roomId.value) }
|
||||
}
|
||||
|
||||
override fun getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification> {
|
||||
|
||||
@@ -17,6 +17,8 @@ import io.element.android.libraries.core.extensions.flatMap
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
@@ -85,6 +87,7 @@ class DefaultNotifiableEventResolver(
|
||||
private val permalinkParser: PermalinkParser,
|
||||
private val callNotificationEventResolver: CallNotificationEventResolver,
|
||||
private val fallbackNotificationFactory: FallbackNotificationFactory,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
) : NotifiableEventResolver {
|
||||
override suspend fun resolveEvents(
|
||||
sessionId: SessionId,
|
||||
@@ -141,7 +144,7 @@ class DefaultNotifiableEventResolver(
|
||||
senderId = content.senderId,
|
||||
roomId = roomId,
|
||||
eventId = eventId,
|
||||
threadId = threadId,
|
||||
threadId = threadId.takeIf { featureFlagService.isFeatureEnabled(FeatureFlags.Threads) },
|
||||
noisy = isNoisy,
|
||||
timestamp = this.timestamp,
|
||||
senderDisambiguatedDisplayName = senderDisambiguatedDisplayName,
|
||||
|
||||
@@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
|
||||
import io.element.android.libraries.push.api.notifications.NotificationCleaner
|
||||
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
|
||||
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom
|
||||
import io.element.android.services.appnavstate.api.AppNavigationStateService
|
||||
@@ -93,10 +94,10 @@ class DefaultNotificationDrawerManager(
|
||||
)
|
||||
}
|
||||
is NavigationState.Thread -> {
|
||||
onEnteringThread(
|
||||
navigationState.parentRoom.parentSpace.parentSession.sessionId,
|
||||
navigationState.parentRoom.roomId,
|
||||
navigationState.threadId
|
||||
clearMessagesForThread(
|
||||
sessionId = navigationState.parentRoom.parentSpace.parentSession.sessionId,
|
||||
roomId = navigationState.parentRoom.roomId,
|
||||
threadId = navigationState.threadId,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -145,6 +146,16 @@ class DefaultNotificationDrawerManager(
|
||||
clearSummaryNotificationIfNeeded(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Should be called when the application is currently opened and showing timeline for the given threadId.
|
||||
* Used to ignore events related to that thread (no need to display notification) and clean any existing notification on this room.
|
||||
*/
|
||||
override fun clearMessagesForThread(sessionId: SessionId, roomId: RoomId, threadId: ThreadId) {
|
||||
val tag = NotificationCreator.messageTag(roomId, threadId)
|
||||
notificationManager.cancel(tag, NotificationIdProvider.getRoomMessagesNotificationId(sessionId))
|
||||
clearSummaryNotificationIfNeeded(sessionId)
|
||||
}
|
||||
|
||||
override fun clearMembershipNotificationForSession(sessionId: SessionId) {
|
||||
activeNotificationsProvider.getMembershipNotificationForSession(sessionId)
|
||||
.forEach { notificationManager.cancel(it.tag, it.id) }
|
||||
@@ -176,16 +187,6 @@ class DefaultNotificationDrawerManager(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Should be called when the application is currently opened and showing timeline for the given threadId.
|
||||
* Used to ignore events related to that thread (no need to display notification) and clean any existing notification on this room.
|
||||
*/
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
private fun onEnteringThread(sessionId: SessionId, roomId: RoomId, threadId: ThreadId) {
|
||||
// TODO maybe we'll have to embed more data in the tag to get a threadId
|
||||
// Do nothing for now
|
||||
}
|
||||
|
||||
private suspend fun renderEvents(eventsToRender: List<NotifiableEvent>) {
|
||||
// Group by sessionId
|
||||
val eventsForSessions = eventsToRender.groupBy {
|
||||
|
||||
@@ -16,9 +16,11 @@ import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.room.CreateTimelineParams
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.room.isDm
|
||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory
|
||||
import io.element.android.libraries.push.api.notifications.NotificationCleaner
|
||||
import io.element.android.libraries.push.impl.R
|
||||
@@ -72,8 +74,12 @@ class NotificationBroadcastReceiverHandler(
|
||||
notificationCleaner.clearEvent(sessionId, eventId)
|
||||
}
|
||||
actionIds.markRoomRead -> if (roomId != null) {
|
||||
notificationCleaner.clearMessagesForRoom(sessionId, roomId)
|
||||
handleMarkAsRead(sessionId, roomId)
|
||||
if (threadId == null) {
|
||||
notificationCleaner.clearMessagesForRoom(sessionId, roomId)
|
||||
} else {
|
||||
notificationCleaner.clearMessagesForThread(sessionId, roomId, threadId)
|
||||
}
|
||||
handleMarkAsRead(sessionId, roomId, threadId)
|
||||
}
|
||||
actionIds.join -> if (roomId != null) {
|
||||
notificationCleaner.clearMembershipNotificationForRoom(sessionId, roomId)
|
||||
@@ -96,7 +102,8 @@ class NotificationBroadcastReceiverHandler(
|
||||
client.getRoom(roomId)?.leave()
|
||||
}
|
||||
|
||||
private fun handleMarkAsRead(sessionId: SessionId, roomId: RoomId) = appCoroutineScope.launch {
|
||||
@Suppress("unused")
|
||||
private fun handleMarkAsRead(sessionId: SessionId, roomId: RoomId, threadId: ThreadId?) = appCoroutineScope.launch {
|
||||
val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch
|
||||
val isSendPublicReadReceiptsEnabled = sessionPreferencesStore.get(sessionId, this).isSendPublicReadReceiptsEnabled().first()
|
||||
val receiptType = if (isSendPublicReadReceiptsEnabled) {
|
||||
@@ -104,7 +111,26 @@ class NotificationBroadcastReceiverHandler(
|
||||
} else {
|
||||
ReceiptType.READ_PRIVATE
|
||||
}
|
||||
client.getRoom(roomId)?.markAsRead(receiptType = receiptType)
|
||||
val room = client.getJoinedRoom(roomId) ?: return@launch
|
||||
val timeline = if (threadId != null) {
|
||||
room.createTimeline(CreateTimelineParams.Threaded(threadId)).getOrNull()
|
||||
} else {
|
||||
room.liveTimeline
|
||||
}
|
||||
timeline?.markAsRead(receiptType)
|
||||
?.onSuccess {
|
||||
if (threadId != null) {
|
||||
Timber.d("Marked thread $threadId in room $roomId as read with receipt type $receiptType")
|
||||
} else {
|
||||
Timber.d("Marked room $roomId as read with receipt type $receiptType")
|
||||
}
|
||||
}
|
||||
?.onFailure {
|
||||
Timber.e(it, "Fails to mark as read with receipt type $receiptType")
|
||||
}
|
||||
if (timeline?.mode != Timeline.Mode.Live) {
|
||||
timeline?.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSmartReply(
|
||||
|
||||
@@ -18,6 +18,7 @@ import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.push.impl.R
|
||||
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
|
||||
@@ -82,32 +83,38 @@ class DefaultNotificationDataFactory(
|
||||
): List<RoomNotification> {
|
||||
val messagesToDisplay = messages.filterNot { it.canNotBeDisplayed() }
|
||||
.groupBy { it.roomId }
|
||||
return messagesToDisplay.map { (roomId, events) ->
|
||||
return messagesToDisplay.flatMap { (roomId, events) ->
|
||||
val roomName = events.lastOrNull()?.roomName ?: roomId.value
|
||||
val isDm = events.lastOrNull()?.roomIsDm ?: false
|
||||
val notification = roomGroupMessageCreator.createRoomMessage(
|
||||
currentUser = currentUser,
|
||||
events = events,
|
||||
roomId = roomId,
|
||||
imageLoader = imageLoader,
|
||||
existingNotification = getExistingNotificationForMessages(currentUser.userId, roomId),
|
||||
color = color,
|
||||
)
|
||||
RoomNotification(
|
||||
notification = notification,
|
||||
roomId = roomId,
|
||||
summaryLine = createRoomMessagesGroupSummaryLine(events, roomName, isDm),
|
||||
messageCount = events.size,
|
||||
latestTimestamp = events.maxOf { it.timestamp },
|
||||
shouldBing = events.any { it.noisy }
|
||||
)
|
||||
val eventsByThreadId = events.groupBy { it.threadId }
|
||||
|
||||
eventsByThreadId.map { (threadId, events) ->
|
||||
val notification = roomGroupMessageCreator.createRoomMessage(
|
||||
currentUser = currentUser,
|
||||
events = events,
|
||||
roomId = roomId,
|
||||
threadId = threadId,
|
||||
imageLoader = imageLoader,
|
||||
existingNotification = getExistingNotificationForMessages(currentUser.userId, roomId, threadId),
|
||||
color = color,
|
||||
)
|
||||
RoomNotification(
|
||||
notification = notification,
|
||||
roomId = roomId,
|
||||
threadId = threadId,
|
||||
summaryLine = createRoomMessagesGroupSummaryLine(events, roomName, isDm),
|
||||
messageCount = events.size,
|
||||
latestTimestamp = events.maxOf { it.timestamp },
|
||||
shouldBing = events.any { it.noisy }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun NotifiableMessageEvent.canNotBeDisplayed() = isRedacted
|
||||
|
||||
private fun getExistingNotificationForMessages(sessionId: SessionId, roomId: RoomId): Notification? {
|
||||
return activeNotificationsProvider.getMessageNotificationsForRoom(sessionId, roomId).firstOrNull()?.notification
|
||||
private fun getExistingNotificationForMessages(sessionId: SessionId, roomId: RoomId, threadId: ThreadId?): Notification? {
|
||||
return activeNotificationsProvider.getMessageNotificationsForRoom(sessionId, roomId, threadId).firstOrNull()?.notification
|
||||
}
|
||||
|
||||
@JvmName("toNotificationInvites")
|
||||
@@ -228,6 +235,7 @@ class DefaultNotificationDataFactory(
|
||||
data class RoomNotification(
|
||||
val notification: Notification,
|
||||
val roomId: RoomId,
|
||||
val threadId: ThreadId?,
|
||||
val summaryLine: CharSequence,
|
||||
val messageCount: Int,
|
||||
val latestTimestamp: Long,
|
||||
@@ -236,6 +244,7 @@ data class RoomNotification(
|
||||
fun isDataEqualTo(other: RoomNotification): Boolean {
|
||||
return notification == other.notification &&
|
||||
roomId == other.roomId &&
|
||||
threadId == other.threadId &&
|
||||
summaryLine.toString() == other.summaryLine.toString() &&
|
||||
messageCount == other.messageCount &&
|
||||
latestTimestamp == other.latestTimestamp &&
|
||||
|
||||
@@ -15,6 +15,7 @@ import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
|
||||
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
|
||||
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||
@@ -64,8 +65,12 @@ class NotificationRenderer(
|
||||
}
|
||||
|
||||
roomNotifications.forEach { notificationData ->
|
||||
val tag = NotificationCreator.messageTag(
|
||||
roomId = notificationData.roomId,
|
||||
threadId = notificationData.threadId
|
||||
)
|
||||
notificationDisplayer.showNotificationMessage(
|
||||
tag = notificationData.roomId.value,
|
||||
tag = tag,
|
||||
id = NotificationIdProvider.getRoomMessagesNotificationId(currentUser.userId),
|
||||
notification = notificationData.notification
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ import coil3.ImageLoader
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
|
||||
import io.element.android.libraries.push.impl.R
|
||||
@@ -27,6 +28,7 @@ interface RoomGroupMessageCreator {
|
||||
currentUser: MatrixUser,
|
||||
events: List<NotifiableMessageEvent>,
|
||||
roomId: RoomId,
|
||||
threadId: ThreadId?,
|
||||
imageLoader: ImageLoader,
|
||||
existingNotification: Notification?,
|
||||
@ColorInt color: Int,
|
||||
@@ -43,6 +45,7 @@ class DefaultRoomGroupMessageCreator(
|
||||
currentUser: MatrixUser,
|
||||
events: List<NotifiableMessageEvent>,
|
||||
roomId: RoomId,
|
||||
threadId: ThreadId?,
|
||||
imageLoader: ImageLoader,
|
||||
existingNotification: Notification?,
|
||||
@ColorInt color: Int,
|
||||
@@ -73,7 +76,7 @@ class DefaultRoomGroupMessageCreator(
|
||||
customSound = events.last().soundName,
|
||||
isUpdated = events.last().isUpdated,
|
||||
),
|
||||
threadId = lastKnownRoomEvent.threadId,
|
||||
threadId = threadId,
|
||||
largeIcon = largeBitmap,
|
||||
lastMessageTimestamp = lastMessageTimestamp,
|
||||
tickerText = tickerText,
|
||||
|
||||
@@ -107,7 +107,7 @@ class DefaultNotificationConversationService(
|
||||
val shortcutInfo = ShortcutInfoCompat.Builder(context, createShortcutId(sessionId, roomId))
|
||||
.setShortLabel(roomName)
|
||||
.setIcon(icon)
|
||||
.setIntent(intentProvider.getViewRoomIntent(sessionId, roomId, threadId = null))
|
||||
.setIntent(intentProvider.getViewRoomIntent(sessionId, roomId, threadId = null, eventId = null))
|
||||
.setCategories(categories)
|
||||
.setLongLived(true)
|
||||
.let {
|
||||
|
||||
@@ -10,6 +10,7 @@ package io.element.android.libraries.push.impl.notifications.factories
|
||||
import android.app.Notification
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.Icon
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationCompat.MessagingStyle
|
||||
@@ -20,7 +21,7 @@ import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
@@ -38,6 +39,7 @@ import io.element.android.libraries.push.impl.notifications.model.InviteNotifiab
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.shortcut.createShortcutId
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
|
||||
interface NotificationCreator {
|
||||
@@ -86,6 +88,17 @@ interface NotificationCreator {
|
||||
fun createDiagnosticNotification(
|
||||
@ColorInt color: Int,
|
||||
): Notification
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Creates a tag for a message notification given its [roomId] and optional [threadId].
|
||||
*/
|
||||
fun messageTag(roomId: RoomId, threadId: ThreadId?): String = if (threadId != null) {
|
||||
"$roomId|$threadId"
|
||||
} else {
|
||||
roomId.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
@@ -99,7 +112,7 @@ class DefaultNotificationCreator(
|
||||
private val quickReplyActionFactory: QuickReplyActionFactory,
|
||||
private val bitmapLoader: NotificationBitmapLoader,
|
||||
private val acceptInvitationActionFactory: AcceptInvitationActionFactory,
|
||||
private val rejectInvitationActionFactory: RejectInvitationActionFactory
|
||||
private val rejectInvitationActionFactory: RejectInvitationActionFactory,
|
||||
) : NotificationCreator {
|
||||
/**
|
||||
* Create a notification for a Room.
|
||||
@@ -117,9 +130,10 @@ class DefaultNotificationCreator(
|
||||
@ColorInt color: Int,
|
||||
): Notification {
|
||||
// Build the pending intent for when the notification is clicked
|
||||
val eventId = events.firstOrNull()?.eventId
|
||||
val openIntent = when {
|
||||
threadId != null -> pendingIntentFactory.createOpenThreadPendingIntent(roomInfo, threadId)
|
||||
else -> pendingIntentFactory.createOpenRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId)
|
||||
threadId != null -> pendingIntentFactory.createOpenThreadPendingIntent(roomInfo.sessionId, roomInfo.roomId, eventId, threadId)
|
||||
else -> pendingIntentFactory.createOpenRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId, eventId)
|
||||
}
|
||||
val smallIcon = CommonDrawables.ic_notification
|
||||
val containsMissedCall = events.any { it.type == EventType.RTC_NOTIFICATION }
|
||||
@@ -140,19 +154,30 @@ class DefaultNotificationCreator(
|
||||
// Must match those created in the ShortcutInfoCompat.Builder()
|
||||
// for the notification to appear as a "Conversation":
|
||||
// https://developer.android.com/develop/ui/views/notifications/conversations
|
||||
.setShortcutId(createShortcutId(roomInfo.sessionId, roomInfo.roomId))
|
||||
.apply {
|
||||
if (threadId == null) {
|
||||
setShortcutId(createShortcutId(roomInfo.sessionId, roomInfo.roomId))
|
||||
}
|
||||
}
|
||||
// Auto-bundling is enabled for 4 or more notifications on API 24+ (N+)
|
||||
// devices and all Wear devices. But we want a custom grouping, so we specify the groupID
|
||||
.setGroup(roomInfo.sessionId.value)
|
||||
.setGroupSummary(false)
|
||||
// In order to avoid notification making sound twice (due to the summary notification)
|
||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
|
||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
||||
// Remove notification after opening it or using an action
|
||||
.setAutoCancel(true)
|
||||
}
|
||||
|
||||
val messagingStyle = existingNotification?.let {
|
||||
MessagingStyle.extractMessagingStyleFromNotification(it)
|
||||
} ?: messagingStyleFromCurrentUser(roomInfo.sessionId, currentUser, imageLoader, roomInfo.roomDisplayName, !roomInfo.isDm)
|
||||
} ?: messagingStyleFromCurrentUser(
|
||||
user = currentUser,
|
||||
imageLoader = imageLoader,
|
||||
roomName = roomInfo.roomDisplayName,
|
||||
isThread = threadId != null,
|
||||
roomIsGroup = !roomInfo.isDm,
|
||||
)
|
||||
|
||||
messagingStyle.addMessagesFromEvents(events, imageLoader)
|
||||
|
||||
@@ -162,19 +187,6 @@ class DefaultNotificationCreator(
|
||||
.setWhen(lastMessageTimestamp)
|
||||
// MESSAGING_STYLE sets title and content for API 16 and above devices.
|
||||
.setStyle(messagingStyle)
|
||||
// Not needed anymore?
|
||||
// Title for API < 16 devices.
|
||||
.setContentTitle(roomInfo.roomDisplayName.annotateForDebug(1))
|
||||
// Content for API < 16 devices.
|
||||
.setContentText(stringProvider.getString(R.string.notification_new_messages).annotateForDebug(2))
|
||||
// Number of new notifications for API <24 (M and below) devices.
|
||||
.setSubText(
|
||||
stringProvider.getQuantityString(
|
||||
R.plurals.notification_new_messages_for_room,
|
||||
messagingStyle.messages.size,
|
||||
messagingStyle.messages.size
|
||||
).annotateForDebug(3)
|
||||
)
|
||||
.setSmallIcon(smallIcon)
|
||||
// Set primary color (important for Wear 2.0 Notifications).
|
||||
.setColor(color)
|
||||
@@ -197,8 +209,8 @@ class DefaultNotificationCreator(
|
||||
// Clear existing actions since we might be updating an existing notification
|
||||
clearActions()
|
||||
// Add actions and notification intents
|
||||
// Mark room as read
|
||||
addAction(markAsReadActionFactory.create(roomInfo))
|
||||
// Mark room/thread as read
|
||||
addAction(markAsReadActionFactory.create(roomInfo, threadId))
|
||||
// Quick reply
|
||||
if (!roomInfo.hasSmartReplyError) {
|
||||
val latestEventId = events.lastOrNull()?.eventId
|
||||
@@ -208,7 +220,7 @@ class DefaultNotificationCreator(
|
||||
setContentIntent(openIntent)
|
||||
}
|
||||
if (largeIcon != null) {
|
||||
setLargeIcon(largeIcon)
|
||||
setLargeIcon(Icon.createWithBitmap(largeIcon))
|
||||
}
|
||||
setDeleteIntent(pendingIntentFactory.createDismissRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId))
|
||||
|
||||
@@ -239,7 +251,7 @@ class DefaultNotificationCreator(
|
||||
addAction(rejectInvitationActionFactory.create(inviteNotifiableEvent))
|
||||
addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent))
|
||||
// Build the pending intent for when the notification is clicked
|
||||
setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent(inviteNotifiableEvent.sessionId, inviteNotifiableEvent.roomId))
|
||||
setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent(inviteNotifiableEvent.sessionId, inviteNotifiableEvent.roomId, null))
|
||||
|
||||
if (inviteNotifiableEvent.noisy) {
|
||||
// Compat
|
||||
@@ -279,7 +291,7 @@ class DefaultNotificationCreator(
|
||||
.setSmallIcon(smallIcon)
|
||||
.setColor(color)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent(simpleNotifiableEvent.sessionId, simpleNotifiableEvent.roomId))
|
||||
.setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent(simpleNotifiableEvent.sessionId, simpleNotifiableEvent.roomId, null))
|
||||
.apply {
|
||||
if (simpleNotifiableEvent.noisy) {
|
||||
// Compat
|
||||
@@ -447,21 +459,26 @@ class DefaultNotificationCreator(
|
||||
}
|
||||
|
||||
private suspend fun messagingStyleFromCurrentUser(
|
||||
sessionId: SessionId,
|
||||
user: MatrixUser,
|
||||
imageLoader: ImageLoader,
|
||||
roomName: String,
|
||||
isThread: Boolean,
|
||||
roomIsGroup: Boolean
|
||||
): MessagingStyle {
|
||||
return MessagingStyle(
|
||||
Person.Builder()
|
||||
.setName(user.displayName?.annotateForDebug(50))
|
||||
.setIcon(bitmapLoader.getUserIcon(user.avatarUrl, imageLoader))
|
||||
.setKey(sessionId.value)
|
||||
.setKey(user.userId.value)
|
||||
.build()
|
||||
).also {
|
||||
it.conversationTitle = roomName.takeIf { roomIsGroup }
|
||||
it.isGroupConversation = roomIsGroup
|
||||
it.conversationTitle = if (isThread) {
|
||||
stringProvider.getString(CommonStrings.notification_thread_in_room, roomName)
|
||||
} else {
|
||||
roomName
|
||||
}
|
||||
// So the avatar is displayed even if they're part of a conversation
|
||||
it.isGroupConversation = roomIsGroup || isThread
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.push.impl.intent.IntentProvider
|
||||
import io.element.android.libraries.push.impl.notifications.NotificationActionIds
|
||||
import io.element.android.libraries.push.impl.notifications.NotificationBroadcastReceiver
|
||||
import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo
|
||||
import io.element.android.libraries.push.impl.notifications.TestNotificationReceiver
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
|
||||
@@ -32,19 +31,19 @@ class PendingIntentFactory(
|
||||
private val actionIds: NotificationActionIds,
|
||||
) {
|
||||
fun createOpenSessionPendingIntent(sessionId: SessionId): PendingIntent? {
|
||||
return createRoomPendingIntent(sessionId = sessionId, roomId = null, threadId = null)
|
||||
return createRoomPendingIntent(sessionId = sessionId, roomId = null, eventId = null, threadId = null)
|
||||
}
|
||||
|
||||
fun createOpenRoomPendingIntent(sessionId: SessionId, roomId: RoomId): PendingIntent? {
|
||||
return createRoomPendingIntent(sessionId = sessionId, roomId = roomId, threadId = null)
|
||||
fun createOpenRoomPendingIntent(sessionId: SessionId, roomId: RoomId, eventId: EventId?): PendingIntent? {
|
||||
return createRoomPendingIntent(sessionId = sessionId, roomId = roomId, eventId = eventId, threadId = null)
|
||||
}
|
||||
|
||||
fun createOpenThreadPendingIntent(roomInfo: RoomEventGroupInfo, threadId: ThreadId?): PendingIntent? {
|
||||
return createRoomPendingIntent(sessionId = roomInfo.sessionId, roomId = roomInfo.roomId, threadId = threadId)
|
||||
fun createOpenThreadPendingIntent(sessionId: SessionId, roomId: RoomId, eventId: EventId?, threadId: ThreadId): PendingIntent? {
|
||||
return createRoomPendingIntent(sessionId = sessionId, roomId = roomId, eventId = eventId, threadId = threadId)
|
||||
}
|
||||
|
||||
private fun createRoomPendingIntent(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?): PendingIntent? {
|
||||
val intent = intentProvider.getViewRoomIntent(sessionId = sessionId, roomId = roomId, threadId = threadId)
|
||||
private fun createRoomPendingIntent(sessionId: SessionId, roomId: RoomId?, eventId: EventId?, threadId: ThreadId?): PendingIntent? {
|
||||
val intent = intentProvider.getViewRoomIntent(sessionId = sessionId, roomId = roomId, eventId = eventId, threadId = threadId)
|
||||
return PendingIntent.getActivity(
|
||||
context,
|
||||
clock.epochMillis().toInt(),
|
||||
|
||||
@@ -16,6 +16,7 @@ import io.element.android.appconfig.NotificationConfig
|
||||
import io.element.android.libraries.androidutils.uri.createIgnoredUri
|
||||
import io.element.android.libraries.designsystem.icons.CompoundDrawables
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.push.impl.R
|
||||
import io.element.android.libraries.push.impl.notifications.NotificationActionIds
|
||||
import io.element.android.libraries.push.impl.notifications.NotificationBroadcastReceiver
|
||||
@@ -30,15 +31,16 @@ class MarkAsReadActionFactory(
|
||||
private val stringProvider: StringProvider,
|
||||
private val clock: SystemClock,
|
||||
) {
|
||||
fun create(roomInfo: RoomEventGroupInfo): NotificationCompat.Action? {
|
||||
fun create(roomInfo: RoomEventGroupInfo, threadId: ThreadId?): NotificationCompat.Action? {
|
||||
if (!NotificationConfig.SHOW_MARK_AS_READ_ACTION) return null
|
||||
val sessionId = roomInfo.sessionId.value
|
||||
val roomId = roomInfo.roomId.value
|
||||
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
|
||||
intent.action = actionIds.markRoomRead
|
||||
intent.data = createIgnoredUri("markRead/$sessionId/$roomId")
|
||||
intent.data = createIgnoredUri("markRead/$sessionId/$roomId" + threadId?.let { "/$it" }.orEmpty())
|
||||
intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId)
|
||||
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId)
|
||||
threadId?.let { intent.putExtra(NotificationBroadcastReceiver.KEY_THREAD_ID, threadId.value) }
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
clock.epochMillis().toInt(),
|
||||
|
||||
@@ -183,9 +183,9 @@ class DefaultPushHandler(
|
||||
}
|
||||
}
|
||||
|
||||
// Process redactions of messages
|
||||
// Process redactions of messages in background to not block operations with higher priority
|
||||
if (redactions.isNotEmpty()) {
|
||||
onRedactedEventReceived.onRedactedEventsReceived(redactions)
|
||||
appCoroutineScope.launch { onRedactedEventReceived.onRedactedEventsReceived(redactions) }
|
||||
}
|
||||
|
||||
// Find and process ringing call notifications separately
|
||||
|
||||
@@ -16,7 +16,6 @@ import androidx.core.text.buildSpannedString
|
||||
import androidx.core.text.inSpans
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.push.impl.notifications.ActiveNotificationsProvider
|
||||
import io.element.android.libraries.push.impl.notifications.NotificationDisplayer
|
||||
@@ -24,71 +23,63 @@ import io.element.android.libraries.push.impl.notifications.factories.DefaultNot
|
||||
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
interface OnRedactedEventReceived {
|
||||
fun onRedactedEventsReceived(redactions: List<ResolvedPushEvent.Redaction>)
|
||||
suspend fun onRedactedEventsReceived(redactions: List<ResolvedPushEvent.Redaction>)
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultOnRedactedEventReceived(
|
||||
private val activeNotificationsProvider: ActiveNotificationsProvider,
|
||||
private val notificationDisplayer: NotificationDisplayer,
|
||||
@AppCoroutineScope
|
||||
private val coroutineScope: CoroutineScope,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val stringProvider: StringProvider,
|
||||
) : OnRedactedEventReceived {
|
||||
override fun onRedactedEventsReceived(redactions: List<ResolvedPushEvent.Redaction>) {
|
||||
coroutineScope.launch {
|
||||
val redactionsBySessionIdAndRoom = redactions.groupBy { redaction ->
|
||||
redaction.sessionId to redaction.roomId
|
||||
override suspend fun onRedactedEventsReceived(redactions: List<ResolvedPushEvent.Redaction>) {
|
||||
val redactionsBySessionIdAndRoom = redactions.groupBy { redaction ->
|
||||
redaction.sessionId to redaction.roomId
|
||||
}
|
||||
for ((keys, roomRedactions) in redactionsBySessionIdAndRoom) {
|
||||
val (sessionId, roomId) = keys
|
||||
// Get all notifications for the room, including those for threads
|
||||
val notifications = activeNotificationsProvider.getAllMessageNotificationsForRoom(sessionId, roomId)
|
||||
if (notifications.isEmpty()) {
|
||||
Timber.d("No notifications found for redacted event")
|
||||
}
|
||||
for ((keys, roomRedactions) in redactionsBySessionIdAndRoom) {
|
||||
val (sessionId, roomId) = keys
|
||||
val notifications = activeNotificationsProvider.getMessageNotificationsForRoom(
|
||||
sessionId,
|
||||
roomId,
|
||||
notifications.forEach { statusBarNotification ->
|
||||
val notification = statusBarNotification.notification
|
||||
val messagingStyle = MessagingStyle.extractMessagingStyleFromNotification(notification)
|
||||
if (messagingStyle == null) {
|
||||
Timber.w("Unable to retrieve messaging style from notification")
|
||||
return@forEach
|
||||
}
|
||||
val messageToRedactIndex = messagingStyle.messages.indexOfFirst { message ->
|
||||
roomRedactions.any { it.redactedEventId.value == message.extras.getString(DefaultNotificationCreator.MESSAGE_EVENT_ID) }
|
||||
}
|
||||
if (messageToRedactIndex == -1) {
|
||||
Timber.d("Unable to find the message to remove from notification")
|
||||
return@forEach
|
||||
}
|
||||
val oldMessage = messagingStyle.messages[messageToRedactIndex]
|
||||
val content = buildSpannedString {
|
||||
inSpans(StyleSpan(Typeface.ITALIC)) {
|
||||
append(stringProvider.getString(CommonStrings.common_message_removed))
|
||||
}
|
||||
}
|
||||
val newMessage = MessagingStyle.Message(
|
||||
content,
|
||||
oldMessage.timestamp,
|
||||
oldMessage.person
|
||||
)
|
||||
messagingStyle.messages[messageToRedactIndex] = newMessage
|
||||
notificationDisplayer.showNotificationMessage(
|
||||
statusBarNotification.tag,
|
||||
statusBarNotification.id,
|
||||
NotificationCompat.Builder(context, notification)
|
||||
.setStyle(messagingStyle)
|
||||
.build()
|
||||
)
|
||||
if (notifications.isEmpty()) {
|
||||
Timber.d("No notifications found for redacted event")
|
||||
}
|
||||
notifications.forEach { statusBarNotification ->
|
||||
val notification = statusBarNotification.notification
|
||||
val messagingStyle = MessagingStyle.extractMessagingStyleFromNotification(notification)
|
||||
if (messagingStyle == null) {
|
||||
Timber.w("Unable to retrieve messaging style from notification")
|
||||
return@forEach
|
||||
}
|
||||
val messageToRedactIndex = messagingStyle.messages.indexOfFirst { message ->
|
||||
roomRedactions.any { it.redactedEventId.value == message.extras.getString(DefaultNotificationCreator.MESSAGE_EVENT_ID) }
|
||||
}
|
||||
if (messageToRedactIndex == -1) {
|
||||
Timber.d("Unable to find the message to remove from notification")
|
||||
return@forEach
|
||||
}
|
||||
val oldMessage = messagingStyle.messages[messageToRedactIndex]
|
||||
val content = buildSpannedString {
|
||||
inSpans(StyleSpan(Typeface.ITALIC)) {
|
||||
append(stringProvider.getString(CommonStrings.common_message_removed))
|
||||
}
|
||||
}
|
||||
val newMessage = MessagingStyle.Message(
|
||||
content,
|
||||
oldMessage.timestamp,
|
||||
oldMessage.person
|
||||
)
|
||||
messagingStyle.messages[messageToRedactIndex] = newMessage
|
||||
notificationDisplayer.showNotificationMessage(
|
||||
statusBarNotification.tag,
|
||||
statusBarNotification.id,
|
||||
NotificationCompat.Builder(context, notification)
|
||||
.setStyle(messagingStyle)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_THREAD_ID
|
||||
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
@@ -80,8 +81,35 @@ class DefaultActiveNotificationsProviderTest {
|
||||
)
|
||||
val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications)
|
||||
|
||||
assertThat(activeNotificationsProvider.getMessageNotificationsForRoom(A_SESSION_ID, A_ROOM_ID)).hasSize(1)
|
||||
assertThat(activeNotificationsProvider.getMessageNotificationsForRoom(A_SESSION_ID_2, A_ROOM_ID_2)).isEmpty()
|
||||
assertThat(activeNotificationsProvider.getMessageNotificationsForRoom(A_SESSION_ID, A_ROOM_ID, null)).hasSize(1)
|
||||
assertThat(activeNotificationsProvider.getMessageNotificationsForRoom(A_SESSION_ID_2, A_ROOM_ID_2, null)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMessageNotificationsForRoom with thread id returns only message notifications for a thread using those session and room ids`() {
|
||||
val activeNotifications = listOf(
|
||||
aStatusBarNotification(
|
||||
id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID),
|
||||
groupId = A_SESSION_ID.value,
|
||||
tag = "$A_ROOM_ID|$A_THREAD_ID",
|
||||
),
|
||||
aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value, tag = A_ROOM_ID.value),
|
||||
aStatusBarNotification(
|
||||
id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID_2),
|
||||
groupId = A_SESSION_ID_2.value,
|
||||
tag = "$A_ROOM_ID|$A_THREAD_ID",
|
||||
),
|
||||
aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID_2), groupId = A_SESSION_ID_2.value, tag = A_ROOM_ID.value),
|
||||
aStatusBarNotification(
|
||||
id = notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID_2),
|
||||
groupId = A_SESSION_ID_2.value,
|
||||
tag = "$A_ROOM_ID|$A_THREAD_ID",
|
||||
),
|
||||
)
|
||||
val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications)
|
||||
|
||||
assertThat(activeNotificationsProvider.getMessageNotificationsForRoom(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID)).hasSize(1)
|
||||
assertThat(activeNotificationsProvider.getMessageNotificationsForRoom(A_SESSION_ID_2, A_ROOM_ID_2, A_THREAD_ID)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -53,6 +53,7 @@ class DefaultBaseRoomGroupMessageCreatorTest {
|
||||
roomId = A_ROOM_ID,
|
||||
imageLoader = fakeImageLoader.getImageLoader(),
|
||||
existingNotification = null,
|
||||
threadId = null,
|
||||
color = A_COLOR_INT,
|
||||
)
|
||||
assertThat(result.number).isEqualTo(1)
|
||||
@@ -76,6 +77,7 @@ class DefaultBaseRoomGroupMessageCreatorTest {
|
||||
roomId = A_ROOM_ID,
|
||||
imageLoader = fakeImageLoader.getImageLoader(),
|
||||
existingNotification = null,
|
||||
threadId = null,
|
||||
color = A_COLOR_INT,
|
||||
)
|
||||
@Suppress("DEPRECATION")
|
||||
@@ -141,6 +143,7 @@ class DefaultBaseRoomGroupMessageCreatorTest {
|
||||
roomId = A_ROOM_ID,
|
||||
imageLoader = fakeImageLoader.getImageLoader(),
|
||||
existingNotification = null,
|
||||
threadId = null,
|
||||
color = A_COLOR_INT,
|
||||
)
|
||||
assertThat(result.number).isEqualTo(1)
|
||||
@@ -160,6 +163,7 @@ class DefaultBaseRoomGroupMessageCreatorTest {
|
||||
roomId = A_ROOM_ID,
|
||||
imageLoader = fakeImageLoader.getImageLoader(),
|
||||
existingNotification = null,
|
||||
threadId = null,
|
||||
color = A_COLOR_INT,
|
||||
)
|
||||
assertThat(result.number).isEqualTo(2)
|
||||
@@ -189,6 +193,7 @@ class DefaultBaseRoomGroupMessageCreatorTest {
|
||||
roomId = A_ROOM_ID,
|
||||
imageLoader = fakeImageLoader.getImageLoader(),
|
||||
existingNotification = null,
|
||||
threadId = null,
|
||||
color = A_COLOR_INT,
|
||||
)
|
||||
val actionTitles = result.actions?.map { it.title }
|
||||
@@ -214,6 +219,7 @@ class DefaultBaseRoomGroupMessageCreatorTest {
|
||||
roomId = A_ROOM_ID,
|
||||
imageLoader = fakeImageLoader.getImageLoader(),
|
||||
existingNotification = null,
|
||||
threadId = null,
|
||||
color = A_COLOR_INT,
|
||||
)
|
||||
assertThat(result.number).isEqualTo(1)
|
||||
|
||||
@@ -9,6 +9,7 @@ package io.element.android.libraries.push.impl.notifications
|
||||
|
||||
import android.content.Context
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.exception.NotificationResolverException
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
@@ -854,7 +855,8 @@ class DefaultNotifiableEventResolverTest {
|
||||
fallbackNotificationFactory = FallbackNotificationFactory(
|
||||
clock = FakeSystemClock(),
|
||||
stringProvider = FakeStringProvider(defaultResult = "You have new messages.")
|
||||
)
|
||||
),
|
||||
featureFlagService = FakeFeatureFlagService(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ class DefaultNotificationDrawerManagerTest {
|
||||
// For now just call all the API. Later, add more valuable tests.
|
||||
val matrixUser = aMatrixUser(id = A_SESSION_ID.value, displayName = "alice", avatarUrl = "mxc://data")
|
||||
val mockRoomGroupMessageCreator = FakeRoomGroupMessageCreator(
|
||||
createRoomMessageResult = lambdaRecorder { user, _, roomId, _, existingNotification ->
|
||||
createRoomMessageResult = lambdaRecorder { user, _, roomId, _, _, existingNotification ->
|
||||
assertThat(user).isEqualTo(matrixUser)
|
||||
assertThat(roomId).isEqualTo(A_ROOM_ID)
|
||||
assertThat(existingNotification).isNull()
|
||||
@@ -144,9 +144,16 @@ class DefaultNotificationDrawerManagerTest {
|
||||
messageCreator.createRoomMessageResult.assertions()
|
||||
.isCalledExactly(3)
|
||||
.withSequence(
|
||||
listOf(value(aMatrixUser(id = A_SESSION_ID.value, displayName = "alice")), any(), any(), any(), any()),
|
||||
listOf(value(aMatrixUser(id = A_SESSION_ID.value, displayName = A_SESSION_ID.value)), any(), any(), any(), any()),
|
||||
listOf(value(aMatrixUser(id = A_SESSION_ID.value, displayName = A_SESSION_ID.value, avatarUrl = AN_AVATAR_URL)), any(), any(), any(), any()),
|
||||
listOf(value(aMatrixUser(id = A_SESSION_ID.value, displayName = "alice")), any(), any(), any(), any(), any()),
|
||||
listOf(value(aMatrixUser(id = A_SESSION_ID.value, displayName = A_SESSION_ID.value)), any(), any(), any(), any(), any()),
|
||||
listOf(
|
||||
value(aMatrixUser(id = A_SESSION_ID.value, displayName = A_SESSION_ID.value, avatarUrl = AN_AVATAR_URL)),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
any()
|
||||
),
|
||||
)
|
||||
|
||||
defaultNotificationDrawerManager.destroy()
|
||||
|
||||
@@ -43,6 +43,7 @@ class DefaultSummaryGroupMessageCreatorTest {
|
||||
messageCount = 1,
|
||||
latestTimestamp = A_FAKE_TIMESTAMP + 10,
|
||||
shouldBing = true,
|
||||
threadId = null,
|
||||
)
|
||||
),
|
||||
invitationNotifications = emptyList(),
|
||||
|
||||
@@ -222,10 +222,11 @@ class NotificationBroadcastReceiverHandlerTest {
|
||||
)
|
||||
val clearMessagesForRoomLambda = lambdaRecorder<SessionId, RoomId, Unit> { _, _ -> }
|
||||
val markAsReadResult = lambdaRecorder<ReceiptType, Result<Unit>> { Result.success(Unit) }
|
||||
val timeline = FakeTimeline(markAsReadResult = markAsReadResult)
|
||||
val joinedRoom = FakeJoinedRoom(
|
||||
baseRoom = FakeBaseRoom(
|
||||
markAsReadResult = markAsReadResult,
|
||||
),
|
||||
baseRoom = FakeBaseRoom(),
|
||||
liveTimeline = timeline,
|
||||
createTimelineResult = { Result.success(timeline) },
|
||||
)
|
||||
val fakeNotificationCleaner = FakeNotificationCleaner(
|
||||
clearMessagesForRoomLambda = clearMessagesForRoomLambda,
|
||||
|
||||
@@ -97,6 +97,7 @@ class NotificationDataFactoryTest {
|
||||
currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL),
|
||||
events = events,
|
||||
roomId = A_ROOM_ID,
|
||||
threadId = null,
|
||||
imageLoader = FakeImageLoader().getImageLoader(),
|
||||
existingNotification = null,
|
||||
color = A_COLOR_INT,
|
||||
@@ -105,7 +106,8 @@ class NotificationDataFactoryTest {
|
||||
summaryLine = "A room name: Bob Hello world!",
|
||||
messageCount = events.size,
|
||||
latestTimestamp = events.maxOf { it.timestamp },
|
||||
shouldBing = events.any { it.noisy }
|
||||
shouldBing = events.any { it.noisy },
|
||||
threadId = null,
|
||||
)
|
||||
val roomWithMessage = listOf(A_MESSAGE_EVENT)
|
||||
|
||||
@@ -152,6 +154,7 @@ class NotificationDataFactoryTest {
|
||||
currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL),
|
||||
events = withRedactedRemoved,
|
||||
roomId = A_ROOM_ID,
|
||||
threadId = null,
|
||||
imageLoader = FakeImageLoader().getImageLoader(),
|
||||
existingNotification = null,
|
||||
color = A_COLOR_INT,
|
||||
@@ -160,7 +163,8 @@ class NotificationDataFactoryTest {
|
||||
summaryLine = "A room name: Bob Hello world!",
|
||||
messageCount = withRedactedRemoved.size,
|
||||
latestTimestamp = withRedactedRemoved.maxOf { it.timestamp },
|
||||
shouldBing = withRedactedRemoved.any { it.noisy }
|
||||
shouldBing = withRedactedRemoved.any { it.noisy },
|
||||
threadId = null,
|
||||
)
|
||||
|
||||
val fakeImageLoader = FakeImageLoader()
|
||||
|
||||
@@ -71,7 +71,7 @@ class NotificationRendererTest {
|
||||
|
||||
@Test
|
||||
fun `given a room message group notification is added when rendering then show the message notification and update summary`() = runTest {
|
||||
roomGroupMessageCreator.createRoomMessageResult = lambdaRecorder { _, _, _, _, _ -> A_NOTIFICATION }
|
||||
roomGroupMessageCreator.createRoomMessageResult = lambdaRecorder { _, _, _, _, _, _ -> A_NOTIFICATION }
|
||||
|
||||
renderEventsAsNotifications(listOf(aNotifiableMessageEvent()))
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import io.element.android.libraries.push.impl.notifications.factories.action.Acc
|
||||
import io.element.android.libraries.push.impl.notifications.factories.action.MarkAsReadActionFactory
|
||||
import io.element.android.libraries.push.impl.notifications.factories.action.QuickReplyActionFactory
|
||||
import io.element.android.libraries.push.impl.notifications.factories.action.RejectInvitationActionFactory
|
||||
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
|
||||
@@ -249,7 +250,7 @@ class DefaultNotificationCreatorTest {
|
||||
currentUser = aMatrixUser(),
|
||||
existingNotification = null,
|
||||
imageLoader = FakeImageLoader().getImageLoader(),
|
||||
events = emptyList(),
|
||||
events = listOf(aNotifiableMessageEvent()),
|
||||
color = A_COLOR_INT,
|
||||
)
|
||||
result.commonAssertions()
|
||||
@@ -276,7 +277,7 @@ class DefaultNotificationCreatorTest {
|
||||
currentUser = aMatrixUser(),
|
||||
existingNotification = null,
|
||||
imageLoader = FakeImageLoader().getImageLoader(),
|
||||
events = emptyList(),
|
||||
events = listOf(aNotifiableMessageEvent()),
|
||||
color = A_COLOR_INT,
|
||||
)
|
||||
result.commonAssertions()
|
||||
|
||||
@@ -8,11 +8,12 @@
|
||||
package io.element.android.libraries.push.impl.notifications.factories
|
||||
|
||||
import android.content.Intent
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.push.impl.intent.IntentProvider
|
||||
|
||||
class FakeIntentProvider : IntentProvider {
|
||||
override fun getViewRoomIntent(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?) = Intent(Intent.ACTION_VIEW)
|
||||
override fun getViewRoomIntent(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?, eventId: EventId?) = Intent(Intent.ACTION_VIEW)
|
||||
}
|
||||
|
||||
@@ -10,18 +10,24 @@ package io.element.android.libraries.push.impl.notifications.fake
|
||||
import android.service.notification.StatusBarNotification
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.push.impl.notifications.ActiveNotificationsProvider
|
||||
|
||||
class FakeActiveNotificationsProvider(
|
||||
private val getMessageNotificationsForRoomResult: (SessionId, RoomId) -> List<StatusBarNotification> = { _, _ -> emptyList() },
|
||||
private val getMessageNotificationsForRoomResult: (SessionId, RoomId, ThreadId?) -> List<StatusBarNotification> = { _, _, _ -> emptyList() },
|
||||
private val getAllMessageNotificationsForRoomResult: (SessionId, RoomId) -> List<StatusBarNotification> = { _, _ -> emptyList() },
|
||||
private val getNotificationsForSessionResult: (SessionId) -> List<StatusBarNotification> = { emptyList() },
|
||||
private val getMembershipNotificationForSessionResult: (SessionId) -> List<StatusBarNotification> = { emptyList() },
|
||||
private val getMembershipNotificationForRoomResult: (SessionId, RoomId) -> List<StatusBarNotification> = { _, _ -> emptyList() },
|
||||
private val getSummaryNotificationResult: (SessionId) -> StatusBarNotification? = { null },
|
||||
private val countResult: (SessionId) -> Int = { 0 },
|
||||
) : ActiveNotificationsProvider {
|
||||
override fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification> {
|
||||
return getMessageNotificationsForRoomResult(sessionId, roomId)
|
||||
override fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId, threadId: ThreadId?): List<StatusBarNotification> {
|
||||
return getMessageNotificationsForRoomResult(sessionId, roomId, threadId)
|
||||
}
|
||||
|
||||
override fun getAllMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification> {
|
||||
return getAllMessageNotificationsForRoomResult(sessionId, roomId)
|
||||
}
|
||||
|
||||
override fun getNotificationsForSession(sessionId: SessionId): List<StatusBarNotification> {
|
||||
|
||||
@@ -11,25 +11,29 @@ import android.app.Notification
|
||||
import androidx.annotation.ColorInt
|
||||
import coil3.ImageLoader
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.push.impl.notifications.RoomGroupMessageCreator
|
||||
import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||
import io.element.android.tests.testutils.lambda.LambdaFiveParamsRecorder
|
||||
import io.element.android.tests.testutils.lambda.LambdaSixParamsRecorder
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
|
||||
// We just can't make the param types fit
|
||||
@Suppress("MaxLineLength", "ktlint:standard:max-line-length", "ktlint:standard:parameter-wrapping")
|
||||
class FakeRoomGroupMessageCreator(
|
||||
var createRoomMessageResult: LambdaFiveParamsRecorder<MatrixUser, List<NotifiableMessageEvent>, RoomId, ImageLoader, Notification?, Notification> =
|
||||
lambdaRecorder { _, _, _, _, _ -> A_NOTIFICATION }
|
||||
var createRoomMessageResult: LambdaSixParamsRecorder<MatrixUser, List<NotifiableMessageEvent>, RoomId, ThreadId?, ImageLoader, Notification?, Notification> =
|
||||
lambdaRecorder { _, _, _, _, _, _ -> A_NOTIFICATION }
|
||||
) : RoomGroupMessageCreator {
|
||||
override suspend fun createRoomMessage(
|
||||
currentUser: MatrixUser,
|
||||
events: List<NotifiableMessageEvent>,
|
||||
roomId: RoomId,
|
||||
threadId: ThreadId?,
|
||||
imageLoader: ImageLoader,
|
||||
existingNotification: Notification?,
|
||||
@ColorInt color: Int,
|
||||
): Notification {
|
||||
return createRoomMessageResult(currentUser, events, roomId, imageLoader, existingNotification)
|
||||
return createRoomMessageResult(currentUser, events, roomId, threadId, imageLoader, existingNotification)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,21 +7,30 @@
|
||||
|
||||
package io.element.android.libraries.push.impl.push
|
||||
|
||||
import android.app.Notification
|
||||
import android.service.notification.StatusBarNotification
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.Person
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_THREAD_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||
import io.element.android.libraries.push.impl.notifications.factories.DefaultNotificationCreator
|
||||
import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider
|
||||
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer
|
||||
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
|
||||
import io.element.android.services.toolbox.test.strings.FakeStringProvider
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -29,43 +38,113 @@ import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class DefaultOnRedactedEventReceivedTest {
|
||||
private val fakePerson = Person.Builder().setName(A_USER_NAME).setKey(A_USER_ID.value).build()
|
||||
private val fakeMessage = NotificationCompat.MessagingStyle.Message("A message", 0L, fakePerson).also {
|
||||
it.extras.putString(DefaultNotificationCreator.MESSAGE_EVENT_ID, AN_EVENT_ID.value)
|
||||
}
|
||||
private val fakeNotification = NotificationCompat.Builder(InstrumentationRegistry.getInstrumentation().targetContext, "aChannel")
|
||||
.setStyle(
|
||||
NotificationCompat.MessagingStyle(fakePerson)
|
||||
.addMessage(fakeMessage)
|
||||
)
|
||||
.setGroup(A_SESSION_ID.value)
|
||||
.build()
|
||||
|
||||
private val fakeIncorrectMessage = NotificationCompat.MessagingStyle.Message("The wrong message", 0L, fakePerson).also {
|
||||
it.extras.putString(DefaultNotificationCreator.MESSAGE_EVENT_ID, AN_EVENT_ID_2.value)
|
||||
}
|
||||
private val fakeIncorrectNotification = NotificationCompat.Builder(InstrumentationRegistry.getInstrumentation().targetContext, "aChannel")
|
||||
.setGroup(A_SESSION_ID.value)
|
||||
.setStyle(
|
||||
NotificationCompat.MessagingStyle(fakePerson)
|
||||
.addMessage(fakeIncorrectMessage)
|
||||
)
|
||||
.build()
|
||||
|
||||
@Test
|
||||
fun `when no notifications are found, nothing happen`() = runTest {
|
||||
val showNotificationLambda = lambdaRecorder<String?, Int, Notification, Boolean> { _, _, _ -> true }
|
||||
val sut = createDefaultOnRedactedEventReceived(
|
||||
getMessageNotificationsForRoomResult = { _, _ -> emptyList() }
|
||||
getAllMessageNotificationsForRoomResult = { _, _ -> emptyList() },
|
||||
displayer = FakeNotificationDisplayer(showNotificationLambda),
|
||||
)
|
||||
sut.onRedactedEventsReceived(listOf(ResolvedPushEvent.Redaction(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, null)))
|
||||
showNotificationLambda.assertions().isNeverCalled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when a notification is found, try to retrieve the message`() = runTest {
|
||||
val showNotificationLambda = lambdaRecorder<String?, Int, Notification, Boolean> { tag, id, _ ->
|
||||
assertThat(tag).isEqualTo(A_ROOM_ID.value)
|
||||
assertThat(id).isEqualTo(1)
|
||||
true
|
||||
}
|
||||
val sut = createDefaultOnRedactedEventReceived(
|
||||
getMessageNotificationsForRoomResult = { _, _ ->
|
||||
getAllMessageNotificationsForRoomResult = { _, _ ->
|
||||
listOf(
|
||||
mockk {
|
||||
every { notification } returns mockk {}
|
||||
every { id } returns 1
|
||||
every { notification } returns fakeNotification
|
||||
every { tag } returns A_ROOM_ID.value
|
||||
},
|
||||
mockk {
|
||||
every { id } returns 2
|
||||
every { notification } returns fakeIncorrectNotification
|
||||
every { tag } returns A_ROOM_ID.value
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
displayer = FakeNotificationDisplayer(showNotificationLambda),
|
||||
)
|
||||
sut.onRedactedEventsReceived(listOf(ResolvedPushEvent.Redaction(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, null)))
|
||||
showNotificationLambda.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
private fun TestScope.createDefaultOnRedactedEventReceived(
|
||||
getMessageNotificationsForRoomResult: (SessionId, RoomId) -> List<StatusBarNotification> = { _, _ -> lambdaError() },
|
||||
@Test
|
||||
fun `when thread notifications are found, try to retrieve the message`() = runTest {
|
||||
val showNotificationLambda = lambdaRecorder<String?, Int, Notification, Boolean> { tag, id, _ ->
|
||||
assertThat(tag).isEqualTo("$A_ROOM_ID|$A_THREAD_ID")
|
||||
assertThat(id).isEqualTo(1)
|
||||
true
|
||||
}
|
||||
val sut = createDefaultOnRedactedEventReceived(
|
||||
getAllMessageNotificationsForRoomResult = { _, _ ->
|
||||
listOf(
|
||||
mockk {
|
||||
every { id } returns 1
|
||||
every { notification } returns fakeNotification
|
||||
every { tag } returns "$A_ROOM_ID|$A_THREAD_ID"
|
||||
},
|
||||
mockk {
|
||||
every { id } returns 2
|
||||
every { notification } returns fakeIncorrectNotification
|
||||
every { tag } returns A_ROOM_ID.value
|
||||
}
|
||||
)
|
||||
},
|
||||
displayer = FakeNotificationDisplayer(showNotificationMessageResult = showNotificationLambda),
|
||||
)
|
||||
sut.onRedactedEventsReceived(listOf(ResolvedPushEvent.Redaction(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, null)))
|
||||
|
||||
showNotificationLambda.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
private fun createDefaultOnRedactedEventReceived(
|
||||
getAllMessageNotificationsForRoomResult: (SessionId, RoomId) -> List<StatusBarNotification> = { _, _ -> lambdaError() },
|
||||
displayer: FakeNotificationDisplayer = FakeNotificationDisplayer(),
|
||||
): DefaultOnRedactedEventReceived {
|
||||
val context = InstrumentationRegistry.getInstrumentation().context
|
||||
return DefaultOnRedactedEventReceived(
|
||||
activeNotificationsProvider = FakeActiveNotificationsProvider(
|
||||
getMessageNotificationsForRoomResult = getMessageNotificationsForRoomResult,
|
||||
getMessageNotificationsForRoomResult = { _, _, _ -> lambdaError() },
|
||||
getAllMessageNotificationsForRoomResult = getAllMessageNotificationsForRoomResult,
|
||||
getNotificationsForSessionResult = { lambdaError() },
|
||||
getMembershipNotificationForSessionResult = { lambdaError() },
|
||||
getMembershipNotificationForRoomResult = { _, _ -> lambdaError() },
|
||||
getSummaryNotificationResult = { lambdaError() },
|
||||
countResult = { lambdaError() },
|
||||
),
|
||||
notificationDisplayer = FakeNotificationDisplayer(),
|
||||
coroutineScope = this,
|
||||
notificationDisplayer = displayer,
|
||||
context = context,
|
||||
stringProvider = FakeStringProvider(),
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ import io.element.android.tests.testutils.lambda.lambdaError
|
||||
class FakeOnRedactedEventReceived(
|
||||
private val onRedactedEventsReceivedResult: (List<ResolvedPushEvent.Redaction>) -> Unit = { lambdaError() },
|
||||
) : OnRedactedEventReceived {
|
||||
override fun onRedactedEventsReceived(redactions: List<ResolvedPushEvent.Redaction>) {
|
||||
override suspend fun onRedactedEventsReceived(redactions: List<ResolvedPushEvent.Redaction>) {
|
||||
onRedactedEventsReceivedResult(redactions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,14 @@ package io.element.android.libraries.push.test.notifications
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.push.api.notifications.NotificationCleaner
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
|
||||
class FakeNotificationCleaner(
|
||||
private val clearAllMessagesEventsLambda: (SessionId) -> Unit = { lambdaError() },
|
||||
private val clearMessagesForRoomLambda: (SessionId, RoomId) -> Unit = { _, _ -> lambdaError() },
|
||||
private val clearMessagesForThreadLambda: (SessionId, RoomId, ThreadId) -> Unit = { _, _, _ -> lambdaError() },
|
||||
private val clearEventLambda: (SessionId, EventId) -> Unit = { _, _ -> lambdaError() },
|
||||
private val clearMembershipNotificationForSessionLambda: (SessionId) -> Unit = { lambdaError() },
|
||||
private val clearMembershipNotificationForRoomLambda: (SessionId, RoomId) -> Unit = { _, _ -> lambdaError() }
|
||||
@@ -28,6 +30,10 @@ class FakeNotificationCleaner(
|
||||
clearMessagesForRoomLambda(sessionId, roomId)
|
||||
}
|
||||
|
||||
override fun clearMessagesForThread(sessionId: SessionId, roomId: RoomId, threadId: ThreadId) {
|
||||
clearMessagesForThreadLambda(sessionId, roomId, threadId)
|
||||
}
|
||||
|
||||
override fun clearEvent(sessionId: SessionId, eventId: EventId) {
|
||||
clearEventLambda(sessionId, eventId)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user