Merge pull request #310 from ouchadam/shortcut-icons

Fix missing icon on shortcut entries
This commit is contained in:
Adam Brown 2023-01-17 21:56:21 +00:00 committed by GitHub
commit 5599243615
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 88 additions and 23 deletions

View File

@ -165,6 +165,7 @@ internal class FeatureModules internal constructor(
DirectoryModule( DirectoryModule(
context = context, context = context,
chatEngine = chatEngineModule.engine, chatEngine = chatEngineModule.engine,
iconLoader = imageLoaderModule.iconLoader(),
) )
} }
val loginModule by unsafeLazy { val loginModule by unsafeLazy {

View File

@ -20,7 +20,7 @@ class ExpectTest(override val coroutineContext: CoroutineContext) : ExpectTestSc
override fun verifyExpects() { override fun verifyExpects() {
expects.forEach { (times, block) -> coVerify(exactly = times) { block.invoke(this) } } 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> T.expectUnit(times: Int, block: suspend MockKMatcherScope.(T) -> Unit) { override fun <T> T.expectUnit(times: Int, block: suspend MockKMatcherScope.(T) -> Unit) {

View File

@ -4,5 +4,6 @@ plugins {
dependencies { dependencies {
compileOnly project(":domains:android:stub") compileOnly project(":domains:android:stub")
compileOnly libs.androidx.annotation
implementation project(":core") implementation project(":core")
} }

View File

@ -1,20 +1,35 @@
package app.dapk.st.core package app.dapk.st.core
import android.os.Build import android.os.Build
import androidx.annotation.ChecksSdkIntAtLeast
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O, lambda = 0)
fun <T> DeviceMeta.isAtLeastO(block: () -> T, fallback: () -> T = { throw IllegalStateException("not handled") }): T { fun <T> DeviceMeta.isAtLeastO(block: () -> T, fallback: () -> T = { throw IllegalStateException("not handled") }): T {
return if (this.apiVersion >= Build.VERSION_CODES.O) block() else fallback() 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 fun DeviceMeta.isAtLeastS() = this.apiVersion >= Build.VERSION_CODES.S
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O, lambda = 0)
fun DeviceMeta.onAtLeastO(block: () -> Unit) { 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 <T> DeviceMeta.whenPOrHigher(block: () -> T, fallback: () -> T) = whenXOrHigher(Build.VERSION_CODES.P, block, fallback) 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.whenOOrHigher(block: () -> T, fallback: () -> T) = whenXOrHigher(Build.VERSION_CODES.O, block, fallback)
@ChecksSdkIntAtLeast(parameter = 0, lambda = 1)
inline fun <T> DeviceMeta.whenXOrHigher(version: Int, block: () -> T, fallback: () -> T): T { inline fun <T> DeviceMeta.whenXOrHigher(version: Int, block: () -> T, fallback: () -> T): T {
return if (this.apiVersion >= version) block() else fallback() return if (this.apiVersion >= version) block() else fallback()
} }

View File

@ -1,8 +1,12 @@
package fake package fake
import android.app.Notification import android.app.Notification
import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import test.delegateReturn
class FakeNotificationBuilder { class FakeNotificationBuilder {
val instance = mockk<Notification.Builder>(relaxed = true) val instance = mockk<Notification.Builder>(relaxed = true)
fun givenBuilds() = every { instance.build() }.delegateReturn()
} }

View File

@ -8,6 +8,7 @@ android {
dependencies { dependencies {
implementation project(":domains:android:compose-core") implementation project(":domains:android:compose-core")
implementation project(":domains:android:imageloader")
implementation "chat-engine:chat-engine" implementation "chat-engine:chat-engine"
implementation 'screen-state:screen-android' implementation 'screen-state:screen-android'
implementation project(":features:messenger") implementation project(":features:messenger")

View File

@ -4,19 +4,17 @@ import android.content.Context
import app.dapk.st.core.JobBag import app.dapk.st.core.JobBag
import app.dapk.st.core.ProvidableModule import app.dapk.st.core.ProvidableModule
import app.dapk.st.directory.state.DirectoryEvent 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.directory.state.directoryReducer
import app.dapk.st.engine.ChatEngine import app.dapk.st.engine.ChatEngine
import app.dapk.st.state.createStateViewModel import app.dapk.st.imageloader.IconLoader
class DirectoryModule( class DirectoryModule(
private val context: Context, private val context: Context,
private val chatEngine: ChatEngine, private val chatEngine: ChatEngine,
private val iconLoader: IconLoader,
) : ProvidableModule { ) : ProvidableModule {
fun directoryState(): DirectoryState { fun directoryReducer(eventEmitter: suspend (DirectoryEvent) -> Unit) = directoryReducer(chatEngine, shortcutHandler(), JobBag(), eventEmitter)
return createStateViewModel { directoryReducer(it) }
}
fun directoryReducer(eventEmitter: suspend (DirectoryEvent) -> Unit) = directoryReducer(chatEngine, ShortcutHandler(context), JobBag(), eventEmitter) private fun shortcutHandler() = ShortcutHandler(context, iconLoader)
} }

View File

@ -1,19 +1,24 @@
package app.dapk.st.directory package app.dapk.st.directory
import android.content.Context import android.content.Context
import android.content.pm.ShortcutInfo
import androidx.core.app.Person import androidx.core.app.Person
import androidx.core.content.LocusIdCompat
import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import app.dapk.st.engine.RoomOverview import app.dapk.st.engine.RoomOverview
import app.dapk.st.imageloader.IconLoader
import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.RoomId
import app.dapk.st.messenger.MessengerActivity 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<RoomId>() private val cachedRoomIds = mutableListOf<RoomId>()
fun onDirectoryUpdate(overviews: List<RoomOverview>) { suspend fun onDirectoryUpdate(overviews: List<RoomOverview>) {
val update = overviews.map { it.roomId } val update = overviews.map { it.roomId }
if (cachedRoomIds != update) { if (cachedRoomIds != update) {
cachedRoomIds.clear() cachedRoomIds.clear()
@ -21,12 +26,14 @@ internal class ShortcutHandler(private val context: Context) {
val maxShortcutCountPerActivity = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context) val maxShortcutCountPerActivity = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context)
overviews overviews
.sortedByDescending { it.lastMessage?.utcTimestamp }
.take(maxShortcutCountPerActivity) .take(maxShortcutCountPerActivity)
.forEachIndexed { index, room -> .forEachIndexed { index, room ->
val build = ShortcutInfoCompat.Builder(context, room.roomId.value) val build = ShortcutInfoCompat.Builder(context, room.roomId.value)
.setShortLabel(room.roomName ?: "N/A") .setShortLabel(room.roomName ?: "N/A")
.setLongLabel(room.roomName ?: "N/A") .setLongLabel(room.roomName ?: "N/A")
.setRank(index) .setRank(index)
.setLocusId((LocusIdCompat(room.roomId.value)))
.run { .run {
this.setPerson( this.setPerson(
Person.Builder() Person.Builder()
@ -35,9 +42,14 @@ internal class ShortcutHandler(private val context: Context) {
.build() .build()
) )
} }
.run {
room.roomAvatarUrl?.let { iconLoader.load(it.value) }?.let {
this.setIcon(IconCompat.createFromIcon(context, it))
} ?: this
}
.setIntent(MessengerActivity.newShortcutInstance(context, room.roomId)) .setIntent(MessengerActivity.newShortcutInstance(context, room.roomId))
.setLongLived(true) .setLongLived(true)
.setCategories(setOf(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION)) .setIsConversation()
.build() .build()
ShortcutManagerCompat.pushDynamicShortcut(context, build) ShortcutManagerCompat.pushDynamicShortcut(context, build)
} }

View File

@ -3,6 +3,7 @@ package app.dapk.st.messenger
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.LocusId
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -10,11 +11,8 @@ import androidx.compose.material3.Surface
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import app.dapk.st.core.AndroidUri import app.dapk.st.core.*
import app.dapk.st.core.DapkActivity
import app.dapk.st.core.MimeType
import app.dapk.st.core.extensions.unsafeLazy import app.dapk.st.core.extensions.unsafeLazy
import app.dapk.st.core.module
import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.RoomId
import app.dapk.st.messenger.gallery.GetImageFromGallery import app.dapk.st.messenger.gallery.GetImageFromGallery
import app.dapk.st.messenger.state.ComposerStateChange import app.dapk.st.messenger.state.ComposerStateChange
@ -58,6 +56,8 @@ class MessengerActivity : DapkActivity() {
val payload = readPayload<MessagerActivityPayload>() val payload = readPayload<MessagerActivityPayload>()
val factory = ImageRequest.Builder(applicationContext).fetcherFactory(module.decryptingFetcherFactory(RoomId(payload.roomId))) val factory = ImageRequest.Builder(applicationContext).fetcherFactory(module.decryptingFetcherFactory(RoomId(payload.roomId)))
module.deviceMeta.onAtLeastR { setLocusContext(LocusId(payload.roomId), savedInstanceState) }
val galleryLauncher = registerForActivityResult(GetImageFromGallery()) { val galleryLauncher = registerForActivityResult(GetImageFromGallery()) {
it?.let { uri -> it?.let { uri ->
state.dispatch( state.dispatch(

View File

@ -16,7 +16,7 @@ class MessengerModule(
private val chatEngine: ChatEngine, private val chatEngine: ChatEngine,
private val context: Context, private val context: Context,
private val messageOptionsStore: MessageOptionsStore, private val messageOptionsStore: MessageOptionsStore,
private val deviceMeta: DeviceMeta, val deviceMeta: DeviceMeta,
) : ProvidableModule { ) : ProvidableModule {
internal fun messengerState(launchPayload: MessagerActivityPayload): MessengerState { internal fun messengerState(launchPayload: MessagerActivityPayload): MessengerState {

View File

@ -4,13 +4,14 @@ import android.annotation.SuppressLint
import android.app.Notification import android.app.Notification
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.LocusId
import android.graphics.drawable.Icon import android.graphics.drawable.Icon
import app.dapk.st.core.DeviceMeta import app.dapk.st.core.DeviceMeta
import app.dapk.st.core.isAtLeastO import app.dapk.st.core.isAtLeastO
import app.dapk.st.core.onAtLeastO import app.dapk.st.core.onAtLeastO
import app.dapk.st.core.onAtLeastQ
@SuppressLint("NewApi") @SuppressLint("NewApi")
@Suppress("ObjectPropertyName")
private val _builderFactory: (Context, String, DeviceMeta) -> Notification.Builder = { context, channel, deviceMeta -> private val _builderFactory: (Context, String, DeviceMeta) -> Notification.Builder = { context, channel, deviceMeta ->
deviceMeta.isAtLeastO( deviceMeta.isAtLeastO(
block = { Notification.Builder(context, channel) }, block = { Notification.Builder(context, channel) },
@ -22,7 +23,8 @@ class AndroidNotificationBuilder(
private val context: Context, private val context: Context,
private val deviceMeta: DeviceMeta, private val deviceMeta: DeviceMeta,
private val notificationStyleBuilder: AndroidNotificationStyleBuilder, 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") @SuppressLint("NewApi")
fun build(notification: AndroidNotification): Notification { fun build(notification: AndroidNotification): Notification {
@ -41,7 +43,8 @@ class AndroidNotificationBuilder(
} }
.ifNotNull(notification.category) { setCategory(it) } .ifNotNull(notification.category) { setCategory(it) }
.ifNotNull(notification.shortcutId) { .ifNotNull(notification.shortcutId) {
deviceMeta.onAtLeastO { setShortcutId(notification.shortcutId) } with(notificationExtensions) { applyLocusId(it) }
deviceMeta.onAtLeastO { setShortcutId(it) }
} }
.ifNotNull(notification.smallIcon) { setSmallIcon(it) } .ifNotNull(notification.smallIcon) { setSmallIcon(it) }
.ifNotNull(notification.largeIcon) { setLargeIcon(it) } .ifNotNull(notification.largeIcon) { setLargeIcon(it) }

View File

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

View File

@ -1,12 +1,14 @@
package app.dapk.st.notifications package app.dapk.st.notifications
import android.app.Notification
import app.dapk.st.core.DeviceMeta import app.dapk.st.core.DeviceMeta
import fixture.NotificationDelegateFixtures.anAndroidNotification
import fake.FakeContext import fake.FakeContext
import fake.FakeNotificationBuilder import fake.FakeNotificationBuilder
import fake.aFakeMessagingStyle import fake.aFakeMessagingStyle
import fixture.NotificationDelegateFixtures.anAndroidNotification
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test import org.junit.Test
import test.delegateReturn import test.delegateReturn
import test.runExpectTest import test.runExpectTest
@ -15,21 +17,30 @@ private val A_MESSAGING_STYLE = aFakeMessagingStyle()
class AndroidNotificationBuilderTest { class AndroidNotificationBuilderTest {
private val aPlatformNotification = mockk<Notification>()
private val fakeContext = FakeContext() private val fakeContext = FakeContext()
private val fakeNotificationBuilder = FakeNotificationBuilder() private val fakeNotificationBuilder = FakeNotificationBuilder()
private val fakeAndroidNotificationStyleBuilder = FakeAndroidNotificationStyleBuilder() private val fakeAndroidNotificationStyleBuilder = FakeAndroidNotificationStyleBuilder()
private val fakeNotificationExtensions = FakeNotificationExtensions()
private val builder = AndroidNotificationBuilder( private val builder = AndroidNotificationBuilder(
fakeContext.instance, fakeContext.instance,
DeviceMeta(apiVersion = 26), DeviceMeta(apiVersion = 26),
fakeAndroidNotificationStyleBuilder.instance, fakeAndroidNotificationStyleBuilder.instance,
builderFactory = { _, _, _ -> fakeNotificationBuilder.instance }, builderFactory = { _, _, _ -> fakeNotificationBuilder.instance },
notificationExtensions = fakeNotificationExtensions
) )
@Test @Test
fun `applies all builder options`() = runExpectTest { fun `applies all builder options`() = runExpectTest {
val notification = anAndroidNotification() val notification = anAndroidNotification()
fakeAndroidNotificationStyleBuilder.given(notification.messageStyle!!).returns(A_MESSAGING_STYLE) fakeAndroidNotificationStyleBuilder.given(notification.messageStyle!!).returns(A_MESSAGING_STYLE)
fakeNotificationBuilder.givenBuilds().returns(aPlatformNotification)
with(fakeNotificationExtensions) {
fakeNotificationExtensions.expect { fakeNotificationBuilder.instance.applyLocusId(notification.shortcutId!!) }
}
fakeNotificationBuilder.instance.captureExpects { fakeNotificationBuilder.instance.captureExpects {
it.setOnlyAlertOnce(!notification.alertMoreThanOnce) it.setOnlyAlertOnce(!notification.alertMoreThanOnce)
it.setAutoCancel(notification.autoCancel) it.setAutoCancel(notification.autoCancel)
@ -45,9 +56,9 @@ class AndroidNotificationBuilderTest {
it.setLargeIcon(notification.largeIcon) it.setLargeIcon(notification.largeIcon)
it.build() it.build()
} }
val result = builder.build(notification)
val ignoredResult = builder.build(notification) result shouldBeEqualTo aPlatformNotification
verifyExpects() verifyExpects()
} }
} }
@ -57,3 +68,5 @@ class FakeAndroidNotificationStyleBuilder {
fun given(style: AndroidNotificationStyle) = every { instance.build(style) }.delegateReturn() fun given(style: AndroidNotificationStyle) = every { instance.build(style) }.delegateReturn()
} }
private class FakeNotificationExtensions : NotificationExtensions by mockk()

View File

@ -13,6 +13,7 @@ sqldelight = { id = "com.squareup.sqldelight", version.ref = "sqldelight" }
[libraries] [libraries]
android-desugar = { group = "com.android.tools", name = "desugar_jdk_libs", version = "1.1.5" } 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" } 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" } accompanist-systemuicontroller = { group = "com.google.accompanist", name = "accompanist-systemuicontroller", version = "0.28.0" }