improved notification testing

This commit is contained in:
Adam Brown 2022-06-11 10:21:51 +01:00
parent bb47fcd9d6
commit 65bf8c0d64
34 changed files with 1174 additions and 253 deletions

View File

@ -1,15 +1,14 @@
package app.dapk.st.graph package app.dapk.st.graph
import android.app.Application import android.app.Application
import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build
import app.dapk.db.DapkDb import app.dapk.db.DapkDb
import app.dapk.st.BuildConfig import app.dapk.st.BuildConfig
import app.dapk.st.SharedPreferencesDelegate import app.dapk.st.SharedPreferencesDelegate
import app.dapk.st.core.BuildMeta import app.dapk.st.core.*
import app.dapk.st.core.CoreAndroidModule
import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.SingletonFlows
import app.dapk.st.core.extensions.ErrorTracker import app.dapk.st.core.extensions.ErrorTracker
import app.dapk.st.core.extensions.unsafeLazy import app.dapk.st.core.extensions.unsafeLazy
import app.dapk.st.directory.DirectoryModule import app.dapk.st.directory.DirectoryModule
@ -89,6 +88,22 @@ internal class AppModule(context: Application, logger: MatrixLogger) {
val domainModules = DomainModules(matrixModules, trackingModule.errorTracker) val domainModules = DomainModules(matrixModules, trackingModule.errorTracker)
val coreAndroidModule = CoreAndroidModule(intentFactory = object : IntentFactory { 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 home(context: Context) = Intent(context, MainActivity::class.java)
override fun messenger(context: Context, roomId: RoomId) = MessengerActivity.newInstance(context, roomId) override fun messenger(context: Context, roomId: RoomId) = MessengerActivity.newInstance(context, roomId)
override fun messengerShortcut(context: Context, roomId: RoomId) = MessengerActivity.newShortcutInstance(context, roomId) override fun messengerShortcut(context: Context, roomId: RoomId) = MessengerActivity.newShortcutInstance(context, roomId)
@ -175,6 +190,7 @@ internal class FeatureModules internal constructor(
workModule.workScheduler(), workModule.workScheduler(),
intentFactory = coreAndroidModule.intentFactory(), intentFactory = coreAndroidModule.intentFactory(),
dispatchers = coroutineDispatchers, dispatchers = coroutineDispatchers,
deviceMeta = DeviceMeta(Build.VERSION.SDK_INT)
) )
} }

View File

@ -3,4 +3,4 @@ package app.dapk.st.core
data class BuildMeta( data class BuildMeta(
val versionName: String, val versionName: String,
val versionCode: Int, val versionCode: Int,
) )

View File

@ -0,0 +1,5 @@
package app.dapk.st.core
data class DeviceMeta(
val apiVersion: Int
)

View File

@ -1,9 +1,6 @@
package test package test
import io.mockk.MockKMatcherScope import io.mockk.*
import io.mockk.MockKVerificationScope
import io.mockk.coJustRun
import io.mockk.coVerify
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
@ -15,9 +12,11 @@ fun runExpectTest(testBody: suspend ExpectTestScope.() -> Unit) {
class ExpectTest(override val coroutineContext: CoroutineContext) : ExpectTestScope { class ExpectTest(override val coroutineContext: CoroutineContext) : ExpectTestScope {
private val expects = mutableListOf<Pair<Int, suspend MockKVerificationScope.() -> Unit>>() private val expects = mutableListOf<Pair<Int, suspend MockKVerificationScope.() -> Unit>>()
private val groups = mutableListOf<suspend MockKVerificationScope.() -> Unit>()
override fun verifyExpects() = expects.forEach { (times, block) -> override fun verifyExpects() {
coVerify(exactly = times) { block.invoke(this) } expects.forEach { (times, block) -> coVerify(exactly = times) { block.invoke(this) } }
groups.forEach { coVerifyOrder { it.invoke(this) } }
} }
override fun <T> T.expectUnit(times: Int, block: suspend MockKMatcherScope.(T) -> Unit) { override fun <T> 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) }) expects.add(times to { block(this@expectUnit) })
} }
override fun <T> T.captureExpects(block: suspend MockKMatcherScope.(T) -> Unit) {
groups.add { block(this@captureExpects) }
}
} }
private fun Any.ignore() = Unit private fun Any.ignore() = Unit
@ -32,4 +34,5 @@ private fun Any.ignore() = Unit
interface ExpectTestScope : CoroutineScope { interface ExpectTestScope : CoroutineScope {
fun verifyExpects() fun verifyExpects()
fun <T> T.expectUnit(times: Int = 1, block: suspend MockKMatcherScope.(T) -> Unit) fun <T> T.expectUnit(times: Int = 1, block: suspend MockKMatcherScope.(T) -> Unit)
fun <T> T.captureExpects(block: suspend MockKMatcherScope.(T) -> Unit)
} }

