diff --git a/app/src/debug/java/to/bitkit/ui/utils/ScreenDeepLinkRuntime.kt b/app/src/debug/java/to/bitkit/ui/utils/ScreenDeepLinkRuntime.kt new file mode 100644 index 000000000..fe5febcd7 --- /dev/null +++ b/app/src/debug/java/to/bitkit/ui/utils/ScreenDeepLinkRuntime.kt @@ -0,0 +1,65 @@ +package to.bitkit.ui.utils + +import android.net.Uri +import androidx.navigation.NavDeepLink +import androidx.navigation.navDeepLink +import to.bitkit.ui.Routes +import to.bitkit.ui.components.Sheet +import to.bitkit.ui.screens.wallets.receive.ReceiveRoute +import to.bitkit.ui.sheets.BackupRoute +import to.bitkit.ui.sheets.SendRoute +import to.bitkit.ui.sheets.WidgetsRoute +import to.bitkit.ui.sheets.hardware.HardwareRoute +import kotlin.reflect.KClass + +internal object ScreenDeepLinkRuntime { + val isEnabled = true + + val sheetIds: Set + get() { + val families = FAMILIES.mapNotNull { ScreenDeepLinks.kebabId(it.type) } + val standalone = STANDALONE.mapNotNull { ScreenDeepLinks.kebabId(it::class) } + return (families + standalone).toSet() + } + + fun linksFor(route: KClass): List { + val basePath = ScreenDeepLinks.basePath(route) ?: return emptyList() + return listOf(navDeepLink(route = route, basePath = basePath) {}) + } + + fun sheetFor(uri: Uri): Sheet? { + if (!ScreenDeepLinks.isScreenDeepLink(uri)) return null + + val segments = uri.pathSegments.orEmpty() + if (segments.isEmpty() || segments.size > 2) return null + + val id = segments.first().lowercase() + val child = segments.getOrElse(1) { "" } + + FAMILIES.firstOrNull { ScreenDeepLinks.kebabId(it.type) == id } + ?.let { return it.fromDeepLink(child) } + + if (child.isNotEmpty()) return null + + return STANDALONE.firstOrNull { ScreenDeepLinks.kebabId(it::class) == id } + } +} + +private class SheetDeepLinkFamily( + val type: KClass, + val fromDeepLink: (String) -> Sheet?, +) + +private val FAMILIES: List = listOf( + SheetDeepLinkFamily(Sheet.Send::class) { path -> SendRoute.fromDeepLink(path)?.let { Sheet.Send(it) } }, + SheetDeepLinkFamily(Sheet.Receive::class) { path -> ReceiveRoute.fromDeepLink(path)?.let { Sheet.Receive(it) } }, + SheetDeepLinkFamily(Sheet.Backup::class) { path -> BackupRoute.fromDeepLink(path)?.let { Sheet.Backup(it) } }, + SheetDeepLinkFamily(Sheet.Widgets::class) { path -> WidgetsRoute.fromDeepLink(path)?.let { Sheet.Widgets(it) } }, + SheetDeepLinkFamily(Sheet.Hardware::class) { path -> HardwareRoute.fromDeepLink(path)?.let { Sheet.Hardware(it) } }, +) + +private val STANDALONE: List = listOf( + Sheet.ActivityDateRangeSelector, + Sheet.ActivityTagSelector, + Sheet.QrScanner(), +) diff --git a/app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt b/app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt index c36abff2a..e727b0f2d 100644 --- a/app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt +++ b/app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt @@ -3,7 +3,6 @@ package to.bitkit.ui.utils import android.content.Intent import android.net.Uri import androidx.navigation.NavDeepLink -import androidx.navigation.navDeepLink import to.bitkit.ui.Routes import kotlin.reflect.KClass @@ -15,6 +14,10 @@ object ScreenDeepLinks { private val CAMEL_HUMP = Regex("(?<=[a-z0-9])(?=[A-Z])") + val isEnabled: Boolean get() = ScreenDeepLinkRuntime.isEnabled + + fun shouldQueue(isDevModeEnabled: Boolean): Boolean = isEnabled && isDevModeEnabled + fun screenId(route: KClass): String? = kebabId(route) fun kebabId(route: KClass<*>): String? { @@ -24,10 +27,8 @@ object ScreenDeepLinks { fun basePath(route: KClass): String? = screenId(route)?.let { "$BASE_URI/$it" } - fun linksFor(route: KClass): List { - val basePath = basePath(route) ?: return emptyList() - return listOf(navDeepLink(route = route, basePath = basePath) {}) - } + fun linksFor(route: KClass): List = + ScreenDeepLinkRuntime.linksFor(route) fun matchStart(path: String, default: T, starts: List): T? = when { path.isEmpty() -> default diff --git a/app/src/main/java/to/bitkit/ui/utils/SheetDeepLinks.kt b/app/src/main/java/to/bitkit/ui/utils/SheetDeepLinks.kt index 15d9b4e85..0c3c8981b 100644 --- a/app/src/main/java/to/bitkit/ui/utils/SheetDeepLinks.kt +++ b/app/src/main/java/to/bitkit/ui/utils/SheetDeepLinks.kt @@ -2,54 +2,10 @@ package to.bitkit.ui.utils import android.net.Uri import to.bitkit.ui.components.Sheet -import to.bitkit.ui.screens.wallets.receive.ReceiveRoute -import to.bitkit.ui.sheets.BackupRoute -import to.bitkit.ui.sheets.SendRoute -import to.bitkit.ui.sheets.WidgetsRoute -import to.bitkit.ui.sheets.hardware.HardwareRoute -import kotlin.reflect.KClass object SheetDeepLinks { - private class Family( - val type: KClass, - val fromDeepLink: (String) -> Sheet?, - ) - - private val FAMILIES: List = listOf( - Family(Sheet.Send::class) { path -> SendRoute.fromDeepLink(path)?.let { Sheet.Send(it) } }, - Family(Sheet.Receive::class) { path -> ReceiveRoute.fromDeepLink(path)?.let { Sheet.Receive(it) } }, - Family(Sheet.Backup::class) { path -> BackupRoute.fromDeepLink(path)?.let { Sheet.Backup(it) } }, - Family(Sheet.Widgets::class) { path -> WidgetsRoute.fromDeepLink(path)?.let { Sheet.Widgets(it) } }, - Family(Sheet.Hardware::class) { path -> HardwareRoute.fromDeepLink(path)?.let { Sheet.Hardware(it) } }, - ) - - private val STANDALONE: List = listOf( - Sheet.ActivityDateRangeSelector, - Sheet.ActivityTagSelector, - Sheet.QrScanner(), - ) - val sheetIds: Set - get() { - val families = FAMILIES.mapNotNull { ScreenDeepLinks.kebabId(it.type) } - val standalone = STANDALONE.mapNotNull { ScreenDeepLinks.kebabId(it::class) } - return (families + standalone).toSet() - } - - fun sheetFor(uri: Uri): Sheet? { - if (!ScreenDeepLinks.isScreenDeepLink(uri)) return null - - val segments = uri.pathSegments.orEmpty() - if (segments.isEmpty() || segments.size > 2) return null - - val id = segments.first().lowercase() - val child = segments.getOrElse(1) { "" } - - FAMILIES.firstOrNull { ScreenDeepLinks.kebabId(it.type) == id } - ?.let { return it.fromDeepLink(child) } - - if (child.isNotEmpty()) return null + get() = ScreenDeepLinkRuntime.sheetIds - return STANDALONE.firstOrNull { ScreenDeepLinks.kebabId(it::class) == id } - } + fun sheetFor(uri: Uri): Sheet? = ScreenDeepLinkRuntime.sheetFor(uri) } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index e6cecbb97..849726cc2 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -3718,8 +3718,8 @@ class AppViewModel @Inject constructor( } if (ScreenDeepLinks.isScreenDeepLink(uri)) { - if (!settingsStore.data.first().isDevModeEnabled) { - Logger.warn("Ignoring screen deeplink, dev mode is off", context = TAG) + if (!ScreenDeepLinks.shouldQueue(settingsStore.data.first().isDevModeEnabled)) { + Logger.warn("Ignoring screen deeplink, not queued", context = TAG) return@launch } diff --git a/app/src/release/java/to/bitkit/ui/utils/ScreenDeepLinkRuntime.kt b/app/src/release/java/to/bitkit/ui/utils/ScreenDeepLinkRuntime.kt new file mode 100644 index 000000000..f20d6bb5e --- /dev/null +++ b/app/src/release/java/to/bitkit/ui/utils/ScreenDeepLinkRuntime.kt @@ -0,0 +1,18 @@ +package to.bitkit.ui.utils + +import android.net.Uri +import androidx.navigation.NavDeepLink +import to.bitkit.ui.Routes +import to.bitkit.ui.components.Sheet +import kotlin.reflect.KClass + +internal object ScreenDeepLinkRuntime { + val isEnabled = false + val sheetIds: Set = emptySet() + + @Suppress("UNUSED_PARAMETER") + fun linksFor(route: KClass): List = emptyList() + + @Suppress("UNUSED_PARAMETER") + fun sheetFor(uri: Uri): Sheet? = null +} diff --git a/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt b/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt index 0de5f16db..03803aa52 100644 --- a/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt +++ b/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt @@ -1,5 +1,6 @@ package to.bitkit.ui.utils +import android.content.Intent import android.net.Uri import org.junit.Test import org.junit.runner.RunWith @@ -7,7 +8,6 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import to.bitkit.test.BaseUnitTest import to.bitkit.ui.Routes -import kotlin.reflect.KClass import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull @@ -17,26 +17,6 @@ import kotlin.test.assertTrue @Config(sdk = [34]) @RunWith(RobolectricTestRunner::class) class ScreenDeepLinksTest : BaseUnitTest() { - private companion object { - val SENSITIVE_ROUTES: List> = listOf( - Routes.AuthCheck::class, - Routes.CriticalUpdate::class, - Routes.ExternalAmount::class, - Routes.ExternalConfirm::class, - Routes.ExternalSuccess::class, - Routes.LegacyRnRecovery::class, - Routes.LnurlChannel::class, - Routes.RecoveryMnemonic::class, - Routes.RecoveryMode::class, - Routes.SavingsProgress::class, - Routes.SettingUp::class, - Routes.SpendingAdvanced::class, - Routes.SpendingConfirm::class, - Routes.SpendingHwSign::class, - Routes.SpendingHwSigned::class, - ) - } - @Test fun `screen id is derived from the route name in kebab-case`() { val home = ScreenDeepLinks.screenId(Routes.Home::class) @@ -50,6 +30,7 @@ class ScreenDeepLinksTest : BaseUnitTest() { @Test fun `routes without arguments produce a bare pattern`() { + if (!ScreenDeepLinks.isEnabled) return val links = ScreenDeepLinks.linksFor(Routes.Settings::class) assertEquals(1, links.size) @@ -58,6 +39,7 @@ class ScreenDeepLinksTest : BaseUnitTest() { @Test fun `required arguments are appended as path segments`() { + if (!ScreenDeepLinks.isEnabled) return val links = ScreenDeepLinks.linksFor(Routes.ActivityAssignContact::class) assertEquals("bitkit://screen/activity-assign-contact/{id}", links.single().uriPattern) @@ -65,6 +47,7 @@ class ScreenDeepLinksTest : BaseUnitTest() { @Test fun `a route with both argument kinds keeps the required one in the path`() { + if (!ScreenDeepLinks.isEnabled) return val links = ScreenDeepLinks.linksFor(Routes.ActivityDetail::class) assertEquals("bitkit://screen/activity-detail/{id}?walletId={walletId}", links.single().uriPattern) @@ -72,6 +55,7 @@ class ScreenDeepLinksTest : BaseUnitTest() { @Test fun `arguments with defaults are appended as query parameters`() { + if (!ScreenDeepLinks.isEnabled) return val links = ScreenDeepLinks.linksFor(Routes.Contacts::class) assertEquals( @@ -90,7 +74,7 @@ class ScreenDeepLinksTest : BaseUnitTest() { } @Test - fun `every deep-linkable route has a unique screen id and one link`() { + fun `every deep-linkable route has a unique screen id`() { val ids = mutableMapOf() Routes.DeepLinkable::class.sealedSubclasses.forEach { route -> @@ -100,7 +84,6 @@ class ScreenDeepLinksTest : BaseUnitTest() { assertNotNull(id, "route $name has no screen id") val clash = ids.put(id, name) assertNull(clash, "screen id '$id' is used by both $clash and $name") - assertEquals(1, ScreenDeepLinks.linksFor(route).size, "route $name has no deep link") } } @@ -132,4 +115,14 @@ class ScreenDeepLinksTest : BaseUnitTest() { assertFalse(pubkyAuth) assertFalse(lightning) } + + @Test + fun `screen uris are detached from the activity intent`() { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("bitkit://screen/settings")) + + val detached = ScreenDeepLinks.detachScreenUri(intent) + + assertTrue(detached) + assertNull(intent.data) + } } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index afb29020e..6822141c6 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -112,6 +112,7 @@ import to.bitkit.ui.shared.toast.ToastQueueManager import to.bitkit.ui.sheets.SendRoute import to.bitkit.ui.sheets.hardware.HardwareRoute import to.bitkit.ui.theme.TRANSITION_SCREEN_MS +import to.bitkit.ui.utils.ScreenDeepLinks import to.bitkit.usecases.FormatMoneyValue import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase import to.bitkit.utils.AppError @@ -1134,13 +1135,17 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `screen deeplink is held for replay when dev mode is on`() = test { + fun `screen deeplink is held for replay only on debug when dev mode is on`() = test { settingsData.value = SettingsData(isDevModeEnabled = true) sut.handleDeeplinkIntent(screenIntent("settings")) advanceUntilIdle() - assertNotNull(sut.pendingScreenDeepLink.value) + if (ScreenDeepLinks.isEnabled) { + assertNotNull(sut.pendingScreenDeepLink.value) + } else { + assertNull(sut.pendingScreenDeepLink.value) + } } @Test diff --git a/app/src/test/java/to/bitkit/ui/utils/SheetDeepLinksTest.kt b/app/src/testDebug/java/to/bitkit/ui/utils/SheetDeepLinksTest.kt similarity index 100% rename from app/src/test/java/to/bitkit/ui/utils/SheetDeepLinksTest.kt rename to app/src/testDebug/java/to/bitkit/ui/utils/SheetDeepLinksTest.kt diff --git a/journeys/deeplinks/screen-deeplink.xml b/journeys/deeplinks/screen-deeplink.xml index e3e71243d..bd4c51851 100644 --- a/journeys/deeplinks/screen-deeplink.xml +++ b/journeys/deeplinks/screen-deeplink.xml @@ -1,5 +1,5 @@ - Precondition: onboarded dev wallet, dev mode on, at least one log file present. + Precondition: onboarded debug build, dev mode on, at least one log file present. Run `adb shell am start -W -a android.intent.action.VIEW -d "bitkit://screen/settings" to.bitkit.dev` Verify that the Settings screen is visible with the "General", "Security" and "Advanced" tabs @@ -18,7 +18,7 @@ Run `adb shell am force-stop to.bitkit.dev` Run `adb shell am start -a android.intent.action.VIEW -d "bitkit://screen/settings" to.bitkit.dev` Verify that the wallet overview is visible and Settings is not, on a cold start with dev mode off - Verify that logcat contains "Ignoring screen deeplink, dev mode is off" + Verify that logcat contains "Ignoring screen deeplink, not queued" Open Settings ▸ Support and tap the version row five times to turn dev mode back on