Browse Source

Display room invitation notification (#735)

* Notifications: Add some extra mappings so we keep the original contents and can pass it later to an UI layer

* Fix notifications not appearing for a room if the app was on that room when it went to background.

* Modernize how we create spannable strings for notifications, remove unneeded dependency

* Remove actions from invite notifications temporarily

* Add `NotificationDrawerManager` interface to be able to clear membership notifications when accepting or rejecting a room invite

* Fix tests

* Add comment to clarify some weird behaviours

* Address review comments

* Set circle shape for `largeBitmap` in message notifications

* Fix no avatar in DM rooms

* Fix rebase issues

* Add invite list pending intent:

- Refactor pending intents.
- Make `DeepLinkData` a sealed interface.
- Fix and add tests.

* Rename `navigate__` functions to `attach__`

* Add an extra test case for the `InviteList` deep link

* Address most review comments.

* Fix rebase issue

* Add fallback notification type, allow dismissing invite notifications.

Fallback notifications have a different underlying type and can be dismissed at will.

* Fix tests
pull/835/head
Jorge Martin Espinosa 1 year ago committed by GitHub
parent
commit
a0c1f2c18a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      app/src/main/AndroidManifest.xml
  2. 2
      app/src/main/kotlin/io/element/android/x/ElementXApplication.kt
  3. 11
      app/src/main/kotlin/io/element/android/x/intent/IntentProviderImpl.kt
  4. 15
      appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
  5. 11
      appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
  6. 2
      appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt
  7. 2
      features/invitelist/impl/build.gradle.kts
  8. 7
      features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListPresenter.kt
  9. 76
      features/invitelist/impl/src/test/kotlin/io/element/android/features/invitelist/impl/InviteListPresenterTests.kt
  10. 1
      gradle/libs.versions.toml
  11. 4
      libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/Constants.kt
  12. 11
      libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeepLinkCreator.kt
  13. 18
      libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeeplinkData.kt
  14. 24
      libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeeplinkParser.kt
  15. 15
      libraries/deeplink/src/test/kotlin/io/element/android/libraries/deeplink/DeepLinkCreatorTest.kt
  16. 10
      libraries/deeplink/src/test/kotlin/io/element/android/libraries/deeplink/DeeplinkParserTest.kt
  17. 57
      libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt
  18. 24
      libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt
  19. 3
      libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt
  20. 101
      libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventMapper.kt
  21. 63
      libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt
  22. 25
      libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationDrawerManager.kt
  23. 5
      libraries/push/impl/build.gradle.kts
  24. 7
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt
  25. 33
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/PushBindsModule.kt
  26. 9
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/intent/IntentProvider.kt
  27. 31
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt
  28. 12
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.kt
  29. 228
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt
  30. 2
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationActionIds.kt
  31. 1
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBitmapLoader.kt
  32. 21
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiver.kt
  33. 17
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueue.kt
  34. 21
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactory.kt
  35. 5
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationIdProvider.kt
  36. 40
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt
  37. 4
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationState.kt
  38. 33
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt
  39. 2
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt
  40. 62
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationFactory.kt
  41. 46
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/PendingIntentFactory.kt
  42. 37
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/FallbackNotifiableEvent.kt
  43. 4
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/InviteNotifiableEvent.kt
  44. 1
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableEvent.kt
  45. 17
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt
  46. 2
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/SimpleNotifiableEvent.kt
  47. 10
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt
  48. 2
      libraries/push/impl/src/main/res/values/localazy.xml
  49. 2
      libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessorTest.kt
  50. 2
      libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueueTest.kt
  51. 6
      libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt
  52. 3
      libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationFactory.kt
  53. 29
      libraries/push/test/build.gradle.kts
  54. 48
      libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationDrawerManager.kt
  55. 2
      settings.gradle.kts

6
app/src/main/AndroidManifest.xml

@ -44,14 +44,14 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!-- Handle deep-link for notification, uncomment to be able to test deeplink with ./tools/adb/deeplink.sh --> <!-- Handle deep-link for notification ./tools/adb/deeplink.sh -->
<!--intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<data <data
android:host="open" android:host="open"
android:scheme="elementx" /> android:scheme="elementx" />
</intent-filter--> </intent-filter>
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />

2
app/src/main/kotlin/io/element/android/x/ElementXApplication.kt

@ -18,8 +18,8 @@ package io.element.android.x
import android.app.Application import android.app.Application
import androidx.startup.AppInitializer import androidx.startup.AppInitializer
import io.element.android.x.di.AppComponent
import io.element.android.libraries.di.DaggerComponentOwner import io.element.android.libraries.di.DaggerComponentOwner
import io.element.android.x.di.AppComponent
import io.element.android.x.di.DaggerAppComponent import io.element.android.x.di.DaggerAppComponent
import io.element.android.x.info.logApplicationInfo import io.element.android.x.info.logApplicationInfo
import io.element.android.x.initializer.CrashInitializer import io.element.android.x.initializer.CrashInitializer

11
app/src/main/kotlin/io/element/android/x/intent/IntentProviderImpl.kt

@ -35,14 +35,21 @@ class IntentProviderImpl @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val deepLinkCreator: DeepLinkCreator, private val deepLinkCreator: DeepLinkCreator,
) : IntentProvider { ) : IntentProvider {
override fun getViewIntent( override fun getViewRoomIntent(
sessionId: SessionId, sessionId: SessionId,
roomId: RoomId?, roomId: RoomId?,
threadId: ThreadId?, threadId: ThreadId?,
): Intent { ): Intent {
return Intent(context, MainActivity::class.java).apply { return Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW action = Intent.ACTION_VIEW
data = deepLinkCreator.create(sessionId, roomId, threadId).toUri() data = deepLinkCreator.room(sessionId, roomId, threadId).toUri()
}
}
override fun getInviteListIntent(sessionId: SessionId): Intent {
return Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW
data = deepLinkCreator.inviteList(sessionId).toUri()
} }
} }
} }

15
appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt

@ -18,9 +18,7 @@ package io.element.android.appnav
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@ -30,7 +28,6 @@ import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
@ -58,6 +55,7 @@ import io.element.android.libraries.architecture.animation.rememberDefaultTransi
import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
@ -65,6 +63,7 @@ import io.element.android.libraries.matrix.api.core.MAIN_SPACE
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.ui.di.MatrixUIBindings import io.element.android.libraries.matrix.ui.di.MatrixUIBindings
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -89,6 +88,7 @@ class LoggedInFlowNode @AssistedInject constructor(
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
private val notificationDrawerManager: NotificationDrawerManager,
snackbarDispatcher: SnackbarDispatcher, snackbarDispatcher: SnackbarDispatcher,
) : BackstackNode<LoggedInFlowNode.NavTarget>( ) : BackstackNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
@ -340,4 +340,13 @@ class LoggedInFlowNode @AssistedInject constructor(
PermanentChild(navTarget = NavTarget.Permanent) PermanentChild(navTarget = NavTarget.Permanent)
} }
} }
internal suspend fun attachRoom(deeplinkData: DeeplinkData.Room) {
backstack.push(NavTarget.Room(deeplinkData.roomId))
}
internal suspend fun attachInviteList(deeplinkData: DeeplinkData.InviteList) {
notificationDrawerManager.clearMembershipNotificationForSession(deeplinkData.sessionId)
backstack.push(NavTarget.InviteList)
}
} }

11
appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt

@ -253,13 +253,10 @@ class RootFlowNode @AssistedInject constructor(
Timber.d("Navigating to $deeplinkData") Timber.d("Navigating to $deeplinkData")
attachSession(deeplinkData.sessionId) attachSession(deeplinkData.sessionId)
.apply { .apply {
val roomId = deeplinkData.roomId when (deeplinkData) {
if (roomId == null) { is DeeplinkData.Root -> attachRoot()
// In case room is not provided, ensure the app navigate back to the room list is DeeplinkData.Room -> attachRoom(deeplinkData)
attachRoot() is DeeplinkData.InviteList -> attachInviteList(deeplinkData)
} else {
attachRoom(roomId)
// TODO .attachThread(deeplinkData.threadId)
} }
} }
} }

2
appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt

@ -21,6 +21,8 @@ import io.element.android.features.login.api.oidc.OidcAction
import io.element.android.features.login.api.oidc.OidcIntentResolver import io.element.android.features.login.api.oidc.OidcIntentResolver
import io.element.android.libraries.deeplink.DeeplinkData import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.deeplink.DeeplinkParser import io.element.android.libraries.deeplink.DeeplinkParser
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject

2
features/invitelist/impl/build.gradle.kts

@ -43,6 +43,7 @@ dependencies {
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
implementation(projects.libraries.push.api)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)
@ -50,6 +51,7 @@ dependencies {
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.features.invitelist.test) testImplementation(projects.features.invitelist.test)
testImplementation(projects.features.analytics.test) testImplementation(projects.features.analytics.test)

7
features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListPresenter.kt

@ -37,6 +37,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomSummary import io.element.android.libraries.matrix.api.room.RoomSummary
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.extensions.toAnalyticsJoinedRoom import io.element.android.services.analytics.api.extensions.toAnalyticsJoinedRoom
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
@ -49,6 +50,7 @@ class InviteListPresenter @Inject constructor(
private val client: MatrixClient, private val client: MatrixClient,
private val store: SeenInvitesStore, private val store: SeenInvitesStore,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val notificationDrawerManager: NotificationDrawerManager,
) : Presenter<InviteListState> { ) : Presenter<InviteListState> {
@Composable @Composable
@ -138,6 +140,7 @@ class InviteListPresenter @Inject constructor(
suspend { suspend {
client.getRoom(roomId)?.use { client.getRoom(roomId)?.use {
it.acceptInvitation().getOrThrow() it.acceptInvitation().getOrThrow()
notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId)
analyticsService.capture(it.toAnalyticsJoinedRoom(JoinedRoom.Trigger.Invite)) analyticsService.capture(it.toAnalyticsJoinedRoom(JoinedRoom.Trigger.Invite))
} }
roomId roomId
@ -148,7 +151,9 @@ class InviteListPresenter @Inject constructor(
suspend { suspend {
client.getRoom(roomId)?.use { client.getRoom(roomId)?.use {
it.rejectInvitation().getOrThrow() it.rejectInvitation().getOrThrow()
} ?: Unit notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId)
}
Unit
}.runCatchingUpdatingState(declinedAction) }.runCatchingUpdatingState(declinedAction)
} }

76
features/invitelist/impl/src/test/kotlin/io/element/android/features/invitelist/impl/InviteListPresenterTests.kt

@ -21,10 +21,12 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth import com.google.common.truth.Truth
import io.element.android.features.analytics.test.FakeAnalyticsService import io.element.android.features.analytics.test.FakeAnalyticsService
import io.element.android.features.invitelist.api.SeenInvitesStore
import io.element.android.features.invitelist.test.FakeSeenInvitesStore import io.element.android.features.invitelist.test.FakeSeenInvitesStore
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
@ -39,6 +41,9 @@ import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
import io.element.android.libraries.push.test.notifications.FakeNotificationDrawerManager
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -47,12 +52,8 @@ class InviteListPresenterTests {
@Test @Test
fun `present - starts empty, adds invites when received`() = runTest { fun `present - starts empty, adds invites when received`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource() val roomSummaryDataSource = FakeRoomSummaryDataSource()
val presenter = InviteListPresenter( val presenter = createPresenter(
FakeMatrixClient( FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
roomSummaryDataSource = roomSummaryDataSource,
),
FakeSeenInvitesStore(),
FakeAnalyticsService(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -72,12 +73,8 @@ class InviteListPresenterTests {
@Test @Test
fun `present - uses user ID and avatar for direct invites`() = runTest { fun `present - uses user ID and avatar for direct invites`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource().withDirectChatInvitation() val roomSummaryDataSource = FakeRoomSummaryDataSource().withDirectChatInvitation()
val presenter = InviteListPresenter( val presenter = createPresenter(
FakeMatrixClient( FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
roomSummaryDataSource = roomSummaryDataSource,
),
FakeSeenInvitesStore(),
FakeAnalyticsService(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -102,12 +99,8 @@ class InviteListPresenterTests {
@Test @Test
fun `present - includes sender details for room invites`() = runTest { fun `present - includes sender details for room invites`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation() val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
val presenter = InviteListPresenter( val presenter = createPresenter(
FakeMatrixClient( FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
roomSummaryDataSource = roomSummaryDataSource,
),
FakeSeenInvitesStore(),
FakeAnalyticsService(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -136,6 +129,7 @@ class InviteListPresenterTests {
), ),
FakeSeenInvitesStore(), FakeSeenInvitesStore(),
FakeAnalyticsService(), FakeAnalyticsService(),
FakeNotificationDrawerManager()
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -155,12 +149,8 @@ class InviteListPresenterTests {
@Test @Test
fun `present - shows confirm dialog for declining room invites`() = runTest { fun `present - shows confirm dialog for declining room invites`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation() val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
val presenter = InviteListPresenter( val presenter = createPresenter(
FakeMatrixClient( FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
roomSummaryDataSource = roomSummaryDataSource,
),
FakeSeenInvitesStore(),
FakeAnalyticsService(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -180,12 +170,8 @@ class InviteListPresenterTests {
@Test @Test
fun `present - hides confirm dialog when cancelling`() = runTest { fun `present - hides confirm dialog when cancelling`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation() val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
val presenter = InviteListPresenter( val presenter = createPresenter(
FakeMatrixClient( FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
roomSummaryDataSource = roomSummaryDataSource,
),
FakeSeenInvitesStore(),
FakeAnalyticsService(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -205,11 +191,12 @@ class InviteListPresenterTests {
@Test @Test
fun `present - declines invite after confirming`() = runTest { fun `present - declines invite after confirming`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation() val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
val fakeNotificationDrawerManager = FakeNotificationDrawerManager()
val client = FakeMatrixClient( val client = FakeMatrixClient(
roomSummaryDataSource = roomSummaryDataSource, roomSummaryDataSource = roomSummaryDataSource,
) )
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client, FakeSeenInvitesStore(), FakeAnalyticsService()) val presenter = createPresenter(client = client, notificationDrawerManager = fakeNotificationDrawerManager)
client.givenGetRoomResult(A_ROOM_ID, room) client.givenGetRoomResult(A_ROOM_ID, room)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
@ -225,6 +212,7 @@ class InviteListPresenterTests {
skipItems(2) skipItems(2)
Truth.assertThat(room.isInviteRejected).isTrue() Truth.assertThat(room.isInviteRejected).isTrue()
Truth.assertThat(fakeNotificationDrawerManager.getClearMembershipNotificationForRoomCount(client.sessionId, A_ROOM_ID)).isEqualTo(1)
} }
} }
@ -235,7 +223,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource, roomSummaryDataSource = roomSummaryDataSource,
) )
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client, FakeSeenInvitesStore(), FakeAnalyticsService()) val presenter = createPresenter(client)
val ex = Throwable("Ruh roh!") val ex = Throwable("Ruh roh!")
room.givenRejectInviteResult(Result.failure(ex)) room.givenRejectInviteResult(Result.failure(ex))
client.givenGetRoomResult(A_ROOM_ID, room) client.givenGetRoomResult(A_ROOM_ID, room)
@ -266,7 +254,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource, roomSummaryDataSource = roomSummaryDataSource,
) )
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client, FakeSeenInvitesStore(), FakeAnalyticsService()) val presenter = createPresenter(client)
val ex = Throwable("Ruh roh!") val ex = Throwable("Ruh roh!")
room.givenRejectInviteResult(Result.failure(ex)) room.givenRejectInviteResult(Result.failure(ex))
client.givenGetRoomResult(A_ROOM_ID, room) client.givenGetRoomResult(A_ROOM_ID, room)
@ -294,11 +282,12 @@ class InviteListPresenterTests {
@Test @Test
fun `present - accepts invites and sets state on success`() = runTest { fun `present - accepts invites and sets state on success`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation() val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
val fakeNotificationDrawerManager = FakeNotificationDrawerManager()
val client = FakeMatrixClient( val client = FakeMatrixClient(
roomSummaryDataSource = roomSummaryDataSource, roomSummaryDataSource = roomSummaryDataSource,
) )
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client, FakeSeenInvitesStore(), FakeAnalyticsService()) val presenter = createPresenter(client = client, notificationDrawerManager = fakeNotificationDrawerManager)
client.givenGetRoomResult(A_ROOM_ID, room) client.givenGetRoomResult(A_ROOM_ID, room)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
@ -311,6 +300,7 @@ class InviteListPresenterTests {
Truth.assertThat(room.isInviteAccepted).isTrue() Truth.assertThat(room.isInviteAccepted).isTrue()
Truth.assertThat(newState.acceptedAction).isEqualTo(Async.Success(A_ROOM_ID)) Truth.assertThat(newState.acceptedAction).isEqualTo(Async.Success(A_ROOM_ID))
Truth.assertThat(fakeNotificationDrawerManager.getClearMembershipNotificationForRoomCount(client.sessionId, A_ROOM_ID)).isEqualTo(1)
} }
} }
@ -321,7 +311,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource, roomSummaryDataSource = roomSummaryDataSource,
) )
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client, FakeSeenInvitesStore(), FakeAnalyticsService()) val presenter = createPresenter(client)
val ex = Throwable("Ruh roh!") val ex = Throwable("Ruh roh!")
room.givenAcceptInviteResult(Result.failure(ex)) room.givenAcceptInviteResult(Result.failure(ex))
client.givenGetRoomResult(A_ROOM_ID, room) client.givenGetRoomResult(A_ROOM_ID, room)
@ -346,7 +336,7 @@ class InviteListPresenterTests {
roomSummaryDataSource = roomSummaryDataSource, roomSummaryDataSource = roomSummaryDataSource,
) )
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client, FakeSeenInvitesStore(), FakeAnalyticsService()) val presenter = createPresenter(client)
val ex = Throwable("Ruh roh!") val ex = Throwable("Ruh roh!")
room.givenAcceptInviteResult(Result.failure(ex)) room.givenAcceptInviteResult(Result.failure(ex))
client.givenGetRoomResult(A_ROOM_ID, room) client.givenGetRoomResult(A_ROOM_ID, room)
@ -376,6 +366,7 @@ class InviteListPresenterTests {
), ),
store, store,
FakeAnalyticsService(), FakeAnalyticsService(),
FakeNotificationDrawerManager()
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -413,6 +404,7 @@ class InviteListPresenterTests {
), ),
store, store,
FakeAnalyticsService(), FakeAnalyticsService(),
FakeNotificationDrawerManager()
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -500,4 +492,16 @@ class InviteListPresenterTests {
unreadNotificationCount = 0, unreadNotificationCount = 0,
) )
) )
private fun createPresenter(
client: MatrixClient,
seenInvitesStore: SeenInvitesStore = FakeSeenInvitesStore(),
fakeAnalyticsService: AnalyticsService = FakeAnalyticsService(),
notificationDrawerManager: NotificationDrawerManager = FakeNotificationDrawerManager()
) = InviteListPresenter(
client,
seenInvitesStore,
fakeAnalyticsService,
notificationDrawerManager
)
} }

1
gradle/libs.versions.toml

@ -152,7 +152,6 @@ sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extension
sqlcipher = "net.zetetic:android-database-sqlcipher:4.5.4" sqlcipher = "net.zetetic:android-database-sqlcipher:4.5.4"
sqlite = "androidx.sqlite:sqlite:2.3.1" sqlite = "androidx.sqlite:sqlite:2.3.1"
unifiedpush = "com.github.UnifiedPush:android-connector:2.1.1" unifiedpush = "com.github.UnifiedPush:android-connector:2.1.1"
gujun_span = "me.gujun.android:span:1.7"
otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5" otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5"
vanniktech_blurhash = "com.vanniktech:blurhash:0.1.0" vanniktech_blurhash = "com.vanniktech:blurhash:0.1.0"
vanniktech_emoji = "com.vanniktech:emoji-google:0.16.0" vanniktech_emoji = "com.vanniktech:emoji-google:0.16.0"

4
libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/Constants.kt

@ -18,3 +18,7 @@ package io.element.android.libraries.deeplink
internal const val SCHEME = "elementx" internal const val SCHEME = "elementx"
internal const val HOST = "open" internal const val HOST = "open"
object DeepLinkPaths {
const val INVITE_LIST = "invites"
}

11
libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeepLinkCreator.kt

@ -22,7 +22,7 @@ import io.element.android.libraries.matrix.api.core.ThreadId
import javax.inject.Inject import javax.inject.Inject
class DeepLinkCreator @Inject constructor() { class DeepLinkCreator @Inject constructor() {
fun create(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?): String { fun room(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?): String {
return buildString { return buildString {
append("$SCHEME://$HOST/") append("$SCHEME://$HOST/")
append(sessionId.value) append(sessionId.value)
@ -36,4 +36,13 @@ class DeepLinkCreator @Inject constructor() {
} }
} }
} }
fun inviteList(sessionId: SessionId): String {
return buildString {
append("$SCHEME://$HOST/")
append(sessionId.value)
append("/")
append(DeepLinkPaths.INVITE_LIST)
}
}
} }

18
libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeeplinkData.kt

@ -20,8 +20,16 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
data class DeeplinkData( sealed interface DeeplinkData {
val sessionId: SessionId, /** Session id is common for all deep links. */
val roomId: RoomId? = null, val sessionId: SessionId
val threadId: ThreadId? = null,
) /** 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 the invites list, with the given [sessionId]. */
data class InviteList(override val sessionId: SessionId) : DeeplinkData
}

24
libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeeplinkParser.kt

@ -18,6 +18,7 @@ package io.element.android.libraries.deeplink
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
@ -36,12 +37,21 @@ class DeeplinkParser @Inject constructor() {
if (host != HOST) return null if (host != HOST) return null
val pathBits = path.orEmpty().split("/").drop(1) val pathBits = path.orEmpty().split("/").drop(1)
val sessionId = pathBits.elementAtOrNull(0)?.let(::SessionId) ?: return null val sessionId = pathBits.elementAtOrNull(0)?.let(::SessionId) ?: return null
val roomId = pathBits.elementAtOrNull(1)?.let(::RoomId) val screenPathComponent = pathBits.elementAtOrNull(1)
val threadId = pathBits.elementAtOrNull(2)?.let(::ThreadId) val roomId = tryOrNull { screenPathComponent?.let(::RoomId) }
return DeeplinkData(
sessionId = sessionId, return when {
roomId = roomId, roomId != null -> {
threadId = threadId, val threadId = pathBits.elementAtOrNull(2)?.let(::ThreadId)
) DeeplinkData.Room(sessionId, roomId, threadId)
}
screenPathComponent == DeepLinkPaths.INVITE_LIST -> {
DeeplinkData.InviteList(sessionId)
}
screenPathComponent == null -> {
DeeplinkData.Root(sessionId)
}
else -> null
}
} }
} }

15
libraries/deeplink/src/test/kotlin/io/element/android/libraries/deeplink/DeepLinkCreatorTest.kt

@ -25,13 +25,20 @@ import org.junit.Test
class DeepLinkCreatorTest { class DeepLinkCreatorTest {
@Test @Test
fun create() { fun room() {
val sut = DeepLinkCreator() val sut = DeepLinkCreator()
assertThat(sut.create(A_SESSION_ID, null, null)) assertThat(sut.room(A_SESSION_ID, null, null))
.isEqualTo("elementx://open/@alice:server.org") .isEqualTo("elementx://open/@alice:server.org")
assertThat(sut.create(A_SESSION_ID, A_ROOM_ID, null)) assertThat(sut.room(A_SESSION_ID, A_ROOM_ID, null))
.isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain") .isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain")
assertThat(sut.create(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID)) assertThat(sut.room(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID))
.isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId") .isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId")
} }
@Test
fun inviteList() {
val sut = DeepLinkCreator()
assertThat(sut.inviteList(A_SESSION_ID))
.isEqualTo("elementx://open/@alice:server.org/invites")
}
} }

10
libraries/deeplink/src/test/kotlin/io/element/android/libraries/deeplink/DeeplinkParserTest.kt

@ -36,6 +36,8 @@ class DeeplinkParserTest {
"elementx://open/@alice:server.org/!aRoomId:domain" "elementx://open/@alice:server.org/!aRoomId:domain"
const val A_URI_WITH_ROOM_WITH_THREAD = const val A_URI_WITH_ROOM_WITH_THREAD =
"elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId" "elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId"
const val A_URI_FOR_INVITE_LIST =
"elementx://open/@alice:server.org/invites"
} }
private val sut = DeeplinkParser() private val sut = DeeplinkParser()
@ -43,11 +45,13 @@ class DeeplinkParserTest {
@Test @Test
fun `nominal cases`() { fun `nominal cases`() {
assertThat(sut.getFromIntent(createIntent(A_URI))) assertThat(sut.getFromIntent(createIntent(A_URI)))
.isEqualTo(DeeplinkData(A_SESSION_ID, null, null)) .isEqualTo(DeeplinkData.Root(A_SESSION_ID))
assertThat(sut.getFromIntent(createIntent(A_URI_WITH_ROOM))) assertThat(sut.getFromIntent(createIntent(A_URI_WITH_ROOM)))
.isEqualTo(DeeplinkData(A_SESSION_ID, A_ROOM_ID, null)) .isEqualTo(DeeplinkData.Room(A_SESSION_ID, A_ROOM_ID, null))
assertThat(sut.getFromIntent(createIntent(A_URI_WITH_ROOM_WITH_THREAD))) assertThat(sut.getFromIntent(createIntent(A_URI_WITH_ROOM_WITH_THREAD)))
.isEqualTo(DeeplinkData(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID)) .isEqualTo(DeeplinkData.Room(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID))
assertThat(sut.getFromIntent(createIntent(A_URI_FOR_INVITE_LIST)))
.isEqualTo(DeeplinkData.InviteList(A_SESSION_ID))
} }
@Test @Test

57
libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt

@ -19,6 +19,8 @@ package io.element.android.libraries.matrix.api.notification
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
data class NotificationData( data class NotificationData(
val senderId: UserId, val senderId: UserId,
@ -36,7 +38,60 @@ data class NotificationData(
data class NotificationEvent( data class NotificationEvent(
val timestamp: Long, val timestamp: Long,
val content: String, val content: NotificationContent,
// For images for instance // For images for instance
val contentUrl: String? val contentUrl: String?
) )
sealed interface NotificationContent {
sealed interface MessageLike : NotificationContent {
object CallAnswer : MessageLike
object CallInvite : MessageLike
object CallHangup : MessageLike
object CallCandidates : MessageLike
object KeyVerificationReady : MessageLike
object KeyVerificationStart : MessageLike
object KeyVerificationCancel : MessageLike
object KeyVerificationAccept : MessageLike
object KeyVerificationKey : MessageLike
object KeyVerificationMac : MessageLike
object KeyVerificationDone : MessageLike
data class ReactionContent(
val relatedEventId: String
) : MessageLike
object RoomEncrypted : MessageLike
data class RoomMessage(
val messageType: MessageType
) : MessageLike
object RoomRedaction : MessageLike
object Sticker : MessageLike
}
sealed interface StateEvent : NotificationContent {
object PolicyRuleRoom : StateEvent
object PolicyRuleServer : StateEvent
object PolicyRuleUser : StateEvent
object RoomAliases : StateEvent
object RoomAvatar : StateEvent
object RoomCanonicalAlias : StateEvent
object RoomCreate : StateEvent
object RoomEncryption : StateEvent
object RoomGuestAccess : StateEvent
object RoomHistoryVisibility : StateEvent
object RoomJoinRules : StateEvent
data class RoomMemberContent(
val userId: String,
val membershipState: RoomMembershipState
) : StateEvent
object RoomName : StateEvent
object RoomPinnedEvents : StateEvent
object RoomPowerLevels : StateEvent
object RoomServerAcl : StateEvent
object RoomThirdPartyInvite : StateEvent
object RoomTombstone : StateEvent
object RoomTopic : StateEvent
object SpaceChild : StateEvent
object SpaceParent : StateEvent
}
}

24
libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt

@ -28,19 +28,19 @@ class NotificationMapper {
private val timelineEventMapper = TimelineEventMapper() private val timelineEventMapper = TimelineEventMapper()
fun map(notificationItem: NotificationItem): NotificationData { fun map(notificationItem: NotificationItem): NotificationData {
return notificationItem.use { return notificationItem.use { item ->
NotificationData( NotificationData(
senderId = UserId(it.event.senderId()), senderId = UserId(item.event.senderId()),
eventId = EventId(it.event.eventId()), eventId = EventId(item.event.eventId()),
roomId = RoomId(it.roomInfo.id), roomId = RoomId(item.roomInfo.id),
senderAvatarUrl = it.senderInfo.avatarUrl, senderAvatarUrl = item.senderInfo.avatarUrl,
senderDisplayName = it.senderInfo.displayName, senderDisplayName = item.senderInfo.displayName,
roomAvatarUrl = it.roomInfo.avatarUrl, roomAvatarUrl = item.roomInfo.avatarUrl ?: item.senderInfo.avatarUrl.takeIf { item.roomInfo.isDirect },
roomDisplayName = it.roomInfo.displayName, roomDisplayName = item.roomInfo.displayName,
isDirect = it.roomInfo.isDirect, isDirect = item.roomInfo.isDirect,
isEncrypted = it.roomInfo.isEncrypted.orFalse(), isEncrypted = item.roomInfo.isEncrypted.orFalse(),
isNoisy = it.isNoisy, isNoisy = item.isNoisy,
event = it.event.use { event -> timelineEventMapper.map(event) } event = item.event.use { event -> timelineEventMapper.map(event) }
) )
} }
} }

3
libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt

@ -36,7 +36,8 @@ class RustNotificationService(
filterByPushRules: Boolean, filterByPushRules: Boolean,
): Result<NotificationData?> { ): Result<NotificationData?> {
return runCatching { return runCatching {
client.getNotificationItem(roomId.value, eventId.value, filterByPushRules)?.use(notificationMapper::map) val item = client.getNotificationItem(roomId.value, eventId.value, filterByPushRules)
item?.use(notificationMapper::map)
} }
} }
} }

101
libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventMapper.kt

@ -16,9 +16,11 @@
package io.element.android.libraries.matrix.impl.notification package io.element.android.libraries.matrix.impl.notification
import io.element.android.libraries.matrix.api.notification.NotificationContent
import io.element.android.libraries.matrix.api.notification.NotificationEvent import io.element.android.libraries.matrix.api.notification.NotificationEvent
import io.element.android.libraries.matrix.impl.room.RoomMemberMapper
import io.element.android.libraries.matrix.impl.timeline.item.event.EventMessageMapper
import org.matrix.rustcomponents.sdk.MessageLikeEventContent import org.matrix.rustcomponents.sdk.MessageLikeEventContent
import org.matrix.rustcomponents.sdk.MessageType
import org.matrix.rustcomponents.sdk.StateEventContent import org.matrix.rustcomponents.sdk.StateEventContent
import org.matrix.rustcomponents.sdk.TimelineEvent import org.matrix.rustcomponents.sdk.TimelineEvent
import org.matrix.rustcomponents.sdk.TimelineEventType import org.matrix.rustcomponents.sdk.TimelineEventType
@ -38,71 +40,62 @@ class TimelineEventMapper @Inject constructor() {
} }
} }
private fun TimelineEventType.toContent(): String { private fun TimelineEventType.toContent(): NotificationContent {
return when (this) { return when (this) {
is TimelineEventType.MessageLike -> content.toContent() is TimelineEventType.MessageLike -> content.toContent()
is TimelineEventType.State -> content.toContent() is TimelineEventType.State -> content.toContent()
} }
} }
private fun StateEventContent.toContent(): String { private fun StateEventContent.toContent(): NotificationContent.StateEvent {
return when (this) { return when (this) {
StateEventContent.PolicyRuleRoom -> "PolicyRuleRoom" StateEventContent.PolicyRuleRoom -> NotificationContent.StateEvent.PolicyRuleRoom
StateEventContent.PolicyRuleServer -> "PolicyRuleServer" StateEventContent.PolicyRuleServer -> NotificationContent.StateEvent.PolicyRuleServer
StateEventContent.PolicyRuleUser -> "PolicyRuleUser" StateEventContent.PolicyRuleUser -> NotificationContent.StateEvent.PolicyRuleUser
StateEventContent.RoomAliases -> "RoomAliases" StateEventContent.RoomAliases -> NotificationContent.StateEvent.RoomAliases
StateEventContent.RoomAvatar -> "RoomAvatar" StateEventContent.RoomAvatar -> NotificationContent.StateEvent.RoomAvatar
StateEventContent.RoomCanonicalAlias -> "RoomCanonicalAlias" StateEventContent.RoomCanonicalAlias -> NotificationContent.StateEvent.RoomCanonicalAlias
StateEventContent.RoomCreate -> "RoomCreate" StateEventContent.RoomCreate -> NotificationContent.StateEvent.RoomCreate
StateEventContent.RoomEncryption -> "RoomEncryption" StateEventContent.RoomEncryption -> NotificationContent.StateEvent.RoomEncryption
StateEventContent.RoomGuestAccess -> "RoomGuestAccess" StateEventContent.RoomGuestAccess -> NotificationContent.StateEvent.RoomGuestAccess
StateEventContent.RoomHistoryVisibility -> "RoomHistoryVisibility" StateEventContent.RoomHistoryVisibility -> NotificationContent.StateEvent.RoomHistoryVisibility
StateEventContent.RoomJoinRules -> "RoomJoinRules" StateEventContent.RoomJoinRules -> NotificationContent.StateEvent.RoomJoinRules
is StateEventContent.RoomMemberContent -> "$userId is now $membershipState" is StateEventContent.RoomMemberContent -> {
StateEventContent.RoomName -> "RoomName" NotificationContent.StateEvent.RoomMemberContent(userId, RoomMemberMapper.mapMembership(membershipState))
StateEventContent.RoomPinnedEvents -> "RoomPinnedEvents" }
StateEventContent.RoomPowerLevels -> "RoomPowerLevels" StateEventContent.RoomName -> NotificationContent.StateEvent.RoomName
StateEventContent.RoomServerAcl -> "RoomServerAcl" StateEventContent.RoomPinnedEvents -> NotificationContent.StateEvent.RoomPinnedEvents
StateEventContent.RoomThirdPartyInvite -> "RoomThirdPartyInvite" StateEventContent.RoomPowerLevels -> NotificationContent.StateEvent.RoomPowerLevels
StateEventContent.RoomTombstone -> "RoomTombstone" StateEventContent.RoomServerAcl -> NotificationContent.StateEvent.RoomServerAcl
StateEventContent.RoomTopic -> "RoomTopic" StateEventContent.RoomThirdPartyInvite -> NotificationContent.StateEvent.RoomThirdPartyInvite
StateEventContent.SpaceChild -> "SpaceChild" StateEventContent.RoomTombstone -> NotificationContent.StateEvent.RoomTombstone
StateEventContent.SpaceParent -> "SpaceParent" StateEventContent.RoomTopic -> NotificationContent.StateEvent.RoomTopic
StateEventContent.SpaceChild -> NotificationContent.StateEvent.SpaceChild
StateEventContent.SpaceParent -> NotificationContent.StateEvent.SpaceParent
} }
} }
private fun MessageLikeEventContent.toContent(): String { private fun MessageLikeEventContent.toContent(): NotificationContent.MessageLike {
return use { return use {
when (it) { when (it) {
MessageLikeEventContent.CallAnswer -> "CallAnswer" MessageLikeEventContent.CallAnswer -> NotificationContent.MessageLike.CallAnswer
MessageLikeEventContent.CallCandidates -> "CallCandidates" MessageLikeEventContent.CallCandidates -> NotificationContent.MessageLike.CallCandidates
MessageLikeEventContent.CallHangup -> "CallHangup" MessageLikeEventContent.CallHangup -> NotificationContent.MessageLike.CallHangup
MessageLikeEventContent.CallInvite -> "CallInvite" MessageLikeEventContent.CallInvite -> NotificationContent.MessageLike.CallInvite
MessageLikeEventContent.KeyVerificationAccept -> "KeyVerificationAccept" MessageLikeEventContent.KeyVerificationAccept -> NotificationContent.MessageLike.KeyVerificationAccept
MessageLikeEventContent.KeyVerificationCancel -> "KeyVerificationCancel" MessageLikeEventContent.KeyVerificationCancel -> NotificationContent.MessageLike.KeyVerificationCancel
MessageLikeEventContent.KeyVerificationDone -> "KeyVerificationDone" MessageLikeEventContent.KeyVerificationDone -> NotificationContent.MessageLike.KeyVerificationDone
MessageLikeEventContent.KeyVerificationKey -> "KeyVerificationKey" MessageLikeEventContent.KeyVerificationKey -> NotificationContent.MessageLike.KeyVerificationKey
MessageLikeEventContent.KeyVerificationMac -> "KeyVerificationMac" MessageLikeEventContent.KeyVerificationMac -> NotificationContent.MessageLike.KeyVerificationMac
MessageLikeEventContent.KeyVerificationReady -> "KeyVerificationReady" MessageLikeEventContent.KeyVerificationReady -> NotificationContent.MessageLike.KeyVerificationReady
MessageLikeEventContent.KeyVerificationStart -> "KeyVerificationStart" MessageLikeEventContent.KeyVerificationStart -> NotificationContent.MessageLike.KeyVerificationStart
is MessageLikeEventContent.ReactionContent -> "Reacted to ${it.relatedEventId.take(8)}" is MessageLikeEventContent.ReactionContent -> NotificationContent.MessageLike.ReactionContent(it.relatedEventId)
MessageLikeEventContent.RoomEncrypted -> "RoomEncrypted" MessageLikeEventContent.RoomEncrypted -> NotificationContent.MessageLike.RoomEncrypted
is MessageLikeEventContent.RoomMessage -> it.messageType.toContent() is MessageLikeEventContent.RoomMessage -> {
MessageLikeEventContent.RoomRedaction -> "RoomRedaction" NotificationContent.MessageLike.RoomMessage(EventMessageMapper().mapMessageType(it.messageType))
MessageLikeEventContent.Sticker -> "Sticker" }
MessageLikeEventContent.RoomRedaction -> NotificationContent.MessageLike.RoomRedaction
MessageLikeEventContent.Sticker -> NotificationContent.MessageLike.Sticker
} }
} }
} }
private fun MessageType.toContent(): String {
return when (this) {
is MessageType.Audio -> content.use { it.body }
is MessageType.Emote -> content.body
is MessageType.File -> content.use { it.body }
is MessageType.Image -> content.use { it.body }
is MessageType.Location -> content.body
is MessageType.Notice -> content.body
is MessageType.Text -> content.body
is MessageType.Video -> content.use { it.body }
}
}

63
libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt

