From 2d2478ddd5d799e7d4f946b827265f96c1d59d3c Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Wed, 10 Apr 2024 15:41:18 -0600 Subject: [PATCH 01/17] make the tool and shortcut for a PendingShortcut internal --- .../main/kotlin/org/cru/godtools/shortcuts/PendingShortcut.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/PendingShortcut.kt b/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/PendingShortcut.kt index c94bcd14c5..9b874485c8 100644 --- a/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/PendingShortcut.kt +++ b/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/PendingShortcut.kt @@ -3,9 +3,9 @@ package org.cru.godtools.shortcuts import androidx.core.content.pm.ShortcutInfoCompat import kotlinx.coroutines.sync.Mutex -class PendingShortcut internal constructor(val tool: String) { +class PendingShortcut internal constructor(internal val tool: String) { internal val mutex = Mutex() @Volatile - var shortcut: ShortcutInfoCompat? = null + internal var shortcut: ShortcutInfoCompat? = null } From ad3632f535361c42a0bbcf2aa1d3f041d7eb3ceb Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Fri, 12 Apr 2024 10:27:21 -0600 Subject: [PATCH 02/17] create a ShortcutId sealed class to represent shortcut ids This will standardize parsing/generation of shortcut ids --- .../shortcuts/GodToolsShortcutManager.kt | 11 ++++--- .../org/cru/godtools/shortcuts/ShortcutId.kt | 31 +++++++++++++++++++ .../cru/godtools/shortcuts/ShortcutIdTest.kt | 24 ++++++++++++++ 3 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/ShortcutId.kt create mode 100644 ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/ShortcutIdTest.kt diff --git a/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt b/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt index d0fdeb0304..4a7d49d8f0 100644 --- a/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt +++ b/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt @@ -107,7 +107,7 @@ class GodToolsShortcutManager @VisibleForTesting internal constructor( @Subscribe fun onToolUsed(event: ToolUsedEvent) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { - shortcutManager?.reportShortcutUsed(event.toolCode.toolShortcutId) + shortcutManager?.reportShortcutUsed(ShortcutId.Tool(event.toolCode).id) } } // endregion Events @@ -129,12 +129,13 @@ class GodToolsShortcutManager @VisibleForTesting internal constructor( @AnyThread fun getPendingToolShortcut(code: String?): PendingShortcut? { if (!isEnabled) return null - val id = code?.toolShortcutId ?: return null + if (code == null) return null + val id = ShortcutId.Tool(code) return synchronized(pendingShortcuts) { - pendingShortcuts[id]?.get() - ?: PendingShortcut(code).also { - pendingShortcuts[id] = WeakReference(it) + pendingShortcuts[id.id]?.get() + ?: PendingShortcut(id.tool).also { + pendingShortcuts[id.id] = WeakReference(it) coroutineScope.launch { updatePendingShortcut(it) } } } diff --git a/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/ShortcutId.kt b/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/ShortcutId.kt new file mode 100644 index 0000000000..e8e592fd92 --- /dev/null +++ b/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/ShortcutId.kt @@ -0,0 +1,31 @@ +package org.cru.godtools.shortcuts + +internal sealed interface ShortcutId { + val id: String + + companion object { + const val SEPARATOR = "|" + + fun parseId(id: String): ShortcutId? = when (id.substringBefore(SEPARATOR)) { + Tool.TYPE -> Tool.parseId(id) + else -> null + } + } + + data class Tool internal constructor(val tool: String) : ShortcutId { + override val id = listOf(TYPE, tool).joinToString(SEPARATOR) + + companion object { + const val TYPE = "tool" + + fun parseId(id: String): Tool? { + val components = id.split(SEPARATOR) + return when { + components.size < 2 -> null + components[0] != TYPE -> null + else -> Tool(components[1]) + } + } + } + } +} diff --git a/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/ShortcutIdTest.kt b/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/ShortcutIdTest.kt new file mode 100644 index 0000000000..b69239e758 --- /dev/null +++ b/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/ShortcutIdTest.kt @@ -0,0 +1,24 @@ +package org.cru.godtools.shortcuts + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ShortcutIdTest { + @Test + fun `parseId() - Tool shortcuts`() { + assertNull(ShortcutId.parseId("tool")) + assertEquals(ShortcutId.Tool("kgp"), ShortcutId.parseId("tool|kgp")) + assertEquals(ShortcutId.Tool("kgp"), ShortcutId.parseId("tool|kgp|en")) + } + + @Test + fun `parseId() - Invalid`() { + assertNull(ShortcutId.parseId("invalid-asldf")) + } + + @Test + fun `Tool - id`() { + assertEquals("tool|kgp", ShortcutId.Tool("kgp").id) + } +} From 89bb80fe8e79aa8627d1e5df821968330d02a74c Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Fri, 12 Apr 2024 10:57:47 -0600 Subject: [PATCH 03/17] update createToolShortcut method to utilize the ShortcutId object --- .../shortcuts/GodToolsShortcutManager.kt | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt b/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt index 4a7d49d8f0..18f0cfe8ab 100644 --- a/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt +++ b/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt @@ -221,31 +221,42 @@ class GodToolsShortcutManager @VisibleForTesting internal constructor( .associateBy { it.id } } + private suspend fun createToolShortcut(id: ShortcutId.Tool) = withContext(ioDispatcher) { + createToolShortcut(id, toolsRepository.findTool(id.tool)) + } + + private suspend fun createToolShortcut(tool: Tool): ShortcutInfoCompat? { + return createToolShortcut( + id = tool.code?.let { ShortcutId.Tool(it) } ?: return null, + tool = tool + ) + } + @AnyThread - private suspend fun createToolShortcut(tool: Tool) = withContext(ioDispatcher) { - val code = tool.code ?: return@withContext null + private suspend fun createToolShortcut(id: ShortcutId.Tool, tool: Tool?) = withContext(ioDispatcher) { + if (tool == null) return@withContext null // generate the list of locales to use for this tool val locales = buildList { - val translation = translationsRepository.findLatestTranslation(code, settings.appLanguage) - ?: translationsRepository.findLatestTranslation(code, tool.defaultLocale) + val translation = translationsRepository.findLatestTranslation(id.tool, settings.appLanguage) + ?: translationsRepository.findLatestTranslation(id.tool, tool.defaultLocale) ?: return@withContext null add(translation.languageCode) } // generate the target intent for this shortcut val intent = when (tool.type) { - Tool.Type.ARTICLE -> context.createArticlesIntent(code, locales[0]) - Tool.Type.CYOA -> context.createCyoaActivityIntent(code, *locales.toTypedArray()) - Tool.Type.TRACT -> context.createTractActivityIntent(code, *locales.toTypedArray()) + Tool.Type.ARTICLE -> context.createArticlesIntent(id.tool, locales[0]) + Tool.Type.CYOA -> context.createCyoaActivityIntent(id.tool, *locales.toTypedArray()) + Tool.Type.TRACT -> context.createTractActivityIntent(id.tool, *locales.toTypedArray()) else -> return@withContext null } intent.action = Intent.ACTION_VIEW intent.putExtra(SHORTCUT_LAUNCH, true) // Generate the shortcut label - val label = sequenceOf(settings.appLanguage, tool.defaultLocale).includeFallbacks().distinct() - .firstNotNullOfOrNull { translationsRepository.findLatestTranslation(code, it) } + val label = locales.asSequence().includeFallbacks().distinct() + .firstNotNullOfOrNull { translationsRepository.findLatestTranslation(id.tool, it) } .getName(tool, context) // create the icon bitmap @@ -266,7 +277,7 @@ class GodToolsShortcutManager @VisibleForTesting internal constructor( ?: IconCompat.createWithResource(context, org.cru.godtools.ui.R.mipmap.ic_launcher) // build the shortcut - ShortcutInfoCompat.Builder(context, code.toolShortcutId) + ShortcutInfoCompat.Builder(context, id.id) .setAlwaysBadged() .setIntent(intent) .setShortLabel(label) From 927b78fb623dc2fd513544e4227c68313760a5c0 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Fri, 12 Apr 2024 15:34:04 -0600 Subject: [PATCH 04/17] rework Shortcut Manager test to better mock instant app behavior --- .../shortcuts/GodToolsShortcutManagerTest.kt | 85 ++++++++++++------- 1 file changed, 56 insertions(+), 29 deletions(-) diff --git a/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt b/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt index b4901a726c..835a2ba3c4 100644 --- a/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt +++ b/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt @@ -19,6 +19,14 @@ import io.mockk.spyk import io.mockk.unmockkStatic import io.mockk.verify import java.util.EnumSet +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch @@ -31,14 +39,6 @@ import org.cru.godtools.db.repository.ToolsRepository import org.cru.godtools.model.Tool import org.cru.godtools.model.randomTool import org.greenrobot.eventbus.EventBus -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test import org.junit.runner.RunWith import org.robolectric.Shadows import org.robolectric.annotation.Config @@ -75,10 +75,9 @@ class GodToolsShortcutManagerTest { ) } - @Before + @BeforeTest fun setup() { mockkStatic(InstantApps::class) - every { InstantApps.isInstantApp(any()) } returns false val rawApp = ApplicationProvider.getApplicationContext() Shadows.shadowOf(rawApp).grantPermissions(INSTALL_SHORTCUT_PERMISSION) @@ -96,13 +95,45 @@ class GodToolsShortcutManagerTest { every { getSystemService(ShortcutManager::class.java) } returns shortcutManagerService } } + + mockInstantApp(false) } - @After + @AfterTest fun cleanup() { unmockkStatic(InstantApps::class) } + // region isEnabled + @Test + fun `isEnabled - default behavior`() { + assertTrue(shortcutManager.isEnabled) + } + + @Test + fun `isEnabled - Instant App`() { + mockInstantApp(true) + + assertFalse(shortcutManager.isEnabled) + } + // endregion isEnabled + + // region Events - EventBus + @Test + fun `EventBus - register callback`() { + assertTrue(shortcutManager.isEnabled) + verify { eventBus.register(shortcutManager) } + } + + @Test + fun `EventBus - Don't register when an Instant App`() { + mockInstantApp(true) + + assertFalse(shortcutManager.isEnabled) + verify { eventBus wasNot Called } + } + // endregion Events - EventBus + // region Pending Shortcuts // region canPinShortcut(tool) @Test @@ -137,10 +168,9 @@ class GodToolsShortcutManagerTest { coVerifyAll { toolsRepository.findTool("kgp") } // prevent garbage collection of the shortcut during the test - assertNotNull( - "Reference the shortcut here to prevent garbage collection from collecting it during the test.", - shortcut - ) + assertNotNull(shortcut) { + "Reference the shortcut here to prevent garbage collection from collecting it during the test." + } } // endregion Pending Shortcuts @@ -164,20 +194,10 @@ class GodToolsShortcutManagerTest { // endregion Update Existing Shortcuts // region Instant App - @Test - fun `Instant App - Don't register with EventBus`() { - every { InstantApps.isInstantApp(any()) } returns true - - assertFalse(shortcutManager.isEnabled) - verify { eventBus wasNot Called } - } - @Test @Config(sdk = [Build.VERSION_CODES.N_MR1, NEWEST_SDK]) fun `Instant App - canPinToolShortcut() - GT-1977`() { - every { InstantApps.isInstantApp(any()) } returns true - // Instant Apps don't have access to the system ShortcutManager - every { app.getSystemService() } returns null + mockInstantApp(true) assertFalse(shortcutManager.canPinToolShortcut(randomTool("kgp", type = Tool.Type.TRACT))) } @@ -185,13 +205,20 @@ class GodToolsShortcutManagerTest { @Test @Config(sdk = [Build.VERSION_CODES.N_MR1, NEWEST_SDK]) fun `Instant App - updateDynamicShortcuts()`() = testScope.runTest { - every { InstantApps.isInstantApp(any()) } returns true - // Instant Apps don't have access to the system ShortcutManager - every { app.getSystemService() } returns null + mockInstantApp(true) // This should be a no-op shortcutManager.updateDynamicShortcuts(emptyMap()) verify { toolsRepository wasNot Called } } // endregion Instant App + + private fun mockInstantApp(isInstantApp: Boolean) { + every { InstantApps.isInstantApp(any()) } returns isInstantApp + + if (::shortcutManagerService.isInitialized) { + // Instant Apps don't have access to the system ShortcutManager + every { app.getSystemService() } returns shortcutManagerService.takeUnless { isInstantApp } + } + } } From 9873949543ed6b90b9254848126fceffb54d970d Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Fri, 12 Apr 2024 15:53:51 -0600 Subject: [PATCH 05/17] update onToolUsed event to always record the shortcut usage --- .../shortcuts/GodToolsShortcutManager.kt | 5 ++- .../shortcuts/GodToolsShortcutManagerTest.kt | 33 ++++++++++++++++--- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt b/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt index 18f0cfe8ab..59c353aa0f 100644 --- a/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt +++ b/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt @@ -106,9 +106,8 @@ class GodToolsShortcutManager @VisibleForTesting internal constructor( @AnyThread @Subscribe fun onToolUsed(event: ToolUsedEvent) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { - shortcutManager?.reportShortcutUsed(ShortcutId.Tool(event.toolCode).id) - } + if (!isEnabled) return + ShortcutManagerCompat.reportShortcutUsed(context, ShortcutId.Tool(event.toolCode).id) } // endregion Events diff --git a/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt b/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt index 835a2ba3c4..8592095269 100644 --- a/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt +++ b/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt @@ -6,13 +6,16 @@ import android.content.pm.ResolveInfo import android.content.pm.ShortcutManager import android.os.Build import androidx.core.content.getSystemService +import androidx.core.content.pm.ShortcutManagerCompat import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.android.gms.common.wrappers.InstantApps import io.mockk.Called +import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerifyAll import io.mockk.every +import io.mockk.just import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.spyk @@ -37,6 +40,7 @@ import kotlinx.coroutines.test.runTest import org.ccci.gto.android.common.testing.timber.ExceptionRaisingTree import org.cru.godtools.db.repository.ToolsRepository import org.cru.godtools.model.Tool +import org.cru.godtools.model.event.ToolUsedEvent import org.cru.godtools.model.randomTool import org.greenrobot.eventbus.EventBus import org.junit.runner.RunWith @@ -77,7 +81,7 @@ class GodToolsShortcutManagerTest { @BeforeTest fun setup() { - mockkStatic(InstantApps::class) + mockkStatic(InstantApps::class, ShortcutManagerCompat::class) val rawApp = ApplicationProvider.getApplicationContext() Shadows.shadowOf(rawApp).grantPermissions(INSTALL_SHORTCUT_PERMISSION) @@ -101,7 +105,7 @@ class GodToolsShortcutManagerTest { @AfterTest fun cleanup() { - unmockkStatic(InstantApps::class) + unmockkStatic(InstantApps::class, ShortcutManagerCompat::class) } // region isEnabled @@ -118,7 +122,8 @@ class GodToolsShortcutManagerTest { } // endregion isEnabled - // region Events - EventBus + // region Events + // region EventBus @Test fun `EventBus - register callback`() { assertTrue(shortcutManager.isEnabled) @@ -132,7 +137,27 @@ class GodToolsShortcutManagerTest { assertFalse(shortcutManager.isEnabled) verify { eventBus wasNot Called } } - // endregion Events - EventBus + // endregion EventBus + + // region onToolUsed() + @Test + fun `onToolUsed()`() { + every { ShortcutManagerCompat.reportShortcutUsed(any(), any()) } just Runs + + shortcutManager.onToolUsed(ToolUsedEvent("kgp")) + verify { ShortcutManagerCompat.reportShortcutUsed(any(), "tool|kgp") } + } + + @Test + fun `onToolUsed() - Instant App`() { + mockInstantApp(true) + every { ShortcutManagerCompat.reportShortcutUsed(any(), any()) } answers { callOriginal() } + + shortcutManager.onToolUsed(ToolUsedEvent("kgp")) + verify(exactly = 0) { ShortcutManagerCompat.reportShortcutUsed(any(), any()) } + } + // endregion onToolUsed() + // endregion Events // region Pending Shortcuts // region canPinShortcut(tool) From 23029533946117742c6202e1da16fd6adef7d69d Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Fri, 12 Apr 2024 17:04:09 -0600 Subject: [PATCH 06/17] update Unit Tests for canPinToolShortcut() --- .../shortcuts/GodToolsShortcutManagerTest.kt | 50 ++++++++++++------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt b/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt index 8592095269..dadce6eb57 100644 --- a/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt +++ b/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt @@ -21,11 +21,9 @@ import io.mockk.mockkStatic import io.mockk.spyk import io.mockk.unmockkStatic import io.mockk.verify -import java.util.EnumSet import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test -import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -160,20 +158,44 @@ class GodToolsShortcutManagerTest { // endregion Events // region Pending Shortcuts - // region canPinShortcut(tool) + // region canPinToolShortcut(tool) @Test - fun verifyCanPinToolShortcut() { - val supportedTypes = EnumSet.of(Tool.Type.ARTICLE, Tool.Type.CYOA, Tool.Type.TRACT) - Tool.Type.values().forEach { - assertEquals(supportedTypes.contains(it), shortcutManager.canPinToolShortcut(randomTool(type = it))) - } + fun `canPinToolShortcut() - Valid - Launcher Supports Pinning`() { + every { ShortcutManagerCompat.isRequestPinShortcutSupported(any()) } returns true + + assertTrue(shortcutManager.canPinToolShortcut(randomTool(type = Tool.Type.ARTICLE))) + assertTrue(shortcutManager.canPinToolShortcut(randomTool(type = Tool.Type.CYOA))) + assertTrue(shortcutManager.canPinToolShortcut(randomTool(type = Tool.Type.TRACT))) + verify { ShortcutManagerCompat.isRequestPinShortcutSupported(any()) } } @Test - fun verifyCanPinToolShortcutNull() { + fun `canPinToolShortcut() - Valid - Launcher Doesn't Support Pinning`() { + every { ShortcutManagerCompat.isRequestPinShortcutSupported(any()) } returns false + + assertFalse(shortcutManager.canPinToolShortcut(randomTool(type = Tool.Type.ARTICLE))) + assertFalse(shortcutManager.canPinToolShortcut(randomTool(type = Tool.Type.CYOA))) + assertFalse(shortcutManager.canPinToolShortcut(randomTool(type = Tool.Type.TRACT))) + verify { ShortcutManagerCompat.isRequestPinShortcutSupported(any()) } + } + + @Test + fun `canPinToolShortcut() - Invalid`() { assertFalse(shortcutManager.canPinToolShortcut(null)) + assertFalse(shortcutManager.canPinToolShortcut(randomTool(type = Tool.Type.LESSON))) + assertFalse(shortcutManager.canPinToolShortcut(randomTool(type = Tool.Type.META))) + assertFalse(shortcutManager.canPinToolShortcut(randomTool(type = Tool.Type.UNKNOWN))) + verify(exactly = 0) { ShortcutManagerCompat.isRequestPinShortcutSupported(any()) } } - // endregion canPinShortcut(tool) + + @Test + fun `canPinToolShortcut() - Instant App - GT-1977`() { + mockInstantApp(true) + every { ShortcutManagerCompat.isRequestPinShortcutSupported(any()) } answers { callOriginal() } + + Tool.Type.entries.forEach { assertFalse(shortcutManager.canPinToolShortcut(randomTool(type = it))) } + } + // endregion canPinToolShortcut(tool) @Test fun verifyGetPendingToolShortcutInvalidTool() = testScope.runTest { @@ -219,14 +241,6 @@ class GodToolsShortcutManagerTest { // endregion Update Existing Shortcuts // region Instant App - @Test - @Config(sdk = [Build.VERSION_CODES.N_MR1, NEWEST_SDK]) - fun `Instant App - canPinToolShortcut() - GT-1977`() { - mockInstantApp(true) - - assertFalse(shortcutManager.canPinToolShortcut(randomTool("kgp", type = Tool.Type.TRACT))) - } - @Test @Config(sdk = [Build.VERSION_CODES.N_MR1, NEWEST_SDK]) fun `Instant App - updateDynamicShortcuts()`() = testScope.runTest { From d99537787b4d01a8ffa74ef2c093c5459cdfdfa4 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Mon, 15 Apr 2024 09:52:27 -0600 Subject: [PATCH 07/17] move the app mock setup out of the setup method --- .../shortcuts/GodToolsShortcutManagerTest.kt | 42 +++++++++---------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt b/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt index dadce6eb57..837df182d8 100644 --- a/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt +++ b/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt @@ -54,9 +54,25 @@ private const val INSTALL_SHORTCUT_PERMISSION = "com.android.launcher.permission @Config(sdk = [OLDEST_SDK, Build.VERSION_CODES.N, Build.VERSION_CODES.N_MR1, NEWEST_SDK]) @OptIn(ExperimentalCoroutinesApi::class) class GodToolsShortcutManagerTest { - private lateinit var app: Application + private val app = spyk( + ApplicationProvider.getApplicationContext() + .also { Shadows.shadowOf(it).grantPermissions(INSTALL_SHORTCUT_PERMISSION) } + ) { + val rawPm = packageManager + every { packageManager } returns spyk(rawPm) { + val shortcutReceiver = ResolveInfo().apply { + activityInfo = ActivityInfo().apply { permission = INSTALL_SHORTCUT_PERMISSION } + } + every { queryBroadcastReceivers(match { it.action == ACTION_INSTALL_SHORTCUT }, 0) } + .returns(listOf(shortcutReceiver)) + } + } private val eventBus: EventBus = mockk(relaxUnitFun = true) - private lateinit var shortcutManagerService: ShortcutManager + private val shortcutManagerService = app.getSystemService()?.let { + spyk(it) { + every { app.getSystemService(ShortcutManager::class.java) } returns this + } + } private val testScope = TestScope() private val toolsRepository: ToolsRepository = mockk { coEvery { findTool(any()) } returns null @@ -80,24 +96,6 @@ class GodToolsShortcutManagerTest { @BeforeTest fun setup() { mockkStatic(InstantApps::class, ShortcutManagerCompat::class) - - val rawApp = ApplicationProvider.getApplicationContext() - Shadows.shadowOf(rawApp).grantPermissions(INSTALL_SHORTCUT_PERMISSION) - app = spyk(rawApp) { - val pm = spyk(packageManager) { - val shortcutReceiver = ResolveInfo().apply { - activityInfo = ActivityInfo().apply { permission = INSTALL_SHORTCUT_PERMISSION } - } - every { queryBroadcastReceivers(match { it.action == ACTION_INSTALL_SHORTCUT }, 0) } - .returns(listOf(shortcutReceiver)) - } - every { packageManager } returns pm - getSystemService()?.let { sm -> - shortcutManagerService = spyk(sm) - every { getSystemService(ShortcutManager::class.java) } returns shortcutManagerService - } - } - mockInstantApp(false) } @@ -235,7 +233,7 @@ class GodToolsShortcutManagerTest { } coVerifyAll { toolsRepository.getNormalTools() - shortcutManagerService wasNot Called + shortcutManagerService!! wasNot Called } } // endregion Update Existing Shortcuts @@ -255,7 +253,7 @@ class GodToolsShortcutManagerTest { private fun mockInstantApp(isInstantApp: Boolean) { every { InstantApps.isInstantApp(any()) } returns isInstantApp - if (::shortcutManagerService.isInitialized) { + if (shortcutManagerService != null) { // Instant Apps don't have access to the system ShortcutManager every { app.getSystemService() } returns shortcutManagerService.takeUnless { isInstantApp } } From 9826da24e4e3f826b1448b5df1cb21f71d99045d Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Mon, 15 Apr 2024 11:41:43 -0600 Subject: [PATCH 08/17] update updateDynamicShortcuts() to utilize ShortcutManagerCompat and directly generate the needed shortcuts --- .../shortcuts/GodToolsShortcutManager.kt | 19 ++++++++++------- .../shortcuts/GodToolsShortcutManagerTest.kt | 21 +++++++------------ 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt b/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt index 59c353aa0f..b498e95298 100644 --- a/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt +++ b/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt @@ -26,9 +26,13 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -172,27 +176,26 @@ class GodToolsShortcutManager @VisibleForTesting internal constructor( @RequiresApi(Build.VERSION_CODES.N_MR1) internal suspend fun updateShortcuts() = updateShortcutsMutex.withLock { val shortcuts = createAllShortcuts() - updateDynamicShortcuts(shortcuts) + updateDynamicShortcuts() updatePinnedShortcuts(shortcuts) } @VisibleForTesting - @RequiresApi(Build.VERSION_CODES.N_MR1) - internal suspend fun updateDynamicShortcuts(shortcuts: Map) { - val manager = shortcutManager ?: return + internal suspend fun updateDynamicShortcuts() { + if (!isEnabled) return val dynamicShortcuts = withContext(ioDispatcher) { toolsRepository.getNormalTools() .filter { it.isFavorite } .sortedWith(Tool.COMPARATOR_FAVORITE_ORDER) - .asSequence() - .mapNotNull { shortcuts[it.shortcutId]?.toShortcutInfo() } - .take(manager.maxShortcutCountPerActivity) + .asFlow() + .mapNotNull { createToolShortcut(it) } + .take(ShortcutManagerCompat.getMaxShortcutCountPerActivity(context)) .toList() } try { - manager.dynamicShortcuts = dynamicShortcuts + ShortcutManagerCompat.setDynamicShortcuts(context, dynamicShortcuts) } catch (e: IllegalStateException) { Timber.tag("GodToolsShortcutManager").e(e, "Error updating dynamic shortcuts") } diff --git a/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt b/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt index 837df182d8..bc7f58b55b 100644 --- a/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt +++ b/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt @@ -219,36 +219,29 @@ class GodToolsShortcutManagerTest { } // endregion Pending Shortcuts - // region Update Existing Shortcuts + // region updateDynamicShortcuts() @Test - @Config(sdk = [Build.VERSION_CODES.N_MR1, NEWEST_SDK]) - fun testUpdateDynamicShortcutsDoesntInterceptChildCancelledException() = testScope.runTest { + fun `updateDynamicShortcuts() - Don't intercept CancelledException`() = testScope.runTest { coEvery { toolsRepository.getNormalTools() } throws CancellationException() ExceptionRaisingTree.plant().use { - launch { shortcutManager.updateDynamicShortcuts(emptyMap()) }.apply { + launch { shortcutManager.updateDynamicShortcuts() }.apply { join() assertTrue(isCancelled) } } - coVerifyAll { - toolsRepository.getNormalTools() - shortcutManagerService!! wasNot Called - } + coVerifyAll { toolsRepository.getNormalTools() } } - // endregion Update Existing Shortcuts - // region Instant App @Test - @Config(sdk = [Build.VERSION_CODES.N_MR1, NEWEST_SDK]) - fun `Instant App - updateDynamicShortcuts()`() = testScope.runTest { + fun `updateDynamicShortcuts() - Instant App`() = testScope.runTest { mockInstantApp(true) // This should be a no-op - shortcutManager.updateDynamicShortcuts(emptyMap()) + shortcutManager.updateDynamicShortcuts() verify { toolsRepository wasNot Called } } - // endregion Instant App + // endregion updateDynamicShortcuts() private fun mockInstantApp(isInstantApp: Boolean) { every { InstantApps.isInstantApp(any()) } returns isInstantApp From f6b12bb4b5c9e02735ab38a5868104bdfa6a3467 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Wed, 17 Apr 2024 11:26:44 -0600 Subject: [PATCH 09/17] add unit tests for createToolShortcut() --- gradle/libs.versions.toml | 2 +- .../shortcuts/GodToolsShortcutManager.kt | 34 +++++----- .../org/cru/godtools/shortcuts/ShortcutId.kt | 2 + .../shortcuts/GodToolsShortcutManagerTest.kt | 65 ++++++++++++++++++- 4 files changed, 85 insertions(+), 18 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8f256914ec..659c9710df 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,7 @@ firebase-crashlytics = "18.6.4" firebase-perf = "20.5.2" godtoolsShared = "1.0.1" google-auto-value = "1.10.4" -gtoSupport = "4.2.0" +gtoSupport = "4.2.1-SNAPSHOT" kotlin = "1.9.23" kotlinCoroutines = "1.8.0" kotlinKover = "0.7.6" diff --git a/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt b/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt index b498e95298..cb4f7cdca8 100644 --- a/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt +++ b/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt @@ -38,7 +38,6 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.ccci.gto.android.common.picasso.getBitmap -import org.ccci.gto.android.common.util.includeFallbacks import org.cru.godtools.base.Settings import org.cru.godtools.base.ToolFileSystem import org.cru.godtools.base.tool.SHORTCUT_LAUNCH @@ -60,6 +59,8 @@ private const val TYPE_TOOL = "tool|" internal const val DELAY_UPDATE_SHORTCUTS = 5000L internal const val DELAY_UPDATE_PENDING_SHORTCUTS = 100L +private val SUPPORTED_TOOL_TYPES = setOf(Tool.Type.ARTICLE, Tool.Type.CYOA, Tool.Type.TRACT) + @Singleton class GodToolsShortcutManager @VisibleForTesting internal constructor( private val attachmentsRepository: AttachmentsRepository, @@ -234,32 +235,35 @@ class GodToolsShortcutManager @VisibleForTesting internal constructor( ) } - @AnyThread - private suspend fun createToolShortcut(id: ShortcutId.Tool, tool: Tool?) = withContext(ioDispatcher) { + @VisibleForTesting + internal suspend fun createToolShortcut(id: ShortcutId.Tool, tool: Tool?) = withContext(ioDispatcher) { if (tool == null) return@withContext null - - // generate the list of locales to use for this tool - val locales = buildList { - val translation = translationsRepository.findLatestTranslation(id.tool, settings.appLanguage) - ?: translationsRepository.findLatestTranslation(id.tool, tool.defaultLocale) - ?: return@withContext null - add(translation.languageCode) + val type = tool.type + if (type !in SUPPORTED_TOOL_TYPES) return@withContext null + + // generate the list of translations to use for this tool + val translations = buildList { + if (id.isFavoriteToolShortcut) { + val favoriteTranslation = translationsRepository.findLatestTranslation(id.tool, settings.appLanguage) + ?: translationsRepository.findLatestTranslation(id.tool, tool.defaultLocale) + if (favoriteTranslation != null) add(favoriteTranslation) + } } + if (translations.isEmpty()) return@withContext null // generate the target intent for this shortcut - val intent = when (tool.type) { + val locales = translations.map { it.languageCode } + val intent = when (type) { Tool.Type.ARTICLE -> context.createArticlesIntent(id.tool, locales[0]) Tool.Type.CYOA -> context.createCyoaActivityIntent(id.tool, *locales.toTypedArray()) Tool.Type.TRACT -> context.createTractActivityIntent(id.tool, *locales.toTypedArray()) - else -> return@withContext null + else -> error("Unexpected Tool Type: $type") } intent.action = Intent.ACTION_VIEW intent.putExtra(SHORTCUT_LAUNCH, true) // Generate the shortcut label - val label = locales.asSequence().includeFallbacks().distinct() - .firstNotNullOfOrNull { translationsRepository.findLatestTranslation(id.tool, it) } - .getName(tool, context) + val label = translations.first().getName(tool, context) // create the icon bitmap val icon: IconCompat = tool.detailsBannerId diff --git a/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/ShortcutId.kt b/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/ShortcutId.kt index e8e592fd92..850a39f692 100644 --- a/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/ShortcutId.kt +++ b/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/ShortcutId.kt @@ -15,6 +15,8 @@ internal sealed interface ShortcutId { data class Tool internal constructor(val tool: String) : ShortcutId { override val id = listOf(TYPE, tool).joinToString(SEPARATOR) + val isFavoriteToolShortcut get() = true + companion object { const val TYPE = "tool" diff --git a/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt b/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt index bc7f58b55b..4c8463088e 100644 --- a/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt +++ b/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt @@ -1,6 +1,7 @@ package org.cru.godtools.shortcuts import android.app.Application +import android.content.Intent import android.content.pm.ActivityInfo import android.content.pm.ResolveInfo import android.content.pm.ShortcutManager @@ -21,9 +22,11 @@ import io.mockk.mockkStatic import io.mockk.spyk import io.mockk.unmockkStatic import io.mockk.verify +import java.util.Locale import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -36,10 +39,16 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.ccci.gto.android.common.testing.timber.ExceptionRaisingTree +import org.ccci.gto.android.common.util.content.equalsIntent +import org.cru.godtools.base.Settings +import org.cru.godtools.base.tool.SHORTCUT_LAUNCH +import org.cru.godtools.base.ui.createTractActivityIntent import org.cru.godtools.db.repository.ToolsRepository +import org.cru.godtools.db.repository.TranslationsRepository import org.cru.godtools.model.Tool import org.cru.godtools.model.event.ToolUsedEvent import org.cru.godtools.model.randomTool +import org.cru.godtools.model.randomTranslation import org.greenrobot.eventbus.EventBus import org.junit.runner.RunWith import org.robolectric.Shadows @@ -68,6 +77,9 @@ class GodToolsShortcutManagerTest { } } private val eventBus: EventBus = mockk(relaxUnitFun = true) + private val settings: Settings = mockk { + every { appLanguage } returns Locale.ENGLISH + } private val shortcutManagerService = app.getSystemService()?.let { spyk(it) { every { app.getSystemService(ShortcutManager::class.java) } returns this @@ -77,6 +89,9 @@ class GodToolsShortcutManagerTest { private val toolsRepository: ToolsRepository = mockk { coEvery { findTool(any()) } returns null } + private val translationsRepository: TranslationsRepository = mockk { + coEvery { findLatestTranslation(any(), any()) } returns null + } private val shortcutManager by lazy { GodToolsShortcutManager( @@ -85,9 +100,9 @@ class GodToolsShortcutManagerTest { eventBus = eventBus, fs = mockk(), picasso = mockk(), - settings = mockk(), + settings = settings, toolsRepository = toolsRepository, - translationsRepository = mockk(), + translationsRepository = translationsRepository, coroutineScope = testScope.backgroundScope, ioDispatcher = UnconfinedTestDispatcher(testScope.testScheduler) ) @@ -243,6 +258,52 @@ class GodToolsShortcutManagerTest { } // endregion updateDynamicShortcuts() + // region createToolShortcut() + @Test + fun `createToolShortcut() - Valid - Favorite Tool Shortcut - Tract`() = testScope.runTest { + val id = ShortcutId.Tool("tool") + val tool = randomTool("tool", type = Tool.Type.TRACT, detailsBannerId = null) + val translation = randomTranslation("tool", Locale.ENGLISH) + coEvery { translationsRepository.findLatestTranslation("tool", Locale.ENGLISH) } returns translation + + assertNotNull(shortcutManager.createToolShortcut(id, tool)) { + assertEquals(translation.name, it.shortLabel.toString()) + assertEquals(translation.name, it.longLabel.toString()) + + val expectedIntent = app.createTractActivityIntent("tool", Locale.ENGLISH) + .setAction(Intent.ACTION_VIEW) + .putExtra(SHORTCUT_LAUNCH, true) + assertTrue(expectedIntent equalsIntent it.intent) + } + } + + @Test + fun `createToolShortcut() - Invalid - Tool Not Found`() = testScope.runTest { + val id = ShortcutId.Tool("tool") + + assertNull(shortcutManager.createToolShortcut(id, null)) + verify { translationsRepository wasNot Called } + } + + @Test + fun `createToolShortcut() - Invalid - Unsupported Tool Type`() = testScope.runTest { + val id = ShortcutId.Tool("tool") + val tool = randomTool("tool", type = Tool.Type.LESSON) + val translation = randomTranslation("tool", Locale.ENGLISH) + coEvery { translationsRepository.findLatestTranslation("tool", Locale.ENGLISH) } returns translation + + assertNull(shortcutManager.createToolShortcut(id, tool)) + } + + @Test + fun `createToolShortcut() - Invalid - No Translations - Favorite Tool Shortcut`() = testScope.runTest { + val id = ShortcutId.Tool("tool") + val tool = randomTool("tool", type = Tool.Type.TRACT) + + assertNull(shortcutManager.createToolShortcut(id, tool)) + } + // endregion createToolShortcut() + private fun mockInstantApp(isInstantApp: Boolean) { every { InstantApps.isInstantApp(any()) } returns isInstantApp From 344cccb07be7d51089846f3ade427f1b934465a9 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Wed, 17 Apr 2024 14:19:53 -0600 Subject: [PATCH 10/17] update updatePinnedShortcuts to only update shortcuts that actually exist --- .../shortcuts/GodToolsShortcutManager.kt | 53 +++++++++------ .../shortcuts/GodToolsShortcutManagerTest.kt | 64 +++++++++++++++++++ 2 files changed, 99 insertions(+), 18 deletions(-) diff --git a/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt b/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt index cb4f7cdca8..2a3bc92ada 100644 --- a/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt +++ b/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt @@ -10,6 +10,8 @@ import androidx.annotation.VisibleForTesting import androidx.core.content.getSystemService import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.content.pm.ShortcutManagerCompat.FLAG_MATCH_CACHED +import androidx.core.content.pm.ShortcutManagerCompat.FLAG_MATCH_PINNED import androidx.core.graphics.drawable.IconCompat import com.google.android.gms.common.wrappers.InstantApps import com.squareup.picasso.Picasso @@ -19,6 +21,7 @@ import java.lang.ref.WeakReference import javax.inject.Inject import javax.inject.Singleton import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -174,11 +177,13 @@ class GodToolsShortcutManager @VisibleForTesting internal constructor( private val updateShortcutsMutex = Mutex() @VisibleForTesting - @RequiresApi(Build.VERSION_CODES.N_MR1) - internal suspend fun updateShortcuts() = updateShortcutsMutex.withLock { - val shortcuts = createAllShortcuts() - updateDynamicShortcuts() - updatePinnedShortcuts(shortcuts) + internal suspend fun updateShortcuts() { + updateShortcutsMutex.withLock { + coroutineScope { + launch { updateDynamicShortcuts() } + launch { updatePinnedShortcuts() } + } + } } @VisibleForTesting @@ -202,12 +207,31 @@ class GodToolsShortcutManager @VisibleForTesting internal constructor( } } - @RequiresApi(Build.VERSION_CODES.N_MR1) - private fun updatePinnedShortcuts(shortcuts: Map) { - shortcutManager?.apply { - disableShortcuts(pinnedShortcuts.map { it.id }.filterNot { shortcuts.containsKey(it) }) - enableShortcuts(shortcuts.keys.toList()) - ShortcutManagerCompat.updateShortcuts(context, shortcuts.values.toList()) + @VisibleForTesting + internal suspend fun updatePinnedShortcuts() { + if (!isEnabled) return + + withContext(ioDispatcher) { + val types = FLAG_MATCH_PINNED or FLAG_MATCH_CACHED + val (invalid, shortcuts) = ShortcutManagerCompat.getShortcuts(context, types) + .mapNotNull { it.id to ShortcutId.parseId(it.id) } + .map { (id, shortcutId) -> + when (shortcutId) { + null -> CompletableDeferred(id to null) + is ShortcutId.Tool -> async { shortcutId.id to createToolShortcut(shortcutId) } + } + } + .awaitAll() + .partition { it.second == null } + .let { it.first.map { it.first } to it.second.mapNotNull { it.second } } + + if (invalid.isNotEmpty()) { + ShortcutManagerCompat.disableShortcuts(context, invalid, null) + } + if (shortcuts.isNotEmpty()) { + ShortcutManagerCompat.enableShortcuts(context, shortcuts) + ShortcutManagerCompat.updateShortcuts(context, shortcuts) + } } } // endregion Update Existing Shortcuts @@ -217,13 +241,6 @@ class GodToolsShortcutManager @VisibleForTesting internal constructor( updatePendingShortcuts() } - private suspend fun createAllShortcuts() = withContext(ioDispatcher) { - toolsRepository.getAllTools() - .map { async { createToolShortcut(it) } }.awaitAll() - .filterNotNull() - .associateBy { it.id } - } - private suspend fun createToolShortcut(id: ShortcutId.Tool) = withContext(ioDispatcher) { createToolShortcut(id, toolsRepository.findTool(id.tool)) } diff --git a/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt b/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt index 4c8463088e..bdb5da1fe2 100644 --- a/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt +++ b/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt @@ -7,6 +7,7 @@ import android.content.pm.ResolveInfo import android.content.pm.ShortcutManager import android.os.Build import androidx.core.content.getSystemService +import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -22,6 +23,7 @@ import io.mockk.mockkStatic import io.mockk.spyk import io.mockk.unmockkStatic import io.mockk.verify +import io.mockk.verifyAll import java.util.Locale import kotlin.test.AfterTest import kotlin.test.BeforeTest @@ -111,6 +113,10 @@ class GodToolsShortcutManagerTest { @BeforeTest fun setup() { mockkStatic(InstantApps::class, ShortcutManagerCompat::class) + every { ShortcutManagerCompat.enableShortcuts(any(), any()) } just Runs + every { ShortcutManagerCompat.updateShortcuts(any(), any()) } returns true + every { ShortcutManagerCompat.disableShortcuts(any(), any(), any()) } just Runs + mockInstantApp(false) } @@ -258,6 +264,64 @@ class GodToolsShortcutManagerTest { } // endregion updateDynamicShortcuts() + // region updatePinnedShortcuts() + @Test + fun `updatePinnedShortcuts() - No Pinned Shortcuts`() = testScope.runTest { + every { ShortcutManagerCompat.getShortcuts(any(), any()) } returns emptyList() + + shortcutManager.updatePinnedShortcuts() + verifyAll { + ShortcutManagerCompat.getShortcuts(any(), any()) + } + } + + @Test + fun `updatePinnedShortcuts() - Update Existing`() = testScope.runTest { + val id = ShortcutId.Tool("tool") + val tool = randomTool("tool", type = Tool.Type.TRACT, detailsBannerId = null) + val translation = randomTranslation("tool", Locale.ENGLISH) + val shortcut = ShortcutInfoCompat.Builder(app, id.id) + .setShortLabel("label") + .setIntent(Intent()) + .build() + coEvery { toolsRepository.findTool("tool") } returns tool + coEvery { translationsRepository.findLatestTranslation("tool", Locale.ENGLISH) } returns translation + every { ShortcutManagerCompat.getShortcuts(any(), any()) } returns listOf(shortcut) + + shortcutManager.updatePinnedShortcuts() + verifyAll { + ShortcutManagerCompat.getShortcuts(any(), any()) + ShortcutManagerCompat.enableShortcuts(any(), match { it.map { it.id } == listOf(id.id) }) + ShortcutManagerCompat.updateShortcuts(any(), match { it.map { it.id } == listOf(id.id) }) + } + } + + @Test + fun `updatePinnedShortcuts() - Disable Invalid`() = testScope.runTest { + val shortcut = ShortcutInfoCompat.Builder(app, "invalid") + .setShortLabel("label") + .setIntent(Intent()) + .build() + every { ShortcutManagerCompat.getShortcuts(any(), any()) } returns listOf(shortcut) + + shortcutManager.updatePinnedShortcuts() + verifyAll { + ShortcutManagerCompat.getShortcuts(any(), any()) + ShortcutManagerCompat.disableShortcuts(any(), listOf("invalid"), any()) + } + } + + @Test + fun `updatePinnedShortcuts() - Instant App`() = testScope.runTest { + mockInstantApp(true) + + shortcutManager.updatePinnedShortcuts() + verify(exactly = 0) { + ShortcutManagerCompat.getShortcuts(any(), any()) + } + } + // endregion updatePinnedShortcuts() + // region createToolShortcut() @Test fun `createToolShortcut() - Valid - Favorite Tool Shortcut - Tract`() = testScope.runTest { From a392aad830f89da1a1313ab23f70a8d00e28b031 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Wed, 17 Apr 2024 15:16:22 -0600 Subject: [PATCH 11/17] there is no need to only target new SDKs with the ShortcutManager --- .../shortcuts/GodToolsShortcutManager.kt | 1 - .../GodToolsShortcutManagerDispatcherTest.kt | 19 ------------------- 2 files changed, 20 deletions(-) diff --git a/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt b/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt index 2a3bc92ada..a53281d344 100644 --- a/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt +++ b/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt @@ -352,7 +352,6 @@ class GodToolsShortcutManager @VisibleForTesting internal constructor( @VisibleForTesting internal val updateShortcutsJob = coroutineScope.launch { if (!manager.isEnabled) return@launch - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) return@launch merge( settings.appLanguageFlow, diff --git a/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerDispatcherTest.kt b/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerDispatcherTest.kt index 0036f04050..5e6e92e3fc 100644 --- a/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerDispatcherTest.kt +++ b/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerDispatcherTest.kt @@ -1,6 +1,5 @@ package org.cru.godtools.shortcuts -import android.os.Build import androidx.test.ext.junit.runners.AndroidJUnit4 import io.mockk.Called import io.mockk.clearMocks @@ -27,9 +26,6 @@ import org.cru.godtools.db.repository.TranslationsRepository import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith -import org.robolectric.annotation.Config -import org.robolectric.annotation.Config.NEWEST_SDK -import org.robolectric.annotation.Config.OLDEST_SDK @RunWith(AndroidJUnit4::class) @OptIn(ExperimentalCoroutinesApi::class) @@ -98,7 +94,6 @@ class GodToolsShortcutManagerDispatcherTest { // region updateShortcutsJob @Test - @Config(sdk = [Build.VERSION_CODES.N_MR1, NEWEST_SDK]) fun `updateShortcutsJob - Triggers once on startup`() = testScope.runTest { dispatcher.updatePendingShortcutsJob.cancel() verify { shortcutManager wasNot Called } @@ -111,7 +106,6 @@ class GodToolsShortcutManagerDispatcherTest { } @Test - @Config(sdk = [Build.VERSION_CODES.N_MR1, NEWEST_SDK]) fun `updateShortcutsJob - Trigger on primaryLanguage Update`() = testScope.runTest { dispatcher.updatePendingShortcutsJob.cancel() runCurrent() @@ -126,7 +120,6 @@ class GodToolsShortcutManagerDispatcherTest { } @Test - @Config(sdk = [Build.VERSION_CODES.N_MR1, NEWEST_SDK]) fun `updateShortcutsJob - Trigger on attachments Update`() = testScope.runTest { dispatcher.updatePendingShortcutsJob.cancel() runCurrent() @@ -141,7 +134,6 @@ class GodToolsShortcutManagerDispatcherTest { } @Test - @Config(sdk = [Build.VERSION_CODES.N_MR1, NEWEST_SDK]) fun `updateShortcutsJob - Trigger on tools Update`() = testScope.runTest { dispatcher.updatePendingShortcutsJob.cancel() runCurrent() @@ -156,7 +148,6 @@ class GodToolsShortcutManagerDispatcherTest { } @Test - @Config(sdk = [Build.VERSION_CODES.N_MR1, NEWEST_SDK]) fun `updateShortcutsJob - Trigger on translations Update`() = testScope.runTest { dispatcher.updatePendingShortcutsJob.cancel() runCurrent() @@ -171,7 +162,6 @@ class GodToolsShortcutManagerDispatcherTest { } @Test - @Config(sdk = [Build.VERSION_CODES.N_MR1, NEWEST_SDK]) fun `updateShortcutsJob - Aggregates multiple events`() = testScope.runTest { dispatcher.updatePendingShortcutsJob.cancel() @@ -196,14 +186,5 @@ class GodToolsShortcutManagerDispatcherTest { advanceTimeBy(10 * DELAY_UPDATE_SHORTCUTS) confirmVerified(shortcutManager) } - - @Test - @Config(sdk = [OLDEST_SDK, Build.VERSION_CODES.N]) - fun `updateShortcutsJob - Not Available For Old Sdks`() = testScope.runTest { - dispatcher.updatePendingShortcutsJob.cancel() - runCurrent() - assertTrue(dispatcher.updateShortcutsJob.isCompleted) - verify { shortcutManager wasNot Called } - } // endregion updateShortcutsJob } From 13630d9346c1a27182d0a48a7d3e87a0d305f894 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Wed, 17 Apr 2024 15:17:30 -0600 Subject: [PATCH 12/17] remove some dead code --- .../cru/godtools/shortcuts/GodToolsShortcutManager.kt | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt b/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt index a53281d344..32393a6c1e 100644 --- a/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt +++ b/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt @@ -2,12 +2,9 @@ package org.cru.godtools.shortcuts import android.content.Context import android.content.Intent -import android.content.pm.ShortcutManager import android.os.Build import androidx.annotation.AnyThread -import androidx.annotation.RequiresApi import androidx.annotation.VisibleForTesting -import androidx.core.content.getSystemService import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.content.pm.ShortcutManagerCompat.FLAG_MATCH_CACHED @@ -57,8 +54,6 @@ import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import timber.log.Timber -private const val TYPE_TOOL = "tool|" - internal const val DELAY_UPDATE_SHORTCUTS = 5000L internal const val DELAY_UPDATE_PENDING_SHORTCUTS = 100L @@ -99,9 +94,6 @@ class GodToolsShortcutManager @VisibleForTesting internal constructor( coroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) ) - @get:RequiresApi(Build.VERSION_CODES.N_MR1) - private val shortcutManager by lazy { context.getSystemService() } - @VisibleForTesting internal val isEnabled = !InstantApps.isInstantApp(context) @@ -365,6 +357,3 @@ class GodToolsShortcutManager @VisibleForTesting internal constructor( } } } - -private val Tool.shortcutId get() = code?.toolShortcutId -private val String.toolShortcutId get() = "$TYPE_TOOL$this" From abae4d94df009e453a8f935ade83228c040d0f7e Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Wed, 17 Apr 2024 16:20:22 -0600 Subject: [PATCH 13/17] update PendingShortcut to track a shortcut id --- .../shortcuts/GodToolsShortcutManager.kt | 6 ++-- .../cru/godtools/shortcuts/PendingShortcut.kt | 2 +- .../shortcuts/GodToolsShortcutManagerTest.kt | 33 ++++++++++++++++++- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt b/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt index 32393a6c1e..1c5b23b1e1 100644 --- a/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt +++ b/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt @@ -133,7 +133,7 @@ class GodToolsShortcutManager @VisibleForTesting internal constructor( return synchronized(pendingShortcuts) { pendingShortcuts[id.id]?.get() - ?: PendingShortcut(id.tool).also { + ?: PendingShortcut(id).also { pendingShortcuts[id.id] = WeakReference(it) coroutineScope.launch { updatePendingShortcut(it) } } @@ -161,7 +161,9 @@ class GodToolsShortcutManager @VisibleForTesting internal constructor( @AnyThread private suspend fun updatePendingShortcut(shortcut: PendingShortcut) = shortcut.mutex.withLock { - toolsRepository.findTool(shortcut.tool)?.let { shortcut.shortcut = createToolShortcut(it) } + shortcut.shortcut = when (shortcut.id) { + is ShortcutId.Tool -> createToolShortcut(shortcut.id) + } } // endregion Pending Shortcuts diff --git a/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/PendingShortcut.kt b/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/PendingShortcut.kt index 9b874485c8..beb616aa00 100644 --- a/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/PendingShortcut.kt +++ b/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/PendingShortcut.kt @@ -3,7 +3,7 @@ package org.cru.godtools.shortcuts import androidx.core.content.pm.ShortcutInfoCompat import kotlinx.coroutines.sync.Mutex -class PendingShortcut internal constructor(internal val tool: String) { +class PendingShortcut internal constructor(internal val id: ShortcutId) { internal val mutex = Mutex() @Volatile diff --git a/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt b/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt index bdb5da1fe2..c70046bec9 100644 --- a/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt +++ b/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt @@ -216,14 +216,45 @@ class GodToolsShortcutManagerTest { } // endregion canPinToolShortcut(tool) + // region getPendingToolShortcut() @Test - fun verifyGetPendingToolShortcutInvalidTool() = testScope.runTest { + fun `getPendingToolShortcut() - Invalid`() = testScope.runTest { val shortcut = shortcutManager.getPendingToolShortcut("invalid")!! runCurrent() coVerifyAll { toolsRepository.findTool("invalid") } assertNull(shortcut.shortcut) } + @Test + fun `getPendingToolShortcut() - Instant App`() = testScope.runTest { + mockInstantApp(true) + + assertNull(shortcutManager.getPendingToolShortcut("tool")) + verify { + toolsRepository wasNot Called + } + } + + @Test + fun `getPendingToolShortcut() - Valid`() = testScope.runTest { + val tool = randomTool("tool", type = Tool.Type.TRACT, detailsBannerId = null) + val translation = randomTranslation("tool", Locale.ENGLISH) + coEvery { toolsRepository.findTool("tool") } returns tool + coEvery { translationsRepository.findLatestTranslation("tool", Locale.ENGLISH) } returns translation + + val pending = shortcutManager.getPendingToolShortcut("tool")!! + assertEquals(ShortcutId.Tool("tool"), pending.id) + assertNull(pending.shortcut) + runCurrent() + assertNotNull(pending.shortcut) { + val expectedIntent = app.createTractActivityIntent("tool", Locale.ENGLISH) + .setAction(Intent.ACTION_VIEW) + .putExtra(SHORTCUT_LAUNCH, true) + assertTrue(expectedIntent equalsIntent it.intent) + } + } + // endregion getPendingToolShortcut() + @Test fun verifyUpdatePendingToolShortcuts() = testScope.runTest { val shortcut = shortcutManager.getPendingToolShortcut("kgp")!! From cc708acdcc767d16c6126afdb713e0604dd2a3ff Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Thu, 18 Apr 2024 10:56:17 -0600 Subject: [PATCH 14/17] prefer testing createToolShortcut(id) --- .../godtools/shortcuts/GodToolsShortcutManager.kt | 15 ++++++--------- .../shortcuts/GodToolsShortcutManagerTest.kt | 12 ++++++++---- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt b/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt index 1c5b23b1e1..567ddecd8b 100644 --- a/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt +++ b/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt @@ -235,19 +235,16 @@ class GodToolsShortcutManager @VisibleForTesting internal constructor( updatePendingShortcuts() } - private suspend fun createToolShortcut(id: ShortcutId.Tool) = withContext(ioDispatcher) { + @VisibleForTesting + internal suspend fun createToolShortcut(id: ShortcutId.Tool) = withContext(ioDispatcher) { createToolShortcut(id, toolsRepository.findTool(id.tool)) } - private suspend fun createToolShortcut(tool: Tool): ShortcutInfoCompat? { - return createToolShortcut( - id = tool.code?.let { ShortcutId.Tool(it) } ?: return null, - tool = tool - ) - } + private suspend fun createToolShortcut(tool: Tool) = tool.code + ?.let { ShortcutId.Tool(it) } + ?.let { createToolShortcut(it, tool) } - @VisibleForTesting - internal suspend fun createToolShortcut(id: ShortcutId.Tool, tool: Tool?) = withContext(ioDispatcher) { + private suspend fun createToolShortcut(id: ShortcutId.Tool, tool: Tool?) = withContext(ioDispatcher) { if (tool == null) return@withContext null val type = tool.type if (type !in SUPPORTED_TOOL_TYPES) return@withContext null diff --git a/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt b/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt index c70046bec9..c845e1f16b 100644 --- a/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt +++ b/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt @@ -359,9 +359,10 @@ class GodToolsShortcutManagerTest { val id = ShortcutId.Tool("tool") val tool = randomTool("tool", type = Tool.Type.TRACT, detailsBannerId = null) val translation = randomTranslation("tool", Locale.ENGLISH) + coEvery { toolsRepository.findTool("tool") } returns tool coEvery { translationsRepository.findLatestTranslation("tool", Locale.ENGLISH) } returns translation - assertNotNull(shortcutManager.createToolShortcut(id, tool)) { + assertNotNull(shortcutManager.createToolShortcut(id)) { assertEquals(translation.name, it.shortLabel.toString()) assertEquals(translation.name, it.longLabel.toString()) @@ -375,8 +376,9 @@ class GodToolsShortcutManagerTest { @Test fun `createToolShortcut() - Invalid - Tool Not Found`() = testScope.runTest { val id = ShortcutId.Tool("tool") + coEvery { toolsRepository.findTool("tool") } returns null - assertNull(shortcutManager.createToolShortcut(id, null)) + assertNull(shortcutManager.createToolShortcut(id)) verify { translationsRepository wasNot Called } } @@ -385,17 +387,19 @@ class GodToolsShortcutManagerTest { val id = ShortcutId.Tool("tool") val tool = randomTool("tool", type = Tool.Type.LESSON) val translation = randomTranslation("tool", Locale.ENGLISH) + coEvery { toolsRepository.findTool("tool") } returns tool coEvery { translationsRepository.findLatestTranslation("tool", Locale.ENGLISH) } returns translation - assertNull(shortcutManager.createToolShortcut(id, tool)) + assertNull(shortcutManager.createToolShortcut(id)) } @Test fun `createToolShortcut() - Invalid - No Translations - Favorite Tool Shortcut`() = testScope.runTest { val id = ShortcutId.Tool("tool") val tool = randomTool("tool", type = Tool.Type.TRACT) + coEvery { toolsRepository.findTool("tool") } returns tool - assertNull(shortcutManager.createToolShortcut(id, tool)) + assertNull(shortcutManager.createToolShortcut(id)) } // endregion createToolShortcut() From 5d8f5424b7ff2814519c51522ac8bf34fc71734a Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Wed, 17 Apr 2024 16:38:29 -0600 Subject: [PATCH 15/17] add support for including locales in shortcut ids --- .../org/cru/godtools/shortcuts/ShortcutId.kt | 15 ++++++--- .../cru/godtools/shortcuts/ShortcutIdTest.kt | 31 +++++++++++++++++-- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/ShortcutId.kt b/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/ShortcutId.kt index 850a39f692..cd58f2c28c 100644 --- a/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/ShortcutId.kt +++ b/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/ShortcutId.kt @@ -1,5 +1,7 @@ package org.cru.godtools.shortcuts +import java.util.Locale + internal sealed interface ShortcutId { val id: String @@ -12,10 +14,12 @@ internal sealed interface ShortcutId { } } - data class Tool internal constructor(val tool: String) : ShortcutId { - override val id = listOf(TYPE, tool).joinToString(SEPARATOR) + data class Tool internal constructor(val tool: String, val locales: List) : ShortcutId { + internal constructor(tool: String, vararg locales: Locale?) : this(tool, locales.filterNotNull()) + + override val id = (listOf(TYPE, tool) + locales.map { it.toLanguageTag() }).joinToString(SEPARATOR) - val isFavoriteToolShortcut get() = true + val isFavoriteToolShortcut get() = locales.isEmpty() companion object { const val TYPE = "tool" @@ -25,7 +29,10 @@ internal sealed interface ShortcutId { return when { components.size < 2 -> null components[0] != TYPE -> null - else -> Tool(components[1]) + else -> Tool( + tool = components[1], + locales = components.subList(2, components.size).map { Locale.forLanguageTag(it) } + ) } } } diff --git a/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/ShortcutIdTest.kt b/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/ShortcutIdTest.kt index b69239e758..fe8c368010 100644 --- a/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/ShortcutIdTest.kt +++ b/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/ShortcutIdTest.kt @@ -1,24 +1,49 @@ package org.cru.godtools.shortcuts +import java.util.Locale import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertTrue class ShortcutIdTest { @Test - fun `parseId() - Tool shortcuts`() { + fun `ShortcutId - parseId() - Tool shortcuts`() { assertNull(ShortcutId.parseId("tool")) assertEquals(ShortcutId.Tool("kgp"), ShortcutId.parseId("tool|kgp")) - assertEquals(ShortcutId.Tool("kgp"), ShortcutId.parseId("tool|kgp|en")) + assertEquals(ShortcutId.Tool("kgp", Locale.ENGLISH), ShortcutId.parseId("tool|kgp|en")) } @Test - fun `parseId() - Invalid`() { + fun `ShortcutId - parseId() - Invalid`() { assertNull(ShortcutId.parseId("invalid-asldf")) } @Test fun `Tool - id`() { assertEquals("tool|kgp", ShortcutId.Tool("kgp").id) + assertEquals("tool|kgp|en|fr", ShortcutId.Tool("kgp", Locale.ENGLISH, Locale.FRENCH).id) + } + + @Test + fun `Tool - parseId() - Valid`() { + assertNotNull(ShortcutId.Tool.parseId("tool|kgp")) { + assertEquals("kgp", it.tool) + assertEquals(emptyList(), it.locales) + assertTrue(it.isFavoriteToolShortcut) + } + assertNotNull(ShortcutId.Tool.parseId("tool|kgp|en|fr")) { + assertEquals("kgp", it.tool) + assertEquals(listOf(Locale.ENGLISH, Locale.FRENCH), it.locales) + assertFalse(it.isFavoriteToolShortcut) + } + } + + @Test + fun `Tool - parseId() - Invalid`() { + assertNull(ShortcutId.Tool.parseId("tool")) + assertNull(ShortcutId.Tool.parseId("other|kgp")) } } From 0b30d2fbf3e3b635ae6c104b044e4a0b67a9a450 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Wed, 17 Apr 2024 16:43:14 -0600 Subject: [PATCH 16/17] update createToolShortcut to use shortcutId locales --- .../shortcuts/GodToolsShortcutManager.kt | 14 +++++++------ .../shortcuts/GodToolsShortcutManagerTest.kt | 21 +++++++++++++++++++ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt b/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt index 567ddecd8b..e2ff2166f1 100644 --- a/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt +++ b/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt @@ -29,6 +29,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.take @@ -250,12 +251,13 @@ class GodToolsShortcutManager @VisibleForTesting internal constructor( if (type !in SUPPORTED_TOOL_TYPES) return@withContext null // generate the list of translations to use for this tool - val translations = buildList { - if (id.isFavoriteToolShortcut) { - val favoriteTranslation = translationsRepository.findLatestTranslation(id.tool, settings.appLanguage) - ?: translationsRepository.findLatestTranslation(id.tool, tool.defaultLocale) - if (favoriteTranslation != null) add(favoriteTranslation) - } + val translations = if (id.isFavoriteToolShortcut) { + flowOf(settings.appLanguage, tool.defaultLocale) + .mapNotNull { translationsRepository.findLatestTranslation(id.tool, it) } + .take(1) + .toList() + } else { + id.locales.mapNotNull { translationsRepository.findLatestTranslation(id.tool, it) } } if (translations.isEmpty()) return@withContext null diff --git a/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt b/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt index c845e1f16b..67c9607a1d 100644 --- a/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt +++ b/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt @@ -373,6 +373,27 @@ class GodToolsShortcutManagerTest { } } + @Test + fun `createToolShortcut() - Valid - With Locales - Tract`() = testScope.runTest { + val id = ShortcutId.Tool("tool", Locale.FRENCH, Locale.GERMAN) + val tool = randomTool("tool", type = Tool.Type.TRACT, detailsBannerId = null) + val frTranslation = randomTranslation("tool", Locale.FRENCH) + val deTranslation = randomTranslation("tool", Locale.GERMAN) + coEvery { toolsRepository.findTool("tool") } returns tool + coEvery { translationsRepository.findLatestTranslation("tool", Locale.FRENCH) } returns frTranslation + coEvery { translationsRepository.findLatestTranslation("tool", Locale.GERMAN) } returns deTranslation + + assertNotNull(shortcutManager.createToolShortcut(id)) { + assertEquals(frTranslation.name, it.shortLabel.toString()) + assertEquals(frTranslation.name, it.longLabel.toString()) + + val expectedIntent = app.createTractActivityIntent("tool", Locale.FRENCH, Locale.GERMAN) + .setAction(Intent.ACTION_VIEW) + .putExtra(SHORTCUT_LAUNCH, true) + assertTrue(expectedIntent equalsIntent it.intent) + } + } + @Test fun `createToolShortcut() - Invalid - Tool Not Found`() = testScope.runTest { val id = ShortcutId.Tool("tool") From 8f8e1553dce3fc079b232120569acc351919f32d Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Fri, 19 Apr 2024 11:31:35 -0600 Subject: [PATCH 17/17] get a PendingShortcut for the currently selected languages --- .../ui/tooldetails/ToolDetailsPresenter.kt | 10 +++++++++- .../tooldetails/ToolDetailsPresenterTest.kt | 2 +- .../shortcuts/GodToolsShortcutManager.kt | 5 +++-- .../shortcuts/GodToolsShortcutManagerTest.kt | 19 +++++++++++++++++++ 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/org/cru/godtools/ui/tooldetails/ToolDetailsPresenter.kt b/app/src/main/kotlin/org/cru/godtools/ui/tooldetails/ToolDetailsPresenter.kt index 812f4c88d7..c7b3357fb7 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/tooldetails/ToolDetailsPresenter.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/tooldetails/ToolDetailsPresenter.kt @@ -97,7 +97,15 @@ class ToolDetailsPresenter @AssistedInject constructor( val tool by toolsRepository.produceToolState(toolCode) val translation by rememberUpdatedState(rememberPrimaryTranslation(tool, toolCode)) val secondTranslation by translationsRepository.produceLatestTranslationState(toolCode, screen.secondLanguage) - val pendingShortcut by remember { derivedStateOf { shortcutManager.getPendingToolShortcut(toolCode) } } + val pendingShortcut by remember { + derivedStateOf { + shortcutManager.getPendingToolShortcut( + toolCode, + translation?.languageCode, + secondTranslation?.languageCode + ) + } + } val isConnected by isConnected.collectAsState() val eventSink: (Event) -> Unit = remember { diff --git a/app/src/testDebug/kotlin/org/cru/godtools/ui/tooldetails/ToolDetailsPresenterTest.kt b/app/src/testDebug/kotlin/org/cru/godtools/ui/tooldetails/ToolDetailsPresenterTest.kt index e03ea3252e..443f14fa59 100644 --- a/app/src/testDebug/kotlin/org/cru/godtools/ui/tooldetails/ToolDetailsPresenterTest.kt +++ b/app/src/testDebug/kotlin/org/cru/godtools/ui/tooldetails/ToolDetailsPresenterTest.kt @@ -402,7 +402,7 @@ class ToolDetailsPresenterTest { fun `Event - PinShortcut`() = runTest { val pendingShortcut: PendingShortcut = mockk() every { shortcutManager.canPinToolShortcut(any()) } returns true - every { shortcutManager.getPendingToolShortcut(TOOL) } returns pendingShortcut + every { shortcutManager.getPendingToolShortcut(any(), *anyVararg()) } returns pendingShortcut every { shortcutManager.pinShortcut(pendingShortcut) } just Runs createPresenter().test { diff --git a/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt b/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt index e2ff2166f1..f364154e7b 100644 --- a/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt +++ b/ui/shortcuts/src/main/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManager.kt @@ -15,6 +15,7 @@ import com.squareup.picasso.Picasso import dagger.hilt.android.qualifiers.ApplicationContext import java.io.IOException import java.lang.ref.WeakReference +import java.util.Locale import javax.inject.Inject import javax.inject.Singleton import kotlin.coroutines.CoroutineContext @@ -127,10 +128,10 @@ class GodToolsShortcutManager @VisibleForTesting internal constructor( } @AnyThread - fun getPendingToolShortcut(code: String?): PendingShortcut? { + fun getPendingToolShortcut(code: String?, vararg locales: Locale?): PendingShortcut? { if (!isEnabled) return null if (code == null) return null - val id = ShortcutId.Tool(code) + val id = ShortcutId.Tool(code, *locales) return synchronized(pendingShortcuts) { pendingShortcuts[id.id]?.get() diff --git a/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt b/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt index 67c9607a1d..10517bdc80 100644 --- a/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt +++ b/ui/shortcuts/src/test/kotlin/org/cru/godtools/shortcuts/GodToolsShortcutManagerTest.kt @@ -253,6 +253,25 @@ class GodToolsShortcutManagerTest { assertTrue(expectedIntent equalsIntent it.intent) } } + + @Test + fun `getPendingToolShortcut() - Valid - With Language`() = testScope.runTest { + val tool = randomTool("tool", type = Tool.Type.TRACT, detailsBannerId = null) + val translation = randomTranslation("tool", Locale.FRENCH) + coEvery { toolsRepository.findTool("tool") } returns tool + coEvery { translationsRepository.findLatestTranslation("tool", Locale.FRENCH) } returns translation + + val pending = shortcutManager.getPendingToolShortcut("tool", Locale.FRENCH)!! + assertEquals(ShortcutId.Tool("tool", Locale.FRENCH), pending.id) + assertNull(pending.shortcut) + runCurrent() + assertNotNull(pending.shortcut) { + val expectedIntent = app.createTractActivityIntent("tool", Locale.FRENCH) + .setAction(Intent.ACTION_VIEW) + .putExtra(SHORTCUT_LAUNCH, true) + assertTrue(expectedIntent equalsIntent it.intent) + } + } // endregion getPendingToolShortcut() @Test