View File

@ -0,0 +1,19 @@
package app.dapk.st.core
import android.os.Build
fun <T> 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 <T> DeviceMeta.whenPOrHigher(block: () -> T, fallback: () -> T) = whenXOrHigher(Build.VERSION_CODES.P, block, fallback)
inline fun <T> DeviceMeta.whenOOrHigher(block: () -> T, fallback: () -> T) = whenXOrHigher(Build.VERSION_CODES.O, block, fallback)
inline fun <T> DeviceMeta.whenXOrHigher(version: Int, block: () -> T, fallback: () -> T): T {
return if (this.apiVersion >= version) block() else fallback()
}

View File

@ -0,0 +1,8 @@
package fake
import android.content.Context
import io.mockk.mockk
class FakeContext {
val instance = mockk<Context>()
}

View File

@ -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<String>()
val instance = mockk<Notification.InboxStyle>()
val lines = mutableListOf<String>()
val summary: String
get() = _summary.captured
fun captureInteractions() {
every { instance.addLine(capture(lines)) } returns instance
every { instance.setSummaryText(capture(_summary)) } returns instance
}
}

View File

@ -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<Notification.MessagingStyle>()
}
fun aFakeMessagingStyle() = FakeMessagingStyle().instance

View File

@ -0,0 +1,8 @@
package fake
import android.app.Notification
import io.mockk.mockk
class FakeNotificationBuilder {
val instance = mockk<Notification.Builder>(relaxed = true)
}

View File

@ -0,0 +1,8 @@
package fake
import android.app.Person
import io.mockk.mockk
class FakePersonBuilder {
val instance = mockk<Person.Builder>(relaxed = true)
}

View File

@ -1,6 +1,7 @@
package app.dapk.st.navigator package app.dapk.st.navigator
import android.app.Activity import android.app.Activity
import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.RoomId
@ -40,6 +41,8 @@ interface Navigator {
interface IntentFactory { interface IntentFactory {
fun notificationOpenApp(context: Context): PendingIntent
fun notificationOpenMessage(context: Context, roomId: RoomId): PendingIntent
fun home(context: Context): Intent fun home(context: Context): Intent
fun messenger(context: Context, roomId: RoomId): Intent fun messenger(context: Context, roomId: RoomId): Intent
fun messengerShortcut(context: Context, roomId: RoomId): Intent fun messengerShortcut(context: Context, roomId: RoomId): Intent

View File

@ -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 <T> 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)
}

View File

@ -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<String>, 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<AndroidMessage>,
) : 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)
}
}

View File

@ -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))
}
}
}

View File

@ -1,198 +1,58 @@
package app.dapk.st.notifications package app.dapk.st.notifications
import android.app.Notification import android.app.Notification
import android.app.PendingIntent
import android.app.Person
import android.content.Context import android.content.Context
import android.content.Intent import app.dapk.st.core.DeviceMeta
import android.os.Build import app.dapk.st.core.whenPOrHigher
import androidx.annotation.RequiresApi
import app.dapk.st.imageloader.IconLoader import app.dapk.st.imageloader.IconLoader
import app.dapk.st.matrix.common.RoomId 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.matrix.sync.RoomOverview
import app.dapk.st.messenger.MessengerActivity
import app.dapk.st.navigator.IntentFactory import app.dapk.st.navigator.IntentFactory
private const val GROUP_ID = "st" private const val GROUP_ID = "st"
private const val channelId = "message" private const val channelId = "message"
class NotificationFactory( class NotificationFactory(
private val iconLoader: IconLoader,
private val context: Context, private val context: Context,
private val notificationStyleFactory: NotificationStyleFactory,
private val intentFactory: IntentFactory, private val intentFactory: IntentFactory,
private val iconLoader: IconLoader,
private val deviceMeta: DeviceMeta,
) { ) {
private val shouldAlwaysAlertDms = true private val shouldAlwaysAlertDms = true
private fun RoomEvent.toNotifiableContent(): String = when (this) { suspend fun createMessageNotification(
is RoomEvent.Image -> "\uD83D\uDCF7"
is RoomEvent.Message -> this.content
is RoomEvent.Reply -> this.message.toNotifiableContent()
}
suspend fun createNotifications(allUnread: Map<RoomOverview, List<RoomEvent>>, roomsWithNewEvents: Set<RoomId>, newRooms: Set<RoomId>): 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<NotificationDelegate.Room>().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<NotificationDelegate>, 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<NotificationDelegate>.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<Notifiable>, 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(
events: List<Notifiable>, events: List<Notifiable>,
roomOverview: RoomOverview, roomOverview: RoomOverview,
roomsWithNewEvents: Set<RoomId>, roomsWithNewEvents: Set<RoomId>,
newRooms: Set<RoomId> newRooms: Set<RoomId>
): NotificationDelegate { ): NotificationTypes {
val sortedEvents = events.sortedBy { it.utcTimestamp } val sortedEvents = events.sortedBy { it.utcTimestamp }
val messageStyle = notificationStyleFactory.message(sortedEvents, roomOverview)
val messageStyle = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { val openRoomIntent = intentFactory.notificationOpenMessage(context, roomOverview.roomId)
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 shouldAlertMoreThanOnce = when { val shouldAlertMoreThanOnce = when {
roomOverview.isDm() -> roomsWithNewEvents.contains(roomOverview.roomId) && shouldAlwaysAlertDms roomOverview.isDm() -> roomsWithNewEvents.contains(roomOverview.roomId) && shouldAlwaysAlertDms
else -> newRooms.contains(roomOverview.roomId) else -> newRooms.contains(roomOverview.roomId)
} }
return NotificationDelegate.Room( return NotificationTypes.Room(
builder() AndroidNotification(
.setWhen(sortedEvents.last().utcTimestamp) channelId = channelId,
.setShowWhen(true) whenTimestamp = sortedEvents.last().utcTimestamp,
.setGroup(GROUP_ID) groupId = GROUP_ID,
.run { groupAlertBehavior = deviceMeta.whenPOrHigher(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { block = { Notification.GROUP_ALERT_SUMMARY },
this.setGroupAlertBehavior(Notification.GROUP_ALERT_SUMMARY) fallback = { null }
} else { ),
this shortcutId = roomOverview.roomId.value,
} alertMoreThanOnce = shouldAlertMoreThanOnce,
} contentIntent = openRoomIntent,
.setOnlyAlertOnce(!shouldAlertMoreThanOnce) messageStyle = messageStyle,
.setContentIntent(openRoomIntent) category = Notification.CATEGORY_MESSAGE,
.setStyle(messageStyle) smallIcon = R.drawable.ic_notification_small_icon,
.setCategory(Notification.CATEGORY_MESSAGE) largeIcon = roomOverview.roomAvatarUrl?.let { iconLoader.load(it.value) },
.run { autoCancel = true
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(),
roomId = roomOverview.roomId, roomId = roomOverview.roomId,
summary = sortedEvents.last().content, summary = sortedEvents.last().content,
messageCount = sortedEvents.size, messageCount = sortedEvents.size,
@ -200,16 +60,19 @@ class NotificationFactory(
) )
} }
private fun builder(channel: String = channelId) = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { fun createSummary(notifications: List<NotificationTypes.Room>): AndroidNotification {
Notification.Builder(context, channel) val summaryInboxStyle = notificationStyleFactory.summary(notifications)
} else { val openAppIntent = intentFactory.notificationOpenApp(context)
Notification.Builder(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 private fun RoomOverview.isDm() = !this.isGroup
data class Notifications(val summaryNotification: Notification?, val delegates: List<NotificationDelegate>)
data class Notifiable(val content: String, val utcTimestamp: Long, val author: RoomMember)

View File

@ -1,6 +1,5 @@
package app.dapk.st.notifications package app.dapk.st.notifications
import android.app.Notification
import android.app.NotificationManager import android.app.NotificationManager
import app.dapk.st.core.AppLogTag import app.dapk.st.core.AppLogTag
import app.dapk.st.core.CoroutineDispatchers 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.common.RoomId
import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.matrix.sync.RoomEvent
import app.dapk.st.matrix.sync.RoomOverview import app.dapk.st.matrix.sync.RoomOverview
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
private const val SUMMARY_NOTIFICATION_ID = 101 private const val SUMMARY_NOTIFICATION_ID = 101
@ -17,13 +15,14 @@ private const val MESSAGE_NOTIFICATION_ID = 100
class NotificationRenderer( class NotificationRenderer(
private val notificationManager: NotificationManager, private val notificationManager: NotificationManager,
private val notificationFactory: NotificationFactory, private val notificationStateMapper: NotificationStateMapper,
private val androidNotificationBuilder: AndroidNotificationBuilder,
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
) { ) {
suspend fun render(allUnread: Map<RoomOverview, List<RoomEvent>>, removedRooms: Set<RoomId>, roomsWithNewEvents: Set<RoomId>, newRooms: Set<RoomId>) { suspend fun render(state: NotificationState) {
removedRooms.forEach { notificationManager.cancel(it.value, MESSAGE_NOTIFICATION_ID) } state.removedRooms.forEach { notificationManager.cancel(it.value, MESSAGE_NOTIFICATION_ID) }
val notifications = notificationFactory.createNotifications(allUnread, roomsWithNewEvents, newRooms) val notifications = notificationStateMapper.mapToNotifications(state)
withContext(dispatchers.main) { withContext(dispatchers.main) {
notifications.summaryNotification.ifNull { notifications.summaryNotification.ifNull {
@ -31,31 +30,46 @@ class NotificationRenderer(
notificationManager.cancel(SUMMARY_NOTIFICATION_ID) notificationManager.cancel(SUMMARY_NOTIFICATION_ID)
} }
val onlyContainsRemovals = removedRooms.isNotEmpty() && roomsWithNewEvents.isEmpty() val onlyContainsRemovals = state.onlyContainsRemovals()
notifications.delegates.forEach { notifications.delegates.forEach {
when (it) { when (it) {
is NotificationDelegate.DismissRoom -> notificationManager.cancel(it.roomId.value, MESSAGE_NOTIFICATION_ID) is NotificationTypes.DismissRoom -> notificationManager.cancel(it.roomId.value, MESSAGE_NOTIFICATION_ID)
is NotificationDelegate.Room -> { is NotificationTypes.Room -> {
if (!onlyContainsRemovals) { if (!onlyContainsRemovals) {
log(AppLogTag.NOTIFICATION, "notifying ${it.roomId.value}") 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 { notifications.summaryNotification?.let {
if (notifications.delegates.filterIsInstance<NotificationDelegate.Room>().isNotEmpty() && !onlyContainsRemovals) { if (notifications.delegates.filterIsInstance<NotificationTypes.Room>().isNotEmpty() && !onlyContainsRemovals) {
log(AppLogTag.NOTIFICATION, "notifying summary") log(AppLogTag.NOTIFICATION, "notifying summary")
notificationManager.notify(SUMMARY_NOTIFICATION_ID, it) notificationManager.notify(SUMMARY_NOTIFICATION_ID, it.build(androidNotificationBuilder))
} }
} }
} }
} }
} }
sealed interface NotificationDelegate { data class NotificationState(
data class Room(val notification: Notification, val roomId: RoomId, val summary: String, val messageCount: Int, val isAlerting: Boolean) : NotificationDelegate val allUnread: Map<RoomOverview, List<RoomEvent>>,
data class DismissRoom(val roomId: RoomId) : NotificationDelegate val removedRooms: Set<RoomId>,
val roomsWithNewEvents: Set<RoomId>,
val newRooms: Set<RoomId>
)
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
} }

View File

@ -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<NotificationTypes.Room>()
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<NotificationTypes.Room>) = when {
roomNotifications.isNotEmpty() -> {
notificationFactory.createSummary(roomNotifications)
}
else -> null
}
}
data class Notifications(val summaryNotification: AndroidNotification?, val delegates: List<NotificationTypes>)

View File

@ -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<NotificationTypes.Room>) = Inbox(
lines = notifications
.sortedBy { it.notification.whenTimestamp }
.map { it.summary },
summary = "${notifications.countMessages()} messages from ${notifications.size} chats",
)
private fun List<NotificationTypes.Room>.countMessages() = this.sumOf { it.messageCount }
suspend fun message(events: List<Notifiable>, 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<Notifiable>, 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,
)
}
)
}

View File

@ -3,6 +3,7 @@ package app.dapk.st.notifications
import android.app.NotificationManager import android.app.NotificationManager
import android.content.Context import android.content.Context
import app.dapk.st.core.CoroutineDispatchers import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.DeviceMeta
import app.dapk.st.core.ProvidableModule import app.dapk.st.core.ProvidableModule
import app.dapk.st.imageloader.IconLoader import app.dapk.st.imageloader.IconLoader
import app.dapk.st.matrix.common.CredentialsStore import app.dapk.st.matrix.common.CredentialsStore
@ -24,6 +25,7 @@ class NotificationsModule(
private val workScheduler: WorkScheduler, private val workScheduler: WorkScheduler,
private val intentFactory: IntentFactory, private val intentFactory: IntentFactory,
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
private val deviceMeta: DeviceMeta,
) : ProvidableModule { ) : ProvidableModule {
fun pushUseCase() = pushService fun pushUseCase() = pushService
@ -32,9 +34,23 @@ class NotificationsModule(
fun firebasePushTokenUseCase() = firebasePushTokenUseCase fun firebasePushTokenUseCase() = firebasePushTokenUseCase
fun roomStore() = roomStore fun roomStore() = roomStore
fun notificationsUseCase() = RenderNotificationsUseCase( fun notificationsUseCase() = RenderNotificationsUseCase(
NotificationRenderer(notificationManager(), NotificationFactory(iconLoader, context, intentFactory), dispatchers), notificationRenderer = NotificationRenderer(
ObserveUnreadNotificationsUseCaseImpl(roomStore), notificationManager(),
NotificationChannels(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 private fun notificationManager() = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

View File

@ -26,10 +26,12 @@ class RenderNotificationsUseCase(
private suspend fun renderUnreadChange(allUnread: Map<RoomOverview, List<RoomEvent>>, diff: NotificationDiff) { private suspend fun renderUnreadChange(allUnread: Map<RoomOverview, List<RoomEvent>>, diff: NotificationDiff) {
log(NOTIFICATION, "unread changed - render notifications") log(NOTIFICATION, "unread changed - render notifications")
notificationRenderer.render( notificationRenderer.render(
allUnread = allUnread, NotificationState(
removedRooms = diff.removed.keys, allUnread = allUnread,
roomsWithNewEvents = diff.changedOrNew.keys, removedRooms = diff.removed.keys,
newRooms = diff.newRooms, roomsWithNewEvents = diff.changedOrNew.keys,
newRooms = diff.newRooms,
)
) )
} }
} }

View File

@ -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<RoomEvent>): List<Notifiable> {
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)

View File

@ -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<AndroidNotificationStyleBuilder>()
fun given(style: AndroidNotificationStyle) = every { instance.build(style) }.delegateReturn()
}

View File

@ -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
}
}

View File

@ -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<PendingIntent>()

View File

@ -1,59 +1,76 @@
package app.dapk.st.notifications 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.FakeNotificationFactory
import fake.FakeNotificationManager import fake.FakeNotificationManager
import fake.aFakeNotification import fake.aFakeNotification
import fixture.CoroutineDispatchersFixture.aCoroutineDispatchers import fixture.CoroutineDispatchersFixture.aCoroutineDispatchers
import fixture.NotificationDelegateFixtures.anAndroidNotification
import fixture.NotificationFixtures.aNotifications
import fixture.NotificationFixtures.aRoomNotification
import fixture.aRoomId import fixture.aRoomId
import io.mockk.every
import io.mockk.mockk
import org.junit.Test import org.junit.Test
import test.delegateReturn
import test.expect import test.expect
import test.runExpectTest import test.runExpectTest
private const val SUMMARY_ID = 101 private const val SUMMARY_ID = 101
private const val ROOM_MESSAGE_ID = 100 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<AndroidNotificationBuilder>()
fun given(notification: AndroidNotification) = every { instance.build(notification) }.delegateReturn()
}
class NotificationRendererTest { class NotificationRendererTest {
private val fakeNotificationManager = FakeNotificationManager() private val fakeNotificationManager = FakeNotificationManager()
private val fakeNotificationFactory = FakeNotificationFactory() private val fakeNotificationFactory = FakeNotificationFactory()
private val fakeAndroidNotificationBuilder = FakeAndroidNotificationBuilder()
private val notificationRenderer = NotificationRenderer( private val notificationRenderer = NotificationRenderer(
fakeNotificationManager.instance, fakeNotificationManager.instance,
fakeNotificationFactory.instance, fakeNotificationFactory.instance,
fakeAndroidNotificationBuilder.instance,
aCoroutineDispatchers() aCoroutineDispatchers()
) )
@Test @Test
fun `given removed rooms when rendering then cancels notifications with cancelled room ids`() = runExpectTest { fun `given removed rooms when rendering then cancels notifications with cancelled room ids`() = runExpectTest {
val removedRooms = setOf(aRoomId("id-1"), aRoomId("id-2")) 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 { fakeNotificationManager.instance.expectUnit {
removedRooms.forEach { removedRoom -> it.cancel(removedRoom.value, ROOM_MESSAGE_ID) } removedRooms.forEach { removedRoom -> it.cancel(removedRoom.value, ROOM_MESSAGE_ID) }
} }
notificationRenderer.render(emptyMap(), removedRooms, emptySet(), emptySet()) notificationRenderer.render(NotificationState(emptyMap(), removedRooms, emptySet(), emptySet()))
verifyExpects() verifyExpects()
} }
@Test @Test
fun `given summary notification is not created, when rendering, then cancels summary notification`() = runExpectTest { 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) } fakeNotificationManager.instance.expectUnit { it.cancel(SUMMARY_ID) }
notificationRenderer.render(emptyMap(), emptySet(), emptySet(), emptySet()) notificationRenderer.render(NotificationState(emptyMap(), emptySet(), emptySet(), emptySet()))
verifyExpects() verifyExpects()
} }
@Test @Test
fun `given update is only removals, when rendering, then only renders room dismiss`() = runExpectTest { 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) } fakeNotificationManager.instance.expectUnit { it.cancel(SUMMARY_ID) }
notificationRenderer.render(emptyMap(), emptySet(), emptySet(), emptySet()) notificationRenderer.render(NotificationState(emptyMap(), emptySet(), emptySet(), emptySet()))
verifyExpects() verifyExpects()
} }
@ -62,24 +79,26 @@ class NotificationRendererTest {
fun `given rooms with events, when rendering, then notifies summary and new rooms`() = runExpectTest { fun `given rooms with events, when rendering, then notifies summary and new rooms`() = runExpectTest {
val roomNotification = aRoomNotification() val roomNotification = aRoomNotification()
val roomsWithNewEvents = setOf(roomNotification.roomId) 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() verifyExpects()
} }
private fun aRoomNotification() = NotificationDelegate.Room(
aFakeNotification(),
aRoomId(),
"a summary line",
messageCount = 1,
isAlerting = false
)
} }
fun aNotificationState(
allUnread: Map<RoomOverview, List<RoomEvent>> = emptyMap(),
removedRooms: Set<RoomId> = emptySet(),
roomsWithNewEvents: Set<RoomId> = emptySet(),
newRooms: Set<RoomId> = emptySet(),
) = NotificationState(allUnread, removedRooms, roomsWithNewEvents, newRooms)

View File

@ -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<NotificationStyleFactory>()
fun givenMessage(events: List<Notifiable>, roomOverview: RoomOverview) = coEvery {
instance.message(events, roomOverview)
}.delegateReturn()
fun givenSummary(notifications: List<NotificationTypes.Room>) = every { instance.summary(notifications) }.delegateReturn()
}
class FakeRoomEventsToNotifiableMapper {
val instance = mockk<RoomEventsToNotifiableMapper>()
fun given(events: List<RoomEvent>) = every { instance.map(events) }.delegateReturn()
}
class FakeNotificationFactory {
val instance = mockk<NotificationFactory>()
fun givenCreateMessage(
events: List<Notifiable>,
roomOverview: RoomOverview,
roomsWithNewEvents: Set<RoomId>,
newRooms: Set<RoomId>
) = coEvery { instance.createMessageNotification(events, roomOverview, roomsWithNewEvents, newRooms) }.delegateReturn()
fun givenCreateSummary(roomNotifications: List<NotificationTypes.Room>) = every { instance.createSummary(roomNotifications) }.delegateReturn()
}

View File

@ -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<Icon>()
}
fun anIcon() = FakeIcon().instance

View File

@ -12,10 +12,10 @@ import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test import org.junit.Test
private val NO_UNREADS = emptyMap<RoomOverview, List<RoomEvent>>() private val NO_UNREADS = emptyMap<RoomOverview, List<RoomEvent>>()
val A_MESSAGE = aRoomMessageEvent(eventId = anEventId("1"), content = "hello") private val A_MESSAGE = aRoomMessageEvent(eventId = anEventId("1"), content = "hello")
val A_MESSAGE_2 = aRoomMessageEvent(eventId = anEventId("2"), content = "world") private val A_MESSAGE_2 = aRoomMessageEvent(eventId = anEventId("2"), content = "world")
val A_ROOM_OVERVIEW = aRoomOverview(roomId = aRoomId("1")) private val A_ROOM_OVERVIEW = aRoomOverview(roomId = aRoomId("1"))
val A_ROOM_OVERVIEW_2 = aRoomOverview(roomId = aRoomId("2")) private val A_ROOM_OVERVIEW_2 = aRoomOverview(roomId = aRoomId("2"))
class ObserveUnreadRenderNotificationsUseCaseTest { class ObserveUnreadRenderNotificationsUseCaseTest {

View File

@ -31,7 +31,7 @@ class RenderNotificationsUseCaseTest {
@Test @Test
fun `given renderable unread events, when listening for changes, then renders change`() = runTest { 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) fakeObserveUnreadNotificationsUseCase.given().emits(AN_UNREAD_NOTIFICATIONS)
renderNotificationsUseCase.listenForNotificationChanges() renderNotificationsUseCase.listenForNotificationChanges()

View File

@ -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
)
)
}
}

View File

@ -1,19 +1,15 @@
package fake package fake
import app.dapk.st.matrix.common.RoomId import app.dapk.st.notifications.NotificationState
import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.notifications.NotificationStateMapper
import app.dapk.st.matrix.sync.RoomOverview
import app.dapk.st.notifications.NotificationFactory
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.mockk import io.mockk.mockk
import test.delegateReturn import test.delegateReturn
class FakeNotificationFactory { class FakeNotificationFactory {
val instance = mockk<NotificationFactory>() val instance = mockk<NotificationStateMapper>()
fun givenNotifications(allUnread: Map<RoomOverview, List<RoomEvent>>, roomsWithNewEvents: Set<RoomId>, newRooms: Set<RoomId>) = coEvery { fun givenNotifications(state: NotificationState) = coEvery { instance.mapToNotifications(state) }.delegateReturn()
instance.createNotifications(allUnread, roomsWithNewEvents, newRooms)
}.delegateReturn()
} }

View File

@ -1,6 +1,7 @@
package fake package fake
import app.dapk.st.notifications.NotificationRenderer import app.dapk.st.notifications.NotificationRenderer
import app.dapk.st.notifications.NotificationState
import app.dapk.st.notifications.UnreadNotifications import app.dapk.st.notifications.UnreadNotifications
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.mockk import io.mockk.mockk
@ -12,10 +13,12 @@ class FakeNotificationRenderer {
unreadNotifications.forEach { unread -> unreadNotifications.forEach { unread ->
coVerify { coVerify {
instance.render( instance.render(
allUnread = unread.first, NotificationState(
removedRooms = unread.second.removed.keys, allUnread = unread.first,
roomsWithNewEvents = unread.second.changedOrNew.keys, removedRooms = unread.second.removed.keys,
newRooms = unread.second.newRooms, roomsWithNewEvents = unread.second.changedOrNew.keys,
newRooms = unread.second.newRooms,
)
) )
} }
} }

View File

@ -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)
}

View File

@ -1,14 +1,32 @@
package fixture package fixture
import android.app.Notification import app.dapk.st.matrix.common.RoomId
import app.dapk.st.notifications.NotificationDelegate import app.dapk.st.notifications.AndroidNotification
import app.dapk.st.notifications.NotificationTypes
import app.dapk.st.notifications.Notifications import app.dapk.st.notifications.Notifications
import fixture.NotificationDelegateFixtures.anAndroidNotification
object NotificationFixtures { object NotificationFixtures {
fun aNotifications( fun aNotifications(
summaryNotification: Notification? = null, summaryNotification: AndroidNotification? = null,
delegates: List<NotificationDelegate> = emptyList(), delegates: List<NotificationTypes> = emptyList(),
) = Notifications(summaryNotification, delegates) ) = 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)
} }