-
Notifications
You must be signed in to change notification settings - Fork 4
feat: add deep links to every screen #1119
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
54fce34
ec09077
1862da0
a31add7
2846c61
511250c
09239fb
ad4ec80
1ff7743
ec39dfb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| package to.bitkit.ui.utils | ||
|
|
||
| import android.content.Context | ||
| import android.content.Intent | ||
| import androidx.activity.ComponentActivity | ||
| import androidx.activity.compose.setContent | ||
| import androidx.compose.material3.Text | ||
| import androidx.compose.runtime.Composable | ||
| import androidx.compose.runtime.remember | ||
| import androidx.compose.ui.platform.LocalContext | ||
| import androidx.compose.ui.test.junit4.createEmptyComposeRule | ||
| import androidx.core.net.toUri | ||
| import androidx.navigation.NavDestination.Companion.hasRoute | ||
| import androidx.navigation.compose.ComposeNavigator | ||
| import androidx.navigation.compose.NavHost | ||
| import androidx.navigation.compose.composable | ||
| import androidx.navigation.testing.TestNavHostController | ||
| import androidx.test.core.app.ActivityScenario | ||
| import androidx.test.core.app.ApplicationProvider | ||
| import androidx.test.ext.junit.runners.AndroidJUnit4 | ||
| import org.junit.Rule | ||
| import org.junit.Test | ||
| import org.junit.runner.RunWith | ||
| import to.bitkit.test.annotations.ComposeUi | ||
| import to.bitkit.ui.Routes | ||
| import kotlin.reflect.KClass | ||
| import kotlin.test.assertNull | ||
| import kotlin.test.assertTrue | ||
|
|
||
| @RunWith(AndroidJUnit4::class) | ||
| @ComposeUi | ||
| class ScreenDeepLinkDetachmentTest { | ||
| private companion object { | ||
| const val SETTINGS_URI = "bitkit://screen/settings" | ||
| const val DENIED_URI = "bitkit://screen/recovery-mnemonic" | ||
| } | ||
|
|
||
| @get:Rule | ||
| val composeTestRule = createEmptyComposeRule() | ||
|
|
||
| private lateinit var navController: TestNavHostController | ||
|
|
||
| @Test | ||
| fun testAttachedScreenUriIsHandledByGraphWithoutGate() { | ||
| withGraph(detach = false) { | ||
| assertTrue(isOn(Routes.Settings::class)) | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| fun testGraphCreationStaysOnHomeAfterDetachment() { | ||
| withGraph(detach = true) { activity -> | ||
| assertNull(activity.intent.data) | ||
| assertTrue(isOn(Routes.Home::class)) | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| fun testDetachedUriReachesSettingsOnlyThroughReplay() { | ||
| withGraph(detach = true) { activity -> | ||
| assertTrue(isOn(Routes.Home::class)) | ||
|
|
||
| replay(activity, SETTINGS_URI) | ||
|
|
||
| assertTrue(isOn(Routes.Settings::class)) | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| fun testDeniedRouteIsNotMatchedByReplay() { | ||
| withGraph(detach = true) { activity -> | ||
| replay(activity, DENIED_URI) | ||
|
|
||
| assertTrue(isOn(Routes.Home::class)) | ||
| } | ||
| } | ||
|
|
||
| private fun withGraph(detach: Boolean, block: (ComponentActivity) -> Unit) { | ||
| val context = ApplicationProvider.getApplicationContext<Context>() | ||
| val launchIntent = Intent(context, ComponentActivity::class.java) | ||
|
|
||
| ActivityScenario.launch<ComponentActivity>(launchIntent).use { scenario -> | ||
| lateinit var activity: ComponentActivity | ||
| lateinit var launched: Intent | ||
|
|
||
| scenario.onActivity { | ||
| activity = it | ||
| launched = it.intent | ||
|
|
||
| val delivered = Intent(Intent.ACTION_VIEW, SETTINGS_URI.toUri()) | ||
| if (detach) { | ||
| ScreenDeepLinks.detachScreenUri(delivered) | ||
| } | ||
| it.intent = delivered | ||
| it.setContent { TestGraph() } | ||
| } | ||
| composeTestRule.waitForIdle() | ||
|
|
||
| block(activity) | ||
|
|
||
| scenario.onActivity { it.intent = launched } | ||
| } | ||
| } | ||
|
|
||
| private fun replay(activity: ComponentActivity, uri: String) { | ||
| activity.runOnUiThread { | ||
| navController.handleDeepLink( | ||
| Intent(Intent.ACTION_VIEW, uri.toUri()) | ||
| .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) | ||
| ) | ||
| } | ||
| composeTestRule.waitForIdle() | ||
| } | ||
|
|
||
| private fun isOn(route: KClass<out Routes>): Boolean = | ||
| navController.currentDestination?.hasRoute(route) == true | ||
|
|
||
| @Composable | ||
| private fun TestGraph() { | ||
| val context = LocalContext.current | ||
| val controller = remember { | ||
| TestNavHostController(context).apply { | ||
| navigatorProvider.addNavigator(ComposeNavigator()) | ||
| } | ||
| } | ||
| navController = controller | ||
|
|
||
| NavHost(navController = controller, startDestination = Routes.Home) { | ||
| composable<Routes.Home>(deepLinks = ScreenDeepLinks.linksFor(Routes.Home::class)) { | ||
| Text("home") | ||
| } | ||
| composable<Routes.Settings>(deepLinks = ScreenDeepLinks.linksFor(Routes.Settings::class)) { | ||
| Text("settings") | ||
| } | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| 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" | ||
|
|
||
| private const val BASE_URI = "$SCHEME://$HOST" | ||
|
|
||
| private val CAMEL_HUMP = Regex("(?<=[a-z0-9])(?=[A-Z])") | ||
|
|
||
| private val DENIED: Set<KClass<out Routes>> = setOf( | ||
|
ovitrif marked this conversation as resolved.
ovitrif marked this conversation as resolved.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
That duplication does not guard future changes:
The navigation model should follow the same ownership principle we use from DDD: behavior and invariants live with the model that owns them. Within this navigation boundary, the sealed The sealed hierarchy should make that choice explicit: sealed interface Routes {
sealed interface DeepLinkable : Routes
sealed interface InternalOnly : Routes
@Serializable
data object Settings : DeepLinkable
@Serializable
data object SpendingConfirm : InternalOnly
}The graph helper should then accept only |
||
| 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, | ||
| ) | ||
|
|
||
| fun isDenied(route: KClass<*>): Boolean = route in DENIED | ||
|
|
||
| fun screenId(route: KClass<*>): String? { | ||
| if (!isScreenRoute(route)) return null | ||
| if (isDenied(route)) return null | ||
|
|
||
| return 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 <T : Any> linksFor(route: KClass<T>): List<NavDeepLink> { | ||
| val basePath = basePath(route) ?: return emptyList() | ||
| return listOf(navDeepLink(route = route, basePath = basePath) {}) | ||
| } | ||
|
|
||
| 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 | ||
| } | ||
|
|
||
| private fun isScreenRoute(route: KClass<*>): Boolean = Routes::class.java.isAssignableFrom(route.java) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| 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 | ||
|
|
||
| object SheetDeepLinks { | ||
| private val SHEETS: List<Sheet> = listOf( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Future flow changes therefore need synchronized maintenance:
The nested navigation models should follow the same DDD ownership principle: each sealed route family owns the invariant that determines whether a state can start independently, while Each sealed route family should make that choice explicit and own its lookup: sealed interface SendRoute {
sealed interface DeepLinkStart : SendRoute
sealed interface InternalOnly : SendRoute
@Serializable
data object Recipient : DeepLinkStart
@Serializable
data object Confirm : InternalOnly
companion object {
fun fromDeepLink(path: String): DeepLinkStart? = TODO()
}
}
|
||
| Sheet.Send(SendRoute.Recipient), | ||
| Sheet.Send(SendRoute.Address), | ||
| Sheet.Send(SendRoute.ContactSelect), | ||
| Sheet.Send(SendRoute.Amount), | ||
| Sheet.Send(SendRoute.QrScanner), | ||
| Sheet.Send(SendRoute.CoinSelection), | ||
| Sheet.Send(SendRoute.AddTag), | ||
| Sheet.Send(SendRoute.ComingSoon), | ||
| Sheet.Send(SendRoute.Support), | ||
|
|
||
| Sheet.Receive(ReceiveRoute.QR), | ||
| Sheet.Receive(ReceiveRoute.Amount), | ||
| Sheet.Receive(ReceiveRoute.EditInvoice), | ||
| Sheet.Receive(ReceiveRoute.AddTag), | ||
| Sheet.Receive(ReceiveRoute.GeoBlock), | ||
|
|
||
| Sheet.Backup(BackupRoute.Intro), | ||
| Sheet.Backup(BackupRoute.MultipleDevices), | ||
| Sheet.Backup(BackupRoute.Metadata), | ||
|
|
||
| Sheet.Widgets(WidgetsRoute.Gallery), | ||
| Sheet.Widgets(WidgetsRoute.PricePreview), | ||
| Sheet.Widgets(WidgetsRoute.PriceEdit), | ||
| Sheet.Widgets(WidgetsRoute.WeatherPreview), | ||
| Sheet.Widgets(WidgetsRoute.WeatherEdit), | ||
| Sheet.Widgets(WidgetsRoute.BlocksPreview), | ||
| Sheet.Widgets(WidgetsRoute.BlocksEdit), | ||
| Sheet.Widgets(WidgetsRoute.HeadlinesPreview), | ||
| Sheet.Widgets(WidgetsRoute.HeadlinesEdit), | ||
| Sheet.Widgets(WidgetsRoute.FactsPreview), | ||
| Sheet.Widgets(WidgetsRoute.CalculatorPreview), | ||
| Sheet.Widgets(WidgetsRoute.SuggestionsPreview), | ||
|
|
||
| Sheet.Hardware(HardwareRoute.Intro), | ||
|
|
||
| Sheet.ActivityDateRangeSelector, | ||
| Sheet.ActivityTagSelector, | ||
| Sheet.QrScanner, | ||
| ) | ||
|
|
||
| private val BY_PATH: Map<String, Sheet> = buildMap { | ||
| SHEETS.forEach { sheet -> | ||
| val sheetId = ScreenDeepLinks.kebabId(sheet::class) ?: return@forEach | ||
| putIfAbsent(sheetId, sheet) | ||
|
|
||
| val route = routeOf(sheet) ?: return@forEach | ||
| val routeId = ScreenDeepLinks.kebabId(route::class) ?: return@forEach | ||
| put("$sheetId/$routeId", sheet) | ||
| } | ||
| } | ||
|
|
||
| val paths: Set<String> get() = BY_PATH.keys | ||
|
|
||
| fun sheetFor(uri: Uri): Sheet? { | ||
| if (!ScreenDeepLinks.isScreenDeepLink(uri)) return null | ||
|
|
||
| val path = uri.pathSegments.orEmpty().joinToString("/").lowercase() | ||
| return BY_PATH[path] | ||
| } | ||
|
|
||
| private fun routeOf(sheet: Sheet): Any? = when (sheet) { | ||
| is Sheet.Send -> sheet.route | ||
| is Sheet.Receive -> sheet.route | ||
| is Sheet.Backup -> sheet.route | ||
| is Sheet.Widgets -> sheet.route | ||
| is Sheet.Hardware -> sheet.route | ||
| else -> null | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
backup/show-mnemonicis denied bySheetDeepLinks, but it reaches this branch and hides the active backup intro beforehandleDeepLinkreports the URI as unhandled. The new sheet journey therefore returns to the wallet overview instead of leaving the visible screen unchanged; any rejected screen URI can similarly dismiss the current sheet. Could we confirm that the URI matches a root destination before hiding the sheet and add regression coverage for a denied URI while a sheet is open?