diff --git a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt index 2fc928e..69065cc 100644 --- a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt +++ b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt @@ -1,15 +1,14 @@ package app.dapk.st.graph import android.app.Application +import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.os.Build import app.dapk.db.DapkDb import app.dapk.st.BuildConfig import app.dapk.st.SharedPreferencesDelegate -import app.dapk.st.core.BuildMeta -import app.dapk.st.core.CoreAndroidModule -import app.dapk.st.core.CoroutineDispatchers -import app.dapk.st.core.SingletonFlows +import app.dapk.st.core.* import app.dapk.st.core.extensions.ErrorTracker import app.dapk.st.core.extensions.unsafeLazy import app.dapk.st.directory.DirectoryModule @@ -91,6 +90,22 @@ internal class AppModule(context: Application, logger: MatrixLogger) { val domainModules = DomainModules(matrixModules, trackingModule.errorTracker) val coreAndroidModule = CoreAndroidModule(intentFactory = object : IntentFactory { + override fun notificationOpenApp(context: Context) = PendingIntent.getActivity( + context, + 1000, + home(context) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK), + PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE + ) + + override fun notificationOpenMessage(context: Context, roomId: RoomId) = PendingIntent.getActivity( + context, + roomId.hashCode(), + messenger(context, roomId) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK), + PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE + ) + override fun home(context: Context) = Intent(context, MainActivity::class.java) override fun messenger(context: Context, roomId: RoomId) = MessengerActivity.newInstance(context, roomId) override fun messengerShortcut(context: Context, roomId: RoomId) = MessengerActivity.newShortcutInstance(context, roomId) @@ -182,6 +197,7 @@ internal class FeatureModules internal constructor( workModule.workScheduler(), intentFactory = coreAndroidModule.intentFactory(), dispatchers = coroutineDispatchers, + deviceMeta = DeviceMeta(Build.VERSION.SDK_INT) ) } diff --git a/core/src/main/kotlin/app/dapk/st/core/BuildMeta.kt b/core/src/main/kotlin/app/dapk/st/core/BuildMeta.kt index b95e217..3fd09ac 100644 --- a/core/src/main/kotlin/app/dapk/st/core/BuildMeta.kt +++ b/core/src/main/kotlin/app/dapk/st/core/BuildMeta.kt @@ -3,4 +3,4 @@ package app.dapk.st.core data class BuildMeta( val versionName: String, val versionCode: Int, -) \ No newline at end of file +) diff --git a/core/src/main/kotlin/app/dapk/st/core/DeviceMeta.kt b/core/src/main/kotlin/app/dapk/st/core/DeviceMeta.kt new file mode 100644 index 0000000..28529d2 --- /dev/null +++ b/core/src/main/kotlin/app/dapk/st/core/DeviceMeta.kt @@ -0,0 +1,5 @@ +package app.dapk.st.core + +data class DeviceMeta( + val apiVersion: Int +) \ No newline at end of file diff --git a/core/src/testFixtures/kotlin/test/ExpectTestScope.kt b/core/src/testFixtures/kotlin/test/ExpectTestScope.kt index 98600e8..f9f3db7 100644 --- a/core/src/testFixtures/kotlin/test/ExpectTestScope.kt +++ b/core/src/testFixtures/kotlin/test/ExpectTestScope.kt @@ -1,9 +1,6 @@ package test -import io.mockk.MockKMatcherScope -import io.mockk.MockKVerificationScope -import io.mockk.coJustRun -import io.mockk.coVerify +import io.mockk.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.test.runTest import kotlin.coroutines.CoroutineContext @@ -15,9 +12,11 @@ fun runExpectTest(testBody: suspend ExpectTestScope.() -> Unit) { class ExpectTest(override val coroutineContext: CoroutineContext) : ExpectTestScope { private val expects = mutableListOf Unit>>() + private val groups = mutableListOf Unit>() - override fun verifyExpects() = expects.forEach { (times, block) -> - coVerify(exactly = times) { block.invoke(this) } + override fun verifyExpects() { + expects.forEach { (times, block) -> coVerify(exactly = times) { block.invoke(this) } } + groups.forEach { coVerifyOrder { it.invoke(this) } } } override fun T.expectUnit(times: Int, block: suspend MockKMatcherScope.(T) -> Unit) { @@ -25,6 +24,9 @@ class ExpectTest(override val coroutineContext: CoroutineContext) : ExpectTestSc expects.add(times to { block(this@expectUnit) }) } + override fun T.captureExpects(block: suspend MockKMatcherScope.(T) -> Unit) { + groups.add { block(this@captureExpects) } + } } private fun Any.ignore() = Unit @@ -32,4 +34,5 @@ private fun Any.ignore() = Unit interface ExpectTestScope : CoroutineScope { fun verifyExpects() fun T.expectUnit(times: Int = 1, block: suspend MockKMatcherScope.(T) -> Unit) + fun T.captureExpects(block: suspend MockKMatcherScope.(T) -> Unit) } \ No newline at end of file diff --git a/domains/android/core/src/main/kotlin/app/dapk/st/core/DeviceMetaExtensions.kt b/domains/android/core/src/main/kotlin/app/dapk/st/core/DeviceMetaExtensions.kt new file mode 100644 index 0000000..3cc7e00 --- /dev/null +++ b/domains/android/core/src/main/kotlin/app/dapk/st/core/DeviceMetaExtensions.kt @@ -0,0 +1,19 @@ +package app.dapk.st.core + +import android.os.Build + +fun DeviceMeta.isAtLeastO(block: () -> T, fallback: () -> T = { throw IllegalStateException("not handled") }): T { + return if (this.apiVersion >= Build.VERSION_CODES.O) block() else fallback() +} + +fun DeviceMeta.onAtLeastO(block: () -> Unit) { + if (this.apiVersion >= Build.VERSION_CODES.O) block() +} + +inline fun DeviceMeta.whenPOrHigher(block: () -> T, fallback: () -> T) = whenXOrHigher(Build.VERSION_CODES.P, block, fallback) +inline fun DeviceMeta.whenOOrHigher(block: () -> T, fallback: () -> T) = whenXOrHigher(Build.VERSION_CODES.O, block, fallback) + +inline fun DeviceMeta.whenXOrHigher(version: Int, block: () -> T, fallback: () -> T): T { + return if (this.apiVersion >= version) block() else fallback() +} + diff --git a/domains/android/stub/src/testFixtures/kotlin/fake/FakeContext.kt b/domains/android/stub/src/testFixtures/kotlin/fake/FakeContext.kt new file mode 100644 index 0000000..623c98c --- /dev/null +++ b/domains/android/stub/src/testFixtures/kotlin/fake/FakeContext.kt @@ -0,0 +1,8 @@ +package fake + +import android.content.Context +import io.mockk.mockk + +class FakeContext { + val instance = mockk() +} \ No newline at end of file diff --git a/domains/android/stub/src/testFixtures/kotlin/fake/FakeInboxStyle.kt b/domains/android/stub/src/testFixtures/kotlin/fake/FakeInboxStyle.kt new file mode 100644 index 0000000..910cfa7 --- /dev/null +++ b/domains/android/stub/src/testFixtures/kotlin/fake/FakeInboxStyle.kt @@ -0,0 +1,22 @@ +package fake + +import android.app.Notification +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot + +class FakeInboxStyle { + private val _summary = slot() + + val instance = mockk() + val lines = mutableListOf() + val summary: String + get() = _summary.captured + + fun captureInteractions() { + every { instance.addLine(capture(lines)) } returns instance + every { instance.setSummaryText(capture(_summary)) } returns instance + } + + +} \ No newline at end of file diff --git a/domains/android/stub/src/testFixtures/kotlin/fake/FakeMessagingStyle.kt b/domains/android/stub/src/testFixtures/kotlin/fake/FakeMessagingStyle.kt new file mode 100644 index 0000000..d76b96a --- /dev/null +++ b/domains/android/stub/src/testFixtures/kotlin/fake/FakeMessagingStyle.kt @@ -0,0 +1,14 @@ +package fake + +import android.app.Notification +import android.app.Person +import io.mockk.every +import io.mockk.mockk + +class FakeMessagingStyle { + var user: Person? = null + val instance = mockk() + +} + +fun aFakeMessagingStyle() = FakeMessagingStyle().instance \ No newline at end of file diff --git a/domains/android/stub/src/testFixtures/kotlin/fake/FakeNotificationBuilder.kt b/domains/android/stub/src/testFixtures/kotlin/fake/FakeNotificationBuilder.kt new file mode 100644 index 0000000..67aa3a0 --- /dev/null +++ b/domains/android/stub/src/testFixtures/kotlin/fake/FakeNotificationBuilder.kt @@ -0,0 +1,8 @@ +package fake + +import android.app.Notification +import io.mockk.mockk + +class FakeNotificationBuilder { + val instance = mockk(relaxed = true) +} \ No newline at end of file diff --git a/domains/android/stub/src/testFixtures/kotlin/fake/FakePersonBuilder.kt b/domains/android/stub/src/testFixtures/kotlin/fake/FakePersonBuilder.kt new file mode 100644 index 0000000..e254d7d --- /dev/null +++ b/domains/android/stub/src/testFixtures/kotlin/fake/FakePersonBuilder.kt @@ -0,0 +1,8 @@ +package fake + +import android.app.Person +import io.mockk.mockk + +class FakePersonBuilder { + val instance = mockk(relaxed = true) +} \ No newline at end of file diff --git a/features/navigator/src/main/kotlin/app/dapk/st/navigator/Navigator.kt b/features/navigator/src/main/kotlin/app/dapk/st/navigator/Navigator.kt index a996466..1e6efea 100644 --- a/features/navigator/src/main/kotlin/app/dapk/st/navigator/Navigator.kt +++ b/features/navigator/src/main/kotlin/app/dapk/st/navigator/Navigator.kt @@ -1,6 +1,7 @@ package app.dapk.st.navigator import android.app.Activity +import android.app.PendingIntent import android.content.Context import android.content.Intent import android.os.Parcel @@ -51,6 +52,8 @@ interface Navigator { interface IntentFactory { + fun notificationOpenApp(context: Context): PendingIntent + fun notificationOpenMessage(context: Context, roomId: RoomId): PendingIntent fun home(context: Context): Intent fun messenger(context: Context, roomId: RoomId): Intent fun messengerShortcut(context: Context, roomId: RoomId): Intent diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/AndroidNotificationBuilder.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/AndroidNotificationBuilder.kt new file mode 100644 index 0000000..6f49daa --- /dev/null +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/AndroidNotificationBuilder.kt @@ -0,0 +1,75 @@ +package app.dapk.st.notifications + +import android.annotation.SuppressLint +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.graphics.drawable.Icon +import app.dapk.st.core.DeviceMeta +import app.dapk.st.core.isAtLeastO +import app.dapk.st.core.onAtLeastO + +@SuppressLint("NewApi") +@Suppress("ObjectPropertyName") +private val _builderFactory: (Context, String, DeviceMeta) -> Notification.Builder = { context, channel, deviceMeta -> + deviceMeta.isAtLeastO( + block = { Notification.Builder(context, channel) }, + fallback = { Notification.Builder(context) } + ) +} + +class AndroidNotificationBuilder( + private val context: Context, + private val deviceMeta: DeviceMeta, + private val notificationStyleBuilder: AndroidNotificationStyleBuilder, + private val builderFactory: (Context, String, DeviceMeta) -> Notification.Builder = _builderFactory +) { + @SuppressLint("NewApi") + fun build(notification: AndroidNotification): Notification { + return builder(notification.channelId) + .apply { setOnlyAlertOnce(!notification.alertMoreThanOnce) } + .apply { setAutoCancel(notification.autoCancel) } + .apply { setGroupSummary(notification.isGroupSummary) } + .ifNotNull(notification.groupId) { setGroup(it) } + .ifNotNull(notification.messageStyle) { style = it.build(notificationStyleBuilder) } + .ifNotNull(notification.contentIntent) { setContentIntent(it) } + .ifNotNull(notification.whenTimestamp) { + setShowWhen(true) + setWhen(it) + } + .ifNotNull(notification.category) { setCategory(it) } + .ifNotNull(notification.shortcutId) { + deviceMeta.onAtLeastO { setShortcutId(notification.shortcutId) } + } + .ifNotNull(notification.smallIcon) { setSmallIcon(it) } + .ifNotNull(notification.largeIcon) { setLargeIcon(it) } + .build() + } + + private fun Notification.Builder.ifNotNull(value: T?, action: Notification.Builder.(T) -> Unit): Notification.Builder { + if (value != null) { + action(value) + } + return this + } + + private fun builder(channel: String) = builderFactory(context, channel, deviceMeta) +} + +data class AndroidNotification( + val channelId: String, + val whenTimestamp: Long? = null, + val isGroupSummary: Boolean = false, + val groupId: String? = null, + val groupAlertBehavior: Int? = null, + val shortcutId: String? = null, + val alertMoreThanOnce: Boolean, + val contentIntent: PendingIntent? = null, + val messageStyle: AndroidNotificationStyle? = null, + val category: String? = null, + val smallIcon: Int? = null, + val largeIcon: Icon? = null, + val autoCancel: Boolean = true, +) { + fun build(builder: AndroidNotificationBuilder) = builder.build(this) +} diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/AndroidNotificationStyle.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/AndroidNotificationStyle.kt new file mode 100644 index 0000000..05dd95e --- /dev/null +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/AndroidNotificationStyle.kt @@ -0,0 +1,30 @@ +package app.dapk.st.notifications + +import android.app.Notification +import android.graphics.drawable.Icon +import android.os.Build +import androidx.annotation.RequiresApi + +sealed interface AndroidNotificationStyle { + + fun build(builder: AndroidNotificationStyleBuilder): Notification.Style + + data class Inbox(val lines: List, val summary: String? = null) : AndroidNotificationStyle { + override fun build(builder: AndroidNotificationStyleBuilder) = builder.build(this) + } + + data class Messaging( + val person: AndroidPerson, + val title: String?, + val isGroup: Boolean, + val content: List, + ) : AndroidNotificationStyle { + + @RequiresApi(Build.VERSION_CODES.P) + override fun build(builder: AndroidNotificationStyleBuilder) = builder.build(this) + + data class AndroidPerson(val name: String, val key: String, val icon: Icon? = null) + data class AndroidMessage(val sender: AndroidPerson, val content: String, val timestamp: Long) + } + +} \ No newline at end of file diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/AndroidNotificationStyleBuilder.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/AndroidNotificationStyleBuilder.kt new file mode 100644 index 0000000..5da92f3 --- /dev/null +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/AndroidNotificationStyleBuilder.kt @@ -0,0 +1,47 @@ +package app.dapk.st.notifications + +import android.annotation.SuppressLint +import android.app.Notification +import android.app.Notification.InboxStyle +import android.app.Notification.MessagingStyle +import android.app.Person +import android.os.Build +import androidx.annotation.RequiresApi + +@SuppressLint("NewApi") +class AndroidNotificationStyleBuilder( + private val personBuilderFactory: () -> Person.Builder = { Person.Builder() }, + private val inboxStyleFactory: () -> InboxStyle = { InboxStyle() }, + private val messagingStyleFactory: (Person) -> MessagingStyle = { MessagingStyle(it) }, +) { + + fun build(style: AndroidNotificationStyle): Notification.Style { + return when (style) { + is AndroidNotificationStyle.Inbox -> style.buildInboxStyle() + is AndroidNotificationStyle.Messaging -> style.buildMessagingStyle() + } + } + + private fun AndroidNotificationStyle.Inbox.buildInboxStyle() = inboxStyleFactory().also { inboxStyle -> + lines.forEach { inboxStyle.addLine(it) } + inboxStyle.setSummaryText(summary) + } + + @RequiresApi(Build.VERSION_CODES.P) + private fun AndroidNotificationStyle.Messaging.buildMessagingStyle() = messagingStyleFactory( + personBuilderFactory() + .setName(person.name) + .setKey(person.key) + .build() + ).also { style -> + content.forEach { + val sender = personBuilderFactory() + .setName(it.sender.name) + .setKey(it.sender.key) + .setIcon(it.sender.icon) + .build() + style.addMessage(MessagingStyle.Message(it.content, it.timestamp, sender)) + } + } + +} \ No newline at end of file diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationFactory.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationFactory.kt index aae49c9..b21d56a 100644 --- a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationFactory.kt +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationFactory.kt @@ -1,198 +1,58 @@ package app.dapk.st.notifications import android.app.Notification -import android.app.PendingIntent -import android.app.Person import android.content.Context -import android.content.Intent -import android.os.Build -import androidx.annotation.RequiresApi +import app.dapk.st.core.DeviceMeta +import app.dapk.st.core.whenPOrHigher import app.dapk.st.imageloader.IconLoader import app.dapk.st.matrix.common.RoomId -import app.dapk.st.matrix.common.RoomMember -import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.matrix.sync.RoomOverview -import app.dapk.st.messenger.MessengerActivity import app.dapk.st.navigator.IntentFactory private const val GROUP_ID = "st" private const val channelId = "message" class NotificationFactory( - private val iconLoader: IconLoader, private val context: Context, + private val notificationStyleFactory: NotificationStyleFactory, private val intentFactory: IntentFactory, + private val iconLoader: IconLoader, + private val deviceMeta: DeviceMeta, ) { - private val shouldAlwaysAlertDms = true - private fun RoomEvent.toNotifiableContent(): String = when (this) { - is RoomEvent.Image -> "\uD83D\uDCF7" - is RoomEvent.Message -> this.content - is RoomEvent.Reply -> this.message.toNotifiableContent() - } - - suspend fun createNotifications(allUnread: Map>, roomsWithNewEvents: Set, newRooms: Set): Notifications { - val notifications = allUnread.map { (roomOverview, events) -> - val messageEvents = events.map { - when (it) { - is RoomEvent.Image -> Notifiable(content = it.toNotifiableContent(), it.utcTimestamp, it.author) - is RoomEvent.Message -> Notifiable(content = it.toNotifiableContent(), it.utcTimestamp, it.author) - is RoomEvent.Reply -> Notifiable(content = it.toNotifiableContent(), it.utcTimestamp, it.author) - } - } - when (messageEvents.isEmpty()) { - true -> NotificationDelegate.DismissRoom(roomOverview.roomId) - false -> createMessageNotification(messageEvents, roomOverview, roomsWithNewEvents, newRooms) - } - } - - val summaryNotification = if (notifications.filterIsInstance().isNotEmpty()) { - val isAlerting = notifications.any { it is NotificationDelegate.Room && it.isAlerting } - createSummary(notifications, isAlerting = isAlerting) - } else { - null - } - return Notifications(summaryNotification, notifications) - } - - private fun createSummary(notifications: List, isAlerting: Boolean): Notification { - val summaryInboxStyle = Notification.InboxStyle().also { style -> - notifications.sortedBy { - when (it) { - is NotificationDelegate.DismissRoom -> -1 - is NotificationDelegate.Room -> it.notification.`when` - } - }.forEach { - when (it) { - is NotificationDelegate.DismissRoom -> { - // do nothing - } - is NotificationDelegate.Room -> { - style.addLine(it.summary) - } - } - } - } - - - if (notifications.size > 1) { - summaryInboxStyle.setSummaryText("${notifications.countMessages()} messages from ${notifications.size} chats") - } - - val openAppIntent = PendingIntent.getActivity( - context, - 1000, - intentFactory.home(context) - .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK), - PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE - ) - - return builder() - .setStyle(summaryInboxStyle) - .setOnlyAlertOnce(!isAlerting) - .setSmallIcon(R.drawable.ic_notification_small_icon) - .setGroupSummary(true) - .setGroup(GROUP_ID) - .setContentIntent(openAppIntent) - .build() - } - - private fun List.countMessages() = this.sumOf { - when (it) { - is NotificationDelegate.DismissRoom -> 0 - is NotificationDelegate.Room -> it.messageCount - } - } - - @RequiresApi(Build.VERSION_CODES.P) - private suspend fun createMessageStyle(events: List, roomOverview: RoomOverview): Notification.MessagingStyle { - val messageStyle = Notification.MessagingStyle( - Person.Builder() - .setName("me") - .setKey(roomOverview.roomId.value) - .build() - ) - - messageStyle.conversationTitle = roomOverview.roomName.takeIf { roomOverview.isGroup } - messageStyle.isGroupConversation = roomOverview.isGroup - - events.forEach { message -> - val sender = Person.Builder() - .setName(message.author.displayName ?: message.author.id.value) - .setIcon(message.author.avatarUrl?.let { iconLoader.load(it.value) }) - .setKey(message.author.id.value) - .build() - - messageStyle.addMessage( - Notification.MessagingStyle.Message( - message.content, - message.utcTimestamp, - sender, - ) - ) - } - return messageStyle - } - - private suspend fun createMessageNotification( + suspend fun createMessageNotification( events: List, roomOverview: RoomOverview, roomsWithNewEvents: Set, newRooms: Set - ): NotificationDelegate { + ): NotificationTypes { val sortedEvents = events.sortedBy { it.utcTimestamp } - - val messageStyle = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - createMessageStyle(sortedEvents, roomOverview) - } else { - val inboxStyle = Notification.InboxStyle() - sortedEvents.forEach { - inboxStyle.addLine("${it.author.displayName ?: it.author.id.value}: ${it.content}") - } - inboxStyle - } - - val openRoomIntent = PendingIntent.getActivity( - context, - roomOverview.roomId.hashCode(), - MessengerActivity.newInstance(context, roomOverview.roomId) - .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK), - PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE - ) - + val messageStyle = notificationStyleFactory.message(sortedEvents, roomOverview) + val openRoomIntent = intentFactory.notificationOpenMessage(context, roomOverview.roomId) val shouldAlertMoreThanOnce = when { roomOverview.isDm() -> roomsWithNewEvents.contains(roomOverview.roomId) && shouldAlwaysAlertDms else -> newRooms.contains(roomOverview.roomId) } - return NotificationDelegate.Room( - builder() - .setWhen(sortedEvents.last().utcTimestamp) - .setShowWhen(true) - .setGroup(GROUP_ID) - .run { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - this.setGroupAlertBehavior(Notification.GROUP_ALERT_SUMMARY) - } else { - this - } - } - .setOnlyAlertOnce(!shouldAlertMoreThanOnce) - .setContentIntent(openRoomIntent) - .setStyle(messageStyle) - .setCategory(Notification.CATEGORY_MESSAGE) - .run { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - this.setShortcutId(roomOverview.roomId.value) - } else { - this - } - } - .setSmallIcon(R.drawable.ic_notification_small_icon) - .setLargeIcon(roomOverview.roomAvatarUrl?.let { iconLoader.load(it.value) }) - .setAutoCancel(true) - .build(), + return NotificationTypes.Room( + AndroidNotification( + channelId = channelId, + whenTimestamp = sortedEvents.last().utcTimestamp, + groupId = GROUP_ID, + groupAlertBehavior = deviceMeta.whenPOrHigher( + block = { Notification.GROUP_ALERT_SUMMARY }, + fallback = { null } + ), + shortcutId = roomOverview.roomId.value, + alertMoreThanOnce = shouldAlertMoreThanOnce, + contentIntent = openRoomIntent, + messageStyle = messageStyle, + category = Notification.CATEGORY_MESSAGE, + smallIcon = R.drawable.ic_notification_small_icon, + largeIcon = roomOverview.roomAvatarUrl?.let { iconLoader.load(it.value) }, + autoCancel = true + ), roomId = roomOverview.roomId, summary = sortedEvents.last().content, messageCount = sortedEvents.size, @@ -200,16 +60,19 @@ class NotificationFactory( ) } - private fun builder(channel: String = channelId) = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - Notification.Builder(context, channel) - } else { - Notification.Builder(context) + fun createSummary(notifications: List): AndroidNotification { + val summaryInboxStyle = notificationStyleFactory.summary(notifications) + val openAppIntent = intentFactory.notificationOpenApp(context) + return AndroidNotification( + channelId = channelId, + messageStyle = summaryInboxStyle, + alertMoreThanOnce = notifications.any { it.isAlerting }, + smallIcon = R.drawable.ic_notification_small_icon, + contentIntent = openAppIntent, + groupId = GROUP_ID, + isGroupSummary = true, + ) } - } private fun RoomOverview.isDm() = !this.isGroup - -data class Notifications(val summaryNotification: Notification?, val delegates: List) - -data class Notifiable(val content: String, val utcTimestamp: Long, val author: RoomMember) diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationRenderer.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationRenderer.kt index 68a498a..5d4e177 100644 --- a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationRenderer.kt +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationRenderer.kt @@ -1,6 +1,5 @@ package app.dapk.st.notifications -import android.app.Notification import android.app.NotificationManager import app.dapk.st.core.AppLogTag import app.dapk.st.core.CoroutineDispatchers @@ -9,7 +8,6 @@ import app.dapk.st.core.log import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.matrix.sync.RoomOverview -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext private const val SUMMARY_NOTIFICATION_ID = 101 @@ -17,13 +15,14 @@ private const val MESSAGE_NOTIFICATION_ID = 100 class NotificationRenderer( private val notificationManager: NotificationManager, - private val notificationFactory: NotificationFactory, + private val notificationStateMapper: NotificationStateMapper, + private val androidNotificationBuilder: AndroidNotificationBuilder, private val dispatchers: CoroutineDispatchers, ) { - suspend fun render(allUnread: Map>, removedRooms: Set, roomsWithNewEvents: Set, newRooms: Set) { - removedRooms.forEach { notificationManager.cancel(it.value, MESSAGE_NOTIFICATION_ID) } - val notifications = notificationFactory.createNotifications(allUnread, roomsWithNewEvents, newRooms) + suspend fun render(state: NotificationState) { + state.removedRooms.forEach { notificationManager.cancel(it.value, MESSAGE_NOTIFICATION_ID) } + val notifications = notificationStateMapper.mapToNotifications(state) withContext(dispatchers.main) { notifications.summaryNotification.ifNull { @@ -31,31 +30,46 @@ class NotificationRenderer( notificationManager.cancel(SUMMARY_NOTIFICATION_ID) } - val onlyContainsRemovals = removedRooms.isNotEmpty() && roomsWithNewEvents.isEmpty() + val onlyContainsRemovals = state.onlyContainsRemovals() notifications.delegates.forEach { when (it) { - is NotificationDelegate.DismissRoom -> notificationManager.cancel(it.roomId.value, MESSAGE_NOTIFICATION_ID) - is NotificationDelegate.Room -> { + is NotificationTypes.DismissRoom -> notificationManager.cancel(it.roomId.value, MESSAGE_NOTIFICATION_ID) + is NotificationTypes.Room -> { if (!onlyContainsRemovals) { log(AppLogTag.NOTIFICATION, "notifying ${it.roomId.value}") - notificationManager.notify(it.roomId.value, MESSAGE_NOTIFICATION_ID, it.notification) + notificationManager.notify(it.roomId.value, MESSAGE_NOTIFICATION_ID, it.notification.build(androidNotificationBuilder)) } } } } notifications.summaryNotification?.let { - if (notifications.delegates.filterIsInstance().isNotEmpty() && !onlyContainsRemovals) { + if (notifications.delegates.filterIsInstance().isNotEmpty() && !onlyContainsRemovals) { log(AppLogTag.NOTIFICATION, "notifying summary") - notificationManager.notify(SUMMARY_NOTIFICATION_ID, it) + notificationManager.notify(SUMMARY_NOTIFICATION_ID, it.build(androidNotificationBuilder)) } } } } - } -sealed interface NotificationDelegate { - data class Room(val notification: Notification, val roomId: RoomId, val summary: String, val messageCount: Int, val isAlerting: Boolean) : NotificationDelegate - data class DismissRoom(val roomId: RoomId) : NotificationDelegate +data class NotificationState( + val allUnread: Map>, + val removedRooms: Set, + val roomsWithNewEvents: Set, + val newRooms: Set +) + +private fun NotificationState.onlyContainsRemovals() = this.removedRooms.isNotEmpty() && this.roomsWithNewEvents.isEmpty() + +sealed interface NotificationTypes { + data class Room( + val notification: AndroidNotification, + val roomId: RoomId, + val summary: String, + val messageCount: Int, + val isAlerting: Boolean + ) : NotificationTypes + + data class DismissRoom(val roomId: RoomId) : NotificationTypes } \ No newline at end of file diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationStateMapper.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationStateMapper.kt new file mode 100644 index 0000000..c9d4c17 --- /dev/null +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationStateMapper.kt @@ -0,0 +1,31 @@ +package app.dapk.st.notifications + +class NotificationStateMapper( + private val roomEventsToNotifiableMapper: RoomEventsToNotifiableMapper, + private val notificationFactory: NotificationFactory, +) { + + suspend fun mapToNotifications(state: NotificationState): Notifications { + val messageNotifications = createMessageNotifications(state) + val roomNotifications = messageNotifications.filterIsInstance() + val summaryNotification = maybeCreateSummary(roomNotifications) + return Notifications(summaryNotification, messageNotifications) + } + + private suspend fun createMessageNotifications(state: NotificationState) = state.allUnread.map { (roomOverview, events) -> + val messageEvents = roomEventsToNotifiableMapper.map(events) + when (messageEvents.isEmpty()) { + true -> NotificationTypes.DismissRoom(roomOverview.roomId) + false -> notificationFactory.createMessageNotification(messageEvents, roomOverview, state.roomsWithNewEvents, state.newRooms) + } + } + + private fun maybeCreateSummary(roomNotifications: List) = when { + roomNotifications.isNotEmpty() -> { + notificationFactory.createSummary(roomNotifications) + } + else -> null + } +} + +data class Notifications(val summaryNotification: AndroidNotification?, val delegates: List) diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationStyleFactory.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationStyleFactory.kt new file mode 100644 index 0000000..232c273 --- /dev/null +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationStyleFactory.kt @@ -0,0 +1,53 @@ +package app.dapk.st.notifications + +import android.annotation.SuppressLint +import app.dapk.st.core.DeviceMeta +import app.dapk.st.core.whenPOrHigher +import app.dapk.st.imageloader.IconLoader +import app.dapk.st.matrix.sync.RoomOverview +import app.dapk.st.notifications.AndroidNotificationStyle.Inbox +import app.dapk.st.notifications.AndroidNotificationStyle.Messaging + +@SuppressLint("NewApi") +class NotificationStyleFactory( + private val iconLoader: IconLoader, + private val deviceMeta: DeviceMeta, +) { + + fun summary(notifications: List) = Inbox( + lines = notifications + .sortedBy { it.notification.whenTimestamp } + .map { it.summary }, + summary = "${notifications.countMessages()} messages from ${notifications.size} chats", + ) + + private fun List.countMessages() = this.sumOf { it.messageCount } + + suspend fun message(events: List, roomOverview: RoomOverview): AndroidNotificationStyle { + return deviceMeta.whenPOrHigher( + block = { createMessageStyle(events, roomOverview) }, + fallback = { + val lines = events.map { "${it.author.displayName ?: it.author.id.value}: ${it.content}" } + Inbox(lines) + } + ) + } + + private suspend fun createMessageStyle(events: List, roomOverview: RoomOverview) = Messaging( + Messaging.AndroidPerson(name = "me", key = roomOverview.roomId.value), + title = roomOverview.roomName.takeIf { roomOverview.isGroup }, + isGroup = roomOverview.isGroup, + content = events.map { message -> + Messaging.AndroidMessage( + Messaging.AndroidPerson( + name = message.author.displayName ?: message.author.id.value, + icon = message.author.avatarUrl?.let { iconLoader.load(it.value) }, + key = message.author.id.value, + ), + content = message.content, + timestamp = message.utcTimestamp, + ) + } + ) + +} 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 2972b52..0d07a7c 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 @@ -3,6 +3,7 @@ package app.dapk.st.notifications import android.app.NotificationManager import android.content.Context 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.common.CredentialsStore @@ -24,6 +25,7 @@ class NotificationsModule( private val workScheduler: WorkScheduler, private val intentFactory: IntentFactory, private val dispatchers: CoroutineDispatchers, + private val deviceMeta: DeviceMeta, ) : ProvidableModule { fun pushUseCase() = pushService @@ -32,9 +34,23 @@ class NotificationsModule( fun firebasePushTokenUseCase() = firebasePushTokenUseCase fun roomStore() = roomStore fun notificationsUseCase() = RenderNotificationsUseCase( - NotificationRenderer(notificationManager(), NotificationFactory(iconLoader, context, intentFactory), dispatchers), - ObserveUnreadNotificationsUseCaseImpl(roomStore), - NotificationChannels(notificationManager()), + notificationRenderer = NotificationRenderer( + notificationManager(), + NotificationStateMapper( + RoomEventsToNotifiableMapper(), + NotificationFactory( + context, + NotificationStyleFactory(iconLoader, deviceMeta), + intentFactory, + iconLoader, + deviceMeta, + ) + ), + AndroidNotificationBuilder(context, deviceMeta, AndroidNotificationStyleBuilder()), + dispatchers + ), + observeRenderableUnreadEventsUseCase = ObserveUnreadNotificationsUseCaseImpl(roomStore), + notificationChannels = NotificationChannels(notificationManager()), ) private fun notificationManager() = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/RenderNotificationsUseCase.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/RenderNotificationsUseCase.kt index d05da67..efb0d21 100644 --- a/features/notifications/src/main/kotlin/app/dapk/st/notifications/RenderNotificationsUseCase.kt +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/RenderNotificationsUseCase.kt @@ -26,10 +26,12 @@ class RenderNotificationsUseCase( private suspend fun renderUnreadChange(allUnread: Map>, diff: NotificationDiff) { log(NOTIFICATION, "unread changed - render notifications") notificationRenderer.render( - allUnread = allUnread, - removedRooms = diff.removed.keys, - roomsWithNewEvents = diff.changedOrNew.keys, - newRooms = diff.newRooms, + NotificationState( + allUnread = allUnread, + removedRooms = diff.removed.keys, + roomsWithNewEvents = diff.changedOrNew.keys, + newRooms = diff.newRooms, + ) ) } } diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/RoomEventsToNotifiableMapper.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/RoomEventsToNotifiableMapper.kt new file mode 100644 index 0000000..9f5c05d --- /dev/null +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/RoomEventsToNotifiableMapper.kt @@ -0,0 +1,26 @@ +package app.dapk.st.notifications + +import app.dapk.st.matrix.common.RoomMember +import app.dapk.st.matrix.sync.RoomEvent + +class RoomEventsToNotifiableMapper { + + fun map(events: List): List { + return events.map { + when (it) { + is RoomEvent.Image -> Notifiable(content = it.toNotifiableContent(), it.utcTimestamp, it.author) + is RoomEvent.Message -> Notifiable(content = it.toNotifiableContent(), it.utcTimestamp, it.author) + is RoomEvent.Reply -> Notifiable(content = it.toNotifiableContent(), it.utcTimestamp, it.author) + } + } + } + + private fun RoomEvent.toNotifiableContent(): String = when (this) { + is RoomEvent.Image -> "\uD83D\uDCF7" + is RoomEvent.Message -> this.content + is RoomEvent.Reply -> this.message.toNotifiableContent() + } + +} + +data class Notifiable(val content: String, val utcTimestamp: Long, val author: RoomMember) diff --git a/features/notifications/src/test/kotlin/app/dapk/st/notifications/AndroidNotificationBuilderTest.kt b/features/notifications/src/test/kotlin/app/dapk/st/notifications/AndroidNotificationBuilderTest.kt new file mode 100644 index 0000000..ce1743f --- /dev/null +++ b/features/notifications/src/test/kotlin/app/dapk/st/notifications/AndroidNotificationBuilderTest.kt @@ -0,0 +1,59 @@ +package app.dapk.st.notifications + +import app.dapk.st.core.DeviceMeta +import fixture.NotificationDelegateFixtures.anAndroidNotification +import fake.FakeContext +import fake.FakeNotificationBuilder +import fake.aFakeMessagingStyle +import io.mockk.every +import io.mockk.mockk +import org.junit.Test +import test.delegateReturn +import test.runExpectTest + +private val A_MESSAGING_STYLE = aFakeMessagingStyle() + +class AndroidNotificationBuilderTest { + + private val fakeContext = FakeContext() + private val fakeNotificationBuilder = FakeNotificationBuilder() + private val fakeAndroidNotificationStyleBuilder = FakeAndroidNotificationStyleBuilder() + + private val builder = AndroidNotificationBuilder( + fakeContext.instance, + DeviceMeta(apiVersion = 26), + fakeAndroidNotificationStyleBuilder.instance, + builderFactory = { _, _, _ -> fakeNotificationBuilder.instance }, + ) + + @Test + fun `applies all builder options`() = runExpectTest { + val notification = anAndroidNotification() + fakeAndroidNotificationStyleBuilder.given(notification.messageStyle!!).returns(A_MESSAGING_STYLE) + fakeNotificationBuilder.instance.captureExpects { + it.setOnlyAlertOnce(!notification.alertMoreThanOnce) + it.setAutoCancel(notification.autoCancel) + it.setGroupSummary(notification.isGroupSummary) + it.setGroup(notification.groupId) + it.setStyle(A_MESSAGING_STYLE) + it.setContentIntent(notification.contentIntent) + it.setShowWhen(true) + it.setWhen(notification.whenTimestamp!!) + it.setCategory(notification.category) + it.setShortcutId(notification.shortcutId) + it.setSmallIcon(notification.smallIcon!!) + it.setLargeIcon(notification.largeIcon) + it.build() + } + + val ignoredResult = builder.build(notification) + + verifyExpects() + } +} + +class FakeAndroidNotificationStyleBuilder { + val instance = mockk() + + fun given(style: AndroidNotificationStyle) = every { instance.build(style) }.delegateReturn() +} diff --git a/features/notifications/src/test/kotlin/app/dapk/st/notifications/AndroidNotificationStyleBuilderTest.kt b/features/notifications/src/test/kotlin/app/dapk/st/notifications/AndroidNotificationStyleBuilderTest.kt new file mode 100644 index 0000000..564dc4a --- /dev/null +++ b/features/notifications/src/test/kotlin/app/dapk/st/notifications/AndroidNotificationStyleBuilderTest.kt @@ -0,0 +1,38 @@ +package app.dapk.st.notifications + +import fake.FakeInboxStyle +import fake.FakeMessagingStyle +import fake.FakePersonBuilder +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test + +class AndroidNotificationStyleBuilderTest { + + private val fakePersonBuilder = FakePersonBuilder() + private val fakeInbox = FakeInboxStyle().also { it.captureInteractions() } + private val fakeMessagingStyle = FakeMessagingStyle() + + private val styleBuilder = AndroidNotificationStyleBuilder( + personBuilderFactory = { fakePersonBuilder.instance }, + inboxStyleFactory = { fakeInbox.instance }, + messagingStyleFactory = { + fakeMessagingStyle.user = it + fakeMessagingStyle.instance + }, + ) + + @Test + fun `given an inbox style, when building android style, then returns framework version`() { + val input = AndroidNotificationStyle.Inbox( + lines = listOf("hello", "world"), + summary = "a summary" + ) + + val result = styleBuilder.build(input) + + result shouldBeEqualTo fakeInbox.instance + fakeInbox.lines shouldBeEqualTo input.lines + fakeInbox.summary shouldBeEqualTo input.summary + } + +} diff --git a/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationFactoryTest.kt b/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationFactoryTest.kt new file mode 100644 index 0000000..350f60b --- /dev/null +++ b/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationFactoryTest.kt @@ -0,0 +1,156 @@ +package app.dapk.st.notifications + +import android.app.Notification +import android.app.PendingIntent +import android.os.Build +import app.dapk.st.core.DeviceMeta +import app.dapk.st.matrix.common.AvatarUrl +import app.dapk.st.matrix.sync.RoomOverview +import fake.FakeContext +import fixture.NotificationDelegateFixtures.anInboxStyle +import fixture.NotificationFixtures.aRoomNotification +import fixture.aRoomId +import fixture.aRoomOverview +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test + +private val AN_OPEN_APP_INTENT = aPendingIntent() +private val AN_OPEN_ROOM_INTENT = aPendingIntent() +private val A_NOTIFICATION_STYLE = anInboxStyle() +private val A_ROOM_ID = aRoomId() +private val A_DM_ROOM_OVERVIEW = aRoomOverview(roomId = A_ROOM_ID, roomAvatarUrl = AvatarUrl("https://a-url.gif"), isGroup = false) +private val A_GROUP_ROOM_OVERVIEW = aRoomOverview(roomId = A_ROOM_ID, roomAvatarUrl = AvatarUrl("https://a-url.gif"), isGroup = true) +private val A_ROOM_ICON = anIcon() +private val LATEST_EVENT = aNotifiable("message three", utcTimestamp = 3) +private val EVENTS = listOf( + aNotifiable("message one", utcTimestamp = 1), + LATEST_EVENT, + aNotifiable("message two", utcTimestamp = 2), +) + + +class NotificationFactoryTest { + + private val fakeContext = FakeContext() + private val fakeNotificationStyleFactory = FakeNotificationStyleFactory() + private val fakeIntentFactory = FakeIntentFactory() + private val fakeIconLoader = FakeIconLoader() + + private val notificationFactory = NotificationFactory( + fakeContext.instance, + fakeNotificationStyleFactory.instance, + fakeIntentFactory, + fakeIconLoader, + DeviceMeta(26), + ) + + @Test + fun `given alerting room notification, when creating summary, then is alerting`() { + val notifications = listOf(aRoomNotification(isAlerting = true)) + fakeIntentFactory.givenNotificationOpenApp(fakeContext.instance).returns(AN_OPEN_APP_INTENT) + fakeNotificationStyleFactory.givenSummary(notifications).returns(anInboxStyle()) + + val result = notificationFactory.createSummary(notifications) + + result shouldBeEqualTo expectedSummary(shouldAlertMoreThanOnce = true) + } + + @Test + fun `given non alerting room notification, when creating summary, then is alerting`() { + val notifications = listOf(aRoomNotification(isAlerting = false)) + fakeIntentFactory.givenNotificationOpenApp(fakeContext.instance).returns(AN_OPEN_APP_INTENT) + fakeNotificationStyleFactory.givenSummary(notifications).returns(anInboxStyle()) + + val result = notificationFactory.createSummary(notifications) + + result shouldBeEqualTo expectedSummary(shouldAlertMoreThanOnce = false) + } + + @Test + fun `given new events in a new group room, when creating message, then alerts`() = runTest { + givenEventsFor(A_GROUP_ROOM_OVERVIEW) + + val result = notificationFactory.createMessageNotification(EVENTS, A_GROUP_ROOM_OVERVIEW, setOf(A_ROOM_ID), newRooms = setOf(A_ROOM_ID)) + + result shouldBeEqualTo expectedMessage( + shouldAlertMoreThanOnce = true, + ) + } + + @Test + fun `given new events in an existing group room, when creating message, then does not alert`() = runTest { + givenEventsFor(A_GROUP_ROOM_OVERVIEW) + + val result = notificationFactory.createMessageNotification(EVENTS, A_GROUP_ROOM_OVERVIEW, setOf(A_ROOM_ID), newRooms = emptySet()) + + result shouldBeEqualTo expectedMessage( + shouldAlertMoreThanOnce = false, + ) + } + + @Test + fun `given new events in a new DM room, when creating message, then alerts`() = runTest { + givenEventsFor(A_DM_ROOM_OVERVIEW) + + val result = notificationFactory.createMessageNotification(EVENTS, A_DM_ROOM_OVERVIEW, setOf(A_ROOM_ID), newRooms = setOf(A_ROOM_ID)) + + result shouldBeEqualTo expectedMessage( + shouldAlertMoreThanOnce = true, + ) + } + + @Test + fun `given new events in an existing DM room, when creating message, then alerts`() = runTest { + givenEventsFor(A_DM_ROOM_OVERVIEW) + + val result = notificationFactory.createMessageNotification(EVENTS, A_DM_ROOM_OVERVIEW, setOf(A_ROOM_ID), newRooms = emptySet()) + + result shouldBeEqualTo expectedMessage( + shouldAlertMoreThanOnce = true, + ) + } + + 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) + fakeIconLoader.given(roomOverview.roomAvatarUrl!!.value).returns(A_ROOM_ICON) + } + + private fun expectedMessage( + shouldAlertMoreThanOnce: Boolean, + ) = NotificationTypes.Room( + AndroidNotification( + channelId = "message", + whenTimestamp = LATEST_EVENT.utcTimestamp, + groupId = "st", + groupAlertBehavior = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) Notification.GROUP_ALERT_SUMMARY else null, + shortcutId = A_ROOM_ID.value, + alertMoreThanOnce = shouldAlertMoreThanOnce, + contentIntent = AN_OPEN_ROOM_INTENT, + messageStyle = A_NOTIFICATION_STYLE, + category = Notification.CATEGORY_MESSAGE, + smallIcon = R.drawable.ic_notification_small_icon, + largeIcon = A_ROOM_ICON, + autoCancel = true + ), + A_ROOM_ID, + summary = LATEST_EVENT.content, + messageCount = EVENTS.size, + isAlerting = shouldAlertMoreThanOnce, + ) + + private fun expectedSummary(shouldAlertMoreThanOnce: Boolean) = AndroidNotification( + channelId = "message", + messageStyle = A_NOTIFICATION_STYLE, + alertMoreThanOnce = shouldAlertMoreThanOnce, + smallIcon = R.drawable.ic_notification_small_icon, + contentIntent = AN_OPEN_APP_INTENT, + groupId = "st", + isGroupSummary = true, + autoCancel = true + ) +} + +fun aPendingIntent() = mockk() \ No newline at end of file diff --git a/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationRendererTest.kt b/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationRendererTest.kt index a89ae5e..bd50bd7 100644 --- a/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationRendererTest.kt +++ b/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationRendererTest.kt @@ -1,59 +1,76 @@ package app.dapk.st.notifications -import fixture.NotificationFixtures.aNotifications +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.matrix.sync.RoomOverview import fake.FakeNotificationFactory import fake.FakeNotificationManager import fake.aFakeNotification import fixture.CoroutineDispatchersFixture.aCoroutineDispatchers +import fixture.NotificationDelegateFixtures.anAndroidNotification +import fixture.NotificationFixtures.aNotifications +import fixture.NotificationFixtures.aRoomNotification import fixture.aRoomId +import io.mockk.every +import io.mockk.mockk import org.junit.Test +import test.delegateReturn import test.expect import test.runExpectTest private const val SUMMARY_ID = 101 private const val ROOM_MESSAGE_ID = 100 -private val A_SUMMARY_NOTIFICATION = aFakeNotification() +private val A_SUMMARY_ANDROID_NOTIFICATION = anAndroidNotification(isGroupSummary = true) +private val A_NOTIFICATION = aFakeNotification() + +class FakeAndroidNotificationBuilder { + val instance = mockk() + + fun given(notification: AndroidNotification) = every { instance.build(notification) }.delegateReturn() +} class NotificationRendererTest { private val fakeNotificationManager = FakeNotificationManager() private val fakeNotificationFactory = FakeNotificationFactory() + private val fakeAndroidNotificationBuilder = FakeAndroidNotificationBuilder() private val notificationRenderer = NotificationRenderer( fakeNotificationManager.instance, fakeNotificationFactory.instance, + fakeAndroidNotificationBuilder.instance, aCoroutineDispatchers() ) @Test fun `given removed rooms when rendering then cancels notifications with cancelled room ids`() = runExpectTest { val removedRooms = setOf(aRoomId("id-1"), aRoomId("id-2")) - fakeNotificationFactory.instance.expect { it.createNotifications(emptyMap(), emptySet(), emptySet()) } + fakeNotificationFactory.instance.expect { it.mapToNotifications(NotificationState(emptyMap(), removedRooms, emptySet(), emptySet())) } fakeNotificationManager.instance.expectUnit { removedRooms.forEach { removedRoom -> it.cancel(removedRoom.value, ROOM_MESSAGE_ID) } } - notificationRenderer.render(emptyMap(), removedRooms, emptySet(), emptySet()) + notificationRenderer.render(NotificationState(emptyMap(), removedRooms, emptySet(), emptySet())) verifyExpects() } @Test fun `given summary notification is not created, when rendering, then cancels summary notification`() = runExpectTest { - fakeNotificationFactory.givenNotifications(emptyMap(), emptySet(), emptySet()).returns(aNotifications(summaryNotification = null)) + fakeNotificationFactory.givenNotifications(aNotificationState()).returns(aNotifications(summaryNotification = null)) fakeNotificationManager.instance.expectUnit { it.cancel(SUMMARY_ID) } - notificationRenderer.render(emptyMap(), emptySet(), emptySet(), emptySet()) + notificationRenderer.render(NotificationState(emptyMap(), emptySet(), emptySet(), emptySet())) verifyExpects() } @Test fun `given update is only removals, when rendering, then only renders room dismiss`() = runExpectTest { - fakeNotificationFactory.givenNotifications(emptyMap(), emptySet(), emptySet()).returns(aNotifications(summaryNotification = null)) + fakeNotificationFactory.givenNotifications(aNotificationState()).returns(aNotifications(summaryNotification = null)) fakeNotificationManager.instance.expectUnit { it.cancel(SUMMARY_ID) } - notificationRenderer.render(emptyMap(), emptySet(), emptySet(), emptySet()) + notificationRenderer.render(NotificationState(emptyMap(), emptySet(), emptySet(), emptySet())) verifyExpects() } @@ -62,24 +79,26 @@ class NotificationRendererTest { fun `given rooms with events, when rendering, then notifies summary and new rooms`() = runExpectTest { val roomNotification = aRoomNotification() val roomsWithNewEvents = setOf(roomNotification.roomId) - fakeNotificationFactory.givenNotifications(emptyMap(), roomsWithNewEvents, emptySet()).returns( - aNotifications(summaryNotification = A_SUMMARY_NOTIFICATION, delegates = listOf(roomNotification)) - ) - fakeNotificationManager.instance.expectUnit { it.notify(SUMMARY_ID, A_SUMMARY_NOTIFICATION) } - fakeNotificationManager.instance.expectUnit { it.notify(roomNotification.roomId.value, ROOM_MESSAGE_ID, roomNotification.notification) } - notificationRenderer.render(emptyMap(), emptySet(), roomsWithNewEvents, emptySet()) + fakeAndroidNotificationBuilder.given(roomNotification.notification).returns(A_NOTIFICATION) + fakeAndroidNotificationBuilder.given(A_SUMMARY_ANDROID_NOTIFICATION).returns(A_NOTIFICATION) + + fakeNotificationFactory.givenNotifications(aNotificationState(roomsWithNewEvents = roomsWithNewEvents)).returns( + aNotifications(summaryNotification = A_SUMMARY_ANDROID_NOTIFICATION, delegates = listOf(roomNotification)) + ) + fakeNotificationManager.instance.expectUnit { it.notify(SUMMARY_ID, A_NOTIFICATION) } + fakeNotificationManager.instance.expectUnit { it.notify(roomNotification.roomId.value, ROOM_MESSAGE_ID, A_NOTIFICATION) } + + notificationRenderer.render(NotificationState(emptyMap(), emptySet(), roomsWithNewEvents, emptySet())) verifyExpects() } - - private fun aRoomNotification() = NotificationDelegate.Room( - aFakeNotification(), - aRoomId(), - "a summary line", - messageCount = 1, - isAlerting = false - ) } +fun aNotificationState( + allUnread: Map> = emptyMap(), + removedRooms: Set = emptySet(), + roomsWithNewEvents: Set = emptySet(), + newRooms: Set = emptySet(), +) = NotificationState(allUnread, removedRooms, roomsWithNewEvents, newRooms) diff --git a/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationStateMapperTest.kt b/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationStateMapperTest.kt new file mode 100644 index 0000000..045ed36 --- /dev/null +++ b/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationStateMapperTest.kt @@ -0,0 +1,142 @@ +package app.dapk.st.notifications + +import android.content.Context +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.navigator.IntentFactory +import fixture.NotificationDelegateFixtures.anAndroidNotification +import fixture.NotificationFixtures.aDismissRoomNotification +import fixture.NotificationFixtures.aRoomNotification +import fixture.aRoomMessageEvent +import fixture.aRoomOverview +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test +import test.delegateReturn + +private val A_SUMMARY_NOTIFICATION = anAndroidNotification() +private val A_ROOM_OVERVIEW = aRoomOverview() + +class NotificationStateMapperTest { + + private val fakeRoomEventsToNotifiableMapper = FakeRoomEventsToNotifiableMapper() + private val fakeNotificationFactory = FakeNotificationFactory() + + private val factory = NotificationStateMapper( + fakeRoomEventsToNotifiableMapper.instance, + fakeNotificationFactory.instance, + ) + + @Test + fun `given no room message events, when mapping notifications, then creates doesn't create summary and dismisses rooms`() = runTest { + val notificationState = aNotificationState(allUnread = mapOf(A_ROOM_OVERVIEW to listOf())) + fakeRoomEventsToNotifiableMapper.given(emptyList()).returns(emptyList()) + + val result = factory.mapToNotifications(notificationState) + + result shouldBeEqualTo Notifications( + summaryNotification = null, + delegates = listOf(aDismissRoomNotification(A_ROOM_OVERVIEW.roomId)) + ) + } + + @Test + fun `given room message events, when mapping notifications, then creates summary and message notifications`() = runTest { + val notificationState = aNotificationState(allUnread = mapOf(aRoomOverview() to listOf(aRoomMessageEvent()))) + val expectedNotification = givenCreatesNotification(notificationState, aRoomNotification()) + fakeNotificationFactory.givenCreateSummary(listOf(expectedNotification)).returns(A_SUMMARY_NOTIFICATION) + + val result = factory.mapToNotifications(notificationState) + + result shouldBeEqualTo Notifications( + summaryNotification = A_SUMMARY_NOTIFICATION, + delegates = listOf(expectedNotification) + ) + +// +// val allUnread = listOf(aRoomMessageEvent()) +// val value = listOf(aNotifiable()) +// fakeRoomEventsToNotifiableMapper.given(allUnread).returns(value) +// +// fakeIntentFactory.notificationOpenApp() +// fakeIntentFactory.notificationOpenMessage() +// +// fakeNotificationStyleFactory.givenMessage(value, aRoomOverview()).returns(aMessagingStyle()) +// fakeNotificationStyleFactory.givenSummary(listOf()).returns(anInboxStyle()) +// +// val result = factory.mapToNotifications( +// allUnread = mapOf( +// aRoomOverview() to allUnread +// ), +// roomsWithNewEvents = setOf(), +// newRooms = setOf() +// ) +// +// +// result shouldBeEqualTo Notifications( +// summaryNotification = anAndroidNotification(), +// delegates = listOf( +// NotificationTypes.Room( +// anAndroidNotification(), +// aRoomId(), +// summary = "a summary", +// messageCount = 1, +// isAlerting = false +// ) +// ) +// ) + } + + private fun givenCreatesNotification(state: NotificationState, result: NotificationTypes.Room): NotificationTypes.Room { + state.allUnread.map { (roomOverview, events) -> + val value = listOf(aNotifiable()) + fakeRoomEventsToNotifiableMapper.given(events).returns(value) + fakeNotificationFactory.givenCreateMessage( + value, + roomOverview, + state.roomsWithNewEvents, + state.newRooms + ).returns(result) + } + return result + } +} + +class FakeIntentFactory : IntentFactory by mockk() { + fun givenNotificationOpenApp(context: Context) = every { notificationOpenApp(context) }.delegateReturn() + fun givenNotificationOpenMessage(context: Context, roomId: RoomId) = every { notificationOpenMessage(context, roomId) }.delegateReturn() +} + +class FakeNotificationStyleFactory { + val instance = mockk() + + fun givenMessage(events: List, roomOverview: RoomOverview) = coEvery { + instance.message(events, roomOverview) + }.delegateReturn() + + fun givenSummary(notifications: List) = every { instance.summary(notifications) }.delegateReturn() + +} + +class FakeRoomEventsToNotifiableMapper { + val instance = mockk() + + fun given(events: List) = every { instance.map(events) }.delegateReturn() +} + +class FakeNotificationFactory { + val instance = mockk() + + fun givenCreateMessage( + events: List, + roomOverview: RoomOverview, + roomsWithNewEvents: Set, + newRooms: Set + ) = coEvery { instance.createMessageNotification(events, roomOverview, roomsWithNewEvents, newRooms) }.delegateReturn() + + fun givenCreateSummary(roomNotifications: List) = every { instance.createSummary(roomNotifications) }.delegateReturn() +} \ No newline at end of file diff --git a/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationStyleFactoryTest.kt b/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationStyleFactoryTest.kt new file mode 100644 index 0000000..c004cb8 --- /dev/null +++ b/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationStyleFactoryTest.kt @@ -0,0 +1,89 @@ +package app.dapk.st.notifications + +import android.graphics.drawable.Icon +import app.dapk.st.core.DeviceMeta +import app.dapk.st.imageloader.IconLoader +import app.dapk.st.matrix.common.AvatarUrl +import app.dapk.st.matrix.common.RoomMember +import app.dapk.st.notifications.AndroidNotificationStyle.Inbox +import app.dapk.st.notifications.AndroidNotificationStyle.Messaging +import fixture.NotificationDelegateFixtures.anAndroidPerson +import fixture.NotificationFixtures.aRoomNotification +import fixture.aRoomMember +import fixture.aRoomOverview +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test +import test.delegateReturn + +private val A_GROUP_ROOM_OVERVIEW = aRoomOverview(roomName = "my awesome room", isGroup = true) + +class NotificationStyleFactoryTest { + + private val fakeIconLoader = FakeIconLoader() + + private val styleFactory = NotificationStyleFactory( + fakeIconLoader, + DeviceMeta(28), + ) + + @Test + fun `when creating summary style, then creates android framework inbox style`() { + val result = styleFactory.summary( + listOf( + aRoomNotification(summary = "room 1 summary", messageCount = 10), + aRoomNotification(summary = "room 2 summary", messageCount = 1), + ) + ) + + result shouldBeEqualTo Inbox( + lines = listOf("room 1 summary", "room 2 summary"), + summary = "11 messages from 2 chats" + ) + } + + @Test + fun `when creating message style, then creates android framework messaging style`() = runTest { + val aMessage = aNotifiable(author = aRoomMember(displayName = "a display name", avatarUrl = AvatarUrl("a-url"))) + val authorIcon = anIcon() + fakeIconLoader.given(aMessage.author.avatarUrl!!.value).returns(authorIcon) + + val result = styleFactory.message(listOf(aMessage), A_GROUP_ROOM_OVERVIEW) + + result shouldBeEqualTo Messaging( + person = Messaging.AndroidPerson(name = "me", key = A_GROUP_ROOM_OVERVIEW.roomId.value, icon = null), + title = A_GROUP_ROOM_OVERVIEW.roomName, + isGroup = true, + content = listOf(aMessage.toAndroidMessage(authorIcon)) + ) + } + +} + +private fun Notifiable.toAndroidMessage(expectedAuthorIcon: Icon) = Messaging.AndroidMessage( + anAndroidPerson( + name = author.displayName!!, + key = author.id.value, + icon = expectedAuthorIcon + ), + content = content, + timestamp = utcTimestamp, +) + +fun aNotifiable( + content: String = "notifiable content", + utcTimestamp: Long = 1000, + author: RoomMember = aRoomMember() +) = Notifiable(content, utcTimestamp, author) + +class FakeIconLoader : IconLoader by mockk() { + fun given(url: String) = coEvery { load(url) }.delegateReturn() +} + +class FakeIcon { + val instance = mockk() +} + +fun anIcon() = FakeIcon().instance \ No newline at end of file diff --git a/features/notifications/src/test/kotlin/app/dapk/st/notifications/ObserveUnreadRenderNotificationsUseCaseTest.kt b/features/notifications/src/test/kotlin/app/dapk/st/notifications/ObserveUnreadRenderNotificationsUseCaseTest.kt index b052378..fdce470 100644 --- a/features/notifications/src/test/kotlin/app/dapk/st/notifications/ObserveUnreadRenderNotificationsUseCaseTest.kt +++ b/features/notifications/src/test/kotlin/app/dapk/st/notifications/ObserveUnreadRenderNotificationsUseCaseTest.kt @@ -12,10 +12,10 @@ 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")) +private val A_MESSAGE = aRoomMessageEvent(eventId = anEventId("1"), content = "hello") +private val A_MESSAGE_2 = aRoomMessageEvent(eventId = anEventId("2"), content = "world") +private val A_ROOM_OVERVIEW = aRoomOverview(roomId = aRoomId("1")) +private val A_ROOM_OVERVIEW_2 = aRoomOverview(roomId = aRoomId("2")) class ObserveUnreadRenderNotificationsUseCaseTest { diff --git a/features/notifications/src/test/kotlin/app/dapk/st/notifications/RenderNotificationsUseCaseTest.kt b/features/notifications/src/test/kotlin/app/dapk/st/notifications/RenderNotificationsUseCaseTest.kt index 4cc7f04..be968b5 100644 --- a/features/notifications/src/test/kotlin/app/dapk/st/notifications/RenderNotificationsUseCaseTest.kt +++ b/features/notifications/src/test/kotlin/app/dapk/st/notifications/RenderNotificationsUseCaseTest.kt @@ -31,7 +31,7 @@ class RenderNotificationsUseCaseTest { @Test fun `given renderable unread events, when listening for changes, then renders change`() = runTest { - fakeNotificationRenderer.instance.expect { it.render(any(), any(), any(), any()) } + fakeNotificationRenderer.instance.expect { it.render(any()) } fakeObserveUnreadNotificationsUseCase.given().emits(AN_UNREAD_NOTIFICATIONS) renderNotificationsUseCase.listenForNotificationChanges() diff --git a/features/notifications/src/test/kotlin/app/dapk/st/notifications/RoomEventsToNotifiableMapperTest.kt b/features/notifications/src/test/kotlin/app/dapk/st/notifications/RoomEventsToNotifiableMapperTest.kt new file mode 100644 index 0000000..11d8241 --- /dev/null +++ b/features/notifications/src/test/kotlin/app/dapk/st/notifications/RoomEventsToNotifiableMapperTest.kt @@ -0,0 +1,73 @@ +package app.dapk.st.notifications + +import fixture.aRoomImageMessageEvent +import fixture.aRoomMessageEvent +import fixture.aRoomReplyMessageEvent +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test + +class RoomEventsToNotifiableMapperTest { + + private val mapper = RoomEventsToNotifiableMapper() + + @Test + fun `given message event, when mapping, then uses original content`() { + val event = aRoomMessageEvent() + + val result = mapper.map(listOf(event)) + + result shouldBeEqualTo listOf( + Notifiable( + content = event.content, + utcTimestamp = event.utcTimestamp, + author = event.author + ) + ) + } + + @Test + fun `given image event, when mapping, then replaces content with camera emoji`() { + val event = aRoomImageMessageEvent() + + val result = mapper.map(listOf(event)) + + result shouldBeEqualTo listOf( + Notifiable( + content = "📷", + utcTimestamp = event.utcTimestamp, + author = event.author + ) + ) + } + + @Test + fun `given reply event with message, when mapping, then uses message for content`() { + val reply = aRoomMessageEvent(utcTimestamp = -1, content = "hello") + val event = aRoomReplyMessageEvent(reply, replyingTo = aRoomImageMessageEvent(utcTimestamp = -1)) + + val result = mapper.map(listOf(event)) + + result shouldBeEqualTo listOf( + Notifiable( + content = reply.content, + utcTimestamp = event.utcTimestamp, + author = event.author + ) + ) + } + + @Test + fun `given reply event with image, when mapping, then uses camera emoji for content`() { + val event = aRoomReplyMessageEvent(aRoomImageMessageEvent(utcTimestamp = -1)) + + val result = mapper.map(listOf(event)) + + result shouldBeEqualTo listOf( + Notifiable( + content = "📷", + utcTimestamp = event.utcTimestamp, + author = event.author + ) + ) + } +} \ No newline at end of file diff --git a/features/notifications/src/test/kotlin/fake/FakeNotificationFactory.kt b/features/notifications/src/test/kotlin/fake/FakeNotificationFactory.kt index 7afae99..012fc25 100644 --- a/features/notifications/src/test/kotlin/fake/FakeNotificationFactory.kt +++ b/features/notifications/src/test/kotlin/fake/FakeNotificationFactory.kt @@ -1,19 +1,15 @@ package fake -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.notifications.NotificationFactory +import app.dapk.st.notifications.NotificationState +import app.dapk.st.notifications.NotificationStateMapper import io.mockk.coEvery import io.mockk.mockk import test.delegateReturn class FakeNotificationFactory { - val instance = mockk() + val instance = mockk() - fun givenNotifications(allUnread: Map>, roomsWithNewEvents: Set, newRooms: Set) = coEvery { - instance.createNotifications(allUnread, roomsWithNewEvents, newRooms) - }.delegateReturn() + fun givenNotifications(state: NotificationState) = coEvery { instance.mapToNotifications(state) }.delegateReturn() } \ No newline at end of file diff --git a/features/notifications/src/test/kotlin/fake/FakeNotificationRenderer.kt b/features/notifications/src/test/kotlin/fake/FakeNotificationRenderer.kt index ad5d03b..fcdc118 100644 --- a/features/notifications/src/test/kotlin/fake/FakeNotificationRenderer.kt +++ b/features/notifications/src/test/kotlin/fake/FakeNotificationRenderer.kt @@ -1,6 +1,7 @@ package fake import app.dapk.st.notifications.NotificationRenderer +import app.dapk.st.notifications.NotificationState import app.dapk.st.notifications.UnreadNotifications import io.mockk.coVerify import io.mockk.mockk @@ -12,10 +13,12 @@ class FakeNotificationRenderer { unreadNotifications.forEach { unread -> coVerify { instance.render( - allUnread = unread.first, - removedRooms = unread.second.removed.keys, - roomsWithNewEvents = unread.second.changedOrNew.keys, - newRooms = unread.second.newRooms, + NotificationState( + allUnread = unread.first, + removedRooms = unread.second.removed.keys, + roomsWithNewEvents = unread.second.changedOrNew.keys, + newRooms = unread.second.newRooms, + ) ) } } diff --git a/features/notifications/src/test/kotlin/fixture/NotificationDelegateFixtures.kt b/features/notifications/src/test/kotlin/fixture/NotificationDelegateFixtures.kt new file mode 100644 index 0000000..93f5718 --- /dev/null +++ b/features/notifications/src/test/kotlin/fixture/NotificationDelegateFixtures.kt @@ -0,0 +1,65 @@ +package fixture + +import android.app.PendingIntent +import android.graphics.drawable.Icon +import app.dapk.st.notifications.AndroidNotification +import app.dapk.st.notifications.AndroidNotificationStyle +import io.mockk.mockk + +object NotificationDelegateFixtures { + + fun anAndroidNotification( + channelId: String = "a channel id", + whenTimestamp: Long? = 10000, + isGroupSummary: Boolean = false, + groupId: String? = "group id", + groupAlertBehavior: Int? = 5, + shortcutId: String? = "shortcut id", + alertMoreThanOnce: Boolean = false, + contentIntent: PendingIntent? = mockk(), + messageStyle: AndroidNotificationStyle? = aMessagingStyle(), + category: String? = "a category", + smallIcon: Int? = 500, + largeIcon: Icon? = mockk(), + autoCancel: Boolean = true, + ) = AndroidNotification( + channelId = channelId, + whenTimestamp = whenTimestamp, + isGroupSummary = isGroupSummary, + groupId = groupId, + groupAlertBehavior = groupAlertBehavior, + shortcutId = shortcutId, + alertMoreThanOnce = alertMoreThanOnce, + contentIntent = contentIntent, + messageStyle = messageStyle, + category = category, + smallIcon = smallIcon, + largeIcon = largeIcon, + autoCancel = autoCancel, + ) + + + fun aMessagingStyle() = AndroidNotificationStyle.Messaging( + anAndroidPerson(), + title = null, + isGroup = false, + content = listOf( + AndroidNotificationStyle.Messaging.AndroidMessage( + anAndroidPerson(), content = "message content", + timestamp = 1000 + ) + ) + ) + + fun anInboxStyle() = AndroidNotificationStyle.Inbox( + lines = listOf("first line"), + summary = null, + ) + + fun anAndroidPerson( + name: String = "a name", + key: String = "a unique key", + icon: Icon? = null, + ) = AndroidNotificationStyle.Messaging.AndroidPerson(name, key, icon) + +} \ No newline at end of file diff --git a/features/notifications/src/test/kotlin/fixture/NotificationFixtures.kt b/features/notifications/src/test/kotlin/fixture/NotificationFixtures.kt index fcd6734..d476f2f 100644 --- a/features/notifications/src/test/kotlin/fixture/NotificationFixtures.kt +++ b/features/notifications/src/test/kotlin/fixture/NotificationFixtures.kt @@ -1,14 +1,32 @@ package fixture -import android.app.Notification -import app.dapk.st.notifications.NotificationDelegate +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.notifications.AndroidNotification +import app.dapk.st.notifications.NotificationTypes import app.dapk.st.notifications.Notifications +import fixture.NotificationDelegateFixtures.anAndroidNotification object NotificationFixtures { fun aNotifications( - summaryNotification: Notification? = null, - delegates: List = emptyList(), + summaryNotification: AndroidNotification? = null, + delegates: List = emptyList(), ) = Notifications(summaryNotification, delegates) + fun aRoomNotification( + summary: String = "a summary line", + messageCount: Int = 1, + isAlerting: Boolean = false, + ) = NotificationTypes.Room( + anAndroidNotification(), + aRoomId(), + summary = summary, + messageCount = messageCount, + isAlerting = isAlerting + ) + + fun aDismissRoomNotification( + roomId: RoomId = aRoomId() + ) = NotificationTypes.DismissRoom(roomId) + } \ No newline at end of file diff --git a/tools/coverage.gradle b/tools/coverage.gradle index f3d0891..6138a33 100644 --- a/tools/coverage.gradle +++ b/tools/coverage.gradle @@ -23,6 +23,7 @@ def excludes = [ // Tmp until serializationx can ignore generated '**/Api*', + '**/RoomEvent*', ] def initializeReport(report, projects, classExcludes) {