improved notification testing
This commit is contained in:
parent
bb47fcd9d6
commit
65bf8c0d64
|
@ -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
|
||||
|
@ -89,6 +88,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)
|
||||
|
@ -175,6 +190,7 @@ internal class FeatureModules internal constructor(
|
|||
workModule.workScheduler(),
|
||||
intentFactory = coreAndroidModule.intentFactory(),
|
||||
dispatchers = coroutineDispatchers,
|
||||
deviceMeta = DeviceMeta(Build.VERSION.SDK_INT)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
package app.dapk.st.core
|
||||
|
||||
data class DeviceMeta(
|
||||
val apiVersion: Int
|
||||
)
|
|
@ -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<Pair<Int, suspend MockKVerificationScope.() -> Unit>>()
|
||||
private val groups = mutableListOf<suspend MockKVerificationScope.() -> 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> 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> 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> T.expectUnit(times: Int = 1, block: suspend MockKMatcherScope.(T) -> Unit)
|
||||
fun <T> T.captureExpects(block: suspend MockKMatcherScope.(T) -> Unit)
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package fake
|
||||
|
||||
import android.content.Context
|
||||
import io.mockk.mockk
|
||||
|
||||
class FakeContext {
|
||||
val instance = mockk<Context>()
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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
|
|
@ -0,0 +1,8 @@
|
|||
package fake
|
||||
|
||||
import android.app.Notification
|
||||
import io.mockk.mockk
|
||||
|
||||
class FakeNotificationBuilder {
|
||||
val instance = mockk<Notification.Builder>(relaxed = true)
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package fake
|
||||
|
||||
import android.app.Person
|
||||
import io.mockk.mockk
|
||||
|
||||
class FakePersonBuilder {
|
||||
val instance = mockk<Person.Builder>(relaxed = true)
|
||||
}
|
|
@ -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 app.dapk.st.matrix.common.RoomId
|
||||
|
@ -40,6 +41,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
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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<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(
|
||||
suspend fun createMessageNotification(
|
||||
events: List<Notifiable>,
|
||||
roomOverview: RoomOverview,
|
||||
roomsWithNewEvents: Set<RoomId>,
|
||||
newRooms: Set<RoomId>
|
||||
): 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<NotificationTypes.Room>): 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<NotificationDelegate>)
|
||||
|
||||
data class Notifiable(val content: String, val utcTimestamp: Long, val author: RoomMember)
|
||||
|
|
|
@ -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<RoomOverview, List<RoomEvent>>, removedRooms: Set<RoomId>, roomsWithNewEvents: Set<RoomId>, newRooms: Set<RoomId>) {
|
||||
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<NotificationDelegate.Room>().isNotEmpty() && !onlyContainsRemovals) {
|
||||
if (notifications.delegates.filterIsInstance<NotificationTypes.Room>().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<RoomOverview, List<RoomEvent>>,
|
||||
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
|
||||
}
|
|
@ -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>)
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -26,10 +26,12 @@ class RenderNotificationsUseCase(
|
|||
private suspend fun renderUnreadChange(allUnread: Map<RoomOverview, List<RoomEvent>>, 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,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
|
@ -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()
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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>()
|
|
@ -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<AndroidNotificationBuilder>()
|
||||
|
||||
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<RoomOverview, List<RoomEvent>> = emptyMap(),
|
||||
removedRooms: Set<RoomId> = emptySet(),
|
||||
roomsWithNewEvents: Set<RoomId> = emptySet(),
|
||||
newRooms: Set<RoomId> = emptySet(),
|
||||
) = NotificationState(allUnread, removedRooms, roomsWithNewEvents, newRooms)
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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
|
|
@ -12,10 +12,10 @@ import org.amshove.kluent.shouldBeEqualTo
|
|||
import org.junit.Test
|
||||
|
||||
private val NO_UNREADS = emptyMap<RoomOverview, List<RoomEvent>>()
|
||||
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 {
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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<NotificationFactory>()
|
||||
val instance = mockk<NotificationStateMapper>()
|
||||
|
||||
fun givenNotifications(allUnread: Map<RoomOverview, List<RoomEvent>>, roomsWithNewEvents: Set<RoomId>, newRooms: Set<RoomId>) = coEvery {
|
||||
instance.createNotifications(allUnread, roomsWithNewEvents, newRooms)
|
||||
}.delegateReturn()
|
||||
fun givenNotifications(state: NotificationState) = coEvery { instance.mapToNotifications(state) }.delegateReturn()
|
||||
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
||||
}
|
|
@ -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<NotificationDelegate> = emptyList(),
|
||||
summaryNotification: AndroidNotification? = null,
|
||||
delegates: List<NotificationTypes> = 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)
|
||||
|
||||
}
|
Loading…
Reference in New Issue