rendering invites as notification which temporarily open the app on tap

This commit is contained in:
Adam Brown 2022-09-11 11:41:28 +01:00 committed by Adam Brown
parent 4b850af810
commit e773bc35c4
18 changed files with 229 additions and 42 deletions

View File

@ -60,7 +60,7 @@ class SmallTalkApplication : Application(), ModuleProvider {
applicationScope.launch {
val notificationsUseCase = notificationsModule.notificationsUseCase()
notificationsUseCase.listenForNotificationChanges()
notificationsUseCase.listenForNotificationChanges(this)
}
}

View File

@ -199,6 +199,7 @@ internal class FeatureModules internal constructor(
NotificationsModule(
imageLoaderModule.iconLoader(),
storeModule.value.roomStore(),
storeModule.value.overviewStore(),
context,
intentFactory = coreAndroidModule.intentFactory(),
dispatchers = coroutineDispatchers,

View File

@ -32,6 +32,8 @@ class AndroidNotificationBuilder(
.apply { setGroupSummary(notification.isGroupSummary) }
.ifNotNull(notification.groupId) { setGroup(it) }
.ifNotNull(notification.messageStyle) { style = it.build(notificationStyleBuilder) }
.ifNotNull(notification.contentTitle) { setContentTitle(it) }
.ifNotNull(notification.contentText) { setContentText(it) }
.ifNotNull(notification.contentIntent) { setContentIntent(it) }
.ifNotNull(notification.whenTimestamp) {
setShowWhen(true)
@ -65,6 +67,8 @@ data class AndroidNotification(
val shortcutId: String? = null,
val alertMoreThanOnce: Boolean,
val contentIntent: PendingIntent? = null,
val contentTitle: String? = null,
val contentText: String? = null,
val messageStyle: AndroidNotificationStyle? = null,
val category: String? = null,
val smallIcon: Int? = null,

View File

@ -8,6 +8,7 @@ import android.os.Build
const val DIRECT_CHANNEL_ID = "direct_channel_id"
const val GROUP_CHANNEL_ID = "group_channel_id"
const val SUMMARY_CHANNEL_ID = "summary_channel_id"
const val INVITE_CHANNEL_ID = "invite_channel_id"
private const val CHATS_NOTIFICATION_GROUP_ID = "chats_notification_group"
@ -45,6 +46,18 @@ class NotificationChannels(
)
}
if (notificationManager.getNotificationChannel(INVITE_CHANNEL_ID) == null) {
notificationManager.createNotificationChannel(
NotificationChannel(
INVITE_CHANNEL_ID,
"Invite notifications",
NotificationManager.IMPORTANCE_DEFAULT,
).also {
it.group = CHATS_NOTIFICATION_GROUP_ID
}
)
}
if (notificationManager.getNotificationChannel(SUMMARY_CHANNEL_ID) == null) {
notificationManager.createNotificationChannel(
NotificationChannel(

View File

@ -8,6 +8,7 @@ import app.dapk.st.imageloader.IconLoader
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.sync.RoomOverview
import app.dapk.st.navigator.IntentFactory
import java.time.Clock
private const val GROUP_ID = "st"
@ -17,6 +18,7 @@ class NotificationFactory(
private val intentFactory: IntentFactory,
private val iconLoader: IconLoader,
private val deviceMeta: DeviceMeta,
private val clock: Clock,
) {
private val shouldAlwaysAlertDms = true
@ -84,6 +86,21 @@ class NotificationFactory(
category = Notification.CATEGORY_MESSAGE,
)
}
fun createInvite(inviteNotification: InviteNotification): AndroidNotification {
val openAppIntent = intentFactory.notificationOpenApp(context)
return AndroidNotification(
channelId = INVITE_CHANNEL_ID,
smallIcon = R.drawable.ic_notification_small_icon,
whenTimestamp = clock.millis(),
alertMoreThanOnce = true,
contentTitle = "Invite",
contentText = inviteNotification.content,
contentIntent = openAppIntent,
category = Notification.CATEGORY_EVENT,
autoCancel = true,
)
}
}
private fun List<NotificationTypes.Room>.mostRecent() = this.sortedBy { it.notification.whenTimestamp }.first()

View File

@ -0,0 +1,25 @@
package app.dapk.st.notifications
import android.app.NotificationManager
private const val INVITE_NOTIFICATION_ID = 103
class NotificationInviteRenderer(
private val notificationManager: NotificationManager,
private val notificationFactory: NotificationFactory,
private val androidNotificationBuilder: AndroidNotificationBuilder,
) {
fun render(inviteNotification: InviteNotification) {
notificationManager.notify(
inviteNotification.roomId.value,
INVITE_NOTIFICATION_ID,
inviteNotification.toAndroidNotification()
)
}
private fun InviteNotification.toAndroidNotification() = androidNotificationBuilder.build(
notificationFactory.createInvite(this)
)
}

View File

@ -13,7 +13,7 @@ import kotlinx.coroutines.withContext
private const val SUMMARY_NOTIFICATION_ID = 101
private const val MESSAGE_NOTIFICATION_ID = 100
class NotificationRenderer(
class NotificationMessageRenderer(
private val notificationManager: NotificationManager,
private val notificationStateMapper: NotificationStateMapper,
private val androidNotificationBuilder: AndroidNotificationBuilder,

View File

@ -6,37 +6,46 @@ import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.DeviceMeta
import app.dapk.st.core.ProvidableModule
import app.dapk.st.imageloader.IconLoader
import app.dapk.st.matrix.sync.OverviewStore
import app.dapk.st.matrix.sync.RoomStore
import app.dapk.st.navigator.IntentFactory
import java.time.Clock
class NotificationsModule(
private val iconLoader: IconLoader,
private val roomStore: RoomStore,
private val overviewStore: OverviewStore,
private val context: Context,
private val intentFactory: IntentFactory,
private val dispatchers: CoroutineDispatchers,
private val deviceMeta: DeviceMeta,
) : ProvidableModule {
fun notificationsUseCase() = RenderNotificationsUseCase(
notificationRenderer = NotificationRenderer(
notificationManager(),
NotificationStateMapper(
RoomEventsToNotifiableMapper(),
NotificationFactory(
context,
NotificationStyleFactory(iconLoader, deviceMeta),
intentFactory,
iconLoader,
deviceMeta,
)
),
AndroidNotificationBuilder(context, deviceMeta, AndroidNotificationStyleBuilder()),
fun notificationsUseCase(): RenderNotificationsUseCase {
val notificationManager = notificationManager()
val androidNotificationBuilder = AndroidNotificationBuilder(context, deviceMeta, AndroidNotificationStyleBuilder())
val notificationFactory = NotificationFactory(
context,
NotificationStyleFactory(iconLoader, deviceMeta),
intentFactory,
iconLoader,
deviceMeta,
Clock.systemUTC(),
)
val notificationMessageRenderer = NotificationMessageRenderer(
notificationManager,
NotificationStateMapper(RoomEventsToNotifiableMapper(), notificationFactory),
androidNotificationBuilder,
dispatchers
),
observeRenderableUnreadEventsUseCase = ObserveUnreadNotificationsUseCaseImpl(roomStore),
notificationChannels = NotificationChannels(notificationManager()),
)
)
return RenderNotificationsUseCase(
notificationRenderer = notificationMessageRenderer,
observeRenderableUnreadEventsUseCase = ObserveUnreadNotificationsUseCaseImpl(roomStore),
notificationChannels = NotificationChannels(notificationManager),
observeInviteNotificationsUseCase = ObserveInviteNotificationsUseCaseImpl(overviewStore),
inviteRenderer = NotificationInviteRenderer(notificationManager, notificationFactory, androidNotificationBuilder)
)
}
private fun notificationManager() = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

View File

@ -0,0 +1,49 @@
package app.dapk.st.notifications
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.sync.InviteMeta
import app.dapk.st.matrix.sync.OverviewStore
import app.dapk.st.matrix.sync.RoomInvite
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.*
internal typealias ObserveInviteNotificationsUseCase = suspend () -> Flow<InviteNotification>
class ObserveInviteNotificationsUseCaseImpl(private val overviewStore: OverviewStore) : ObserveInviteNotificationsUseCase {
override suspend fun invoke(): Flow<InviteNotification> {
return overviewStore.latestInvites()
.diff()
.flatten()
.map {
val text = when (val meta = it.inviteMeta) {
InviteMeta.DirectMessage -> "${it.inviterName()} has invited you to chat"
is InviteMeta.Room -> "${it.inviterName()} has invited you to ${meta.roomName ?: "unnamed room"}"
}
InviteNotification(content = text, roomId = it.roomId)
}
}
private fun Flow<List<RoomInvite>>.diff(): Flow<Set<RoomInvite>> {
val previousInvites = mutableSetOf<RoomInvite>()
return this.distinctUntilChanged()
.map {
val diff = it.toSet() - previousInvites
previousInvites.clear()
previousInvites.addAll(it)
diff
}
}
private fun RoomInvite.inviterName() = this.from.displayName?.let { "$it (${this.from.id.value})" } ?: this.from.id.value
}
@OptIn(FlowPreview::class)
private fun <T> Flow<Set<T>>.flatten() = this.flatMapConcat { items ->
flow { items.forEach { this.emit(it) } }
}
data class InviteNotification(
val content: String,
val roomId: RoomId
)

View File

@ -2,21 +2,27 @@ package app.dapk.st.notifications
import app.dapk.st.matrix.sync.RoomEvent
import app.dapk.st.matrix.sync.RoomOverview
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
class RenderNotificationsUseCase(
private val notificationRenderer: NotificationRenderer,
private val notificationRenderer: NotificationMessageRenderer,
private val inviteRenderer: NotificationInviteRenderer,
private val observeRenderableUnreadEventsUseCase: ObserveUnreadNotificationsUseCase,
private val observeInviteNotificationsUseCase: ObserveInviteNotificationsUseCase,
private val notificationChannels: NotificationChannels,
) {
suspend fun listenForNotificationChanges() {
suspend fun listenForNotificationChanges(scope: CoroutineScope) {
notificationChannels.initChannels()
observeRenderableUnreadEventsUseCase()
.onStart { notificationChannels.initChannels() }
.onEach { (each, diff) -> renderUnreadChange(each, diff) }
.collect()
.launchIn(scope)
observeInviteNotificationsUseCase()
.onEach { inviteRenderer.render(it) }
.launchIn(scope)
}
private suspend fun renderUnreadChange(allUnread: Map<RoomOverview, List<RoomEvent>>, diff: NotificationDiff) {

View File

@ -16,6 +16,9 @@ import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
private const val A_CHANNEL_ID = "a channel id"
private val AN_OPEN_APP_INTENT = aPendingIntent()
@ -38,6 +41,7 @@ class NotificationFactoryTest {
private val fakeNotificationStyleFactory = FakeNotificationStyleFactory()
private val fakeIntentFactory = FakeIntentFactory()
private val fakeIconLoader = FakeIconLoader()
private val fixedClock = Clock.fixed(Instant.ofEpochMilli(0), ZoneOffset.UTC)
private val notificationFactory = NotificationFactory(
fakeContext.instance,
@ -45,6 +49,7 @@ class NotificationFactoryTest {
fakeIntentFactory,
fakeIconLoader,
DeviceMeta(26),
fixedClock
)
@Test
@ -127,6 +132,30 @@ class NotificationFactoryTest {
)
}
@Test
fun `given invite, then creates expected`() {
fakeIntentFactory.givenNotificationOpenApp(fakeContext.instance).returns(AN_OPEN_APP_INTENT)
val content = "Content message"
val result = notificationFactory.createInvite(
InviteNotification(
content = content,
A_ROOM_ID,
)
)
result shouldBeEqualTo AndroidNotification(
channelId = INVITE_CHANNEL_ID,
whenTimestamp = fixedClock.millis(),
alertMoreThanOnce = true,
smallIcon = R.drawable.ic_notification_small_icon,
contentIntent = AN_OPEN_APP_INTENT,
category = Notification.CATEGORY_EVENT,
autoCancel = true,
contentTitle = "Invite",
contentText = content,
)
}
private fun givenEventsFor(roomOverview: RoomOverview) {
fakeIntentFactory.givenNotificationOpenMessage(fakeContext.instance, roomOverview.roomId).returns(AN_OPEN_ROOM_INTENT)
fakeNotificationStyleFactory.givenMessage(EVENTS.sortedBy { it.utcTimestamp }, roomOverview).returns(A_NOTIFICATION_STYLE)

View File

@ -35,7 +35,7 @@ class NotificationRendererTest {
private val fakeNotificationFactory = FakeNotificationFactory()
private val fakeAndroidNotificationBuilder = FakeAndroidNotificationBuilder()
private val notificationRenderer = NotificationRenderer(
private val notificationRenderer = NotificationMessageRenderer(
fakeNotificationManager.instance,
fakeNotificationFactory.instance,
fakeAndroidNotificationBuilder.instance,

View File

@ -1,9 +1,9 @@
package app.dapk.st.notifications
import fake.FakeNotificationChannels
import fake.FakeNotificationRenderer
import fake.FakeObserveUnreadNotificationsUseCase
import fake.*
import fixture.NotificationDiffFixtures.aNotificationDiff
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Test
import test.expect
@ -12,35 +12,41 @@ private val AN_UNREAD_NOTIFICATIONS = UnreadNotifications(emptyMap(), aNotificat
class RenderNotificationsUseCaseTest {
private val fakeNotificationRenderer = FakeNotificationRenderer()
private val fakeNotificationMessageRenderer = FakeNotificationMessageRenderer()
private val fakeNotificationInviteRenderer = FakeNotificationInviteRenderer()
private val fakeObserveUnreadNotificationsUseCase = FakeObserveUnreadNotificationsUseCase()
private val fakeObserveInviteNotificationsUseCase = FakeObserveInviteNotificationsUseCase()
private val fakeNotificationChannels = FakeNotificationChannels().also {
it.instance.expect { it.initChannels() }
}
private val renderNotificationsUseCase = RenderNotificationsUseCase(
fakeNotificationRenderer.instance,
fakeNotificationMessageRenderer.instance,
fakeNotificationInviteRenderer.instance,
fakeObserveUnreadNotificationsUseCase,
fakeObserveInviteNotificationsUseCase,
fakeNotificationChannels.instance,
)
@Test
fun `given events, when listening for changes then initiates channels once`() = runTest {
fakeNotificationRenderer.instance.expect { it.render(any()) }
fakeNotificationMessageRenderer.instance.expect { it.render(any()) }
fakeObserveUnreadNotificationsUseCase.given().emits(AN_UNREAD_NOTIFICATIONS)
fakeObserveInviteNotificationsUseCase.given().emits()
renderNotificationsUseCase.listenForNotificationChanges()
renderNotificationsUseCase.listenForNotificationChanges(TestScope(UnconfinedTestDispatcher()))
fakeNotificationChannels.verifyInitiated()
}
@Test
fun `given renderable unread events, when listening for changes, then renders change`() = runTest {
fakeNotificationRenderer.instance.expect { it.render(any()) }
fakeNotificationMessageRenderer.instance.expect { it.render(any()) }
fakeObserveUnreadNotificationsUseCase.given().emits(AN_UNREAD_NOTIFICATIONS)
fakeObserveInviteNotificationsUseCase.given().emits()
renderNotificationsUseCase.listenForNotificationChanges()
renderNotificationsUseCase.listenForNotificationChanges(TestScope(UnconfinedTestDispatcher()))
fakeNotificationRenderer.verifyRenders(AN_UNREAD_NOTIFICATIONS)
fakeNotificationMessageRenderer.verifyRenders(AN_UNREAD_NOTIFICATIONS)
}
}

View File

@ -0,0 +1,8 @@
package fake
import app.dapk.st.notifications.NotificationInviteRenderer
import io.mockk.mockk
class FakeNotificationInviteRenderer {
val instance = mockk<NotificationInviteRenderer>()
}

View File

@ -1,13 +1,13 @@
package fake
import app.dapk.st.notifications.NotificationRenderer
import app.dapk.st.notifications.NotificationMessageRenderer
import app.dapk.st.notifications.NotificationState
import app.dapk.st.notifications.UnreadNotifications
import io.mockk.coVerify
import io.mockk.mockk
class FakeNotificationRenderer {
val instance = mockk<NotificationRenderer>()
class FakeNotificationMessageRenderer {
val instance = mockk<NotificationMessageRenderer>()
fun verifyRenders(vararg unreadNotifications: UnreadNotifications) {
unreadNotifications.forEach { unread ->
@ -23,4 +23,4 @@ class FakeNotificationRenderer {
}
}
}
}
}

View File

@ -0,0 +1,10 @@
package fake
import app.dapk.st.notifications.ObserveInviteNotificationsUseCase
import io.mockk.coEvery
import io.mockk.mockk
import test.delegateEmit
class FakeObserveInviteNotificationsUseCase : ObserveInviteNotificationsUseCase by mockk() {
fun given() = coEvery { this@FakeObserveInviteNotificationsUseCase.invoke() }.delegateEmit()
}

View File

@ -164,7 +164,8 @@ class TestMatrix(
)
}
}
}
},
roomInviteRemover = { storeModule.overviewStore().removeInvites(listOf(it)) }
)
installSyncService(

View File

@ -74,3 +74,12 @@ task allCodeCoverageReport(type: JacocoReport) {
dependsOn { ["app:assembleDebug"] + projects*.test }
initializeReport(it, projects, excludes)
}
task unitTestCodeCoverageReport(type: JacocoReport) {
outputs.upToDateWhen { false }
rootProject.apply plugin: 'jacoco'
def projects = collectProjects { !it.name.contains("test-harness") && !it.name.contains("stub") && !it.name.contains("-noop") }
dependsOn { ["app:assembleDebug"] + projects*.test }
initializeReport(it, projects, excludes)
}