@ -33,48 +33,17 @@ import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessag
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.impl.media.map import io.element.android.libraries.matrix.impl.media.map
import org.matrix.rustcomponents.sdk.Message import org.matrix.rustcomponents.sdk.Message
import org.matrix.rustcomponents.sdk.MessageType
import org.matrix.rustcomponents.sdk.ProfileDetails import org.matrix.rustcomponents.sdk.ProfileDetails
import org.matrix.rustcomponents.sdk.RepliedToEventDetails import org.matrix.rustcomponents.sdk.RepliedToEventDetails
import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.use
import org.matrix.rustcomponents.sdk.FormattedBody as RustFormattedBody import org.matrix.rustcomponents.sdk.FormattedBody as RustFormattedBody
import org.matrix.rustcomponents.sdk.MessageFormat as RustMessageFormat import org.matrix.rustcomponents.sdk.MessageFormat as RustMessageFormat
import org.matrix.rustcomponents.sdk.MessageType as RustMessageType
class EventMessageMapper { class EventMessageMapper {
fun map(message: Message): MessageContent = message.use { fun map(message: Message): MessageContent = message.use {
val type = it.msgtype().use { type -> val type = it.msgtype().use(this::mapMessageType)
when (type) {
is MessageType.Audio -> {
AudioMessageType(type.content.body, type.content.source.map(), type.content.info?.map())
}
is MessageType.File -> {
FileMessageType(type.content.body, type.content.source.map(), type.content.info?.map())
}
is MessageType.Image -> {
ImageMessageType(type.content.body, type.content.source.map(), type.content.info?.map())
}
is MessageType.Location -> {
LocationMessageType(type.content.body, type.content.geoUri, type.content.description)
}
is MessageType.Notice -> {
NoticeMessageType(type.content.body, type.content.formatted?.map())
}
is MessageType.Text -> {
TextMessageType(type.content.body, type.content.formatted?.map())
}
is MessageType.Emote -> {
EmoteMessageType(type.content.body, type.content.formatted?.map())
}
is MessageType.Video -> {
VideoMessageType(type.content.body, type.content.source.map(), type.content.info?.map())
}
null -> {
UnknownMessageType
}
}
}
val inReplyToId = it.inReplyTo()?.eventId?.let(::EventId) val inReplyToId = it.inReplyTo()?.eventId?.let(::EventId)
val inReplyToEvent: InReplyTo? = (it.inReplyTo()?.event)?.use { details -> val inReplyToEvent: InReplyTo? = (it.inReplyTo()?.event)?.use { details ->
when (details) { when (details) {
@ -99,6 +68,34 @@ class EventMessageMapper {
type = type type = type
) )
} }
fun mapMessageType(type: RustMessageType?) = when (type) {
is RustMessageType.Audio -> {
AudioMessageType(type.content.body, type.content.source.map(), type.content.info?.map())
}
is RustMessageType.File -> {
FileMessageType(type.content.body, type.content.source.map(), type.content.info?.map())
}
is RustMessageType.Image -> {
ImageMessageType(type.content.body, type.content.source.map(), type.content.info?.map())
}
is RustMessageType.Notice -> {
NoticeMessageType(type.content.body, type.content.formatted?.map())
}
is RustMessageType.Text -> {
TextMessageType(type.content.body, type.content.formatted?.map())
}
is RustMessageType.Emote -> {
EmoteMessageType(type.content.body, type.content.formatted?.map())
}
is RustMessageType.Video -> {
VideoMessageType(type.content.body, type.content.source.map(), type.content.info?.map())
}
is RustMessageType.Location -> {
LocationMessageType(type.content.body, type.content.geoUri, type.content.description)
}
null -> UnknownMessageType
}
} }
private fun RustFormattedBody.map(): FormattedBody = FormattedBody( private fun RustFormattedBody.map(): FormattedBody = FormattedBody(

25
libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationDrawerManager.kt

@ -0,0 +1,25 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.api.notifications
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
interface NotificationDrawerManager {
fun clearMembershipNotificationForSession(sessionId: SessionId)
fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId)
}

5
libraries/push/impl/build.gradle.kts

@ -44,6 +44,7 @@ dependencies {
implementation(projects.libraries.network) implementation(projects.libraries.network)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.uiStrings)
api(projects.libraries.pushproviders.api) api(projects.libraries.pushproviders.api)
api(projects.libraries.pushstore.api) api(projects.libraries.pushstore.api)
api(projects.libraries.push.api) api(projects.libraries.push.api)
@ -52,10 +53,6 @@ dependencies {
implementation(projects.services.appnavstate.api) implementation(projects.services.appnavstate.api)
implementation(projects.services.toolbox.api) implementation(projects.services.toolbox.api)
api(libs.gujun.span) {
exclude(group = "com.android.support", module = "support-annotations")
}
// TODO Temporary use the deprecated LocalBroadcastManager, to be changed later. // TODO Temporary use the deprecated LocalBroadcastManager, to be changed later.
implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0") implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0")

7
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt

@ -20,8 +20,7 @@ import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.push.api.PushService import io.element.android.libraries.push.api.PushService
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager
import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager
import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.api.Distributor
import io.element.android.libraries.pushproviders.api.PushProvider import io.element.android.libraries.pushproviders.api.PushProvider
import io.element.android.libraries.pushstore.api.UserPushStoreFactory import io.element.android.libraries.pushstore.api.UserPushStoreFactory
@ -29,13 +28,13 @@ import javax.inject.Inject
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultPushService @Inject constructor( class DefaultPushService @Inject constructor(
private val notificationDrawerManager: NotificationDrawerManager, private val defaultNotificationDrawerManager: DefaultNotificationDrawerManager,
private val pushersManager: PushersManager, private val pushersManager: PushersManager,
private val userPushStoreFactory: UserPushStoreFactory, private val userPushStoreFactory: UserPushStoreFactory,
private val pushProviders: Set<@JvmSuppressWildcards PushProvider>, private val pushProviders: Set<@JvmSuppressWildcards PushProvider>,
) : PushService { ) : PushService {
override fun notificationStyleChanged() { override fun notificationStyleChanged() {
notificationDrawerManager.notificationStyleChanged() defaultNotificationDrawerManager.notificationStyleChanged()
} }
override fun getAvailablePushProviders(): List<PushProvider> { override fun getAvailablePushProviders(): List<PushProvider> {

33
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/PushBindsModule.kt

@ -0,0 +1,33 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.di
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager
@Module
@ContributesTo(AppScope::class)
abstract class PushBindsModule {
@Binds
abstract fun bindNotificationDrawerManager(
defaultNotificationDrawerManager: DefaultNotificationDrawerManager
): NotificationDrawerManager
}

9
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/intent/IntentProvider.kt

@ -23,11 +23,16 @@ import io.element.android.libraries.matrix.api.core.ThreadId
interface IntentProvider { interface IntentProvider {
/** /**
* Provide an intent to start the application. * Provide an intent to start the application on a room or thread.
*/ */
fun getViewIntent( fun getViewRoomIntent(
sessionId: SessionId, sessionId: SessionId,
roomId: RoomId?, roomId: RoomId?,
threadId: ThreadId?, threadId: ThreadId?,
): Intent ): Intent
/**
* Provide an intent to start the application on the invite list.
*/
fun getInviteListIntent(sessionId: SessionId): Intent
} }

31
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDrawerManager.kt → libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt

@ -24,14 +24,16 @@ import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
import io.element.android.libraries.push.api.store.PushDataStore import io.element.android.libraries.push.api.store.PushDataStore
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreMessageEventInRoom import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom
import io.element.android.services.appnavstate.api.AppNavigationState import io.element.android.services.appnavstate.api.AppNavigationState
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -47,7 +49,7 @@ import javax.inject.Inject
* Events can be grouped into the same notification, old (already read) events can be removed to do some cleaning. * Events can be grouped into the same notification, old (already read) events can be removed to do some cleaning.
*/ */
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
class NotificationDrawerManager @Inject constructor( class DefaultNotificationDrawerManager @Inject constructor(
private val pushDataStore: PushDataStore, private val pushDataStore: PushDataStore,
private val notifiableEventProcessor: NotifiableEventProcessor, private val notifiableEventProcessor: NotifiableEventProcessor,
private val notificationRenderer: NotificationRenderer, private val notificationRenderer: NotificationRenderer,
@ -58,7 +60,7 @@ class NotificationDrawerManager @Inject constructor(
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val matrixAuthenticationService: MatrixAuthenticationService, private val matrixAuthenticationService: MatrixAuthenticationService,
) { ) : NotificationDrawerManager {
/** /**
* Lazily initializes the NotificationState as we rely on having a current session in order to fetch the persisted queue of events. * Lazily initializes the NotificationState as we rely on having a current session in order to fetch the persisted queue of events.
*/ */
@ -152,12 +154,27 @@ class NotificationDrawerManager @Inject constructor(
} }
} }
override fun clearMembershipNotificationForSession(sessionId: SessionId) {
updateEvents {
it.clearMembershipNotificationForSession(sessionId)
}
}
/** /**
* Clear invitation notification for the provided room. * Clear invitation notification for the provided room.
*/ */
fun clearMemberShipNotificationForRoom(sessionId: SessionId, roomId: RoomId) { override fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) {
updateEvents {
it.clearMembershipNotificationForRoom(sessionId, roomId)
}
}
/**
* Clear the notifications for a single event.
*/
fun clearEvent(eventId: EventId) {
updateEvents { updateEvents {
it.clearMemberShipNotificationForRoom(sessionId, roomId) it.clearEvent(eventId)
} }
} }
@ -183,7 +200,7 @@ class NotificationDrawerManager @Inject constructor(
} }
} }
private fun updateEvents(action: NotificationDrawerManager.(NotificationEventQueue) -> Unit) { private fun updateEvents(action: DefaultNotificationDrawerManager.(NotificationEventQueue) -> Unit) {
notificationState.updateQueuedEvents(this) { queuedEvents, _ -> notificationState.updateQueuedEvents(this) { queuedEvents, _ ->
action(queuedEvents) action(queuedEvents)
} }
@ -260,6 +277,6 @@ class NotificationDrawerManager @Inject constructor(
} }
fun shouldIgnoreMessageEventInRoom(resolvedEvent: NotifiableMessageEvent): Boolean { fun shouldIgnoreMessageEventInRoom(resolvedEvent: NotifiableMessageEvent): Boolean {
return resolvedEvent.shouldIgnoreMessageEventInRoom(currentAppNavigationState) return resolvedEvent.shouldIgnoreEventInRoom(currentAppNavigationState)
} }
} }

12
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.kt

@ -17,11 +17,12 @@
package io.element.android.libraries.push.impl.notifications package io.element.android.libraries.push.impl.notifications
import io.element.android.libraries.matrix.api.timeline.item.event.EventType import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreMessageEventInRoom import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom
import io.element.android.services.appnavstate.api.AppNavigationState import io.element.android.services.appnavstate.api.AppNavigationState
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -41,7 +42,7 @@ class NotifiableEventProcessor @Inject constructor(
val type = when (it) { val type = when (it) {
is InviteNotifiableEvent -> ProcessedEvent.Type.KEEP is InviteNotifiableEvent -> ProcessedEvent.Type.KEEP
is NotifiableMessageEvent -> when { is NotifiableMessageEvent -> when {
it.shouldIgnoreMessageEventInRoom(appNavigationState) -> { it.shouldIgnoreEventInRoom(appNavigationState) -> {
ProcessedEvent.Type.REMOVE ProcessedEvent.Type.REMOVE
.also { Timber.d("notification message removed due to currently viewing the same room or thread") } .also { Timber.d("notification message removed due to currently viewing the same room or thread") }
} }
@ -53,6 +54,13 @@ class NotifiableEventProcessor @Inject constructor(
EventType.REDACTION -> ProcessedEvent.Type.REMOVE EventType.REDACTION -> ProcessedEvent.Type.REMOVE
else -> ProcessedEvent.Type.KEEP else -> ProcessedEvent.Type.KEEP
} }
is FallbackNotifiableEvent -> when {
it.shouldIgnoreEventInRoom(appNavigationState) -> {
ProcessedEvent.Type.REMOVE
.also { Timber.d("notification fallback removed due to currently viewing the same room or thread") }
}
else -> ProcessedEvent.Type.KEEP
}
} }
ProcessedEvent(type, it) ProcessedEvent(type, it)
} }

228
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt

@ -22,12 +22,26 @@ import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.notification.NotificationContent
import io.element.android.libraries.matrix.api.notification.NotificationData import io.element.android.libraries.matrix.api.notification.NotificationData
import io.element.android.libraries.matrix.api.notification.NotificationEvent import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.log.pushLoggerTag import io.element.android.libraries.push.impl.log.pushLoggerTag
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 import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.toolbox.api.strings.StringProvider import io.element.android.services.toolbox.api.strings.StringProvider
import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.services.toolbox.api.systemclock.SystemClock
import timber.log.Timber import timber.log.Timber
@ -53,73 +67,163 @@ class NotifiableEventResolver @Inject constructor(
suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? { suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? {
// Restore session // Restore session
val session = matrixAuthenticationService.restoreSession(sessionId).getOrNull() ?: return null val session = matrixAuthenticationService.restoreSession(sessionId).getOrNull() ?: return null
// TODO EAx, no need for a session? val notificationService = session.notificationService()
val notificationData = session.let {// TODO Use make the app crashes val notificationData = notificationService.getNotification(
it.notificationService().getNotification(
userId = sessionId, userId = sessionId,
roomId = roomId, roomId = roomId,
eventId = eventId, eventId = eventId,
filterByPushRules = true, // FIXME should be true in the future, but right now it's broken
) // (https://github.com/vector-im/element-x-android/issues/640#issuecomment-1612913658)
}.fold( filterByPushRules = false,
{ ).onFailure {
it Timber.tag(loggerTag.value).e(it, "Unable to resolve event: $eventId.")
}, }.getOrNull()
{
Timber.tag(loggerTag.value).e(it, "Unable to resolve event.") // TODO this notificationData is not always valid at the moment, sometimes the Rust SDK can't fetch the matching event
null return notificationData?.asNotifiableEvent(sessionId)
} ?: fallbackNotifiableEvent(sessionId, roomId, eventId)
).orDefault(roomId, eventId) }
return notificationData.asNotifiableEvent(sessionId) private fun NotificationData.asNotifiableEvent(userId: SessionId): NotifiableEvent? {
return when (val content = this.event.content) {
is NotificationContent.MessageLike.RoomMessage -> {
buildNotifiableMessageEvent(
sessionId = userId,
roomId = roomId,
eventId = eventId,
noisy = isNoisy,
timestamp = event.timestamp,
senderName = senderDisplayName,
senderId = senderId.value,
body = descriptionFromMessageContent(content),
imageUriString = event.contentUrl,
roomName = roomDisplayName,
roomIsDirect = isDirect,
roomAvatarPath = roomAvatarUrl,
senderAvatarPath = senderAvatarUrl,
)
}
is NotificationContent.StateEvent.RoomMemberContent -> {
if (content.membershipState == RoomMembershipState.INVITE) {
InviteNotifiableEvent(
sessionId = userId,
roomId = roomId,
eventId = eventId,
editedEventId = null,
canBeReplaced = true,
roomName = roomDisplayName,
noisy = isNoisy,
timestamp = event.timestamp,
soundName = null,
isRedacted = false,
isUpdated = false,
description = descriptionFromRoomMembershipContent(content, isDirect) ?: return null,
type = null, // TODO check if type is needed anymore
title = null, // TODO check if title is needed anymore
)
} else {
null
}
}
else -> null
}
} }
private fun NotificationData.asNotifiableEvent(userId: SessionId): NotifiableEvent { private fun fallbackNotifiableEvent(
return NotifiableMessageEvent( userId: SessionId,
sessionId = userId, roomId: RoomId,
roomId = roomId, eventId: EventId
eventId = eventId, ) = FallbackNotifiableEvent(
editedEventId = null, sessionId = userId,
canBeReplaced = true, roomId = roomId,
noisy = isNoisy, eventId = eventId,
timestamp = event.timestamp, editedEventId = null,
senderName = senderDisplayName, canBeReplaced = true,
senderId = senderId.value, isRedacted = false,
body = event.content, isUpdated = false,
imageUriString = event.contentUrl, timestamp = clock.epochMillis(),
threadId = null, description = stringProvider.getString(R.string.notification_fallback_content),
roomName = roomDisplayName, )
roomIsDirect = isDirect,
roomAvatarPath = roomAvatarUrl, private fun descriptionFromMessageContent(
senderAvatarPath = senderAvatarUrl, content: NotificationContent.MessageLike.RoomMessage,
soundName = null, ): String {
outGoingMessage = false, return when (val messageType = content.messageType) {
outGoingMessageFailed = false, is AudioMessageType -> messageType.body
isRedacted = false, is EmoteMessageType -> messageType.body
isUpdated = false is FileMessageType -> messageType.body
) is ImageMessageType -> messageType.body
is NoticeMessageType -> messageType.body
is TextMessageType -> messageType.body
is VideoMessageType -> messageType.body
is LocationMessageType -> messageType.body
is UnknownMessageType -> stringProvider.getString(CommonStrings.common_unsupported_event)
}
} }
/** private fun descriptionFromRoomMembershipContent(
* TODO This is a temporary method for EAx. content: NotificationContent.StateEvent.RoomMemberContent,
*/ isDirectRoom: Boolean
private fun NotificationData?.orDefault(roomId: RoomId, eventId: EventId): NotificationData { ): String? {
return this ?: NotificationData( return when (content.membershipState) {
eventId = eventId, RoomMembershipState.INVITE -> {
senderId = UserId("@user:domain"), if (isDirectRoom) {
roomId = roomId, stringProvider.getString(R.string.notification_invite_body)
senderAvatarUrl = null, } else {
senderDisplayName = null, stringProvider.getString(R.string.notification_room_invite_body)
roomAvatarUrl = null, }
roomDisplayName = null, }
isNoisy = false, else -> null
isEncrypted = false, }
isDirect = false,
event = NotificationEvent(
timestamp = clock.epochMillis(),
content = "Message ${eventId.value.take(8)}… in room ${roomId.value.take(8)}",
contentUrl = null
)
)
} }
} }
@Suppress("LongParameterList")
private fun buildNotifiableMessageEvent(
sessionId: SessionId,
roomId: RoomId,
eventId: EventId,
editedEventId: EventId? = null,
canBeReplaced: Boolean = false,
noisy: Boolean,
timestamp: Long,
senderName: String?,
senderId: String?,
body: String?,
// We cannot use Uri? type here, as that could trigger a
// NotSerializableException when persisting this to storage
imageUriString: String? = null,
threadId: ThreadId? = null,
roomName: String? = null,
roomIsDirect: Boolean = false,
roomAvatarPath: String? = null,
senderAvatarPath: String? = null,
soundName: String? = null,
// This is used for >N notification, as the result of a smart reply
outGoingMessage: Boolean = false,
outGoingMessageFailed: Boolean = false,
isRedacted: Boolean = false,
isUpdated: Boolean = false
) = NotifiableMessageEvent(
sessionId = sessionId,
roomId = roomId,
eventId = eventId,
editedEventId = editedEventId,
canBeReplaced = canBeReplaced,
noisy = noisy,
timestamp = timestamp,
senderName = senderName,
senderId = senderId,
body = body,
imageUriString = imageUriString,
threadId = threadId,
roomName = roomName,
roomIsDirect = roomIsDirect,
roomAvatarPath = roomAvatarPath,
senderAvatarPath = senderAvatarPath,
soundName = soundName,
outGoingMessage = outGoingMessage,
outGoingMessageFailed = outGoingMessageFailed,
isRedacted = isRedacted,
isUpdated = isUpdated
)

