diff --git a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt index 9a059c1..101f48a 100644 --- a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt +++ b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt @@ -165,6 +165,7 @@ internal class FeatureModules internal constructor( DirectoryModule( context = context, chatEngine = chatEngineModule.engine, + iconLoader = imageLoaderModule.iconLoader(), ) } val loginModule by unsafeLazy { diff --git a/core/src/testFixtures/kotlin/test/ExpectTestScope.kt b/core/src/testFixtures/kotlin/test/ExpectTestScope.kt index bff2b8f..47e9365 100644 --- a/core/src/testFixtures/kotlin/test/ExpectTestScope.kt +++ b/core/src/testFixtures/kotlin/test/ExpectTestScope.kt @@ -20,7 +20,7 @@ class ExpectTest(override val coroutineContext: CoroutineContext) : ExpectTestSc override fun verifyExpects() { expects.forEach { (times, block) -> coVerify(exactly = times) { block.invoke(this) } } - groups.forEach { coVerifyOrder { it.invoke(this) } } + groups.forEach { coVerifyAll { it.invoke(this) } } } override fun T.expectUnit(times: Int, block: suspend MockKMatcherScope.(T) -> Unit) { diff --git a/domains/android/core/build.gradle b/domains/android/core/build.gradle index d3781c1..46ba403 100644 --- a/domains/android/core/build.gradle +++ b/domains/android/core/build.gradle @@ -4,5 +4,6 @@ plugins { dependencies { compileOnly project(":domains:android:stub") + compileOnly libs.androidx.annotation implementation project(":core") } diff --git a/domains/android/core/src/main/kotlin/app/dapk/st/core/DeviceMetaExtensions.kt b/domains/android/core/src/main/kotlin/app/dapk/st/core/DeviceMetaExtensions.kt index a2dc53e..8b8a5eb 100644 --- a/domains/android/core/src/main/kotlin/app/dapk/st/core/DeviceMetaExtensions.kt +++ b/domains/android/core/src/main/kotlin/app/dapk/st/core/DeviceMetaExtensions.kt @@ -1,20 +1,35 @@ package app.dapk.st.core import android.os.Build +import androidx.annotation.ChecksSdkIntAtLeast +@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O, lambda = 0) fun DeviceMeta.isAtLeastO(block: () -> T, fallback: () -> T = { throw IllegalStateException("not handled") }): T { return if (this.apiVersion >= Build.VERSION_CODES.O) block() else fallback() } +@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S) fun DeviceMeta.isAtLeastS() = this.apiVersion >= Build.VERSION_CODES.S +@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O, lambda = 0) fun DeviceMeta.onAtLeastO(block: () -> Unit) { - if (this.apiVersion >= Build.VERSION_CODES.O) block() + whenXOrHigher(Build.VERSION_CODES.O, block, fallback = {}) +} + +@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.Q, lambda = 0) +fun DeviceMeta.onAtLeastQ(block: () -> Unit) { + whenXOrHigher(Build.VERSION_CODES.Q, block, fallback = {}) +} + +@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.R, lambda = 0) +fun DeviceMeta.onAtLeastR(block: () -> Unit) { + whenXOrHigher(Build.VERSION_CODES.R, block, fallback = {}) } inline fun DeviceMeta.whenPOrHigher(block: () -> T, fallback: () -> T) = whenXOrHigher(Build.VERSION_CODES.P, block, fallback) inline fun DeviceMeta.whenOOrHigher(block: () -> T, fallback: () -> T) = whenXOrHigher(Build.VERSION_CODES.O, block, fallback) +@ChecksSdkIntAtLeast(parameter = 0, lambda = 1) inline fun DeviceMeta.whenXOrHigher(version: Int, block: () -> T, fallback: () -> T): T { return if (this.apiVersion >= version) block() else fallback() } diff --git a/domains/android/stub/src/testFixtures/kotlin/fake/FakeNotificationBuilder.kt b/domains/android/stub/src/testFixtures/kotlin/fake/FakeNotificationBuilder.kt index 67aa3a0..491fd41 100644 --- a/domains/android/stub/src/testFixtures/kotlin/fake/FakeNotificationBuilder.kt +++ b/domains/android/stub/src/testFixtures/kotlin/fake/FakeNotificationBuilder.kt @@ -1,8 +1,12 @@ package fake import android.app.Notification +import io.mockk.every import io.mockk.mockk +import test.delegateReturn class FakeNotificationBuilder { val instance = mockk(relaxed = true) + + fun givenBuilds() = every { instance.build() }.delegateReturn() } \ No newline at end of file diff --git a/features/directory/build.gradle b/features/directory/build.gradle index b3b02ef..ca9e54d 100644 --- a/features/directory/build.gradle +++ b/features/directory/build.gradle @@ -8,6 +8,7 @@ android { dependencies { implementation project(":domains:android:compose-core") + implementation project(":domains:android:imageloader") implementation "chat-engine:chat-engine" implementation 'screen-state:screen-android' implementation project(":features:messenger") diff --git a/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryModule.kt b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryModule.kt index 23318cb..55163e5 100644 --- a/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryModule.kt +++ b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryModule.kt @@ -4,19 +4,17 @@ import android.content.Context import app.dapk.st.core.JobBag import app.dapk.st.core.ProvidableModule import app.dapk.st.directory.state.DirectoryEvent -import app.dapk.st.directory.state.DirectoryState import app.dapk.st.directory.state.directoryReducer import app.dapk.st.engine.ChatEngine -import app.dapk.st.state.createStateViewModel +import app.dapk.st.imageloader.IconLoader class DirectoryModule( private val context: Context, private val chatEngine: ChatEngine, + private val iconLoader: IconLoader, ) : ProvidableModule { - fun directoryState(): DirectoryState { - return createStateViewModel { directoryReducer(it) } - } + fun directoryReducer(eventEmitter: suspend (DirectoryEvent) -> Unit) = directoryReducer(chatEngine, shortcutHandler(), JobBag(), eventEmitter) - fun directoryReducer(eventEmitter: suspend (DirectoryEvent) -> Unit) = directoryReducer(chatEngine, ShortcutHandler(context), JobBag(), eventEmitter) + private fun shortcutHandler() = ShortcutHandler(context, iconLoader) } diff --git a/features/directory/src/main/kotlin/app/dapk/st/directory/ShortcutHandler.kt b/features/directory/src/main/kotlin/app/dapk/st/directory/ShortcutHandler.kt index cec13fc..23492f5 100644 --- a/features/directory/src/main/kotlin/app/dapk/st/directory/ShortcutHandler.kt +++ b/features/directory/src/main/kotlin/app/dapk/st/directory/ShortcutHandler.kt @@ -1,19 +1,24 @@ package app.dapk.st.directory import android.content.Context -import android.content.pm.ShortcutInfo import androidx.core.app.Person +import androidx.core.content.LocusIdCompat import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat import app.dapk.st.engine.RoomOverview +import app.dapk.st.imageloader.IconLoader import app.dapk.st.matrix.common.RoomId import app.dapk.st.messenger.MessengerActivity -internal class ShortcutHandler(private val context: Context) { +internal class ShortcutHandler( + private val context: Context, + private val iconLoader: IconLoader, +) { private val cachedRoomIds = mutableListOf() - fun onDirectoryUpdate(overviews: List) { + suspend fun onDirectoryUpdate(overviews: List) { val update = overviews.map { it.roomId } if (cachedRoomIds != update) { cachedRoomIds.clear() @@ -21,12 +26,14 @@ internal class ShortcutHandler(private val context: Context) { val maxShortcutCountPerActivity = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context) overviews + .sortedByDescending { it.lastMessage?.utcTimestamp } .take(maxShortcutCountPerActivity) .forEachIndexed { index, room -> val build = ShortcutInfoCompat.Builder(context, room.roomId.value) .setShortLabel(room.roomName ?: "N/A") .setLongLabel(room.roomName ?: "N/A") .setRank(index) + .setLocusId((LocusIdCompat(room.roomId.value))) .run { this.setPerson( Person.Builder() @@ -35,9 +42,14 @@ internal class ShortcutHandler(private val context: Context) { .build() ) } + .run { + room.roomAvatarUrl?.let { iconLoader.load(it.value) }?.let { + this.setIcon(IconCompat.createFromIcon(context, it)) + } ?: this + } .setIntent(MessengerActivity.newShortcutInstance(context, room.roomId)) .setLongLived(true) - .setCategories(setOf(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION)) + .setIsConversation() .build() ShortcutManagerCompat.pushDynamicShortcut(context, build) } diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerActivity.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerActivity.kt index 439fdfc..dab90b9 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerActivity.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerActivity.kt @@ -3,6 +3,7 @@ package app.dapk.st.messenger import android.app.Activity import android.content.Context import android.content.Intent +import android.content.LocusId import android.os.Bundle import android.os.Parcelable import androidx.compose.foundation.layout.fillMaxSize @@ -10,11 +11,8 @@ import androidx.compose.material3.Surface import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier -import app.dapk.st.core.AndroidUri -import app.dapk.st.core.DapkActivity -import app.dapk.st.core.MimeType +import app.dapk.st.core.* import app.dapk.st.core.extensions.unsafeLazy -import app.dapk.st.core.module import app.dapk.st.matrix.common.RoomId import app.dapk.st.messenger.gallery.GetImageFromGallery import app.dapk.st.messenger.state.ComposerStateChange @@ -58,6 +56,8 @@ class MessengerActivity : DapkActivity() { val payload = readPayload() val factory = ImageRequest.Builder(applicationContext).fetcherFactory(module.decryptingFetcherFactory(RoomId(payload.roomId))) + module.deviceMeta.onAtLeastR { setLocusContext(LocusId(payload.roomId), savedInstanceState) } + val galleryLauncher = registerForActivityResult(GetImageFromGallery()) { it?.let { uri -> state.dispatch( diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt index d2001c5..13ea4fa 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt @@ -16,7 +16,7 @@ class MessengerModule( private val chatEngine: ChatEngine, private val context: Context, private val messageOptionsStore: MessageOptionsStore, - private val deviceMeta: DeviceMeta, + val deviceMeta: DeviceMeta, ) : ProvidableModule { internal fun messengerState(launchPayload: MessagerActivityPayload): MessengerState { diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/AndroidNotificationBuilder.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/AndroidNotificationBuilder.kt index 5cfbd09..1587e0e 100644 --- a/features/notifications/src/main/kotlin/app/dapk/st/notifications/AndroidNotificationBuilder.kt +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/AndroidNotificationBuilder.kt @@ -4,13 +4,14 @@ import android.annotation.SuppressLint import android.app.Notification import android.app.PendingIntent import android.content.Context +import android.content.LocusId import android.graphics.drawable.Icon import app.dapk.st.core.DeviceMeta import app.dapk.st.core.isAtLeastO import app.dapk.st.core.onAtLeastO +import app.dapk.st.core.onAtLeastQ @SuppressLint("NewApi") -@Suppress("ObjectPropertyName") private val _builderFactory: (Context, String, DeviceMeta) -> Notification.Builder = { context, channel, deviceMeta -> deviceMeta.isAtLeastO( block = { Notification.Builder(context, channel) }, @@ -22,7 +23,8 @@ class AndroidNotificationBuilder( private val context: Context, private val deviceMeta: DeviceMeta, private val notificationStyleBuilder: AndroidNotificationStyleBuilder, - private val builderFactory: (Context, String, DeviceMeta) -> Notification.Builder = _builderFactory + private val builderFactory: (Context, String, DeviceMeta) -> Notification.Builder = _builderFactory, + private val notificationExtensions: NotificationExtensions = DefaultNotificationExtensions(deviceMeta), ) { @SuppressLint("NewApi") fun build(notification: AndroidNotification): Notification { @@ -41,7 +43,8 @@ class AndroidNotificationBuilder( } .ifNotNull(notification.category) { setCategory(it) } .ifNotNull(notification.shortcutId) { - deviceMeta.onAtLeastO { setShortcutId(notification.shortcutId) } + with(notificationExtensions) { applyLocusId(it) } + deviceMeta.onAtLeastO { setShortcutId(it) } } .ifNotNull(notification.smallIcon) { setSmallIcon(it) } .ifNotNull(notification.largeIcon) { setLargeIcon(it) } diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationExtensions.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationExtensions.kt new file mode 100644 index 0000000..2ef6df6 --- /dev/null +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationExtensions.kt @@ -0,0 +1,16 @@ +package app.dapk.st.notifications + +import android.app.Notification +import android.content.LocusId +import app.dapk.st.core.DeviceMeta +import app.dapk.st.core.onAtLeastQ + +interface NotificationExtensions { + fun Notification.Builder.applyLocusId(id: String) +} + +internal class DefaultNotificationExtensions(private val deviceMeta: DeviceMeta) : NotificationExtensions { + override fun Notification.Builder.applyLocusId(id: String) { + deviceMeta.onAtLeastQ { setLocusId(LocusId(id)) } + } +} diff --git a/features/notifications/src/test/kotlin/app/dapk/st/notifications/AndroidNotificationBuilderTest.kt b/features/notifications/src/test/kotlin/app/dapk/st/notifications/AndroidNotificationBuilderTest.kt index ce1743f..fbb5e8e 100644 --- a/features/notifications/src/test/kotlin/app/dapk/st/notifications/AndroidNotificationBuilderTest.kt +++ b/features/notifications/src/test/kotlin/app/dapk/st/notifications/AndroidNotificationBuilderTest.kt @@ -1,12 +1,14 @@ package app.dapk.st.notifications +import android.app.Notification import app.dapk.st.core.DeviceMeta -import fixture.NotificationDelegateFixtures.anAndroidNotification import fake.FakeContext import fake.FakeNotificationBuilder import fake.aFakeMessagingStyle +import fixture.NotificationDelegateFixtures.anAndroidNotification import io.mockk.every import io.mockk.mockk +import org.amshove.kluent.shouldBeEqualTo import org.junit.Test import test.delegateReturn import test.runExpectTest @@ -15,21 +17,30 @@ private val A_MESSAGING_STYLE = aFakeMessagingStyle() class AndroidNotificationBuilderTest { + private val aPlatformNotification = mockk() + private val fakeContext = FakeContext() private val fakeNotificationBuilder = FakeNotificationBuilder() private val fakeAndroidNotificationStyleBuilder = FakeAndroidNotificationStyleBuilder() + private val fakeNotificationExtensions = FakeNotificationExtensions() private val builder = AndroidNotificationBuilder( fakeContext.instance, DeviceMeta(apiVersion = 26), fakeAndroidNotificationStyleBuilder.instance, builderFactory = { _, _, _ -> fakeNotificationBuilder.instance }, + notificationExtensions = fakeNotificationExtensions ) @Test fun `applies all builder options`() = runExpectTest { val notification = anAndroidNotification() fakeAndroidNotificationStyleBuilder.given(notification.messageStyle!!).returns(A_MESSAGING_STYLE) + fakeNotificationBuilder.givenBuilds().returns(aPlatformNotification) + + with(fakeNotificationExtensions) { + fakeNotificationExtensions.expect { fakeNotificationBuilder.instance.applyLocusId(notification.shortcutId!!) } + } fakeNotificationBuilder.instance.captureExpects { it.setOnlyAlertOnce(!notification.alertMoreThanOnce) it.setAutoCancel(notification.autoCancel) @@ -45,9 +56,9 @@ class AndroidNotificationBuilderTest { it.setLargeIcon(notification.largeIcon) it.build() } + val result = builder.build(notification) - val ignoredResult = builder.build(notification) - + result shouldBeEqualTo aPlatformNotification verifyExpects() } } @@ -57,3 +68,5 @@ class FakeAndroidNotificationStyleBuilder { fun given(style: AndroidNotificationStyle) = every { instance.build(style) }.delegateReturn() } + +private class FakeNotificationExtensions : NotificationExtensions by mockk() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index da007be..b2ee200 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ sqldelight = { id = "com.squareup.sqldelight", version.ref = "sqldelight" } [libraries] android-desugar = { group = "com.android.tools", name = "desugar_jdk_libs", version = "1.1.5" } +androidx-annotation = { group = "androidx.annotation", name = "annotation", version = "1.5.0" } compose-coil = { group = "io.coil-kt", name = "coil-compose", version = "2.2.2" } accompanist-systemuicontroller = { group = "com.google.accompanist", name = "accompanist-systemuicontroller", version = "0.28.0" }