Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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")
}
}
}
}
34 changes: 33 additions & 1 deletion app/src/main/java/to/bitkit/ui/ContentView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package to.bitkit.ui

import android.Manifest
import android.content.Intent
import android.net.Uri
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
Expand Down Expand Up @@ -48,6 +49,7 @@ import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.rememberHazeState
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import to.bitkit.appwidget.AppWidgetRefreshReason
Expand Down Expand Up @@ -209,6 +211,8 @@ import to.bitkit.ui.sheets.hardware.HardwareSheet
import to.bitkit.ui.theme.Colors
import to.bitkit.ui.utils.AutoReadClipboardHandler
import to.bitkit.ui.utils.RequestNotificationPermissions
import to.bitkit.ui.utils.ScreenDeepLinks
import to.bitkit.ui.utils.SheetDeepLinks
import to.bitkit.ui.utils.composableWithDefaultTransitions
import to.bitkit.ui.utils.navigationWithDefaultTransitions
import to.bitkit.ui.utils.rememberIs24HourFormat
Expand Down Expand Up @@ -301,6 +305,31 @@ fun ContentView(

LaunchedEffect(Unit) { walletViewModel.handleHideBalanceOnOpen() }

val pendingScreenDeepLink by appViewModel.pendingScreenDeepLink.collectAsStateWithLifecycle()

LaunchedEffect(pendingScreenDeepLink) {
val uri = pendingScreenDeepLink ?: return@LaunchedEffect

navController.currentBackStackEntryFlow.first()
appViewModel.consumeScreenDeepLink()

SheetDeepLinks.sheetFor(uri)?.let {
appViewModel.showSheet(it)
return@LaunchedEffect
}

if (shouldDismissSheetForScreenLink(uri, appViewModel.currentSheet.value)) {
Comment thread
ovitrif marked this conversation as resolved.
Outdated
appViewModel.hideSheet()
}

val request = Intent(Intent.ACTION_VIEW, uri)
Comment thread
ovitrif marked this conversation as resolved.
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
val handled = navController.handleDeepLink(request)
if (!handled) {
Logger.warn("Unhandled screen deeplink '$uri'", context = "ContentView")
}
}

LaunchedEffect(appViewModel) {
appViewModel.mainScreenEffect.collect {
when (it) {
Expand Down Expand Up @@ -964,7 +993,7 @@ private fun NavGraphBuilder.home(
onConsumeHomeWidgetsPageRequest: () -> Unit,
onCalculatorInputActiveChanged: (Boolean) -> Unit,
) {
composable<Routes.Home> {
composable<Routes.Home>(deepLinks = ScreenDeepLinks.linksFor(Routes.Home::class)) {
val isRefreshing by walletViewModel.isRefreshing.collectAsStateWithLifecycle()
val isRecoveryMode by walletViewModel.isRecoveryMode.collectAsStateWithLifecycle()
val hazeState = rememberHazeState()
Expand Down Expand Up @@ -1871,6 +1900,9 @@ fun NavController.navigateToTransferSpendingStart(
deviceId: String,
) = navigateTo(transferSpendingStartRoute(hasSeenSpendingIntro, deviceId))

internal fun shouldDismissSheetForScreenLink(uri: Uri, currentSheet: Sheet?): Boolean =
currentSheet != null && SheetDeepLinks.sheetFor(uri) == null

internal fun transferEffectDestination(effect: TransferEffect): Routes? = when (effect) {
TransferEffect.OnHwTxSigned -> Routes.SpendingHwSigned
TransferEffect.OnSpendingFundingPaid -> Routes.SettingUp
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/to/bitkit/ui/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import to.bitkit.ui.screens.SplashScreen
import to.bitkit.ui.sheets.ForgotPinSheet
import to.bitkit.ui.sheets.NewTransactionSheet
import to.bitkit.ui.theme.AppThemeSurface
import to.bitkit.ui.utils.ScreenDeepLinks
import to.bitkit.ui.utils.composableWithDefaultTransitions
import to.bitkit.ui.utils.enableAppEdgeToEdge
import to.bitkit.utils.Logger
Expand Down Expand Up @@ -235,6 +236,10 @@ class MainActivity : FragmentActivity() {
}

appViewModel.handleDeeplinkIntent(intent)

if (ScreenDeepLinks.detachScreenUri(intent)) {
setIntent(intent)
}
}

/**
Expand Down
69 changes: 69 additions & 0 deletions app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt
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(
Comment thread
ovitrif marked this conversation as resolved.
Outdated
Comment thread
ovitrif marked this conversation as resolved.
Outdated
Comment thread
ovitrif marked this conversation as resolved.
Outdated
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)
}
81 changes: 81 additions & 0 deletions app/src/main/java/to/bitkit/ui/utils/SheetDeepLinks.kt
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(
Comment thread
ovitrif marked this conversation as resolved.
Outdated
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
}
}
Loading
Loading