2
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationActionIds.kt

@ -34,6 +34,8 @@ data class NotificationActionIds @Inject constructor(
val smartReply = "${buildMeta.applicationId}.NotificationActions.SMART_REPLY_ACTION" val smartReply = "${buildMeta.applicationId}.NotificationActions.SMART_REPLY_ACTION"
val dismissSummary = "${buildMeta.applicationId}.NotificationActions.DISMISS_SUMMARY_ACTION" val dismissSummary = "${buildMeta.applicationId}.NotificationActions.DISMISS_SUMMARY_ACTION"
val dismissRoom = "${buildMeta.applicationId}.NotificationActions.DISMISS_ROOM_NOTIF_ACTION" val dismissRoom = "${buildMeta.applicationId}.NotificationActions.DISMISS_ROOM_NOTIF_ACTION"
val dismissInvite = "${buildMeta.applicationId}.NotificationActions.DISMISS_INVITE_NOTIF_ACTION"
val dismissEvent = "${buildMeta.applicationId}.NotificationActions.DISMISS_EVENT_NOTIF_ACTION"
val diagnostic = "${buildMeta.applicationId}.NotificationActions.DIAGNOSTIC" val diagnostic = "${buildMeta.applicationId}.NotificationActions.DIAGNOSTIC"
val push = "${buildMeta.applicationId}.PUSH" val push = "${buildMeta.applicationId}.PUSH"
} }

1
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBitmapLoader.kt

@ -49,6 +49,7 @@ class NotificationBitmapLoader @Inject constructor(
return try { return try {
val imageRequest = ImageRequest.Builder(context) val imageRequest = ImageRequest.Builder(context)
.data(MediaRequestData(MediaSource(path), MediaRequestData.Kind.Thumbnail(1024))) .data(MediaRequestData(MediaSource(path), MediaRequestData.Kind.Thumbnail(1024)))
.transformations(CircleCropTransformation())
.build() .build()
val result = context.imageLoader.execute(imageRequest) val result = context.imageLoader.execute(imageRequest)
result.drawable?.toBitmap() result.drawable?.toBitmap()

21
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiver.kt

@ -22,6 +22,7 @@ import android.content.Intent
import androidx.core.app.RemoteInput import androidx.core.app.RemoteInput
import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
@ -37,7 +38,7 @@ private val loggerTag = LoggerTag("NotificationBroadcastReceiver", notificationL
*/ */
class NotificationBroadcastReceiver : BroadcastReceiver() { class NotificationBroadcastReceiver : BroadcastReceiver() {
@Inject lateinit var notificationDrawerManager: NotificationDrawerManager @Inject lateinit var defaultNotificationDrawerManager: DefaultNotificationDrawerManager
//@Inject lateinit var activeSessionHolder: ActiveSessionHolder //@Inject lateinit var activeSessionHolder: ActiveSessionHolder
//@Inject lateinit var analyticsTracker: AnalyticsTracker //@Inject lateinit var analyticsTracker: AnalyticsTracker
@ -50,24 +51,31 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
Timber.tag(loggerTag.value).v("NotificationBroadcastReceiver received : $intent") Timber.tag(loggerTag.value).v("NotificationBroadcastReceiver received : $intent")
val sessionId = intent.extras?.getString(KEY_SESSION_ID)?.let(::SessionId) ?: return val sessionId = intent.extras?.getString(KEY_SESSION_ID)?.let(::SessionId) ?: return
val roomId = intent.getStringExtra(KEY_ROOM_ID)?.let(::RoomId) val roomId = intent.getStringExtra(KEY_ROOM_ID)?.let(::RoomId)
val eventId = intent.getStringExtra(KEY_EVENT_ID)?.let(::EventId)
when (intent.action) { when (intent.action) {
actionIds.smartReply -> actionIds.smartReply ->
handleSmartReply(intent, context) handleSmartReply(intent, context)
actionIds.dismissRoom -> if (roomId != null) { actionIds.dismissRoom -> if (roomId != null) {
notificationDrawerManager.clearMessagesForRoom(sessionId, roomId) defaultNotificationDrawerManager.clearMessagesForRoom(sessionId, roomId)
} }
actionIds.dismissSummary -> actionIds.dismissSummary ->
notificationDrawerManager.clearAllEvents(sessionId) defaultNotificationDrawerManager.clearAllEvents(sessionId)
actionIds.dismissInvite -> if (roomId != null) {
defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId)
}
actionIds.dismissEvent -> if (eventId != null) {
defaultNotificationDrawerManager.clearEvent(eventId)
}
actionIds.markRoomRead -> if (roomId != null) { actionIds.markRoomRead -> if (roomId != null) {
notificationDrawerManager.clearMessagesForRoom(sessionId, roomId) defaultNotificationDrawerManager.clearMessagesForRoom(sessionId, roomId)
handleMarkAsRead(sessionId, roomId) handleMarkAsRead(sessionId, roomId)
} }
actionIds.join -> if (roomId != null) { actionIds.join -> if (roomId != null) {
notificationDrawerManager.clearMemberShipNotificationForRoom(sessionId, roomId) defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId)
handleJoinRoom(sessionId, roomId) handleJoinRoom(sessionId, roomId)
} }
actionIds.reject -> if (roomId != null) { actionIds.reject -> if (roomId != null) {
notificationDrawerManager.clearMemberShipNotificationForRoom(sessionId, roomId) defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId)
handleRejectRoom(sessionId, roomId) handleRejectRoom(sessionId, roomId)
} }
} }
@ -240,6 +248,7 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
const val KEY_SESSION_ID = "sessionID" const val KEY_SESSION_ID = "sessionID"
const val KEY_ROOM_ID = "roomID" const val KEY_ROOM_ID = "roomID"
const val KEY_THREAD_ID = "threadID" const val KEY_THREAD_ID = "threadID"
const val KEY_EVENT_ID = "eventID"
const val KEY_TEXT_REPLY = "key_text_reply" const val KEY_TEXT_REPLY = "key_text_reply"
} }
} }

17
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueue.kt

