rendering invites as notification which temporarily open the app on tap
This commit is contained in:
parent
4b850af810
commit
e773bc35c4
|
@ -60,7 +60,7 @@ class SmallTalkApplication : Application(), ModuleProvider {
|
|||
|
||||
applicationScope.launch {
|
||||
val notificationsUseCase = notificationsModule.notificationsUseCase()
|
||||
notificationsUseCase.listenForNotificationChanges()
|
||||
notificationsUseCase.listenForNotificationChanges(this)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -199,6 +199,7 @@ internal class FeatureModules internal constructor(
|
|||
NotificationsModule(
|
||||
imageLoaderModule.iconLoader(),
|
||||
storeModule.value.roomStore(),
|
||||
storeModule.value.overviewStore(),
|
||||
context,
|
||||
intentFactory = coreAndroidModule.intentFactory(),
|
||||
dispatchers = coroutineDispatchers,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
|
||||
}
|
|
@ -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,
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
package fake
|
||||
|
||||
import app.dapk.st.notifications.NotificationInviteRenderer
|
||||
import io.mockk.mockk
|
||||
|
||||
class FakeNotificationInviteRenderer {
|
||||
val instance = mockk<NotificationInviteRenderer>()
|
||||
}
|
|
@ -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 {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -164,7 +164,8 @@ class TestMatrix(
|
|||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
roomInviteRemover = { storeModule.overviewStore().removeInvites(listOf(it)) }
|
||||
)
|
||||
|
||||
installSyncService(
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue