Merge branch 'main' of github.com:ouchadam/helium into feature/share-images-via-small-talk

This commit is contained in:
Adam Brown 2022-07-28 22:08:44 +01:00
commit 9c99b6a0a9
27 changed files with 239 additions and 75 deletions

View File

@ -25,7 +25,7 @@ jobs:
- name: Create pip requirements - name: Create pip requirements
run: | run: |
echo "matrix-synapse" > requirements.txt echo "matrix-synapse==v1.60.0" > requirements.txt
- name: Set up Python 3.8 - name: Set up Python 3.8
uses: actions/setup-python@v2 uses: actions/setup-python@v2

View File

@ -61,7 +61,7 @@ android {
} }
dependencies { dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' coreLibraryDesugaring Dependencies.google.jdkLibs
implementation project(":features:home") implementation project(":features:home")
implementation project(":features:directory") implementation project(":features:directory")

View File

@ -9,7 +9,7 @@ buildscript {
classpath Dependencies.mavenCentral.kotlinGradlePlugin classpath Dependencies.mavenCentral.kotlinGradlePlugin
classpath Dependencies.mavenCentral.sqldelightGradlePlugin classpath Dependencies.mavenCentral.sqldelightGradlePlugin
classpath Dependencies.mavenCentral.kotlinSerializationGradlePlugin classpath Dependencies.mavenCentral.kotlinSerializationGradlePlugin
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.8.1' classpath Dependencies.google.firebaseCrashlyticsPlugin
} }
} }
@ -132,11 +132,11 @@ ext.kotlinTest = { dependencies ->
dependencies.testImplementation Dependencies.mavenCentral.kluent dependencies.testImplementation Dependencies.mavenCentral.kluent
dependencies.testImplementation Dependencies.mavenCentral.kotlinTest dependencies.testImplementation Dependencies.mavenCentral.kotlinTest
dependencies.testImplementation "org.jetbrains.kotlin:kotlin-test-junit:1.6.10" dependencies.testImplementation "org.jetbrains.kotlin:kotlin-test-junit:1.6.10"
dependencies.testImplementation 'io.mockk:mockk:1.12.4' dependencies.testImplementation 'io.mockk:mockk:1.12.5'
dependencies.testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.2' dependencies.testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
dependencies.testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2' dependencies.testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.0'
dependencies.testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.2' dependencies.testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.9.0'
} }
ext.kotlinFixtures = { dependencies -> ext.kotlinFixtures = { dependencies ->

View File

@ -31,10 +31,12 @@ class SingletonFlows(
} }
} }
@Suppress("UNCHECKED_CAST")
fun <T> get(key: String): Flow<T> { fun <T> get(key: String): Flow<T> {
return cache[key]!! as Flow<T> return cache[key]!! as Flow<T>
} }
@Suppress("UNCHECKED_CAST")
suspend fun <T> update(key: String, value: T) { suspend fun <T> update(key: String, value: T) {
(cache[key] as? MutableSharedFlow<T>)?.emit(value) (cache[key] as? MutableSharedFlow<T>)?.emit(value)
} }

View File

@ -88,10 +88,10 @@ ext.Dependencies.with {
} }
} }
def kotlinVer = "1.6.10" def kotlinVer = "1.7.0"
def sqldelightVer = "1.5.3" def sqldelightVer = "1.5.3"
def composeVer = "1.1.1" def composeVer = "1.1.1"
def ktorVer = "2.0.2" def ktorVer = "2.0.3"
google = new DependenciesContainer() google = new DependenciesContainer()
google.with { google.with {
@ -102,7 +102,10 @@ ext.Dependencies.with {
androidxComposeMaterial = "androidx.compose.material:material:${composeVer}" androidxComposeMaterial = "androidx.compose.material:material:${composeVer}"
androidxComposeIconsExtended = "androidx.compose.material:material-icons-extended:${composeVer}" androidxComposeIconsExtended = "androidx.compose.material:material-icons-extended:${composeVer}"
androidxActivityCompose = "androidx.activity:activity-compose:1.4.0" androidxActivityCompose = "androidx.activity:activity-compose:1.4.0"
kotlinCompilerExtensionVersion = "${composeVer}" kotlinCompilerExtensionVersion = "1.2.0"
firebaseCrashlyticsPlugin = "com.google.firebase:firebase-crashlytics-gradle:2.9.1"
jdkLibs = "com.android.tools:desugar_jdk_libs:1.1.5"
} }
mavenCentral = new DependenciesContainer() mavenCentral = new DependenciesContainer()
@ -110,8 +113,8 @@ ext.Dependencies.with {
kotlinGradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVer}" kotlinGradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVer}"
kotlinSerializationGradlePlugin = "org.jetbrains.kotlin:kotlin-serialization:${kotlinVer}" kotlinSerializationGradlePlugin = "org.jetbrains.kotlin:kotlin-serialization:${kotlinVer}"
kotlinSerializationJson = "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.3" kotlinSerializationJson = "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.3"
kotlinCoroutinesCore = "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.2" kotlinCoroutinesCore = "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4"
kotlinCoroutinesTest = 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.2' kotlinCoroutinesTest = 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
kotlinTest = "org.jetbrains.kotlin:kotlin-test-junit:${kotlinVer}" kotlinTest = "org.jetbrains.kotlin:kotlin-test-junit:${kotlinVer}"
sqldelightGradlePlugin = "com.squareup.sqldelight:gradle-plugin:${sqldelightVer}" sqldelightGradlePlugin = "com.squareup.sqldelight:gradle-plugin:${sqldelightVer}"
@ -129,11 +132,11 @@ ext.Dependencies.with {
ktorContentNegotiation = "io.ktor:ktor-client-content-negotiation:${ktorVer}" ktorContentNegotiation = "io.ktor:ktor-client-content-negotiation:${ktorVer}"
coil = "io.coil-kt:coil-compose:2.1.0" coil = "io.coil-kt:coil-compose:2.1.0"
accompanistSystemuicontroller = "com.google.accompanist:accompanist-systemuicontroller:0.24.10-beta" accompanistSystemuicontroller = "com.google.accompanist:accompanist-systemuicontroller:0.24.13-rc"
junit = "junit:junit:4.13.2" junit = "junit:junit:4.13.2"
kluent = "org.amshove.kluent:kluent:1.68" kluent = "org.amshove.kluent:kluent:1.68"
mockk = 'io.mockk:mockk:1.12.4' mockk = 'io.mockk:mockk:1.12.5'
matrixOlm = "org.matrix.android:olm-sdk:3.2.12" matrixOlm = "org.matrix.android:olm-sdk:3.2.12"
} }

View File

@ -41,6 +41,9 @@ fun LifecycleEffect(onStart: () -> Unit = {}, onStop: () -> Unit = {}) {
when (event) { when (event) {
Lifecycle.Event.ON_START -> onStart() Lifecycle.Event.ON_START -> onStart()
Lifecycle.Event.ON_STOP -> onStop() Lifecycle.Event.ON_STOP -> onStop()
else -> {
// ignored
}
} }
} }

View File

@ -1,5 +1,5 @@
@file:JvmName("SnapshotStateKt") @file:JvmName("SnapshotStateKt")
@file:Suppress("UNUSED")
package androidx.compose.runtime package androidx.compose.runtime
import kotlin.reflect.KProperty import kotlin.reflect.KProperty

View File

@ -6,6 +6,7 @@ import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain import kotlinx.coroutines.test.setMain
import test.ExpectTest import test.ExpectTest
@Suppress("UNCHECKED_CAST")
class ViewModelTest { class ViewModelTest {
var instance: TestMutableState<Any>? = null var instance: TestMutableState<Any>? = null

View File

@ -93,8 +93,7 @@ internal fun MessengerScreen(roomId: RoomId, attachments: List<MessageAttachment
private fun MessengerViewModel.ObserveEvents() { private fun MessengerViewModel.ObserveEvents() {
StartObserving { StartObserving {
this@ObserveEvents.events.launch { this@ObserveEvents.events.launch {
when (it) { // TODO()
}
} }
} }
} }
@ -438,6 +437,10 @@ private fun ReplyBubbleContent(content: BubbleContent<RoomEvent.Reply>) {
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
} }
is RoomEvent.Reply -> {
// TODO - a reply to a reply
}
} }
} }
@ -475,6 +478,10 @@ private fun ReplyBubbleContent(content: BubbleContent<RoomEvent.Reply>) {
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
} }
is RoomEvent.Reply -> {
// TODO - a reply to a reply
}
} }
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))

View File

@ -34,6 +34,8 @@ class AndroidNotificationStyleBuilder(
.setKey(person.key) .setKey(person.key)
.build() .build()
).also { style -> ).also { style ->
style.conversationTitle = title
style.isGroupConversation = isGroup
content.forEach { content.forEach {
val sender = personBuilderFactory() val sender = personBuilderFactory()
.setName(it.sender.name) .setName(it.sender.name)

View File

@ -1,10 +1,15 @@
package app.dapk.st.notifications package app.dapk.st.notifications
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationChannelGroup
import android.app.NotificationManager import android.app.NotificationManager
import android.os.Build import android.os.Build
private const val channelId = "message" const val DIRECT_CHANNEL_ID = "direct_channel_id"
const val GROUP_CHANNEL_ID = "group_channel_id"
const val SUMMARY_CHANNEL_ID = "summary_channel_id"
private const val CHATS_NOTIFICATION_GROUP_ID = "chats_notification_group"
class NotificationChannels( class NotificationChannels(
private val notificationManager: NotificationManager private val notificationManager: NotificationManager
@ -12,13 +17,43 @@ class NotificationChannels(
fun initChannels() { fun initChannels() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (notificationManager.getNotificationChannel(channelId) == null) { notificationManager.createNotificationChannelGroup(NotificationChannelGroup(CHATS_NOTIFICATION_GROUP_ID, "Chats"))
if (notificationManager.getNotificationChannel(DIRECT_CHANNEL_ID) == null) {
notificationManager.createNotificationChannel( notificationManager.createNotificationChannel(
NotificationChannel( NotificationChannel(
channelId, DIRECT_CHANNEL_ID,
"messages", "Direct notifications",
NotificationManager.IMPORTANCE_HIGH, NotificationManager.IMPORTANCE_HIGH,
).also {
it.enableVibration(true)
it.enableLights(true)
it.group = CHATS_NOTIFICATION_GROUP_ID
}
) )
}
if (notificationManager.getNotificationChannel(GROUP_CHANNEL_ID) == null) {
notificationManager.createNotificationChannel(
NotificationChannel(
GROUP_CHANNEL_ID,
"Group notifications",
NotificationManager.IMPORTANCE_HIGH,
).also {
it.group = CHATS_NOTIFICATION_GROUP_ID
}
)
}
if (notificationManager.getNotificationChannel(SUMMARY_CHANNEL_ID) == null) {
notificationManager.createNotificationChannel(
NotificationChannel(
SUMMARY_CHANNEL_ID,
"Other notifications",
NotificationManager.IMPORTANCE_DEFAULT,
).also {
it.group = CHATS_NOTIFICATION_GROUP_ID
}
) )
} }
} }

View File

@ -10,7 +10,6 @@ import app.dapk.st.matrix.sync.RoomOverview
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"
class NotificationFactory( class NotificationFactory(
private val context: Context, private val context: Context,
@ -35,17 +34,18 @@ class NotificationFactory(
else -> newRooms.contains(roomOverview.roomId) else -> newRooms.contains(roomOverview.roomId)
} }
val last = sortedEvents.last()
return NotificationTypes.Room( return NotificationTypes.Room(
AndroidNotification( AndroidNotification(
channelId = channelId, channelId = SUMMARY_CHANNEL_ID,
whenTimestamp = sortedEvents.last().utcTimestamp, whenTimestamp = last.utcTimestamp,
groupId = GROUP_ID, groupId = GROUP_ID,
groupAlertBehavior = deviceMeta.whenPOrHigher( groupAlertBehavior = deviceMeta.whenPOrHigher(
block = { Notification.GROUP_ALERT_SUMMARY }, block = { Notification.GROUP_ALERT_SUMMARY },
fallback = { null } fallback = { null }
), ),
shortcutId = roomOverview.roomId.value, shortcutId = roomOverview.roomId.value,
alertMoreThanOnce = shouldAlertMoreThanOnce, alertMoreThanOnce = false,
contentIntent = openRoomIntent, contentIntent = openRoomIntent,
messageStyle = messageStyle, messageStyle = messageStyle,
category = Notification.CATEGORY_MESSAGE, category = Notification.CATEGORY_MESSAGE,
@ -54,25 +54,38 @@ class NotificationFactory(
autoCancel = true autoCancel = true
), ),
roomId = roomOverview.roomId, roomId = roomOverview.roomId,
summary = sortedEvents.last().content, summary = last.content,
messageCount = sortedEvents.size, messageCount = sortedEvents.size,
isAlerting = shouldAlertMoreThanOnce isAlerting = shouldAlertMoreThanOnce,
summaryChannelId = when {
roomOverview.isDm() -> DIRECT_CHANNEL_ID
else -> GROUP_CHANNEL_ID
}
) )
} }
fun createSummary(notifications: List<NotificationTypes.Room>): AndroidNotification { fun createSummary(notifications: List<NotificationTypes.Room>): AndroidNotification {
val summaryInboxStyle = notificationStyleFactory.summary(notifications) val summaryInboxStyle = notificationStyleFactory.summary(notifications)
val openAppIntent = intentFactory.notificationOpenApp(context) val openAppIntent = intentFactory.notificationOpenApp(context)
val mostRecent = notifications.mostRecent()
return AndroidNotification( return AndroidNotification(
channelId = channelId, channelId = mostRecent.summaryChannelId,
messageStyle = summaryInboxStyle, messageStyle = summaryInboxStyle,
whenTimestamp = mostRecent.notification.whenTimestamp,
alertMoreThanOnce = notifications.any { it.isAlerting }, alertMoreThanOnce = notifications.any { it.isAlerting },
smallIcon = R.drawable.ic_notification_small_icon, smallIcon = R.drawable.ic_notification_small_icon,
contentIntent = openAppIntent, contentIntent = openAppIntent,
groupId = GROUP_ID, groupId = GROUP_ID,
groupAlertBehavior = deviceMeta.whenPOrHigher(
block = { Notification.GROUP_ALERT_SUMMARY },
fallback = { null }
),
isGroupSummary = true, isGroupSummary = true,
category = Notification.CATEGORY_MESSAGE,
) )
} }
} }
private fun List<NotificationTypes.Room>.mostRecent() = this.sortedBy { it.notification.whenTimestamp }.first()
private fun RoomOverview.isDm() = !this.isGroup private fun RoomOverview.isDm() = !this.isGroup

View File

@ -68,7 +68,8 @@ sealed interface NotificationTypes {
val roomId: RoomId, val roomId: RoomId,
val summary: String, val summary: String,
val messageCount: Int, val messageCount: Int,
val isAlerting: Boolean val isAlerting: Boolean,
val summaryChannelId: String,
) : NotificationTypes ) : NotificationTypes
data class DismissRoom(val roomId: RoomId) : NotificationTypes data class DismissRoom(val roomId: RoomId) : NotificationTypes

View File

@ -16,7 +16,14 @@ class NotificationStateMapper(
val messageEvents = roomEventsToNotifiableMapper.map(events) val messageEvents = roomEventsToNotifiableMapper.map(events)
when (messageEvents.isEmpty()) { when (messageEvents.isEmpty()) {
true -> NotificationTypes.DismissRoom(roomOverview.roomId) true -> NotificationTypes.DismissRoom(roomOverview.roomId)
false -> notificationFactory.createMessageNotification(messageEvents, roomOverview, state.roomsWithNewEvents, state.newRooms) false -> {
notificationFactory.createMessageNotification(
events = messageEvents,
roomOverview = roomOverview,
roomsWithNewEvents = state.roomsWithNewEvents,
newRooms = state.newRooms
)
}
} }
} }

View File

@ -34,10 +34,12 @@ private fun Flow<UnreadNotifications>.onlyRenderableChanges(): Flow<UnreadNotifi
log(AppLogTag.NOTIFICATION, "Ignoring unread change due to no renderable changes") log(AppLogTag.NOTIFICATION, "Ignoring unread change due to no renderable changes")
false false
} }
inferredCurrentNotifications.isEmpty() && diff.removed.isNotEmpty() -> { inferredCurrentNotifications.isEmpty() && diff.removed.isNotEmpty() -> {
log(AppLogTag.NOTIFICATION, "Ignoring unread change due to no currently showing messages and changes are all messages marked as read") log(AppLogTag.NOTIFICATION, "Ignoring unread change due to no currently showing messages and changes are all messages marked as read")
false false
} }
else -> true else -> true
} }
} }
@ -45,29 +47,48 @@ private fun Flow<UnreadNotifications>.onlyRenderableChanges(): Flow<UnreadNotifi
} }
private fun Flow<Map<RoomOverview, List<RoomEvent>>>.mapWithDiff(): Flow<Pair<Map<RoomOverview, List<RoomEvent>>, NotificationDiff>> { private fun Flow<Map<RoomOverview, List<RoomEvent>>>.mapWithDiff(): Flow<Pair<Map<RoomOverview, List<RoomEvent>>, NotificationDiff>> {
val previousUnreadEvents = mutableMapOf<RoomId, List<EventId>>() val previousUnreadEvents = mutableMapOf<RoomId, List<TimestampedEventId>>()
return this.map { each -> return this.map { each ->
val allUnreadIds = each.toIds() val allUnreadIds = each.toTimestampedIds()
val notificationDiff = calculateDiff(allUnreadIds, previousUnreadEvents) val notificationDiff = calculateDiff(allUnreadIds, previousUnreadEvents)
previousUnreadEvents.clearAndPutAll(allUnreadIds) previousUnreadEvents.clearAndPutAll(allUnreadIds)
each to notificationDiff each to notificationDiff
} }
} }
private fun calculateDiff(allUnread: Map<RoomId, List<EventId>>, previousUnread: Map<RoomId, List<EventId>>?): NotificationDiff { private fun calculateDiff(allUnread: Map<RoomId, List<TimestampedEventId>>, previousUnread: Map<RoomId, List<TimestampedEventId>>?): NotificationDiff {
val previousLatestEventTimestamps = previousUnread.toLatestTimestamps()
val newRooms = allUnread.filter { !previousUnread.containsKey(it.key) }.keys val newRooms = allUnread.filter { !previousUnread.containsKey(it.key) }.keys
val unchanged = previousUnread?.filter { allUnread.containsKey(it.key) && it.value == allUnread[it.key] } ?: emptyMap()
val changedOrNew = allUnread.filterNot { unchanged.containsKey(it.key) } val unchanged = previousUnread?.filter {
allUnread.containsKey(it.key) && (it.value == allUnread[it.key])
} ?: emptyMap()
val changedOrNew = allUnread.filterNot { unchanged.containsKey(it.key) }.mapValues { (key, value) ->
val isChangedRoom = !newRooms.contains(key)
if (isChangedRoom) {
val latest = previousLatestEventTimestamps[key] ?: 0L
value.filter {
val isExistingEvent = (previousUnread?.get(key)?.contains(it) ?: false)
!isExistingEvent && it.second > latest
}
} else {
value
}
}.filter { it.value.isNotEmpty() }
val removed = previousUnread?.filter { !allUnread.containsKey(it.key) } ?: emptyMap() val removed = previousUnread?.filter { !allUnread.containsKey(it.key) } ?: emptyMap()
return NotificationDiff(unchanged, changedOrNew, removed, newRooms) return NotificationDiff(unchanged.toEventIds(), changedOrNew.toEventIds(), removed.toEventIds(), newRooms)
} }
private fun List<RoomEvent>.toEventIds() = this.map { it.eventId } private fun Map<RoomId, List<TimestampedEventId>>?.toLatestTimestamps() = this?.mapValues { it.value.maxOf { it.second } } ?: emptyMap()
private fun Map<RoomOverview, List<RoomEvent>>.toIds() = this private fun Map<RoomId, List<TimestampedEventId>>.toEventIds() = this.mapValues { it.value.map { it.first } }
private fun Map<RoomOverview, List<RoomEvent>>.toTimestampedIds() = this
.mapValues { it.value.toEventIds() } .mapValues { it.value.toEventIds() }
.mapKeys { it.key.roomId } .mapKeys { it.key.roomId }
private fun List<RoomEvent>.toEventIds() = this.map { it.eventId to it.utcTimestamp }
private fun <T> Flow<T>.avoidShowingPreviousNotificationsOnLaunch() = drop(1) private fun <T> Flow<T>.avoidShowingPreviousNotificationsOnLaunch() = drop(1)
data class NotificationDiff( data class NotificationDiff(
@ -76,3 +97,5 @@ data class NotificationDiff(
val removed: Map<RoomId, List<EventId>>, val removed: Map<RoomId, List<EventId>>,
val newRooms: Set<RoomId> val newRooms: Set<RoomId>
) )
typealias TimestampedEventId = Pair<EventId, Long>

View File

@ -1,30 +1,25 @@
package app.dapk.st.notifications package app.dapk.st.notifications
import app.dapk.st.core.AppLogTag.NOTIFICATION
import app.dapk.st.core.log
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.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
class RenderNotificationsUseCase( class RenderNotificationsUseCase(
private val notificationRenderer: NotificationRenderer, private val notificationRenderer: NotificationRenderer,
private val observeRenderableUnreadEventsUseCase: ObserveUnreadNotificationsUseCase, private val observeRenderableUnreadEventsUseCase: ObserveUnreadNotificationsUseCase,
notificationChannels: NotificationChannels, private val notificationChannels: NotificationChannels,
) { ) {
init {
notificationChannels.initChannels()
}
suspend fun listenForNotificationChanges() { suspend fun listenForNotificationChanges() {
observeRenderableUnreadEventsUseCase() observeRenderableUnreadEventsUseCase()
.onStart { notificationChannels.initChannels() }
.onEach { (each, diff) -> renderUnreadChange(each, diff) } .onEach { (each, diff) -> renderUnreadChange(each, diff) }
.collect() .collect()
} }
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")
notificationRenderer.render( notificationRenderer.render(
NotificationState( NotificationState(
allUnread = allUnread, allUnread = allUnread,

View File

@ -7,6 +7,7 @@ import app.dapk.st.core.DeviceMeta
import app.dapk.st.matrix.common.AvatarUrl import app.dapk.st.matrix.common.AvatarUrl
import app.dapk.st.matrix.sync.RoomOverview import app.dapk.st.matrix.sync.RoomOverview
import fake.FakeContext import fake.FakeContext
import fixture.NotificationDelegateFixtures.anAndroidNotification
import fixture.NotificationDelegateFixtures.anInboxStyle import fixture.NotificationDelegateFixtures.anInboxStyle
import fixture.NotificationFixtures.aRoomNotification import fixture.NotificationFixtures.aRoomNotification
import fixture.aRoomId import fixture.aRoomId
@ -16,6 +17,7 @@ import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test import org.junit.Test
private const val A_CHANNEL_ID = "a channel id"
private val AN_OPEN_APP_INTENT = aPendingIntent() private val AN_OPEN_APP_INTENT = aPendingIntent()
private val AN_OPEN_ROOM_INTENT = aPendingIntent() private val AN_OPEN_ROOM_INTENT = aPendingIntent()
private val A_NOTIFICATION_STYLE = anInboxStyle() private val A_NOTIFICATION_STYLE = anInboxStyle()
@ -30,7 +32,6 @@ private val EVENTS = listOf(
aNotifiable("message two", utcTimestamp = 2), aNotifiable("message two", utcTimestamp = 2),
) )
class NotificationFactoryTest { class NotificationFactoryTest {
private val fakeContext = FakeContext() private val fakeContext = FakeContext()
@ -48,24 +49,34 @@ class NotificationFactoryTest {
@Test @Test
fun `given alerting room notification, when creating summary, then is alerting`() { fun `given alerting room notification, when creating summary, then is alerting`() {
val notifications = listOf(aRoomNotification(isAlerting = true)) val notifications = listOf(
aRoomNotification(
summaryChannelId = A_CHANNEL_ID,
notification = anAndroidNotification(channelId = A_CHANNEL_ID), isAlerting = true
)
)
fakeIntentFactory.givenNotificationOpenApp(fakeContext.instance).returns(AN_OPEN_APP_INTENT) fakeIntentFactory.givenNotificationOpenApp(fakeContext.instance).returns(AN_OPEN_APP_INTENT)
fakeNotificationStyleFactory.givenSummary(notifications).returns(anInboxStyle()) fakeNotificationStyleFactory.givenSummary(notifications).returns(anInboxStyle())
val result = notificationFactory.createSummary(notifications) val result = notificationFactory.createSummary(notifications)
result shouldBeEqualTo expectedSummary(shouldAlertMoreThanOnce = true) result shouldBeEqualTo expectedSummary(notifications.first().notification, shouldAlertMoreThanOnce = true)
} }
@Test @Test
fun `given non alerting room notification, when creating summary, then is alerting`() { fun `given non alerting room notification, when creating summary, then is alerting`() {
val notifications = listOf(aRoomNotification(isAlerting = false)) val notifications = listOf(
aRoomNotification(
summaryChannelId = A_CHANNEL_ID,
notification = anAndroidNotification(channelId = A_CHANNEL_ID), isAlerting = false
)
)
fakeIntentFactory.givenNotificationOpenApp(fakeContext.instance).returns(AN_OPEN_APP_INTENT) fakeIntentFactory.givenNotificationOpenApp(fakeContext.instance).returns(AN_OPEN_APP_INTENT)
fakeNotificationStyleFactory.givenSummary(notifications).returns(anInboxStyle()) fakeNotificationStyleFactory.givenSummary(notifications).returns(anInboxStyle())
val result = notificationFactory.createSummary(notifications) val result = notificationFactory.createSummary(notifications)
result shouldBeEqualTo expectedSummary(shouldAlertMoreThanOnce = false) result shouldBeEqualTo expectedSummary(notifications.first().notification, shouldAlertMoreThanOnce = false)
} }
@Test @Test
@ -75,6 +86,7 @@ class NotificationFactoryTest {
val result = notificationFactory.createMessageNotification(EVENTS, A_GROUP_ROOM_OVERVIEW, setOf(A_ROOM_ID), newRooms = setOf(A_ROOM_ID)) val result = notificationFactory.createMessageNotification(EVENTS, A_GROUP_ROOM_OVERVIEW, setOf(A_ROOM_ID), newRooms = setOf(A_ROOM_ID))
result shouldBeEqualTo expectedMessage( result shouldBeEqualTo expectedMessage(
channel = GROUP_CHANNEL_ID,
shouldAlertMoreThanOnce = true, shouldAlertMoreThanOnce = true,
) )
} }
@ -86,6 +98,7 @@ class NotificationFactoryTest {
val result = notificationFactory.createMessageNotification(EVENTS, A_GROUP_ROOM_OVERVIEW, setOf(A_ROOM_ID), newRooms = emptySet()) val result = notificationFactory.createMessageNotification(EVENTS, A_GROUP_ROOM_OVERVIEW, setOf(A_ROOM_ID), newRooms = emptySet())
result shouldBeEqualTo expectedMessage( result shouldBeEqualTo expectedMessage(
channel = GROUP_CHANNEL_ID,
shouldAlertMoreThanOnce = false, shouldAlertMoreThanOnce = false,
) )
} }
@ -97,6 +110,7 @@ class NotificationFactoryTest {
val result = notificationFactory.createMessageNotification(EVENTS, A_DM_ROOM_OVERVIEW, setOf(A_ROOM_ID), newRooms = setOf(A_ROOM_ID)) val result = notificationFactory.createMessageNotification(EVENTS, A_DM_ROOM_OVERVIEW, setOf(A_ROOM_ID), newRooms = setOf(A_ROOM_ID))
result shouldBeEqualTo expectedMessage( result shouldBeEqualTo expectedMessage(
channel = DIRECT_CHANNEL_ID,
shouldAlertMoreThanOnce = true, shouldAlertMoreThanOnce = true,
) )
} }
@ -108,6 +122,7 @@ class NotificationFactoryTest {
val result = notificationFactory.createMessageNotification(EVENTS, A_DM_ROOM_OVERVIEW, setOf(A_ROOM_ID), newRooms = emptySet()) val result = notificationFactory.createMessageNotification(EVENTS, A_DM_ROOM_OVERVIEW, setOf(A_ROOM_ID), newRooms = emptySet())
result shouldBeEqualTo expectedMessage( result shouldBeEqualTo expectedMessage(
channel = DIRECT_CHANNEL_ID,
shouldAlertMoreThanOnce = true, shouldAlertMoreThanOnce = true,
) )
} }
@ -119,15 +134,16 @@ class NotificationFactoryTest {
} }
private fun expectedMessage( private fun expectedMessage(
channel: String,
shouldAlertMoreThanOnce: Boolean, shouldAlertMoreThanOnce: Boolean,
) = NotificationTypes.Room( ) = NotificationTypes.Room(
AndroidNotification( AndroidNotification(
channelId = "message", channelId = SUMMARY_CHANNEL_ID,
whenTimestamp = LATEST_EVENT.utcTimestamp, whenTimestamp = LATEST_EVENT.utcTimestamp,
groupId = "st", groupId = "st",
groupAlertBehavior = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) Notification.GROUP_ALERT_SUMMARY else null, groupAlertBehavior = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) Notification.GROUP_ALERT_SUMMARY else null,
shortcutId = A_ROOM_ID.value, shortcutId = A_ROOM_ID.value,
alertMoreThanOnce = shouldAlertMoreThanOnce, alertMoreThanOnce = false,
contentIntent = AN_OPEN_ROOM_INTENT, contentIntent = AN_OPEN_ROOM_INTENT,
messageStyle = A_NOTIFICATION_STYLE, messageStyle = A_NOTIFICATION_STYLE,
category = Notification.CATEGORY_MESSAGE, category = Notification.CATEGORY_MESSAGE,
@ -139,15 +155,18 @@ class NotificationFactoryTest {
summary = LATEST_EVENT.content, summary = LATEST_EVENT.content,
messageCount = EVENTS.size, messageCount = EVENTS.size,
isAlerting = shouldAlertMoreThanOnce, isAlerting = shouldAlertMoreThanOnce,
summaryChannelId = channel,
) )
private fun expectedSummary(shouldAlertMoreThanOnce: Boolean) = AndroidNotification( private fun expectedSummary(notification: AndroidNotification, shouldAlertMoreThanOnce: Boolean) = AndroidNotification(
channelId = "message", channelId = notification.channelId,
whenTimestamp = notification.whenTimestamp,
messageStyle = A_NOTIFICATION_STYLE, messageStyle = A_NOTIFICATION_STYLE,
alertMoreThanOnce = shouldAlertMoreThanOnce, alertMoreThanOnce = shouldAlertMoreThanOnce,
smallIcon = R.drawable.ic_notification_small_icon, smallIcon = R.drawable.ic_notification_small_icon,
contentIntent = AN_OPEN_APP_INTENT, contentIntent = AN_OPEN_APP_INTENT,
groupId = "st", groupId = "st",
category = Notification.CATEGORY_MESSAGE,
isGroupSummary = true, isGroupSummary = true,
autoCancel = true autoCancel = true
) )

View File

@ -3,8 +3,11 @@ package app.dapk.st.notifications
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 fake.FakeRoomStore import fake.FakeRoomStore
import fixture.*
import fixture.NotificationDiffFixtures.aNotificationDiff import fixture.NotificationDiffFixtures.aNotificationDiff
import fixture.aRoomId
import fixture.aRoomMessageEvent
import fixture.aRoomOverview
import fixture.anEventId
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.toList import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -12,8 +15,8 @@ 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>>()
private val A_MESSAGE = aRoomMessageEvent(eventId = anEventId("1"), content = "hello") private val A_MESSAGE = aRoomMessageEvent(eventId = anEventId("1"), content = "hello", utcTimestamp = 1000)
private val A_MESSAGE_2 = aRoomMessageEvent(eventId = anEventId("2"), content = "world") private val A_MESSAGE_2 = aRoomMessageEvent(eventId = anEventId("2"), content = "world", utcTimestamp = 2000)
private val A_ROOM_OVERVIEW = aRoomOverview(roomId = aRoomId("1")) private val A_ROOM_OVERVIEW = aRoomOverview(roomId = aRoomId("1"))
private val A_ROOM_OVERVIEW_2 = aRoomOverview(roomId = aRoomId("2")) private val A_ROOM_OVERVIEW_2 = aRoomOverview(roomId = aRoomId("2"))
@ -48,7 +51,7 @@ class ObserveUnreadRenderNotificationsUseCaseTest {
changedOrNew = A_ROOM_OVERVIEW.toDiff(A_MESSAGE), changedOrNew = A_ROOM_OVERVIEW.toDiff(A_MESSAGE),
newRooms = setOf(A_ROOM_OVERVIEW.roomId) newRooms = setOf(A_ROOM_OVERVIEW.roomId)
), ),
A_ROOM_OVERVIEW.withUnreads(A_MESSAGE, A_MESSAGE_2) to aNotificationDiff(changedOrNew = A_ROOM_OVERVIEW.toDiff(A_MESSAGE, A_MESSAGE_2)) A_ROOM_OVERVIEW.withUnreads(A_MESSAGE, A_MESSAGE_2) to aNotificationDiff(changedOrNew = A_ROOM_OVERVIEW.toDiff(A_MESSAGE_2))
) )
} }
@ -61,7 +64,7 @@ class ObserveUnreadRenderNotificationsUseCaseTest {
val result = useCase.invoke().toList() val result = useCase.invoke().toList()
result shouldBeEqualTo listOf( result shouldBeEqualTo listOf(
A_ROOM_OVERVIEW.withUnreads(A_MESSAGE, A_MESSAGE_2) to aNotificationDiff(changedOrNew = A_ROOM_OVERVIEW.toDiff(A_MESSAGE, A_MESSAGE_2)) A_ROOM_OVERVIEW.withUnreads(A_MESSAGE, A_MESSAGE_2) to aNotificationDiff(changedOrNew = A_ROOM_OVERVIEW.toDiff(A_MESSAGE_2))
) )
} }
@ -76,6 +79,26 @@ class ObserveUnreadRenderNotificationsUseCaseTest {
result shouldBeEqualTo emptyList() result shouldBeEqualTo emptyList()
} }
@Test
fun `given new and then historical message, when reading a message, then only emits the latest`() = runTest {
fakeRoomStore.givenUnreadEvents(
flowOf(
NO_UNREADS,
A_ROOM_OVERVIEW.withUnreads(A_MESSAGE),
A_ROOM_OVERVIEW.withUnreads(A_MESSAGE, A_MESSAGE.copy(eventId = anEventId("old"), utcTimestamp = -1))
)
)
val result = useCase.invoke().toList()
result shouldBeEqualTo listOf(
A_ROOM_OVERVIEW.withUnreads(A_MESSAGE) to aNotificationDiff(
changedOrNew = A_ROOM_OVERVIEW.toDiff(A_MESSAGE),
newRooms = setOf(A_ROOM_OVERVIEW.roomId)
),
)
}
@Test @Test
fun `given initial unreads, when reading a duplicate unread, then emits nothing`() = runTest { fun `given initial unreads, when reading a duplicate unread, then emits nothing`() = runTest {
fakeRoomStore.givenUnreadEvents( fakeRoomStore.givenUnreadEvents(

View File

@ -25,7 +25,12 @@ class RenderNotificationsUseCaseTest {
) )
@Test @Test
fun `when creating use case instance, then initiates channels`() { fun `given events, when listening for changes then initiates channels once`() = runTest {
fakeNotificationRenderer.instance.expect { it.render(any()) }
fakeObserveUnreadNotificationsUseCase.given().emits(AN_UNREAD_NOTIFICATIONS)
renderNotificationsUseCase.listenForNotificationChanges()
fakeNotificationChannels.verifyInitiated() fakeNotificationChannels.verifyInitiated()
} }

View File

@ -14,15 +14,18 @@ object NotificationFixtures {
) = Notifications(summaryNotification, delegates) ) = Notifications(summaryNotification, delegates)
fun aRoomNotification( fun aRoomNotification(
notification: AndroidNotification = anAndroidNotification(),
summary: String = "a summary line", summary: String = "a summary line",
messageCount: Int = 1, messageCount: Int = 1,
isAlerting: Boolean = false, isAlerting: Boolean = false,
summaryChannelId: String = "a-summary-channel-id",
) = NotificationTypes.Room( ) = NotificationTypes.Room(
anAndroidNotification(), notification,
aRoomId(), aRoomId(),
summary = summary, summary = summary,
messageCount = messageCount, messageCount = messageCount,
isAlerting = isAlerting isAlerting = isAlerting,
summaryChannelId = summaryChannelId
) )
fun aDismissRoomNotification( fun aDismissRoomNotification(

View File

@ -199,6 +199,13 @@ private fun RootSettings(page: Page.Root, onClick: (SettingItem) -> Unit) {
item { Spacer(Modifier.height(12.dp)) } item { Spacer(Modifier.height(12.dp)) }
} }
} }
is Lce.Error -> {
// TODO
}
is Lce.Loading -> {
// TODO
}
} }
} }

View File

@ -42,6 +42,13 @@ fun EventLogScreen(viewModel: EventLoggerViewModel) {
} }
} }
} }
is Lce.Error -> {
// TODO
}
is Lce.Loading -> {
// TODO
}
} }
} }

Binary file not shown.

View File

@ -1,6 +1,6 @@
#Sat Apr 02 22:45:08 BST 2022
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStorePath=wrapper/dists distributionSha256Sum=cb87f222c5585bd46838ad4db78463a5c5f3d336e5e2b98dc7c0c586527351c2
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

6
gradlew vendored
View File

@ -205,6 +205,12 @@ set -- \
org.gradle.wrapper.GradleWrapperMain \ org.gradle.wrapper.GradleWrapperMain \
"$@" "$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args. # Use "xargs" to parse quoted args.
# #
# With -n1 it outputs one arg per line, with the quotes and backslashes removed. # With -n1 it outputs one arg per line, with the quotes and backslashes removed.

10
gradlew.bat vendored
View File

@ -40,7 +40,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1 %JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute if %ERRORLEVEL% equ 0 goto execute
echo. echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
@ -75,13 +75,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
:end :end
@rem End local scope for the variables with windows NT shell @rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd if %ERRORLEVEL% equ 0 goto mainEnd
:fail :fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code! rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 set EXIT_CODE=%ERRORLEVEL%
exit /b 1 if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd :mainEnd
if "%OS%"=="Windows_NT" endlocal if "%OS%"=="Windows_NT" endlocal

View File

@ -484,8 +484,8 @@ internal sealed class ApiTimelineEvent {
@Serializable @Serializable
internal data class Info( internal data class Info(
@SerialName("h") val height: Int, @SerialName("h") val height: Int? = null,
@SerialName("w") val width: Int, @SerialName("w") val width: Int? = null,
) )
} }