@ -21,6 +21,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
@ -45,6 +46,7 @@ data class NotificationEventQueue constructor(
is InviteNotifiableEvent -> it.copy(isRedacted = true) is InviteNotifiableEvent -> it.copy(isRedacted = true)
is NotifiableMessageEvent -> it.copy(isRedacted = true) is NotifiableMessageEvent -> it.copy(isRedacted = true)
is SimpleNotifiableEvent -> it.copy(isRedacted = true) is SimpleNotifiableEvent -> it.copy(isRedacted = true)
is FallbackNotifiableEvent -> it.copy(isRedacted = true)
} }
} }
} }
@ -57,7 +59,8 @@ data class NotificationEventQueue constructor(
when (it) { when (it) {
is NotifiableMessageEvent -> roomsLeft.contains(it.roomId) is NotifiableMessageEvent -> roomsLeft.contains(it.roomId)
is InviteNotifiableEvent -> roomsLeft.contains(it.roomId) || roomsJoined.contains(it.roomId) is InviteNotifiableEvent -> roomsLeft.contains(it.roomId) || roomsJoined.contains(it.roomId)
else -> false is SimpleNotifiableEvent -> false
is FallbackNotifiableEvent -> roomsLeft.contains(it.roomId)
} }
} }
} }
@ -127,11 +130,21 @@ data class NotificationEventQueue constructor(
is InviteNotifiableEvent -> with.copy(isUpdated = true) is InviteNotifiableEvent -> with.copy(isUpdated = true)
is NotifiableMessageEvent -> with.copy(isUpdated = true) is NotifiableMessageEvent -> with.copy(isUpdated = true)
is SimpleNotifiableEvent -> with.copy(isUpdated = true) is SimpleNotifiableEvent -> with.copy(isUpdated = true)
is FallbackNotifiableEvent -> with.copy(isUpdated = true)
} }
) )
} }
fun clearMemberShipNotificationForRoom(sessionId: SessionId, roomId: RoomId) { fun clearEvent(eventId: EventId) {
queue.removeAll { it.eventId == eventId }
}
fun clearMembershipNotificationForSession(sessionId: SessionId) {
Timber.d("clearMemberShipOfSession $sessionId")
queue.removeAll { it is InviteNotifiableEvent && it.sessionId == sessionId }
}
fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) {
Timber.d("clearMemberShipOfRoom $sessionId, $roomId") Timber.d("clearMemberShipOfRoom $sessionId, $roomId")
queue.removeAll { it is InviteNotifiableEvent && it.sessionId == sessionId && it.roomId == roomId } queue.removeAll { it is InviteNotifiableEvent && it.sessionId == sessionId && it.roomId == roomId }
} }

21
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactory.kt

