From 58d394f9514ca04a551b9ee35c3ad36d3c3c68a3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 13 Aug 2026 04:12:00 +0200 Subject: [PATCH 01/16] fix: confine screen deeplinks to debug Co-authored-by: Cursor --- .../bitkit/ui/utils/ScreenDeepLinkRuntime.kt | 65 +++++++++++++++++++ .../to/bitkit/ui/utils/ScreenDeepLinks.kt | 10 +-- .../java/to/bitkit/ui/utils/SheetDeepLinks.kt | 48 +------------- .../java/to/bitkit/viewmodels/AppViewModel.kt | 4 +- .../bitkit/ui/utils/ScreenDeepLinkRuntime.kt | 18 +++++ changelog.d/next/1132.security.md | 1 + 6 files changed, 93 insertions(+), 53 deletions(-) create mode 100644 app/src/debug/java/to/bitkit/ui/utils/ScreenDeepLinkRuntime.kt create mode 100644 app/src/release/java/to/bitkit/ui/utils/ScreenDeepLinkRuntime.kt create mode 100644 changelog.d/next/1132.security.md 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..6aecb93b8 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,9 @@ object ScreenDeepLinks { private val CAMEL_HUMP = Regex("(?<=[a-z0-9])(?=[A-Z])") + fun shouldQueue(isDevModeEnabled: Boolean): Boolean = + ScreenDeepLinkRuntime.isEnabled && isDevModeEnabled + fun screenId(route: KClass): String? = kebabId(route) fun kebabId(route: KClass<*>): String? { @@ -24,10 +26,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 6ce64be2a..fe2245605 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -3565,8 +3565,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", 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/changelog.d/next/1132.security.md b/changelog.d/next/1132.security.md new file mode 100644 index 000000000..402813740 --- /dev/null +++ b/changelog.d/next/1132.security.md @@ -0,0 +1 @@ +Screen deep links are limited to debug builds and are no longer honored in store releases, even with Dev Mode enabled. From fd3f9256848d479a16462e7e261a119bb983cce3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 13 Aug 2026 04:12:13 +0200 Subject: [PATCH 02/16] test: cover debug and release deeplinks Co-authored-by: Cursor --- .../to/bitkit/ui/utils/ScreenDeepLinksTest.kt | 79 ++++++------------- .../viewmodels/AppViewModelSendFlowTest.kt | 9 ++- .../utils/ScreenDeepLinksRegistrationTest.kt | 51 ++++++++++++ .../to/bitkit/ui/utils/SheetDeepLinksTest.kt | 0 .../utils/ScreenDeepLinksRegistrationTest.kt | 38 +++++++++ 5 files changed, 120 insertions(+), 57 deletions(-) create mode 100644 app/src/testDebug/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt rename app/src/{test => testDebug}/java/to/bitkit/ui/utils/SheetDeepLinksTest.kt (100%) create mode 100644 app/src/testRelease/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt 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..e9f709c0c 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) @@ -48,38 +28,6 @@ class ScreenDeepLinksTest : BaseUnitTest() { assertEquals("rgs-server", rgsServer) } - @Test - fun `routes without arguments produce a bare pattern`() { - val links = ScreenDeepLinks.linksFor(Routes.Settings::class) - - assertEquals(1, links.size) - assertEquals("bitkit://screen/settings", links.single().uriPattern) - } - - @Test - fun `required arguments are appended as path segments`() { - val links = ScreenDeepLinks.linksFor(Routes.ActivityAssignContact::class) - - assertEquals("bitkit://screen/activity-assign-contact/{id}", links.single().uriPattern) - } - - @Test - fun `a route with both argument kinds keeps the required one in the path`() { - val links = ScreenDeepLinks.linksFor(Routes.ActivityDetail::class) - - assertEquals("bitkit://screen/activity-detail/{id}?walletId={walletId}", links.single().uriPattern) - } - - @Test - fun `arguments with defaults are appended as query parameters`() { - val links = ScreenDeepLinks.linksFor(Routes.Contacts::class) - - assertEquals( - "bitkit://screen/contacts?showAddContactSheet={showAddContactSheet}", - links.single().uriPattern, - ) - } - @Test fun `every route declares whether it may be entered directly`() { val markers = setOf(Routes.DeepLinkable::class, Routes.InternalOnly::class) @@ -90,7 +38,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 and variant-correct links`() { val ids = mutableMapOf() Routes.DeepLinkable::class.sealedSubclasses.forEach { route -> @@ -100,7 +48,12 @@ 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") + val links = ScreenDeepLinks.linksFor(route) + if (ScreenDeepLinkRuntime.isEnabled) { + assertEquals(1, links.size, "route $name has no deep link") + } else { + assertTrue(links.isEmpty(), "route $name leaked a deep link in release") + } } } @@ -132,4 +85,20 @@ class ScreenDeepLinksTest : BaseUnitTest() { assertFalse(pubkyAuth) assertFalse(lightning) } + + @Test + fun `shouldQueue requires a debug runtime and dev mode`() { + assertFalse(ScreenDeepLinks.shouldQueue(isDevModeEnabled = false)) + assertEquals(ScreenDeepLinkRuntime.isEnabled, ScreenDeepLinks.shouldQueue(isDevModeEnabled = true)) + } + + @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 181f88299..fe8b0b589 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -105,6 +105,7 @@ import to.bitkit.ui.components.Sheet import to.bitkit.ui.shared.toast.ToastQueueManager import to.bitkit.ui.sheets.SendRoute import to.bitkit.ui.sheets.hardware.HardwareRoute +import to.bitkit.ui.utils.ScreenDeepLinkRuntime import to.bitkit.usecases.FormatMoneyValue import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase import to.bitkit.utils.AppError @@ -758,13 +759,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 (ScreenDeepLinkRuntime.isEnabled) { + assertNotNull(sut.pendingScreenDeepLink.value) + } else { + assertNull(sut.pendingScreenDeepLink.value) + } } @Test diff --git a/app/src/testDebug/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt b/app/src/testDebug/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt new file mode 100644 index 000000000..c798b7d64 --- /dev/null +++ b/app/src/testDebug/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt @@ -0,0 +1,51 @@ +package to.bitkit.ui.utils + +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import to.bitkit.test.BaseUnitTest +import to.bitkit.ui.Routes +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class ScreenDeepLinksRegistrationTest : BaseUnitTest() { + @Test + fun `debug runtime registers screen deeplinks`() { + assertTrue(ScreenDeepLinkRuntime.isEnabled) + } + + @Test + fun `routes without arguments produce a bare pattern`() { + val links = ScreenDeepLinks.linksFor(Routes.Settings::class) + + assertEquals(1, links.size) + assertEquals("bitkit://screen/settings", links.single().uriPattern) + } + + @Test + fun `required arguments are appended as path segments`() { + val links = ScreenDeepLinks.linksFor(Routes.ActivityAssignContact::class) + + assertEquals("bitkit://screen/activity-assign-contact/{id}", links.single().uriPattern) + } + + @Test + fun `a route with both argument kinds keeps the required one in the path`() { + val links = ScreenDeepLinks.linksFor(Routes.ActivityDetail::class) + + assertEquals("bitkit://screen/activity-detail/{id}?walletId={walletId}", links.single().uriPattern) + } + + @Test + fun `arguments with defaults are appended as query parameters`() { + val links = ScreenDeepLinks.linksFor(Routes.Contacts::class) + + assertEquals( + "bitkit://screen/contacts?showAddContactSheet={showAddContactSheet}", + links.single().uriPattern, + ) + } +} 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/app/src/testRelease/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt b/app/src/testRelease/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt new file mode 100644 index 000000000..3661d8f2d --- /dev/null +++ b/app/src/testRelease/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt @@ -0,0 +1,38 @@ +package to.bitkit.ui.utils + +import android.content.Intent +import android.net.Uri +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import to.bitkit.test.BaseUnitTest +import to.bitkit.ui.Routes +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class ScreenDeepLinksRegistrationTest : BaseUnitTest() { + @Test + fun `release runtime never registers screen deeplinks`() { + assertFalse(ScreenDeepLinkRuntime.isEnabled) + assertTrue(ScreenDeepLinks.linksFor(Routes.Settings::class).isEmpty()) + assertTrue(ScreenDeepLinks.linksFor(Routes.Home::class).isEmpty()) + assertTrue(SheetDeepLinks.sheetIds.isEmpty()) + assertNull(SheetDeepLinks.sheetFor(Uri.parse("bitkit://screen/send"))) + assertNull(SheetDeepLinks.sheetFor(Uri.parse("bitkit://screen/settings"))) + assertFalse(ScreenDeepLinks.shouldQueue(isDevModeEnabled = true)) + } + + @Test + fun `release still detaches screen uris from the intent`() { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("bitkit://screen/settings")) + + val detached = ScreenDeepLinks.detachScreenUri(intent) + + assertTrue(detached) + assertNull(intent.data) + } +} From e5d403275be20577744b28df1262d8f56bdffe45 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 13 Aug 2026 04:13:33 +0200 Subject: [PATCH 03/16] fix: keep debug deeplink ignore log Co-authored-by: Cursor --- app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt | 4 +++- app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) 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 6aecb93b8..7b52a7aff 100644 --- a/app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt +++ b/app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt @@ -14,8 +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 = - ScreenDeepLinkRuntime.isEnabled && isDevModeEnabled + isEnabled && isDevModeEnabled fun screenId(route: KClass): String? = kebabId(route) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index fe2245605..516f6331d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -3566,7 +3566,8 @@ class AppViewModel @Inject constructor( if (ScreenDeepLinks.isScreenDeepLink(uri)) { if (!ScreenDeepLinks.shouldQueue(settingsStore.data.first().isDevModeEnabled)) { - Logger.warn("Ignoring screen deeplink", context = TAG) + val reason = if (ScreenDeepLinks.isEnabled) ", dev mode is off" else "" + Logger.warn("Ignoring screen deeplink$reason", context = TAG) return@launch } From 5bbc03d9050ce9f2cc4b98d107f71371b514d527 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 13 Aug 2026 04:14:19 +0200 Subject: [PATCH 04/16] chore: rename changelog fragment Co-authored-by: Cursor --- changelog.d/next/{1132.security.md => 1157.security.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{1132.security.md => 1157.security.md} (100%) diff --git a/changelog.d/next/1132.security.md b/changelog.d/next/1157.security.md similarity index 100% rename from changelog.d/next/1132.security.md rename to changelog.d/next/1157.security.md From 398c53f50d1c58458d752d70ffe886a4e5749975 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 13 Aug 2026 04:36:10 +0200 Subject: [PATCH 05/16] refactor: move screen deeplinks to debug Co-authored-by: Cursor --- .../bitkit/ui/utils/ScreenDeepLinkRuntime.kt | 65 ------------------- .../to/bitkit/ui/utils/ScreenDeepLinks.kt | 14 ++-- .../java/to/bitkit/ui/utils/SheetDeepLinks.kt | 55 ++++++++++++++++ .../java/to/bitkit/ui/utils/SheetDeepLinks.kt | 11 ---- .../java/to/bitkit/viewmodels/AppViewModel.kt | 5 +- .../bitkit/ui/utils/ScreenDeepLinkRuntime.kt | 18 ----- .../to/bitkit/ui/utils/ScreenDeepLinks.kt | 46 +++++++++++++ .../java/to/bitkit/ui/utils/SheetDeepLinks.kt | 11 ++++ .../to/bitkit/ui/utils/ScreenDeepLinksTest.kt | 7 +- .../viewmodels/AppViewModelSendFlowTest.kt | 4 +- .../utils/ScreenDeepLinksRegistrationTest.kt | 4 +- .../utils/ScreenDeepLinksRegistrationTest.kt | 5 +- 12 files changed, 130 insertions(+), 115 deletions(-) delete mode 100644 app/src/debug/java/to/bitkit/ui/utils/ScreenDeepLinkRuntime.kt rename app/src/{main => debug}/java/to/bitkit/ui/utils/ScreenDeepLinks.kt (85%) create mode 100644 app/src/debug/java/to/bitkit/ui/utils/SheetDeepLinks.kt delete mode 100644 app/src/main/java/to/bitkit/ui/utils/SheetDeepLinks.kt delete mode 100644 app/src/release/java/to/bitkit/ui/utils/ScreenDeepLinkRuntime.kt create mode 100644 app/src/release/java/to/bitkit/ui/utils/ScreenDeepLinks.kt create mode 100644 app/src/release/java/to/bitkit/ui/utils/SheetDeepLinks.kt diff --git a/app/src/debug/java/to/bitkit/ui/utils/ScreenDeepLinkRuntime.kt b/app/src/debug/java/to/bitkit/ui/utils/ScreenDeepLinkRuntime.kt deleted file mode 100644 index fe5febcd7..000000000 --- a/app/src/debug/java/to/bitkit/ui/utils/ScreenDeepLinkRuntime.kt +++ /dev/null @@ -1,65 +0,0 @@ -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/debug/java/to/bitkit/ui/utils/ScreenDeepLinks.kt similarity index 85% rename from app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt rename to app/src/debug/java/to/bitkit/ui/utils/ScreenDeepLinks.kt index 7b52a7aff..cb39e9e57 100644 --- a/app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt +++ b/app/src/debug/java/to/bitkit/ui/utils/ScreenDeepLinks.kt @@ -3,6 +3,7 @@ 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 @@ -10,15 +11,12 @@ object ScreenDeepLinks { const val SCHEME = "bitkit" const val HOST = "screen" + val isEnabled = true + private const val BASE_URI = "$SCHEME://$HOST" 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? { @@ -28,8 +26,10 @@ object ScreenDeepLinks { fun basePath(route: KClass): String? = screenId(route)?.let { "$BASE_URI/$it" } - fun linksFor(route: KClass): List = - ScreenDeepLinkRuntime.linksFor(route) + fun linksFor(route: KClass): List { + val basePath = basePath(route) ?: return emptyList() + return listOf(navDeepLink(route = route, basePath = basePath) {}) + } fun matchStart(path: String, default: T, starts: List): T? = when { path.isEmpty() -> default diff --git a/app/src/debug/java/to/bitkit/ui/utils/SheetDeepLinks.kt b/app/src/debug/java/to/bitkit/ui/utils/SheetDeepLinks.kt new file mode 100644 index 000000000..15d9b4e85 --- /dev/null +++ b/app/src/debug/java/to/bitkit/ui/utils/SheetDeepLinks.kt @@ -0,0 +1,55 @@ +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 + + return STANDALONE.firstOrNull { ScreenDeepLinks.kebabId(it::class) == id } + } +} diff --git a/app/src/main/java/to/bitkit/ui/utils/SheetDeepLinks.kt b/app/src/main/java/to/bitkit/ui/utils/SheetDeepLinks.kt deleted file mode 100644 index 0c3c8981b..000000000 --- a/app/src/main/java/to/bitkit/ui/utils/SheetDeepLinks.kt +++ /dev/null @@ -1,11 +0,0 @@ -package to.bitkit.ui.utils - -import android.net.Uri -import to.bitkit.ui.components.Sheet - -object SheetDeepLinks { - val sheetIds: Set - get() = ScreenDeepLinkRuntime.sheetIds - - 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 516f6331d..d4f2406cb 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -3565,9 +3565,8 @@ class AppViewModel @Inject constructor( } if (ScreenDeepLinks.isScreenDeepLink(uri)) { - if (!ScreenDeepLinks.shouldQueue(settingsStore.data.first().isDevModeEnabled)) { - val reason = if (ScreenDeepLinks.isEnabled) ", dev mode is off" else "" - Logger.warn("Ignoring screen deeplink$reason", context = TAG) + if (!ScreenDeepLinks.isEnabled || !settingsStore.data.first().isDevModeEnabled) { + Logger.warn("Ignoring screen deeplink, dev mode is off", 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 deleted file mode 100644 index f20d6bb5e..000000000 --- a/app/src/release/java/to/bitkit/ui/utils/ScreenDeepLinkRuntime.kt +++ /dev/null @@ -1,18 +0,0 @@ -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/release/java/to/bitkit/ui/utils/ScreenDeepLinks.kt b/app/src/release/java/to/bitkit/ui/utils/ScreenDeepLinks.kt new file mode 100644 index 000000000..5829821ac --- /dev/null +++ b/app/src/release/java/to/bitkit/ui/utils/ScreenDeepLinks.kt @@ -0,0 +1,46 @@ +package to.bitkit.ui.utils + +import android.content.Intent +import android.net.Uri +import androidx.navigation.NavDeepLink +import to.bitkit.ui.Routes +import kotlin.reflect.KClass + +object ScreenDeepLinks { + const val SCHEME = "bitkit" + const val HOST = "screen" + + val isEnabled = false + + private const val BASE_URI = "$SCHEME://$HOST" + + private val CAMEL_HUMP = Regex("(?<=[a-z0-9])(?=[A-Z])") + + fun screenId(route: KClass): String? = kebabId(route) + + fun kebabId(route: KClass<*>): String? { + val name = route.simpleName ?: return null + return CAMEL_HUMP.split(name).joinToString("-") { it.lowercase() } + } + + fun basePath(route: KClass): String? = screenId(route)?.let { "$BASE_URI/$it" } + + @Suppress("UNUSED_PARAMETER") + fun linksFor(route: KClass): List = emptyList() + + fun matchStart(path: String, default: T, starts: List): T? = when { + path.isEmpty() -> default + else -> starts.firstOrNull { kebabId(it::class).equals(path, ignoreCase = true) } + } + + fun isScreenDeepLink(uri: Uri): Boolean = + uri.scheme?.lowercase() == SCHEME && uri.host?.lowercase() == HOST + + fun detachScreenUri(intent: Intent): Boolean { + val uri = intent.data ?: return false + if (!isScreenDeepLink(uri)) return false + + intent.data = null + return true + } +} diff --git a/app/src/release/java/to/bitkit/ui/utils/SheetDeepLinks.kt b/app/src/release/java/to/bitkit/ui/utils/SheetDeepLinks.kt new file mode 100644 index 000000000..993b43ef0 --- /dev/null +++ b/app/src/release/java/to/bitkit/ui/utils/SheetDeepLinks.kt @@ -0,0 +1,11 @@ +package to.bitkit.ui.utils + +import android.net.Uri +import to.bitkit.ui.components.Sheet + +object SheetDeepLinks { + val sheetIds: Set = emptySet() + + @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 e9f709c0c..fe3902fc8 100644 --- a/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt +++ b/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt @@ -49,7 +49,7 @@ class ScreenDeepLinksTest : BaseUnitTest() { val clash = ids.put(id, name) assertNull(clash, "screen id '$id' is used by both $clash and $name") val links = ScreenDeepLinks.linksFor(route) - if (ScreenDeepLinkRuntime.isEnabled) { + if (ScreenDeepLinks.isEnabled) { assertEquals(1, links.size, "route $name has no deep link") } else { assertTrue(links.isEmpty(), "route $name leaked a deep link in release") @@ -87,9 +87,8 @@ class ScreenDeepLinksTest : BaseUnitTest() { } @Test - fun `shouldQueue requires a debug runtime and dev mode`() { - assertFalse(ScreenDeepLinks.shouldQueue(isDevModeEnabled = false)) - assertEquals(ScreenDeepLinkRuntime.isEnabled, ScreenDeepLinks.shouldQueue(isDevModeEnabled = true)) + fun `screen links are enabled only on debug`() { + assertEquals(ScreenDeepLinks.isEnabled, ScreenDeepLinks.linksFor(Routes.Settings::class).isNotEmpty()) } @Test diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index fe8b0b589..fe8f73582 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -105,7 +105,7 @@ import to.bitkit.ui.components.Sheet import to.bitkit.ui.shared.toast.ToastQueueManager import to.bitkit.ui.sheets.SendRoute import to.bitkit.ui.sheets.hardware.HardwareRoute -import to.bitkit.ui.utils.ScreenDeepLinkRuntime +import to.bitkit.ui.utils.ScreenDeepLinks import to.bitkit.usecases.FormatMoneyValue import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase import to.bitkit.utils.AppError @@ -765,7 +765,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.handleDeeplinkIntent(screenIntent("settings")) advanceUntilIdle() - if (ScreenDeepLinkRuntime.isEnabled) { + if (ScreenDeepLinks.isEnabled) { assertNotNull(sut.pendingScreenDeepLink.value) } else { assertNull(sut.pendingScreenDeepLink.value) diff --git a/app/src/testDebug/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt b/app/src/testDebug/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt index c798b7d64..fa64cf1d3 100644 --- a/app/src/testDebug/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt +++ b/app/src/testDebug/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt @@ -13,8 +13,8 @@ import kotlin.test.assertTrue @RunWith(RobolectricTestRunner::class) class ScreenDeepLinksRegistrationTest : BaseUnitTest() { @Test - fun `debug runtime registers screen deeplinks`() { - assertTrue(ScreenDeepLinkRuntime.isEnabled) + fun `debug registers screen deeplinks`() { + assertTrue(ScreenDeepLinks.isEnabled) } @Test diff --git a/app/src/testRelease/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt b/app/src/testRelease/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt index 3661d8f2d..68c36b4c4 100644 --- a/app/src/testRelease/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt +++ b/app/src/testRelease/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt @@ -16,14 +16,13 @@ import kotlin.test.assertTrue @RunWith(RobolectricTestRunner::class) class ScreenDeepLinksRegistrationTest : BaseUnitTest() { @Test - fun `release runtime never registers screen deeplinks`() { - assertFalse(ScreenDeepLinkRuntime.isEnabled) + fun `release never registers screen deeplinks`() { + assertFalse(ScreenDeepLinks.isEnabled) assertTrue(ScreenDeepLinks.linksFor(Routes.Settings::class).isEmpty()) assertTrue(ScreenDeepLinks.linksFor(Routes.Home::class).isEmpty()) assertTrue(SheetDeepLinks.sheetIds.isEmpty()) assertNull(SheetDeepLinks.sheetFor(Uri.parse("bitkit://screen/send"))) assertNull(SheetDeepLinks.sheetFor(Uri.parse("bitkit://screen/settings"))) - assertFalse(ScreenDeepLinks.shouldQueue(isDevModeEnabled = true)) } @Test From 3ebac5501b97e57349ca0b79a70257e5a1745b31 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 13 Aug 2026 04:38:03 +0200 Subject: [PATCH 06/16] test: drop debug vs release source-set tests Co-authored-by: Cursor --- .../to/bitkit/ui/utils/ScreenDeepLinksTest.kt | 78 +++++++++++++------ .../to/bitkit/ui/utils/SheetDeepLinksTest.kt | 0 .../viewmodels/AppViewModelSendFlowTest.kt | 9 +-- .../utils/ScreenDeepLinksRegistrationTest.kt | 51 ------------ .../utils/ScreenDeepLinksRegistrationTest.kt | 37 --------- 5 files changed, 57 insertions(+), 118 deletions(-) rename app/src/{testDebug => test}/java/to/bitkit/ui/utils/SheetDeepLinksTest.kt (100%) delete mode 100644 app/src/testDebug/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt delete mode 100644 app/src/testRelease/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt 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 fe3902fc8..0de5f16db 100644 --- a/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt +++ b/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt @@ -1,6 +1,5 @@ package to.bitkit.ui.utils -import android.content.Intent import android.net.Uri import org.junit.Test import org.junit.runner.RunWith @@ -8,6 +7,7 @@ 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,6 +17,26 @@ 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) @@ -28,6 +48,38 @@ class ScreenDeepLinksTest : BaseUnitTest() { assertEquals("rgs-server", rgsServer) } + @Test + fun `routes without arguments produce a bare pattern`() { + val links = ScreenDeepLinks.linksFor(Routes.Settings::class) + + assertEquals(1, links.size) + assertEquals("bitkit://screen/settings", links.single().uriPattern) + } + + @Test + fun `required arguments are appended as path segments`() { + val links = ScreenDeepLinks.linksFor(Routes.ActivityAssignContact::class) + + assertEquals("bitkit://screen/activity-assign-contact/{id}", links.single().uriPattern) + } + + @Test + fun `a route with both argument kinds keeps the required one in the path`() { + val links = ScreenDeepLinks.linksFor(Routes.ActivityDetail::class) + + assertEquals("bitkit://screen/activity-detail/{id}?walletId={walletId}", links.single().uriPattern) + } + + @Test + fun `arguments with defaults are appended as query parameters`() { + val links = ScreenDeepLinks.linksFor(Routes.Contacts::class) + + assertEquals( + "bitkit://screen/contacts?showAddContactSheet={showAddContactSheet}", + links.single().uriPattern, + ) + } + @Test fun `every route declares whether it may be entered directly`() { val markers = setOf(Routes.DeepLinkable::class, Routes.InternalOnly::class) @@ -38,7 +90,7 @@ class ScreenDeepLinksTest : BaseUnitTest() { } @Test - fun `every deep-linkable route has a unique screen id and variant-correct links`() { + fun `every deep-linkable route has a unique screen id and one link`() { val ids = mutableMapOf() Routes.DeepLinkable::class.sealedSubclasses.forEach { route -> @@ -48,12 +100,7 @@ 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") - val links = ScreenDeepLinks.linksFor(route) - if (ScreenDeepLinks.isEnabled) { - assertEquals(1, links.size, "route $name has no deep link") - } else { - assertTrue(links.isEmpty(), "route $name leaked a deep link in release") - } + assertEquals(1, ScreenDeepLinks.linksFor(route).size, "route $name has no deep link") } } @@ -85,19 +132,4 @@ class ScreenDeepLinksTest : BaseUnitTest() { assertFalse(pubkyAuth) assertFalse(lightning) } - - @Test - fun `screen links are enabled only on debug`() { - assertEquals(ScreenDeepLinks.isEnabled, ScreenDeepLinks.linksFor(Routes.Settings::class).isNotEmpty()) - } - - @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/testDebug/java/to/bitkit/ui/utils/SheetDeepLinksTest.kt b/app/src/test/java/to/bitkit/ui/utils/SheetDeepLinksTest.kt similarity index 100% rename from app/src/testDebug/java/to/bitkit/ui/utils/SheetDeepLinksTest.kt rename to app/src/test/java/to/bitkit/ui/utils/SheetDeepLinksTest.kt diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index fe8f73582..181f88299 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -105,7 +105,6 @@ import to.bitkit.ui.components.Sheet import to.bitkit.ui.shared.toast.ToastQueueManager import to.bitkit.ui.sheets.SendRoute import to.bitkit.ui.sheets.hardware.HardwareRoute -import to.bitkit.ui.utils.ScreenDeepLinks import to.bitkit.usecases.FormatMoneyValue import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase import to.bitkit.utils.AppError @@ -759,17 +758,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `screen deeplink is held for replay only on debug when dev mode is on`() = test { + fun `screen deeplink is held for replay when dev mode is on`() = test { settingsData.value = SettingsData(isDevModeEnabled = true) sut.handleDeeplinkIntent(screenIntent("settings")) advanceUntilIdle() - if (ScreenDeepLinks.isEnabled) { - assertNotNull(sut.pendingScreenDeepLink.value) - } else { - assertNull(sut.pendingScreenDeepLink.value) - } + assertNotNull(sut.pendingScreenDeepLink.value) } @Test diff --git a/app/src/testDebug/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt b/app/src/testDebug/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt deleted file mode 100644 index fa64cf1d3..000000000 --- a/app/src/testDebug/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt +++ /dev/null @@ -1,51 +0,0 @@ -package to.bitkit.ui.utils - -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import to.bitkit.test.BaseUnitTest -import to.bitkit.ui.Routes -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -@Config(sdk = [34]) -@RunWith(RobolectricTestRunner::class) -class ScreenDeepLinksRegistrationTest : BaseUnitTest() { - @Test - fun `debug registers screen deeplinks`() { - assertTrue(ScreenDeepLinks.isEnabled) - } - - @Test - fun `routes without arguments produce a bare pattern`() { - val links = ScreenDeepLinks.linksFor(Routes.Settings::class) - - assertEquals(1, links.size) - assertEquals("bitkit://screen/settings", links.single().uriPattern) - } - - @Test - fun `required arguments are appended as path segments`() { - val links = ScreenDeepLinks.linksFor(Routes.ActivityAssignContact::class) - - assertEquals("bitkit://screen/activity-assign-contact/{id}", links.single().uriPattern) - } - - @Test - fun `a route with both argument kinds keeps the required one in the path`() { - val links = ScreenDeepLinks.linksFor(Routes.ActivityDetail::class) - - assertEquals("bitkit://screen/activity-detail/{id}?walletId={walletId}", links.single().uriPattern) - } - - @Test - fun `arguments with defaults are appended as query parameters`() { - val links = ScreenDeepLinks.linksFor(Routes.Contacts::class) - - assertEquals( - "bitkit://screen/contacts?showAddContactSheet={showAddContactSheet}", - links.single().uriPattern, - ) - } -} diff --git a/app/src/testRelease/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt b/app/src/testRelease/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt deleted file mode 100644 index 68c36b4c4..000000000 --- a/app/src/testRelease/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt +++ /dev/null @@ -1,37 +0,0 @@ -package to.bitkit.ui.utils - -import android.content.Intent -import android.net.Uri -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import to.bitkit.test.BaseUnitTest -import to.bitkit.ui.Routes -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertTrue - -@Config(sdk = [34]) -@RunWith(RobolectricTestRunner::class) -class ScreenDeepLinksRegistrationTest : BaseUnitTest() { - @Test - fun `release never registers screen deeplinks`() { - assertFalse(ScreenDeepLinks.isEnabled) - assertTrue(ScreenDeepLinks.linksFor(Routes.Settings::class).isEmpty()) - assertTrue(ScreenDeepLinks.linksFor(Routes.Home::class).isEmpty()) - assertTrue(SheetDeepLinks.sheetIds.isEmpty()) - assertNull(SheetDeepLinks.sheetFor(Uri.parse("bitkit://screen/send"))) - assertNull(SheetDeepLinks.sheetFor(Uri.parse("bitkit://screen/settings"))) - } - - @Test - fun `release still detaches screen uris from the intent`() { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("bitkit://screen/settings")) - - val detached = ScreenDeepLinks.detachScreenUri(intent) - - assertTrue(detached) - assertNull(intent.data) - } -} From 2d6ac4c55f52da7d4d0de7cdbea81518cb436a0a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 13 Aug 2026 04:38:32 +0200 Subject: [PATCH 07/16] chore: drop changelog fragment Co-authored-by: Cursor --- changelog.d/next/1157.security.md | 1 - 1 file changed, 1 deletion(-) delete mode 100644 changelog.d/next/1157.security.md diff --git a/changelog.d/next/1157.security.md b/changelog.d/next/1157.security.md deleted file mode 100644 index 402813740..000000000 --- a/changelog.d/next/1157.security.md +++ /dev/null @@ -1 +0,0 @@ -Screen deep links are limited to debug builds and are no longer honored in store releases, even with Dev Mode enabled. From 91500f185e49d360d0c869ef684b93f69cc4fa5c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 13 Aug 2026 20:23:43 +0200 Subject: [PATCH 08/16] refactor: share screen deeplink helpers Co-authored-by: Cursor --- .../bitkit/ui/utils/ScreenDeepLinkRuntime.kt | 65 +++++++++++++++++++ .../to/bitkit/ui/utils/ScreenDeepLinks.kt | 49 -------------- .../java/to/bitkit/ui/utils/SheetDeepLinks.kt | 55 ---------------- .../to/bitkit/ui/utils/ScreenDeepLinks.kt | 10 +-- .../java/to/bitkit/ui/utils/SheetDeepLinks.kt | 11 ++++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- .../bitkit/ui/utils/ScreenDeepLinkRuntime.kt | 18 +++++ .../java/to/bitkit/ui/utils/SheetDeepLinks.kt | 11 ---- .../to/bitkit/ui/utils/ScreenDeepLinksTest.kt | 31 ++++++++- .../viewmodels/AppViewModelSendFlowTest.kt | 9 ++- .../to/bitkit/ui/utils/SheetDeepLinksTest.kt | 0 11 files changed, 137 insertions(+), 124 deletions(-) create mode 100644 app/src/debug/java/to/bitkit/ui/utils/ScreenDeepLinkRuntime.kt delete mode 100644 app/src/debug/java/to/bitkit/ui/utils/ScreenDeepLinks.kt delete mode 100644 app/src/debug/java/to/bitkit/ui/utils/SheetDeepLinks.kt rename app/src/{release => main}/java/to/bitkit/ui/utils/ScreenDeepLinks.kt (86%) create mode 100644 app/src/main/java/to/bitkit/ui/utils/SheetDeepLinks.kt create mode 100644 app/src/release/java/to/bitkit/ui/utils/ScreenDeepLinkRuntime.kt delete mode 100644 app/src/release/java/to/bitkit/ui/utils/SheetDeepLinks.kt rename app/src/{test => testDebug}/java/to/bitkit/ui/utils/SheetDeepLinksTest.kt (100%) 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/debug/java/to/bitkit/ui/utils/ScreenDeepLinks.kt b/app/src/debug/java/to/bitkit/ui/utils/ScreenDeepLinks.kt deleted file mode 100644 index cb39e9e57..000000000 --- a/app/src/debug/java/to/bitkit/ui/utils/ScreenDeepLinks.kt +++ /dev/null @@ -1,49 +0,0 @@ -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 - -object ScreenDeepLinks { - const val SCHEME = "bitkit" - const val HOST = "screen" - - val isEnabled = true - - private const val BASE_URI = "$SCHEME://$HOST" - - private val CAMEL_HUMP = Regex("(?<=[a-z0-9])(?=[A-Z])") - - fun screenId(route: KClass): String? = kebabId(route) - - fun kebabId(route: KClass<*>): String? { - val name = route.simpleName ?: return null - return CAMEL_HUMP.split(name).joinToString("-") { it.lowercase() } - } - - 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 matchStart(path: String, default: T, starts: List): T? = when { - path.isEmpty() -> default - else -> starts.firstOrNull { kebabId(it::class).equals(path, ignoreCase = true) } - } - - fun isScreenDeepLink(uri: Uri): Boolean = - uri.scheme?.lowercase() == SCHEME && uri.host?.lowercase() == HOST - - fun detachScreenUri(intent: Intent): Boolean { - val uri = intent.data ?: return false - if (!isScreenDeepLink(uri)) return false - - intent.data = null - return true - } -} diff --git a/app/src/debug/java/to/bitkit/ui/utils/SheetDeepLinks.kt b/app/src/debug/java/to/bitkit/ui/utils/SheetDeepLinks.kt deleted file mode 100644 index 15d9b4e85..000000000 --- a/app/src/debug/java/to/bitkit/ui/utils/SheetDeepLinks.kt +++ /dev/null @@ -1,55 +0,0 @@ -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 - - return STANDALONE.firstOrNull { ScreenDeepLinks.kebabId(it::class) == id } - } -} diff --git a/app/src/release/java/to/bitkit/ui/utils/ScreenDeepLinks.kt b/app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt similarity index 86% rename from app/src/release/java/to/bitkit/ui/utils/ScreenDeepLinks.kt rename to app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt index 5829821ac..e727b0f2d 100644 --- a/app/src/release/java/to/bitkit/ui/utils/ScreenDeepLinks.kt +++ b/app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt @@ -10,12 +10,14 @@ object ScreenDeepLinks { const val SCHEME = "bitkit" const val HOST = "screen" - val isEnabled = false - private const val BASE_URI = "$SCHEME://$HOST" 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? { @@ -25,8 +27,8 @@ object ScreenDeepLinks { fun basePath(route: KClass): String? = screenId(route)?.let { "$BASE_URI/$it" } - @Suppress("UNUSED_PARAMETER") - fun linksFor(route: KClass): List = emptyList() + 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 new file mode 100644 index 000000000..0c3c8981b --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/utils/SheetDeepLinks.kt @@ -0,0 +1,11 @@ +package to.bitkit.ui.utils + +import android.net.Uri +import to.bitkit.ui.components.Sheet + +object SheetDeepLinks { + val sheetIds: Set + get() = ScreenDeepLinkRuntime.sheetIds + + 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 d4f2406cb..3a9daa93c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -3565,7 +3565,7 @@ class AppViewModel @Inject constructor( } if (ScreenDeepLinks.isScreenDeepLink(uri)) { - if (!ScreenDeepLinks.isEnabled || !settingsStore.data.first().isDevModeEnabled) { + if (!ScreenDeepLinks.shouldQueue(settingsStore.data.first().isDevModeEnabled)) { Logger.warn("Ignoring screen deeplink, dev mode is off", 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/release/java/to/bitkit/ui/utils/SheetDeepLinks.kt b/app/src/release/java/to/bitkit/ui/utils/SheetDeepLinks.kt deleted file mode 100644 index 993b43ef0..000000000 --- a/app/src/release/java/to/bitkit/ui/utils/SheetDeepLinks.kt +++ /dev/null @@ -1,11 +0,0 @@ -package to.bitkit.ui.utils - -import android.net.Uri -import to.bitkit.ui.components.Sheet - -object SheetDeepLinks { - val sheetIds: Set = emptySet() - - @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..6e399c7f8 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 @@ -50,6 +51,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 +60,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 +68,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 +76,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 +95,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 and variant-correct links`() { val ids = mutableMapOf() Routes.DeepLinkable::class.sealedSubclasses.forEach { route -> @@ -100,7 +105,12 @@ 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") + val links = ScreenDeepLinks.linksFor(route) + if (ScreenDeepLinks.isEnabled) { + assertEquals(1, links.size, "route $name has no deep link") + } else { + assertTrue(links.isEmpty(), "route $name leaked a deep link in release") + } } } @@ -132,4 +142,21 @@ class ScreenDeepLinksTest : BaseUnitTest() { assertFalse(pubkyAuth) assertFalse(lightning) } + + @Test + fun `screen links are enabled only on debug`() { + assertEquals(ScreenDeepLinks.isEnabled, ScreenDeepLinks.linksFor(Routes.Settings::class).isNotEmpty()) + assertEquals(ScreenDeepLinks.isEnabled, ScreenDeepLinks.shouldQueue(true)) + assertFalse(ScreenDeepLinks.shouldQueue(false)) + } + + @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 181f88299..fe8f73582 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -105,6 +105,7 @@ import to.bitkit.ui.components.Sheet import to.bitkit.ui.shared.toast.ToastQueueManager import to.bitkit.ui.sheets.SendRoute import to.bitkit.ui.sheets.hardware.HardwareRoute +import to.bitkit.ui.utils.ScreenDeepLinks import to.bitkit.usecases.FormatMoneyValue import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase import to.bitkit.utils.AppError @@ -758,13 +759,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 From 3ecf6215dad7a0e9ffa5d001bae30b9de178c921 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 13 Aug 2026 20:23:46 +0200 Subject: [PATCH 09/16] test: fail ci if release enables screen links Co-authored-by: Cursor --- .github/workflows/ci.yml | 9 +++++ Justfile | 24 +++++++++++- app/build.gradle.kts | 4 ++ .../utils/ScreenDeepLinksRegistrationTest.kt | 18 +++++++++ .../utils/ScreenDeepLinksRegistrationTest.kt | 38 +++++++++++++++++++ 5 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 app/src/testDebug/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt create mode 100644 app/src/testRelease/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb222bbe0..0bc34870f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,8 +54,17 @@ jobs: run: ./gradlew assembleDevDebug - name: Run tests + env: + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: ./gradlew testDevDebugUnitTest + - name: Run release screen deeplink gate tests + env: + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: ./gradlew testDevReleaseUnitTest --tests 'to.bitkit.ui.utils.ScreenDeepLinksRegistrationTest' + - name: Upload test report uses: actions/upload-artifact@v7 with: diff --git a/Justfile b/Justfile index 1131f77c1..1c9f09247 100644 --- a/Justfile +++ b/Justfile @@ -185,7 +185,29 @@ install: {{ gradle }} installDevDebug test target="" value="": - {{ if target == "" { gradle + " testDevDebugUnitTest" } else if target == "android" { gradle + " connectedDevDebugAndroidTest" } else if target == "file" { if value == "" { error("usage: just test file PATTERN") } else { gradle + " testDevDebugUnitTest --tests '" + value + "'" } } else if target == "lane" { if value == "" { error("usage: just test lane LANE") } else { gradle + " connectedDevDebug" + value + "AndroidTest" } } else { error("usage: just test [file PATTERN|android|lane LANE]") } }} + #!/usr/bin/env sh + set -eu + if [ "{{ target }}" = "" ]; then + {{ gradle }} testDevDebugUnitTest + {{ gradle }} testDevReleaseUnitTest --tests 'to.bitkit.ui.utils.ScreenDeepLinksRegistrationTest' + elif [ "{{ target }}" = "android" ]; then + {{ gradle }} connectedDevDebugAndroidTest + elif [ "{{ target }}" = "file" ]; then + if [ "{{ value }}" = "" ]; then + echo "usage: just test file PATTERN" >&2 + exit 1 + fi + {{ gradle }} testDevDebugUnitTest --tests '{{ value }}' + elif [ "{{ target }}" = "lane" ]; then + if [ "{{ value }}" = "" ]; then + echo "usage: just test lane LANE" >&2 + exit 1 + fi + {{ gradle }} connectedDevDebug{{ value }}AndroidTest + else + echo "usage: just test [file PATTERN|android|lane LANE]" >&2 + exit 1 + fi lint target="": {{ if target == "" { gradle + " detekt --rerun-tasks" } else if target == "baseline" { gradle + " detektBaseline --rerun-tasks" } else { error("usage: just lint [baseline]") } }} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 11a69278c..6a9dfa77b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,6 +2,7 @@ import com.android.build.api.artifact.SingleArtifact import com.android.build.api.variant.BuildConfigField import com.android.build.api.variant.BuiltArtifactsLoader import com.android.build.api.variant.FilterConfiguration +import com.android.build.api.variant.HasUnitTestBuilder import io.gitlab.arturbosch.detekt.Detekt import org.gradle.api.artifacts.MinimalExternalModuleDependency import org.gradle.api.provider.Provider @@ -315,6 +316,9 @@ kotlin { } androidComponents { + beforeVariants(selector().withFlavor("network", "dev").withBuildType("release")) { variant -> + (variant as HasUnitTestBuilder).enableUnitTest = true + } onVariants { variant -> val buildConfigFields = requireNotNull(variant.buildConfigFields) { "buildFeatures.buildConfig must stay enabled for '${variant.name}'." diff --git a/app/src/testDebug/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt b/app/src/testDebug/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt new file mode 100644 index 000000000..c52ad6593 --- /dev/null +++ b/app/src/testDebug/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt @@ -0,0 +1,18 @@ +package to.bitkit.ui.utils + +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertTrue + +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class ScreenDeepLinksRegistrationTest : BaseUnitTest() { + @Test + fun `debug registers screen deeplinks`() { + assertTrue(ScreenDeepLinks.isEnabled) + assertTrue(ScreenDeepLinks.shouldQueue(true)) + } +} diff --git a/app/src/testRelease/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt b/app/src/testRelease/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt new file mode 100644 index 000000000..bca3b105a --- /dev/null +++ b/app/src/testRelease/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt @@ -0,0 +1,38 @@ +package to.bitkit.ui.utils + +import android.content.Intent +import android.net.Uri +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import to.bitkit.test.BaseUnitTest +import to.bitkit.ui.Routes +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class ScreenDeepLinksRegistrationTest : BaseUnitTest() { + @Test + fun `release never registers screen deeplinks`() { + assertFalse(ScreenDeepLinks.isEnabled) + assertFalse(ScreenDeepLinks.shouldQueue(true)) + assertTrue(ScreenDeepLinks.linksFor(Routes.Settings::class).isEmpty()) + assertTrue(ScreenDeepLinks.linksFor(Routes.Home::class).isEmpty()) + assertTrue(SheetDeepLinks.sheetIds.isEmpty()) + assertNull(SheetDeepLinks.sheetFor(Uri.parse("bitkit://screen/send"))) + assertNull(SheetDeepLinks.sheetFor(Uri.parse("bitkit://screen/settings"))) + } + + @Test + fun `release still detaches screen uris from the intent`() { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("bitkit://screen/settings")) + + val detached = ScreenDeepLinks.detachScreenUri(intent) + + assertTrue(detached) + assertNull(intent.data) + } +} From 753d61b27e39d75b89f92d5014e823ba69ae65d3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 14 Aug 2026 05:22:35 +0200 Subject: [PATCH 10/16] ci: keep release tests off unprotected ci Co-authored-by: Cursor --- .github/workflows/ci.yml | 9 ------ .../utils/ScreenDeepLinksReleaseSourceTest.kt | 32 +++++++++++++++++++ 2 files changed, 32 insertions(+), 9 deletions(-) create mode 100644 app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksReleaseSourceTest.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0bc34870f..fb222bbe0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,17 +54,8 @@ jobs: run: ./gradlew assembleDevDebug - name: Run tests - env: - GITHUB_ACTOR: ${{ github.actor }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: ./gradlew testDevDebugUnitTest - - name: Run release screen deeplink gate tests - env: - GITHUB_ACTOR: ${{ github.actor }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: ./gradlew testDevReleaseUnitTest --tests 'to.bitkit.ui.utils.ScreenDeepLinksRegistrationTest' - - name: Upload test report uses: actions/upload-artifact@v7 with: diff --git a/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksReleaseSourceTest.kt b/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksReleaseSourceTest.kt new file mode 100644 index 000000000..60f3dfe90 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksReleaseSourceTest.kt @@ -0,0 +1,32 @@ +package to.bitkit.ui.utils + +import org.junit.Test +import to.bitkit.test.BaseUnitTest +import java.io.File +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ScreenDeepLinksReleaseSourceTest : BaseUnitTest() { + @Test + fun `release runtime source stays disabled`() { + val source = releaseRuntimeSource() + + assertTrue("val isEnabled = false" in source) + assertFalse("val isEnabled = true" in source) + assertTrue("= emptyList()" in source) + assertTrue("fun sheetFor" in source) + assertTrue("= null" in source) + assertFalse("navDeepLink" in source) + } + + private fun releaseRuntimeSource(): String { + val file = listOf( + File("src/release/java/to/bitkit/ui/utils/ScreenDeepLinkRuntime.kt"), + File("app/src/release/java/to/bitkit/ui/utils/ScreenDeepLinkRuntime.kt"), + ).firstOrNull { it.isFile } + + assertNotNull(file, "release ScreenDeepLinkRuntime.kt not found") + return file.readText() + } +} From 2c94cf9f927c7ea6cfc53a28367d640212a2a372 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 14 Aug 2026 05:55:01 +0200 Subject: [PATCH 11/16] revert: drop release unit test from just test Co-authored-by: Cursor --- Justfile | 24 +----------- app/build.gradle.kts | 4 -- .../utils/ScreenDeepLinksRegistrationTest.kt | 38 ------------------- 3 files changed, 1 insertion(+), 65 deletions(-) delete mode 100644 app/src/testRelease/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt diff --git a/Justfile b/Justfile index 1c9f09247..1131f77c1 100644 --- a/Justfile +++ b/Justfile @@ -185,29 +185,7 @@ install: {{ gradle }} installDevDebug test target="" value="": - #!/usr/bin/env sh - set -eu - if [ "{{ target }}" = "" ]; then - {{ gradle }} testDevDebugUnitTest - {{ gradle }} testDevReleaseUnitTest --tests 'to.bitkit.ui.utils.ScreenDeepLinksRegistrationTest' - elif [ "{{ target }}" = "android" ]; then - {{ gradle }} connectedDevDebugAndroidTest - elif [ "{{ target }}" = "file" ]; then - if [ "{{ value }}" = "" ]; then - echo "usage: just test file PATTERN" >&2 - exit 1 - fi - {{ gradle }} testDevDebugUnitTest --tests '{{ value }}' - elif [ "{{ target }}" = "lane" ]; then - if [ "{{ value }}" = "" ]; then - echo "usage: just test lane LANE" >&2 - exit 1 - fi - {{ gradle }} connectedDevDebug{{ value }}AndroidTest - else - echo "usage: just test [file PATTERN|android|lane LANE]" >&2 - exit 1 - fi + {{ if target == "" { gradle + " testDevDebugUnitTest" } else if target == "android" { gradle + " connectedDevDebugAndroidTest" } else if target == "file" { if value == "" { error("usage: just test file PATTERN") } else { gradle + " testDevDebugUnitTest --tests '" + value + "'" } } else if target == "lane" { if value == "" { error("usage: just test lane LANE") } else { gradle + " connectedDevDebug" + value + "AndroidTest" } } else { error("usage: just test [file PATTERN|android|lane LANE]") } }} lint target="": {{ if target == "" { gradle + " detekt --rerun-tasks" } else if target == "baseline" { gradle + " detektBaseline --rerun-tasks" } else { error("usage: just lint [baseline]") } }} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6a9dfa77b..11a69278c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,7 +2,6 @@ import com.android.build.api.artifact.SingleArtifact import com.android.build.api.variant.BuildConfigField import com.android.build.api.variant.BuiltArtifactsLoader import com.android.build.api.variant.FilterConfiguration -import com.android.build.api.variant.HasUnitTestBuilder import io.gitlab.arturbosch.detekt.Detekt import org.gradle.api.artifacts.MinimalExternalModuleDependency import org.gradle.api.provider.Provider @@ -316,9 +315,6 @@ kotlin { } androidComponents { - beforeVariants(selector().withFlavor("network", "dev").withBuildType("release")) { variant -> - (variant as HasUnitTestBuilder).enableUnitTest = true - } onVariants { variant -> val buildConfigFields = requireNotNull(variant.buildConfigFields) { "buildFeatures.buildConfig must stay enabled for '${variant.name}'." diff --git a/app/src/testRelease/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt b/app/src/testRelease/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt deleted file mode 100644 index bca3b105a..000000000 --- a/app/src/testRelease/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt +++ /dev/null @@ -1,38 +0,0 @@ -package to.bitkit.ui.utils - -import android.content.Intent -import android.net.Uri -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import to.bitkit.test.BaseUnitTest -import to.bitkit.ui.Routes -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertTrue - -@Config(sdk = [34]) -@RunWith(RobolectricTestRunner::class) -class ScreenDeepLinksRegistrationTest : BaseUnitTest() { - @Test - fun `release never registers screen deeplinks`() { - assertFalse(ScreenDeepLinks.isEnabled) - assertFalse(ScreenDeepLinks.shouldQueue(true)) - assertTrue(ScreenDeepLinks.linksFor(Routes.Settings::class).isEmpty()) - assertTrue(ScreenDeepLinks.linksFor(Routes.Home::class).isEmpty()) - assertTrue(SheetDeepLinks.sheetIds.isEmpty()) - assertNull(SheetDeepLinks.sheetFor(Uri.parse("bitkit://screen/send"))) - assertNull(SheetDeepLinks.sheetFor(Uri.parse("bitkit://screen/settings"))) - } - - @Test - fun `release still detaches screen uris from the intent`() { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("bitkit://screen/settings")) - - val detached = ScreenDeepLinks.detachScreenUri(intent) - - assertTrue(detached) - assertNull(intent.data) - } -} From 9ac2eb06c8101ea4750016621b27ec9d90d92b19 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 14 Aug 2026 05:56:27 +0200 Subject: [PATCH 12/16] test: drop release source file gate Co-authored-by: Cursor --- .../utils/ScreenDeepLinksReleaseSourceTest.kt | 32 ------------------- 1 file changed, 32 deletions(-) delete mode 100644 app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksReleaseSourceTest.kt diff --git a/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksReleaseSourceTest.kt b/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksReleaseSourceTest.kt deleted file mode 100644 index 60f3dfe90..000000000 --- a/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksReleaseSourceTest.kt +++ /dev/null @@ -1,32 +0,0 @@ -package to.bitkit.ui.utils - -import org.junit.Test -import to.bitkit.test.BaseUnitTest -import java.io.File -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -class ScreenDeepLinksReleaseSourceTest : BaseUnitTest() { - @Test - fun `release runtime source stays disabled`() { - val source = releaseRuntimeSource() - - assertTrue("val isEnabled = false" in source) - assertFalse("val isEnabled = true" in source) - assertTrue("= emptyList()" in source) - assertTrue("fun sheetFor" in source) - assertTrue("= null" in source) - assertFalse("navDeepLink" in source) - } - - private fun releaseRuntimeSource(): String { - val file = listOf( - File("src/release/java/to/bitkit/ui/utils/ScreenDeepLinkRuntime.kt"), - File("app/src/release/java/to/bitkit/ui/utils/ScreenDeepLinkRuntime.kt"), - ).firstOrNull { it.isFile } - - assertNotNull(file, "release ScreenDeepLinkRuntime.kt not found") - return file.readText() - } -} From ccfd06035bd0ea72eef8f672665b2e479f506ee6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 14 Aug 2026 05:57:53 +0200 Subject: [PATCH 13/16] test: simplify screen deeplink unit tests Co-authored-by: Cursor --- .../to/bitkit/ui/utils/ScreenDeepLinksTest.kt | 36 +------------------ 1 file changed, 1 insertion(+), 35 deletions(-) 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 6e399c7f8..03803aa52 100644 --- a/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt +++ b/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt @@ -8,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 @@ -18,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) @@ -95,7 +74,7 @@ class ScreenDeepLinksTest : BaseUnitTest() { } @Test - fun `every deep-linkable route has a unique screen id and variant-correct links`() { + fun `every deep-linkable route has a unique screen id`() { val ids = mutableMapOf() Routes.DeepLinkable::class.sealedSubclasses.forEach { route -> @@ -105,12 +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") - val links = ScreenDeepLinks.linksFor(route) - if (ScreenDeepLinks.isEnabled) { - assertEquals(1, links.size, "route $name has no deep link") - } else { - assertTrue(links.isEmpty(), "route $name leaked a deep link in release") - } } } @@ -143,13 +116,6 @@ class ScreenDeepLinksTest : BaseUnitTest() { assertFalse(lightning) } - @Test - fun `screen links are enabled only on debug`() { - assertEquals(ScreenDeepLinks.isEnabled, ScreenDeepLinks.linksFor(Routes.Settings::class).isNotEmpty()) - assertEquals(ScreenDeepLinks.isEnabled, ScreenDeepLinks.shouldQueue(true)) - assertFalse(ScreenDeepLinks.shouldQueue(false)) - } - @Test fun `screen uris are detached from the activity intent`() { val intent = Intent(Intent.ACTION_VIEW, Uri.parse("bitkit://screen/settings")) From 6ed8684012ebe8ce798c2f442394ea701416098e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 14 Aug 2026 06:14:13 +0200 Subject: [PATCH 14/16] test: drop debug screen deeplink register test Co-authored-by: Cursor --- .../utils/ScreenDeepLinksRegistrationTest.kt | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 app/src/testDebug/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt diff --git a/app/src/testDebug/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt b/app/src/testDebug/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt deleted file mode 100644 index c52ad6593..000000000 --- a/app/src/testDebug/java/to/bitkit/ui/utils/ScreenDeepLinksRegistrationTest.kt +++ /dev/null @@ -1,18 +0,0 @@ -package to.bitkit.ui.utils - -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import to.bitkit.test.BaseUnitTest -import kotlin.test.assertTrue - -@Config(sdk = [34]) -@RunWith(RobolectricTestRunner::class) -class ScreenDeepLinksRegistrationTest : BaseUnitTest() { - @Test - fun `debug registers screen deeplinks`() { - assertTrue(ScreenDeepLinks.isEnabled) - assertTrue(ScreenDeepLinks.shouldQueue(true)) - } -} From e23264c6873990da54cae24707d4cbd1375ca547 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 14 Aug 2026 06:58:18 +0200 Subject: [PATCH 15/16] ci: retrigger checks after undraft Co-authored-by: Cursor From 962da97278ca22b520fddde1b56c419dd7107c6f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 19:38:48 +0200 Subject: [PATCH 16/16] fix: update screen deeplink skip copy --- app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- journeys/deeplinks/screen-deeplink.xml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 3a9daa93c..234ccccff 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -3566,7 +3566,7 @@ class AppViewModel @Inject constructor( if (ScreenDeepLinks.isScreenDeepLink(uri)) { if (!ScreenDeepLinks.shouldQueue(settingsStore.data.first().isDevModeEnabled)) { - Logger.warn("Ignoring screen deeplink, dev mode is off", context = TAG) + Logger.warn("Ignoring screen deeplink, not queued", context = TAG) return@launch } 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