diff --git a/core/src/main/kotlin/app/dapk/st/core/extensions/MapExtensions.kt b/core/src/main/kotlin/app/dapk/st/core/extensions/MapExtensions.kt index b13a085..5a59d12 100644 --- a/core/src/main/kotlin/app/dapk/st/core/extensions/MapExtensions.kt +++ b/core/src/main/kotlin/app/dapk/st/core/extensions/MapExtensions.kt @@ -1,3 +1,8 @@ package app.dapk.st.core.extensions -fun Map?.containsKey(key: K) = this?.containsKey(key) ?: false \ No newline at end of file +fun Map?.containsKey(key: K) = this?.containsKey(key) ?: false + +fun MutableMap.clearAndPutAll(input: Map) { + this.clear() + this.putAll(input) +} \ No newline at end of file diff --git a/features/notifications/build.gradle b/features/notifications/build.gradle index 5dd0cdf..087828e 100644 --- a/features/notifications/build.gradle +++ b/features/notifications/build.gradle @@ -14,4 +14,10 @@ dependencies { implementation platform('com.google.firebase:firebase-bom:29.0.3') implementation 'com.google.firebase:firebase-messaging' + + kotlinTest(it) + + androidImportFixturesWorkaround(project, project(":core")) + androidImportFixturesWorkaround(project, project(":matrix:common")) + androidImportFixturesWorkaround(project, project(":matrix:services:sync")) } \ No newline at end of file diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsModule.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsModule.kt index e70350b..c49181e 100644 --- a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsModule.kt +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsModule.kt @@ -30,8 +30,8 @@ class NotificationsModule( fun firebasePushTokenUseCase() = firebasePushTokenUseCase fun roomStore() = roomStore fun notificationsUseCase() = NotificationsUseCase( - roomStore, NotificationRenderer(notificationManager(), NotificationFactory(iconLoader, context, intentFactory)), + ObserveUnreadNotificationsUseCaseImpl(roomStore), NotificationChannels(notificationManager()), ) diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsUseCase.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsUseCase.kt index 7806574..991947f 100644 --- a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsUseCase.kt +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsUseCase.kt @@ -2,78 +2,33 @@ package app.dapk.st.notifications import app.dapk.st.core.AppLogTag.NOTIFICATION import app.dapk.st.core.log -import app.dapk.st.matrix.common.EventId -import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.matrix.sync.RoomOverview -import app.dapk.st.matrix.sync.RoomStore -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.onEach class NotificationsUseCase( - private val roomStore: RoomStore, private val notificationRenderer: NotificationRenderer, + private val observeRenderableUnreadEventsUseCase: ObserveUnreadNotificationsUseCase, notificationChannels: NotificationChannels, ) { - private val inferredCurrentNotifications = mutableMapOf>() - private var previousUnreadEvents: Map>? = null - init { notificationChannels.initChannels() } - data class NotificationDiff( - val unchanged: Map>, - val changedOrNew: Map>, - val removed: Map> - ) - suspend fun listenForNotificationChanges() { - roomStore.observeUnread() - .map { each -> - val allUnreadIds = each.toIds() - val notificationDiff = calculateDiff(allUnreadIds, previousUnreadEvents) - previousUnreadEvents = allUnreadIds - each to notificationDiff - } - .skipFirst() - .onEach { (each, diff) -> - when { - diff.changedOrNew.isEmpty() && diff.removed.isEmpty() -> { - log(NOTIFICATION, "Ignoring unread change due to no renderable changes") - } - inferredCurrentNotifications.isEmpty() && diff.removed.isNotEmpty() -> { - log(NOTIFICATION, "Ignoring unread change due to no currently showing messages and changes are all messages marked as read") - } - else -> renderUnreadChange(each, diff) - } - } + observeRenderableUnreadEventsUseCase() + .onEach { (each, diff) -> renderUnreadChange(each, diff) } .collect() } - private fun calculateDiff(allUnread: Map>, previousUnread: Map>?): NotificationDiff { - val unchanged = previousUnread?.filter { allUnread.containsKey(it.key) && it.value == allUnread[it.key] } ?: emptyMap() - val changedOrNew = allUnread.filterNot { unchanged.containsKey(it.key) } - val removed = previousUnread?.filter { !unchanged.containsKey(it.key) } ?: emptyMap() - return NotificationDiff(unchanged, changedOrNew, removed) - } - private suspend fun renderUnreadChange(allUnread: Map>, diff: NotificationDiff) { log(NOTIFICATION, "unread changed - render notifications") - inferredCurrentNotifications.clear() - inferredCurrentNotifications.putAll(allUnread.mapKeys { it.key.roomId }) - notificationRenderer.render( allUnread = allUnread, removedRooms = diff.removed.keys, roomsWithNewEvents = diff.changedOrNew.keys ) } - - private fun Flow.skipFirst() = drop(1) } - -private fun List.toEventIds() = this.map { it.eventId } -private fun Map>.toIds() = this - .mapValues { it.value.toEventIds() } - .mapKeys { it.key.roomId } diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/ObserveUnreadNotificationsUseCaseImpl.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/ObserveUnreadNotificationsUseCaseImpl.kt new file mode 100644 index 0000000..fea6809 --- /dev/null +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/ObserveUnreadNotificationsUseCaseImpl.kt @@ -0,0 +1,75 @@ +package app.dapk.st.notifications + +import app.dapk.st.core.AppLogTag +import app.dapk.st.core.extensions.clearAndPutAll +import app.dapk.st.core.log +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.matrix.sync.RoomOverview +import app.dapk.st.matrix.sync.RoomStore +import kotlinx.coroutines.flow.* + +typealias UnreadNotifications = Pair>, NotificationDiff> +internal typealias ObserveUnreadNotificationsUseCase = suspend () -> Flow + +class ObserveUnreadNotificationsUseCaseImpl(private val roomStore: RoomStore) : ObserveUnreadNotificationsUseCase { + + override suspend fun invoke(): Flow { + return roomStore.observeUnread() + .mapWithDiff() + .avoidShowingPreviousNotificationsOnLaunch() + .onlyRenderableChanges() + } + +} + +private fun Flow.onlyRenderableChanges(): Flow { + val inferredCurrentNotifications = mutableMapOf>() + return this + .filter { (_, diff) -> + when { + diff.changedOrNew.isEmpty() && diff.removed.isEmpty() -> { + log(AppLogTag.NOTIFICATION, "Ignoring unread change due to no renderable changes") + false + } + inferredCurrentNotifications.isEmpty() && diff.removed.isNotEmpty() -> { + log(AppLogTag.NOTIFICATION, "Ignoring unread change due to no currently showing messages and changes are all messages marked as read") + false + } + else -> true + } + } + .onEach { (allUnread, _) -> inferredCurrentNotifications.clearAndPutAll(allUnread.mapKeys { it.key.roomId }) } +} + +private fun Flow>>.mapWithDiff(): Flow>, NotificationDiff>> { + val previousUnreadEvents = mutableMapOf>() + return this.map { each -> + val allUnreadIds = each.toIds() + val notificationDiff = calculateDiff(allUnreadIds, previousUnreadEvents) + previousUnreadEvents.clearAndPutAll(allUnreadIds) + each to notificationDiff + } +} + +private fun calculateDiff(allUnread: Map>, previousUnread: Map>?): NotificationDiff { + val unchanged = previousUnread?.filter { allUnread.containsKey(it.key) && it.value == allUnread[it.key] } ?: emptyMap() + val changedOrNew = allUnread.filterNot { unchanged.containsKey(it.key) } + val removed = previousUnread?.filter { !allUnread.containsKey(it.key) } ?: emptyMap() + return NotificationDiff(unchanged, changedOrNew, removed) +} + +private fun List.toEventIds() = this.map { it.eventId } + +private fun Map>.toIds() = this + .mapValues { it.value.toEventIds() } + .mapKeys { it.key.roomId } + +private fun Flow.avoidShowingPreviousNotificationsOnLaunch() = drop(1) + +data class NotificationDiff( + val unchanged: Map>, + val changedOrNew: Map>, + val removed: Map> +) diff --git a/features/notifications/src/test/kotlin/app/dapk/st/notifications/ObserveUnreadNotificationsUseCaseTest.kt b/features/notifications/src/test/kotlin/app/dapk/st/notifications/ObserveUnreadNotificationsUseCaseTest.kt new file mode 100644 index 0000000..c817089 --- /dev/null +++ b/features/notifications/src/test/kotlin/app/dapk/st/notifications/ObserveUnreadNotificationsUseCaseTest.kt @@ -0,0 +1,102 @@ +package app.dapk.st.notifications + +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.matrix.sync.RoomOverview +import fake.FakeRoomStore +import fixture.aRoomId +import fixture.aRoomMessageEvent +import fixture.aRoomOverview +import fixture.anEventId +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test + +private val NO_UNREADS = emptyMap>() +val A_MESSAGE = aRoomMessageEvent(eventId = anEventId("1"), content = "hello") +val A_MESSAGE_2 = aRoomMessageEvent(eventId = anEventId("2"), content = "world") +val A_ROOM_OVERVIEW = aRoomOverview(roomId = aRoomId("1")) +val A_ROOM_OVERVIEW_2 = aRoomOverview(roomId = aRoomId("2")) + +class ObserveUnreadNotificationsUseCaseTest { + + private val fakeRoomStore = FakeRoomStore() + + private val useCase = ObserveUnreadNotificationsUseCaseImpl(fakeRoomStore) + + @Test + fun `given no initial unreads, when receiving new message, then emits message`() = runTest { + givenNoInitialUnreads(A_ROOM_OVERVIEW.withUnreads(A_MESSAGE)) + + val result = useCase.invoke().toList() + + result shouldBeEqualTo listOf( + A_ROOM_OVERVIEW.withUnreads(A_MESSAGE) to aNotificationDiff(changedOrNew = A_ROOM_OVERVIEW.toDiff(A_MESSAGE)) + ) + } + + @Test + fun `given no initial unreads, when receiving multiple messages, then emits messages`() = runTest { + givenNoInitialUnreads(A_ROOM_OVERVIEW.withUnreads(A_MESSAGE), A_ROOM_OVERVIEW.withUnreads(A_MESSAGE, A_MESSAGE_2)) + + val result = useCase.invoke().toList() + + result shouldBeEqualTo listOf( + A_ROOM_OVERVIEW.withUnreads(A_MESSAGE) to aNotificationDiff(changedOrNew = A_ROOM_OVERVIEW.toDiff(A_MESSAGE)), + A_ROOM_OVERVIEW.withUnreads(A_MESSAGE, A_MESSAGE_2) to aNotificationDiff(changedOrNew = A_ROOM_OVERVIEW.toDiff(A_MESSAGE, A_MESSAGE_2)) + ) + } + + @Test + fun `given initial unreads, when receiving new message, then emits all messages`() = runTest { + fakeRoomStore.givenUnreadEvents( + flowOf(A_ROOM_OVERVIEW.withUnreads(A_MESSAGE), A_ROOM_OVERVIEW.withUnreads(A_MESSAGE, A_MESSAGE_2)) + ) + + val result = useCase.invoke().toList() + + result shouldBeEqualTo listOf( + A_ROOM_OVERVIEW.withUnreads(A_MESSAGE, A_MESSAGE_2) to aNotificationDiff(changedOrNew = A_ROOM_OVERVIEW.toDiff(A_MESSAGE, A_MESSAGE_2)) + ) + } + + @Test + fun `given initial unreads, when reading a message, then emits nothing`() = runTest { + fakeRoomStore.givenUnreadEvents( + flowOf(A_ROOM_OVERVIEW.withUnreads(A_MESSAGE) + A_ROOM_OVERVIEW_2.withUnreads(A_MESSAGE_2), A_ROOM_OVERVIEW.withUnreads(A_MESSAGE)) + ) + + val result = useCase.invoke().toList() + + result shouldBeEqualTo emptyList() + } + + @Test + fun `given initial unreads, when reading a duplicate unread, then emits nothing`() = runTest { + fakeRoomStore.givenUnreadEvents( + flowOf(A_ROOM_OVERVIEW.withUnreads(A_MESSAGE), A_ROOM_OVERVIEW.withUnreads(A_MESSAGE)) + ) + + val result = useCase.invoke().toList() + + result shouldBeEqualTo emptyList() + } + + private fun givenNoInitialUnreads(vararg unreads: Map>) { + fakeRoomStore.givenUnreadEvents( + flowOf(NO_UNREADS, *unreads) + ) + } +} + +private fun aNotificationDiff( + unchanged: Map> = emptyMap(), + changedOrNew: Map> = emptyMap(), + removed: Map> = emptyMap() +) = NotificationDiff(unchanged, changedOrNew, removed) + +private fun RoomOverview.withUnreads(vararg events: RoomEvent) = mapOf(this to events.toList()) +private fun RoomOverview.toDiff(vararg events: RoomEvent) = mapOf(this.roomId to events.map { it.eventId }) diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt index a79bffd..fd405f1 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt @@ -63,7 +63,7 @@ internal class DefaultSyncService( EventLookupUseCase(roomStore) ), RoomOverviewProcessor(roomMembersService), - UnreadEventsUseCase(roomStore, logger), + UnreadEventsProcessor(roomStore, logger), EphemeralEventsUseCase(roomMembersService, syncEventsFlow), ), roomRefresher, diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomProcessor.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomProcessor.kt index a20488e..ede6715 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomProcessor.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomProcessor.kt @@ -13,7 +13,7 @@ internal class RoomProcessor( private val roomDataSource: RoomDataSource, private val timelineEventsProcessor: TimelineEventsProcessor, private val roomOverviewProcessor: RoomOverviewProcessor, - private val unreadEventsUseCase: UnreadEventsUseCase, + private val unreadEventsProcessor: UnreadEventsProcessor, private val ephemeralEventsUseCase: EphemeralEventsUseCase, ) { @@ -29,7 +29,7 @@ internal class RoomProcessor( ) val overview = createRoomOverview(distinctEvents, roomToProcess, previousState) - unreadEventsUseCase.processUnreadState(overview, previousState?.roomOverview, newEvents, roomToProcess.userCredentials.userId, isInitialSync) + unreadEventsProcessor.processUnreadState(overview, previousState?.roomOverview, newEvents, roomToProcess.userCredentials.userId, isInitialSync) return RoomState(overview, distinctEvents).also { roomDataSource.persist(roomToProcess.roomId, previousState, it) diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsUseCase.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsProcessor.kt similarity index 97% rename from matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsUseCase.kt rename to matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsProcessor.kt index c93f662..b4f82a7 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsUseCase.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsProcessor.kt @@ -8,7 +8,7 @@ import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.matrix.sync.RoomOverview import app.dapk.st.matrix.sync.RoomStore -internal class UnreadEventsUseCase( +internal class UnreadEventsProcessor( private val roomStore: RoomStore, private val logger: MatrixLogger, ) { diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsUseCaseTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsProcessorTest.kt similarity index 88% rename from matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsUseCaseTest.kt rename to matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsProcessorTest.kt index eafbcf7..9fd79c2 100644 --- a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsUseCaseTest.kt +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsProcessorTest.kt @@ -14,18 +14,18 @@ private val A_ROOM_MESSAGE_FROM_OTHER = aRoomMessageEvent( author = aRoomMember(id = aUserId("a-different-user")) ) -internal class UnreadEventsUseCaseTest { +internal class UnreadEventsProcessorTest { private val fakeRoomStore = FakeRoomStore() - private val unreadEventsUseCase = UnreadEventsUseCase( + private val unreadEventsProcessor = UnreadEventsProcessor( fakeRoomStore, FakeMatrixLogger() ) @Test fun `given initial sync when processing unread then does mark any events as unread`() = runTest { - unreadEventsUseCase.processUnreadState( + unreadEventsProcessor.processUnreadState( isInitialSync = true, overview = aRoomOverview(), previousState = null, @@ -40,7 +40,7 @@ internal class UnreadEventsUseCaseTest { fun `given read marker has changed when processing unread then marks room read`() = runTest { fakeRoomStore.expect { it.markRead(RoomId(any())) } - unreadEventsUseCase.processUnreadState( + unreadEventsProcessor.processUnreadState( isInitialSync = false, overview = A_ROOM_OVERVIEW.copy(readMarker = anEventId("an-updated-marker")), previousState = A_ROOM_OVERVIEW, @@ -55,7 +55,7 @@ internal class UnreadEventsUseCaseTest { fun `given new events from other users when processing unread then inserts events as unread`() = runTest { fakeRoomStore.expect { it.insertUnread(RoomId(any()), any()) } - unreadEventsUseCase.processUnreadState( + unreadEventsProcessor.processUnreadState( isInitialSync = false, overview = A_ROOM_OVERVIEW, previousState = null, diff --git a/matrix/services/sync/src/testFixtures/kotlin/fake/FakeRoomStore.kt b/matrix/services/sync/src/testFixtures/kotlin/fake/FakeRoomStore.kt index 6324cf6..7719a53 100644 --- a/matrix/services/sync/src/testFixtures/kotlin/fake/FakeRoomStore.kt +++ b/matrix/services/sync/src/testFixtures/kotlin/fake/FakeRoomStore.kt @@ -3,10 +3,13 @@ package fake import app.dapk.st.matrix.common.EventId import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.matrix.sync.RoomOverview import app.dapk.st.matrix.sync.RoomStore import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.flow.Flow class FakeRoomStore : RoomStore by mockk() { @@ -27,4 +30,8 @@ class FakeRoomStore : RoomStore by mockk() { coEvery { findEvent(eventId) } returns result } + fun givenUnreadEvents(unreadEvents: Flow>>) { + every { observeUnread() } returns unreadEvents + } + } \ No newline at end of file