@ -20,6 +20,7 @@ import android.app.Notification
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.notifications.factories.NotificationFactory import io.element.android.libraries.push.impl.notifications.factories.NotificationFactory
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
@ -94,16 +95,35 @@ class NotificationFactory @Inject constructor(
} }
} }
fun List<ProcessedEvent<FallbackNotifiableEvent>>.toNotifications(): List<OneShotNotification> {
return map { (processed, event) ->
when (processed) {
ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.eventId.value)
ProcessedEvent.Type.KEEP -> OneShotNotification.Append(
notificationFactory.createFallbackNotification(event),
OneShotNotification.Append.Meta(
key = event.eventId.value,
summaryLine = event.description.orEmpty(),
isNoisy = false,
timestamp = event.timestamp
)
)
}
}
}
fun createSummaryNotification( fun createSummaryNotification(
currentUser: MatrixUser, currentUser: MatrixUser,
roomNotifications: List<RoomNotification>, roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>, invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>, simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
useCompleteNotificationFormat: Boolean useCompleteNotificationFormat: Boolean
): SummaryNotification { ): SummaryNotification {
val roomMeta = roomNotifications.filterIsInstance<RoomNotification.Message>().map { it.meta } val roomMeta = roomNotifications.filterIsInstance<RoomNotification.Message>().map { it.meta }
val invitationMeta = invitationNotifications.filterIsInstance<OneShotNotification.Append>().map { it.meta } val invitationMeta = invitationNotifications.filterIsInstance<OneShotNotification.Append>().map { it.meta }
val simpleMeta = simpleNotifications.filterIsInstance<OneShotNotification.Append>().map { it.meta } val simpleMeta = simpleNotifications.filterIsInstance<OneShotNotification.Append>().map { it.meta }
val fallbackMeta = simpleNotifications.filterIsInstance<OneShotNotification.Append>().map { it.meta }
return when { return when {
roomMeta.isEmpty() && invitationMeta.isEmpty() && simpleMeta.isEmpty() -> SummaryNotification.Removed roomMeta.isEmpty() && invitationMeta.isEmpty() && simpleMeta.isEmpty() -> SummaryNotification.Removed
else -> SummaryNotification.Update( else -> SummaryNotification.Update(
@ -112,6 +132,7 @@ class NotificationFactory @Inject constructor(
roomNotifications = roomMeta, roomNotifications = roomMeta,
invitationNotifications = invitationMeta, invitationNotifications = invitationMeta,
simpleNotifications = simpleMeta, simpleNotifications = simpleMeta,
fallbackNotifications = fallbackMeta,
useCompleteNotificationFormat = useCompleteNotificationFormat useCompleteNotificationFormat = useCompleteNotificationFormat
) )
) )

5
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationIdProvider.kt

@ -37,12 +37,17 @@ class NotificationIdProvider @Inject constructor() {
return getOffset(sessionId) + ROOM_INVITATION_NOTIFICATION_ID return getOffset(sessionId) + ROOM_INVITATION_NOTIFICATION_ID
} }
fun getFallbackNotificationId(sessionId: SessionId): Int {
return getOffset(sessionId) + FALLBACK_NOTIFICATION_ID
}
private fun getOffset(sessionId: SessionId): Int { private fun getOffset(sessionId: SessionId): Int {
// Compute a int from a string with a low risk of collision. // Compute a int from a string with a low risk of collision.
return abs(sessionId.value.hashCode() % 100_000) * 10 return abs(sessionId.value.hashCode() % 100_000) * 10
} }
companion object { companion object {
private const val FALLBACK_NOTIFICATION_ID = -1
private const val SUMMARY_NOTIFICATION_ID = 0 private const val SUMMARY_NOTIFICATION_ID = 0
private const val ROOM_MESSAGES_NOTIFICATION_ID = 1 private const val ROOM_MESSAGES_NOTIFICATION_ID = 1
private const val ROOM_EVENT_NOTIFICATION_ID = 2 private const val ROOM_EVENT_NOTIFICATION_ID = 2

40
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt

@ -18,6 +18,7 @@ package io.element.android.libraries.push.impl.notifications
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
@ -36,16 +37,18 @@ class NotificationRenderer @Inject constructor(
useCompleteNotificationFormat: Boolean, useCompleteNotificationFormat: Boolean,
eventsToProcess: List<ProcessedEvent<NotifiableEvent>> eventsToProcess: List<ProcessedEvent<NotifiableEvent>>
) { ) {
val (roomEvents, simpleEvents, invitationEvents) = eventsToProcess.groupByType() val groupedEvents = eventsToProcess.groupByType()
with(notificationFactory) { with(notificationFactory) {
val roomNotifications = roomEvents.toNotifications(currentUser) val roomNotifications = groupedEvents.roomEvents.toNotifications(currentUser)
val invitationNotifications = invitationEvents.toNotifications() val invitationNotifications = groupedEvents.invitationEvents.toNotifications()
val simpleNotifications = simpleEvents.toNotifications() val simpleNotifications = groupedEvents.simpleEvents.toNotifications()
val fallbackNotifications = groupedEvents.fallbackEvents.toNotifications()
val summaryNotification = createSummaryNotification( val summaryNotification = createSummaryNotification(
currentUser = currentUser, currentUser = currentUser,
roomNotifications = roomNotifications, roomNotifications = roomNotifications,
invitationNotifications = invitationNotifications, invitationNotifications = invitationNotifications,
simpleNotifications = simpleNotifications, simpleNotifications = simpleNotifications,
fallbackNotifications = fallbackNotifications,
useCompleteNotificationFormat = useCompleteNotificationFormat useCompleteNotificationFormat = useCompleteNotificationFormat
) )
@ -118,6 +121,26 @@ class NotificationRenderer @Inject constructor(
} }
} }
fallbackNotifications.forEach { wrapper ->
when (wrapper) {
is OneShotNotification.Removed -> {
Timber.d("Removing fallback notification ${wrapper.key}")
notificationDisplayer.cancelNotificationMessage(
tag = wrapper.key,
id = notificationIdProvider.getFallbackNotificationId(currentUser.userId)
)
}
is OneShotNotification.Append -> if (useCompleteNotificationFormat) {
Timber.d("Updating fallback notification ${wrapper.meta.key}")
notificationDisplayer.showNotificationMessage(
tag = wrapper.meta.key,
id = notificationIdProvider.getFallbackNotificationId(currentUser.userId),
notification = wrapper.notification
)
}
}
}
// Update summary last to avoid briefly displaying it before other notifications // Update summary last to avoid briefly displaying it before other notifications
if (summaryNotification is SummaryNotification.Update) { if (summaryNotification is SummaryNotification.Update) {
Timber.d("Updating summary notification") Timber.d("Updating summary notification")
@ -139,6 +162,7 @@ private fun List<ProcessedEvent<NotifiableEvent>>.groupByType(): GroupedNotifica
val roomIdToEventMap: MutableMap<RoomId, MutableList<ProcessedEvent<NotifiableMessageEvent>>> = LinkedHashMap() val roomIdToEventMap: MutableMap<RoomId, MutableList<ProcessedEvent<NotifiableMessageEvent>>> = LinkedHashMap()
val simpleEvents: MutableList<ProcessedEvent<SimpleNotifiableEvent>> = ArrayList() val simpleEvents: MutableList<ProcessedEvent<SimpleNotifiableEvent>> = ArrayList()
val invitationEvents: MutableList<ProcessedEvent<InviteNotifiableEvent>> = ArrayList() val invitationEvents: MutableList<ProcessedEvent<InviteNotifiableEvent>> = ArrayList()
val fallbackEvents: MutableList<ProcessedEvent<FallbackNotifiableEvent>> = ArrayList()
forEach { forEach {
when (val event = it.event) { when (val event = it.event) {
is InviteNotifiableEvent -> invitationEvents.add(it.castedToEventType()) is InviteNotifiableEvent -> invitationEvents.add(it.castedToEventType())
@ -147,9 +171,12 @@ private fun List<ProcessedEvent<NotifiableEvent>>.groupByType(): GroupedNotifica
roomEvents.add(it.castedToEventType()) roomEvents.add(it.castedToEventType())
} }
is SimpleNotifiableEvent -> simpleEvents.add(it.castedToEventType()) is SimpleNotifiableEvent -> simpleEvents.add(it.castedToEventType())
is FallbackNotifiableEvent -> {
fallbackEvents.add(it.castedToEventType())
}
} }
} }
return GroupedNotificationEvents(roomIdToEventMap, simpleEvents, invitationEvents) return GroupedNotificationEvents(roomIdToEventMap, simpleEvents, invitationEvents, fallbackEvents)
} }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
@ -158,5 +185,6 @@ private fun <T : NotifiableEvent> ProcessedEvent<NotifiableEvent>.castedToEventT
data class GroupedNotificationEvents( data class GroupedNotificationEvents(
val roomEvents: Map<RoomId, List<ProcessedEvent<NotifiableMessageEvent>>>, val roomEvents: Map<RoomId, List<ProcessedEvent<NotifiableMessageEvent>>>,
val simpleEvents: List<ProcessedEvent<SimpleNotifiableEvent>>, val simpleEvents: List<ProcessedEvent<SimpleNotifiableEvent>>,
val invitationEvents: List<ProcessedEvent<InviteNotifiableEvent>> val invitationEvents: List<ProcessedEvent<InviteNotifiableEvent>>,
val fallbackEvents: List<ProcessedEvent<FallbackNotifiableEvent>>,
) )

4
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationState.kt

@ -39,8 +39,8 @@ class NotificationState(
) { ) {
fun <T> updateQueuedEvents( fun <T> updateQueuedEvents(
drawerManager: NotificationDrawerManager, drawerManager: DefaultNotificationDrawerManager,
action: NotificationDrawerManager.(NotificationEventQueue, List<ProcessedEvent<NotifiableEvent>>) -> T action: DefaultNotificationDrawerManager.(NotificationEventQueue, List<ProcessedEvent<NotifiableEvent>>) -> T
): T { ): T {
return synchronized(queuedEvents) { return synchronized(queuedEvents) {
action(drawerManager, queuedEvents, renderedEvents) action(drawerManager, queuedEvents, renderedEvents)

33
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt

@ -17,8 +17,12 @@
package io.element.android.libraries.push.impl.notifications package io.element.android.libraries.push.impl.notifications
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Typeface
import android.text.style.StyleSpan
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.Person import androidx.core.app.Person
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.R import io.element.android.libraries.push.impl.R
@ -26,8 +30,6 @@ import io.element.android.libraries.push.impl.notifications.debug.annotateForDeb
import io.element.android.libraries.push.impl.notifications.factories.NotificationFactory import io.element.android.libraries.push.impl.notifications.factories.NotificationFactory
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.services.toolbox.api.strings.StringProvider import io.element.android.services.toolbox.api.strings.StringProvider
import me.gujun.android.span.Span
import me.gujun.android.span.span
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -151,30 +153,31 @@ class RoomGroupMessageCreator @Inject constructor(
} }
} }
private fun createFirstMessageSummaryLine(event: NotifiableMessageEvent, roomName: String, roomIsDirect: Boolean): Span { private fun createFirstMessageSummaryLine(event: NotifiableMessageEvent, roomName: String, roomIsDirect: Boolean): CharSequence {
return if (roomIsDirect) { return if (roomIsDirect) {
span { buildSpannedString {
span { inSpans(StyleSpan(Typeface.BOLD)) {
textStyle = "bold" append(event.senderName)
+String.format("%s: ", event.senderName) append(": ")
} }
+(event.description) append(event.description)
} }
} else { } else {
span { buildSpannedString {
span { inSpans(StyleSpan(Typeface.BOLD)) {
textStyle = "bold" append(roomName)
+String.format("%s: %s ", roomName, event.senderName) append(": ")
event.senderName
append(" ")
} }
+(event.description) append(event.description)
} }
} }
} }
private suspend fun getRoomBitmap(events: List<NotifiableMessageEvent>): Bitmap? { private suspend fun getRoomBitmap(events: List<NotifiableMessageEvent>): Bitmap? {
// Use the last event (most recent?) // Use the last event (most recent?)
return events.lastOrNull() return events.reversed().firstNotNullOfOrNull { it.roomAvatarPath }
?.roomAvatarPath
?.let { bitmapLoader.getRoomBitmap(it) } ?.let { bitmapLoader.getRoomBitmap(it) }
} }
} }

2
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt

@ -49,12 +49,14 @@ class SummaryGroupMessageCreator @Inject constructor(
roomNotifications: List<RoomNotification.Message.Meta>, roomNotifications: List<RoomNotification.Message.Meta>,
invitationNotifications: List<OneShotNotification.Append.Meta>, invitationNotifications: List<OneShotNotification.Append.Meta>,
simpleNotifications: List<OneShotNotification.Append.Meta>, simpleNotifications: List<OneShotNotification.Append.Meta>,
fallbackNotifications: List<OneShotNotification.Append.Meta>,
useCompleteNotificationFormat: Boolean useCompleteNotificationFormat: Boolean
): Notification { ): Notification {
val summaryInboxStyle = NotificationCompat.InboxStyle().also { style -> val summaryInboxStyle = NotificationCompat.InboxStyle().also { style ->
roomNotifications.forEach { style.addLine(it.summaryLine.annotateForDebug(40)) } roomNotifications.forEach { style.addLine(it.summaryLine.annotateForDebug(40)) }
invitationNotifications.forEach { style.addLine(it.summaryLine.annotateForDebug(41)) } invitationNotifications.forEach { style.addLine(it.summaryLine.annotateForDebug(41)) }
simpleNotifications.forEach { style.addLine(it.summaryLine.annotateForDebug(42)) } simpleNotifications.forEach { style.addLine(it.summaryLine.annotateForDebug(42)) }
fallbackNotifications.forEach { style.addLine(it.summaryLine) }
} }
val summaryIsNoisy = roomNotifications.any { it.shouldBing } || val summaryIsNoisy = roomNotifications.any { it.shouldBing } ||

62
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationFactory.kt

@ -32,10 +32,9 @@ import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo
import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels
import io.element.android.libraries.push.impl.notifications.debug.annotateForDebug import io.element.android.libraries.push.impl.notifications.debug.annotateForDebug
import io.element.android.libraries.push.impl.notifications.factories.action.AcceptInvitationActionFactory
import io.element.android.libraries.push.impl.notifications.factories.action.MarkAsReadActionFactory import io.element.android.libraries.push.impl.notifications.factories.action.MarkAsReadActionFactory
import io.element.android.libraries.push.impl.notifications.factories.action.QuickReplyActionFactory import io.element.android.libraries.push.impl.notifications.factories.action.QuickReplyActionFactory
import io.element.android.libraries.push.impl.notifications.factories.action.RejectInvitationActionFactory import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import io.element.android.services.toolbox.api.strings.StringProvider import io.element.android.services.toolbox.api.strings.StringProvider
@ -49,8 +48,6 @@ class NotificationFactory @Inject constructor(
private val pendingIntentFactory: PendingIntentFactory, private val pendingIntentFactory: PendingIntentFactory,
private val markAsReadActionFactory: MarkAsReadActionFactory, private val markAsReadActionFactory: MarkAsReadActionFactory,
private val quickReplyActionFactory: QuickReplyActionFactory, private val quickReplyActionFactory: QuickReplyActionFactory,
private val rejectInvitationActionFactory: RejectInvitationActionFactory,
private val acceptInvitationActionFactory: AcceptInvitationActionFactory,
) { ) {
/** /**
* Create a notification for a Room. * Create a notification for a Room.
@ -154,22 +151,12 @@ class NotificationFactory @Inject constructor(
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.setSmallIcon(smallIcon) .setSmallIcon(smallIcon)
.setColor(accentColor) .setColor(accentColor)
.addAction(rejectInvitationActionFactory.create(inviteNotifiableEvent)) // TODO removed for now, will be added back later
.addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent)) // .addAction(rejectInvitationActionFactory.create(inviteNotifiableEvent))
// .addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent))
.apply { .apply {
/*
// Build the pending intent for when the notification is clicked // Build the pending intent for when the notification is clicked
val contentIntent = HomeActivity.newIntent( setContentIntent(pendingIntentFactory.createInviteListPendingIntent(inviteNotifiableEvent.sessionId))
context,
firstStartMainActivity = true,
inviteNotificationRoomId = inviteNotifiableEvent.roomId
)
contentIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
// pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that
contentIntent.data = createIgnoredUri(inviteNotifiableEvent.eventId)
setContentIntent(PendingIntent.getActivity(context, 0, contentIntent, PendingIntentCompat.FLAG_IMMUTABLE))
*/
if (inviteNotifiableEvent.noisy) { if (inviteNotifiableEvent.noisy) {
// Compat // Compat
@ -183,6 +170,12 @@ class NotificationFactory @Inject constructor(
} else { } else {
priority = NotificationCompat.PRIORITY_LOW priority = NotificationCompat.PRIORITY_LOW
} }
setDeleteIntent(
pendingIntentFactory.createDismissInvitePendingIntent(
inviteNotifiableEvent.sessionId,
inviteNotifiableEvent.roomId,
)
)
setAutoCancel(true) setAutoCancel(true)
} }
.build() .build()
@ -223,6 +216,39 @@ class NotificationFactory @Inject constructor(
.build() .build()
} }
fun createFallbackNotification(
fallbackNotifiableEvent: FallbackNotifiableEvent,
): Notification {
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
val smallIcon = R.drawable.ic_notification
val channelId = notificationChannels.getChannelIdForMessage(false)
return NotificationCompat.Builder(context, channelId)
.setOnlyAlertOnce(true)
.setContentTitle(buildMeta.applicationName.annotateForDebug(7))
.setContentText(fallbackNotifiableEvent.description.orEmpty().annotateForDebug(8))
.setGroup(fallbackNotifiableEvent.sessionId.value)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.setSmallIcon(smallIcon)
.setColor(accentColor)
.setAutoCancel(true)
// Ideally we'd use `createOpenRoomPendingIntent` here, but the broken notification might apply to an invite
// and the user won't have access to the room yet, resulting in an error screen.
.setContentIntent(pendingIntentFactory.createOpenSessionPendingIntent(fallbackNotifiableEvent.sessionId))
.setDeleteIntent(
pendingIntentFactory.createDismissEventPendingIntent(
fallbackNotifiableEvent.sessionId,
fallbackNotifiableEvent.roomId,
fallbackNotifiableEvent.eventId
)
)
.apply {
priority = NotificationCompat.PRIORITY_LOW
setAutoCancel(true)
}
.build()
}
/** /**
* Create the summary notification. * Create the summary notification.
*/ */

46
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/PendingIntentFactory.kt

@ -19,8 +19,10 @@ package io.element.android.libraries.push.impl.notifications.factories
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.core.app.PendingIntentCompat
import io.element.android.libraries.androidutils.uri.createIgnoredUri import io.element.android.libraries.androidutils.uri.createIgnoredUri
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
@ -39,19 +41,19 @@ class PendingIntentFactory @Inject constructor(
private val actionIds: NotificationActionIds, private val actionIds: NotificationActionIds,
) { ) {
fun createOpenSessionPendingIntent(sessionId: SessionId): PendingIntent? { fun createOpenSessionPendingIntent(sessionId: SessionId): PendingIntent? {
return createPendingIntent(sessionId = sessionId, roomId = null, threadId = null) return createRoomPendingIntent(sessionId = sessionId, roomId = null, threadId = null)
} }
fun createOpenRoomPendingIntent(sessionId: SessionId, roomId: RoomId): PendingIntent? { fun createOpenRoomPendingIntent(sessionId: SessionId, roomId: RoomId): PendingIntent? {
return createPendingIntent(sessionId = sessionId, roomId = roomId, threadId = null) return createRoomPendingIntent(sessionId = sessionId, roomId = roomId, threadId = null)
} }
fun createOpenThreadPendingIntent(roomInfo: RoomEventGroupInfo, threadId: ThreadId?): PendingIntent? { fun createOpenThreadPendingIntent(roomInfo: RoomEventGroupInfo, threadId: ThreadId?): PendingIntent? {
return createPendingIntent(sessionId = roomInfo.sessionId, roomId = roomInfo.roomId, threadId = threadId) return createRoomPendingIntent(sessionId = roomInfo.sessionId, roomId = roomInfo.roomId, threadId = threadId)
} }
private fun createPendingIntent(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?): PendingIntent? { private fun createRoomPendingIntent(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?): PendingIntent? {
val intent = intentProvider.getViewIntent(sessionId = sessionId, roomId = roomId, threadId = threadId) val intent = intentProvider.getViewRoomIntent(sessionId = sessionId, roomId = roomId, threadId = threadId)
return PendingIntent.getActivity( return PendingIntent.getActivity(
context, context,
clock.epochMillis().toInt(), clock.epochMillis().toInt(),
@ -87,6 +89,35 @@ class PendingIntentFactory @Inject constructor(
) )
} }
fun createDismissInvitePendingIntent(sessionId: SessionId, roomId: RoomId): PendingIntent {
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
intent.action = actionIds.dismissInvite
intent.data = createIgnoredUri("deleteInvite/$sessionId/$roomId")
intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId.value)
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId.value)
return PendingIntent.getBroadcast(
context,
clock.epochMillis().toInt(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
fun createDismissEventPendingIntent(sessionId: SessionId, roomId: RoomId, eventId: EventId): PendingIntent {
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
intent.action = actionIds.dismissEvent
intent.data = createIgnoredUri("deleteEvent/$sessionId/$roomId")
intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId.value)
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId.value)
intent.putExtra(NotificationBroadcastReceiver.KEY_EVENT_ID, eventId.value)
return PendingIntent.getBroadcast(
context,
clock.epochMillis().toInt(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
fun createTestPendingIntent(): PendingIntent? { fun createTestPendingIntent(): PendingIntent? {
val testActionIntent = Intent(context, TestNotificationReceiver::class.java) val testActionIntent = Intent(context, TestNotificationReceiver::class.java)
testActionIntent.action = actionIds.diagnostic testActionIntent.action = actionIds.diagnostic
@ -97,4 +128,9 @@ class PendingIntentFactory @Inject constructor(
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) )
} }
fun createInviteListPendingIntent(sessionId: SessionId): PendingIntent {
val intent = intentProvider.getInviteListIntent(sessionId)
return PendingIntentCompat.getActivity(context, 0, intent, 0, false)
}
} }

37
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/FallbackNotifiableEvent.kt

@ -0,0 +1,37 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications.model
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
/**
* Used for notifications with events that couldn't be retrieved or decrypted, so we don't know their contents.
* These are created separately from message notifications, so they can be displayed differently.
*/
data class FallbackNotifiableEvent(
override val sessionId: SessionId,
override val roomId: RoomId,
override val eventId: EventId,
override val editedEventId: EventId?,
override val description: String?,
override val canBeReplaced: Boolean,
override val isRedacted: Boolean,
override val isUpdated: Boolean,
val timestamp: Long,
) : NotifiableEvent

4
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/InviteNotifiableEvent.kt

@ -27,8 +27,8 @@ data class InviteNotifiableEvent(
override val canBeReplaced: Boolean, override val canBeReplaced: Boolean,
val roomName: String?, val roomName: String?,
val noisy: Boolean, val noisy: Boolean,
val title: String, val title: String?,
val description: String, override val description: String,
val type: String?, val type: String?,
val timestamp: Long, val timestamp: Long,
val soundName: String?, val soundName: String?,

1
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableEvent.kt

@ -29,6 +29,7 @@ sealed interface NotifiableEvent : Serializable {
val roomId: RoomId val roomId: RoomId
val eventId: EventId val eventId: EventId
val editedEventId: EventId? val editedEventId: EventId?
val description: String?
// Used to know if event should be replaced with the one coming from eventstream // Used to know if event should be replaced with the one coming from eventstream
val canBeReplaced: Boolean val canBeReplaced: Boolean

17
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt

@ -16,6 +16,8 @@
package io.element.android.libraries.push.impl.notifications.model package io.element.android.libraries.push.impl.notifications.model
import android.net.Uri import android.net.Uri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
@ -54,7 +56,7 @@ data class NotifiableMessageEvent(
) : NotifiableEvent { ) : NotifiableEvent {
val type: String = EventType.MESSAGE val type: String = EventType.MESSAGE
val description: String = body ?: "" override val description: String = body ?: ""
val title: String = senderName ?: "" val title: String = senderName ?: ""
// TODO EAx The image has to be downloaded and expose using the file provider. // TODO EAx The image has to be downloaded and expose using the file provider.
@ -64,12 +66,21 @@ data class NotifiableMessageEvent(
get() = imageUriString?.let { Uri.parse(it) } get() = imageUriString?.let { Uri.parse(it) }
} }
fun NotifiableMessageEvent.shouldIgnoreMessageEventInRoom( /**
* Used to check if a notification should be ignored based on the current app and navigation state.
*/
fun NotifiableEvent.shouldIgnoreEventInRoom(
appNavigationState: AppNavigationState? appNavigationState: AppNavigationState?
): Boolean { ): Boolean {
val currentSessionId = appNavigationState?.currentSessionId() ?: return false val currentSessionId = appNavigationState?.currentSessionId() ?: return false
return when (val currentRoomId = appNavigationState.currentRoomId()) { return when (val currentRoomId = appNavigationState.currentRoomId()) {
null -> false null -> false
else -> sessionId == currentSessionId && roomId == currentRoomId && threadId == appNavigationState.currentThreadId() else -> isAppInForeground
&& sessionId == currentSessionId
&& roomId == currentRoomId
&& (this as? NotifiableMessageEvent)?.threadId == appNavigationState.currentThreadId()
} }
} }
private val isAppInForeground: Boolean
get() = ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)

2
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/SimpleNotifiableEvent.kt

@ -26,7 +26,7 @@ data class SimpleNotifiableEvent(
override val editedEventId: EventId?, override val editedEventId: EventId?,
val noisy: Boolean, val noisy: Boolean,
val title: String, val title: String,
val description: String, override val description: String,
val type: String?, val type: String?,
val timestamp: Long, val timestamp: Long,
val soundName: String?, val soundName: String?,

10
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt

@ -31,7 +31,7 @@ import io.element.android.libraries.push.impl.PushersManager
import io.element.android.libraries.push.impl.log.pushLoggerTag import io.element.android.libraries.push.impl.log.pushLoggerTag
import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver
import io.element.android.libraries.push.impl.notifications.NotificationActionIds import io.element.android.libraries.push.impl.notifications.NotificationActionIds
import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager
import io.element.android.libraries.push.impl.store.DefaultPushDataStore import io.element.android.libraries.push.impl.store.DefaultPushDataStore
import io.element.android.libraries.pushproviders.api.PushData import io.element.android.libraries.pushproviders.api.PushData
import io.element.android.libraries.pushproviders.api.PushHandler import io.element.android.libraries.pushproviders.api.PushHandler
@ -48,7 +48,7 @@ private val loggerTag = LoggerTag("PushHandler", pushLoggerTag)
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultPushHandler @Inject constructor( class DefaultPushHandler @Inject constructor(
private val notificationDrawerManager: NotificationDrawerManager, private val defaultNotificationDrawerManager: DefaultNotificationDrawerManager,
private val notifiableEventResolver: NotifiableEventResolver, private val notifiableEventResolver: NotifiableEventResolver,
private val defaultPushDataStore: DefaultPushDataStore, private val defaultPushDataStore: DefaultPushDataStore,
private val userPushStoreFactory: UserPushStoreFactory, private val userPushStoreFactory: UserPushStoreFactory,
@ -121,9 +121,9 @@ class DefaultPushHandler @Inject constructor(
return return
} }
val notificationData = notifiableEventResolver.resolveEvent(userId, pushData.roomId, pushData.eventId) val notifiableEvent = notifiableEventResolver.resolveEvent(userId, pushData.roomId, pushData.eventId)
if (notificationData == null) { if (notifiableEvent == null) {
Timber.w("Unable to get a notification data") Timber.w("Unable to get a notification data")
return return
} }
@ -135,7 +135,7 @@ class DefaultPushHandler @Inject constructor(
return return
} }
notificationDrawerManager.onNotifiableEventReceived(notificationData) defaultNotificationDrawerManager.onNotifiableEventReceived(notifiableEvent)
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag(loggerTag.value).e(e, "## handleInternal() failed") Timber.tag(loggerTag.value).e(e, "## handleInternal() failed")
} }

2
libraries/push/impl/src/main/res/values/localazy.xml

@ -4,6 +4,7 @@
<string name="notification_channel_listening_for_events">"Listening for events"</string> <string name="notification_channel_listening_for_events">"Listening for events"</string>
<string name="notification_channel_noisy">"Noisy notifications"</string> <string name="notification_channel_noisy">"Noisy notifications"</string>
<string name="notification_channel_silent">"Silent notifications"</string> <string name="notification_channel_silent">"Silent notifications"</string>
<string name="notification_fallback_content">"Notification"</string>
<string name="notification_inline_reply_failed">"** Failed to send - please open room"</string> <string name="notification_inline_reply_failed">"** Failed to send - please open room"</string>
<string name="notification_invitation_action_join">"Join"</string> <string name="notification_invitation_action_join">"Join"</string>
<string name="notification_invitation_action_reject">"Reject"</string> <string name="notification_invitation_action_reject">"Reject"</string>
@ -47,6 +48,5 @@
<string name="push_distributor_background_sync_android">"Background synchronization"</string> <string name="push_distributor_background_sync_android">"Background synchronization"</string>
<string name="push_distributor_firebase_android">"Google Services"</string> <string name="push_distributor_firebase_android">"Google Services"</string>
<string name="push_no_valid_google_play_services_apk_android">"No valid Google Play Services found. Notifications may not work properly."</string> <string name="push_no_valid_google_play_services_apk_android">"No valid Google Play Services found. Notifications may not work properly."</string>
<string name="notification_fallback_content">"Notification"</string>
<string name="notification_room_action_quick_reply">"Quick reply"</string> <string name="notification_room_action_quick_reply">"Quick reply"</string>
</resources> </resources>

2
libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessorTest.kt

@ -120,6 +120,7 @@ class NotifiableEventProcessorTest {
@Test @Test
fun `given viewing the same room main timeline when processing main timeline message event then removes message`() { fun `given viewing the same room main timeline when processing main timeline message event then removes message`() {
val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = null)) val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = null))
events.forEach { outdatedDetector.givenEventIsOutOfDate(it) }
val result = eventProcessor.process(events, VIEWING_A_ROOM, renderedEvents = emptyList()) val result = eventProcessor.process(events, VIEWING_A_ROOM, renderedEvents = emptyList())
@ -133,6 +134,7 @@ class NotifiableEventProcessorTest {
@Test @Test
fun `given viewing the same thread timeline when processing thread message event then removes message`() { fun `given viewing the same thread timeline when processing thread message event then removes message`() {
val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = A_THREAD_ID)) val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = A_THREAD_ID))
events.forEach { outdatedDetector.givenEventIsOutOfDate(it) }
val result = eventProcessor.process(events, VIEWING_A_THREAD, renderedEvents = emptyList()) val result = eventProcessor.process(events, VIEWING_A_THREAD, renderedEvents = emptyList())

2
libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueueTest.kt

@ -208,7 +208,7 @@ class NotificationEventQueueTest {
) )
) )
queue.clearMemberShipNotificationForRoom(A_SESSION_ID, A_ROOM_ID) queue.clearMembershipNotificationForRoom(A_SESSION_ID, A_ROOM_ID)
assertThat(queue.rawEvents()).isEqualTo(listOf(aNotifiableMessageEvent(roomId = A_ROOM_ID))) assertThat(queue.rawEvents()).isEqualTo(listOf(aNotifiableMessageEvent(roomId = A_ROOM_ID)))
} }

6
libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt

@ -33,7 +33,7 @@ private const val MY_USER_AVATAR_URL = "avatar-url"
private const val USE_COMPLETE_NOTIFICATION_FORMAT = true private const val USE_COMPLETE_NOTIFICATION_FORMAT = true
private val AN_EVENT_LIST = listOf<ProcessedEvent<NotifiableEvent>>() private val AN_EVENT_LIST = listOf<ProcessedEvent<NotifiableEvent>>()
private val A_PROCESSED_EVENTS = GroupedNotificationEvents(emptyMap(), emptyList(), emptyList()) private val A_PROCESSED_EVENTS = GroupedNotificationEvents(emptyMap(), emptyList(), emptyList(), emptyList())
private val A_SUMMARY_NOTIFICATION = SummaryNotification.Update(mockk()) private val A_SUMMARY_NOTIFICATION = SummaryNotification.Update(mockk())
private val A_REMOVE_SUMMARY_NOTIFICATION = SummaryNotification.Removed private val A_REMOVE_SUMMARY_NOTIFICATION = SummaryNotification.Removed
private val A_NOTIFICATION = mockk<Notification>() private val A_NOTIFICATION = mockk<Notification>()
@ -202,13 +202,14 @@ class NotificationRendererTest {
} }
private fun givenNoNotifications() { private fun givenNoNotifications() {
givenNotifications(emptyList(), emptyList(), emptyList(), USE_COMPLETE_NOTIFICATION_FORMAT, A_REMOVE_SUMMARY_NOTIFICATION) givenNotifications(emptyList(), emptyList(), emptyList(), emptyList(), USE_COMPLETE_NOTIFICATION_FORMAT, A_REMOVE_SUMMARY_NOTIFICATION)
} }
private fun givenNotifications( private fun givenNotifications(
roomNotifications: List<RoomNotification> = emptyList(), roomNotifications: List<RoomNotification> = emptyList(),
invitationNotifications: List<OneShotNotification> = emptyList(), invitationNotifications: List<OneShotNotification> = emptyList(),
simpleNotifications: List<OneShotNotification> = emptyList(), simpleNotifications: List<OneShotNotification> = emptyList(),
fallbackNotifications: List<OneShotNotification> = emptyList(),
useCompleteNotificationFormat: Boolean = USE_COMPLETE_NOTIFICATION_FORMAT, useCompleteNotificationFormat: Boolean = USE_COMPLETE_NOTIFICATION_FORMAT,
summaryNotification: SummaryNotification = A_SUMMARY_NOTIFICATION summaryNotification: SummaryNotification = A_SUMMARY_NOTIFICATION
) { ) {
@ -219,6 +220,7 @@ class NotificationRendererTest {
roomNotifications = roomNotifications, roomNotifications = roomNotifications,
invitationNotifications = invitationNotifications, invitationNotifications = invitationNotifications,
simpleNotifications = simpleNotifications, simpleNotifications = simpleNotifications,
fallbackNotifications = fallbackNotifications,
summaryNotification = summaryNotification summaryNotification = summaryNotification
) )
} }

3
libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationFactory.kt

@ -36,12 +36,14 @@ class FakeNotificationFactory {
roomNotifications: List<RoomNotification>, roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>, invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>, simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
summaryNotification: SummaryNotification summaryNotification: SummaryNotification
) { ) {
with(instance) { with(instance) {
coEvery { groupedEvents.roomEvents.toNotifications(matrixUser) } returns roomNotifications coEvery { groupedEvents.roomEvents.toNotifications(matrixUser) } returns roomNotifications
every { groupedEvents.invitationEvents.toNotifications() } returns invitationNotifications every { groupedEvents.invitationEvents.toNotifications() } returns invitationNotifications
every { groupedEvents.simpleEvents.toNotifications() } returns simpleNotifications every { groupedEvents.simpleEvents.toNotifications() } returns simpleNotifications
every { groupedEvents.fallbackEvents.toNotifications() } returns fallbackNotifications
every { every {
createSummaryNotification( createSummaryNotification(
@ -49,6 +51,7 @@ class FakeNotificationFactory {
roomNotifications, roomNotifications,
invitationNotifications, invitationNotifications,
simpleNotifications, simpleNotifications,
fallbackNotifications,
useCompleteNotificationFormat useCompleteNotificationFormat
) )
} returns summaryNotification } returns summaryNotification

29
libraries/push/test/build.gradle.kts

@ -0,0 +1,29 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.push.test"
}
dependencies {
api(projects.libraries.push.api)
implementation(projects.libraries.matrix.api)
implementation(projects.tests.testutils)
}

48
libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationDrawerManager.kt

@ -0,0 +1,48 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.test.notifications
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
class FakeNotificationDrawerManager : NotificationDrawerManager {
private val clearMemberShipNotificationForSessionCallsCount = mutableMapOf<String, Int>()
private val clearMemberShipNotificationForRoomCallsCount = mutableMapOf<String, Int>()
override fun clearMembershipNotificationForSession(sessionId: SessionId) {
clearMemberShipNotificationForSessionCallsCount.merge(sessionId.value, 1) { oldValue, value -> oldValue + value }
}
override fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) {
val key = getMembershipNotificationKey(sessionId, roomId)
clearMemberShipNotificationForRoomCallsCount.merge(key, 1) { oldValue, value -> oldValue + value }
}
fun getClearMembershipNotificationForSessionCount(sessionId: SessionId): Int {
return clearMemberShipNotificationForRoomCallsCount[sessionId.value] ?: 0
}
fun getClearMembershipNotificationForRoomCount(sessionId: SessionId, roomId: RoomId): Int {
val key = getMembershipNotificationKey(sessionId, roomId)
return clearMemberShipNotificationForRoomCallsCount[key] ?: 0
}
private fun getMembershipNotificationKey(sessionId: SessionId, roomId: RoomId): String {
return "$sessionId-$roomId"
}
}

2
settings.gradle.kts

@ -36,8 +36,6 @@ dependencyResolutionManagement {
includeModule("com.github.matrix-org", "matrix-analytics-events") includeModule("com.github.matrix-org", "matrix-analytics-events")
} }
} }
//noinspection JcenterRepositoryObsolete
jcenter()
flatDir { flatDir {
dirs("libraries/matrix/libs") dirs("libraries/matrix/libs")
} }

Loading…
Cancel
Save