Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
23 changes: 23 additions & 0 deletions app/src/main/java/to/bitkit/ui/ContentView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,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 +210,7 @@ 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.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 +303,27 @@ 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
}

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
3 changes: 3 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 @@ -234,6 +235,8 @@ class MainActivity : FragmentActivity() {
return
}

intent.data?.let { if (ScreenDeepLinks.isScreenDeepLink(it)) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) }
Comment thread
ovitrif marked this conversation as resolved.
Outdated

appViewModel.handleDeeplinkIntent(intent)
}

Expand Down
51 changes: 51 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,51 @@
package to.bitkit.ui.utils

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.LegacyRnRecovery::class,
Routes.LnurlChannel::class,
Routes.RecoveryMnemonic::class,
Routes.RecoveryMode::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

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
}
}
4 changes: 2 additions & 2 deletions app/src/main/java/to/bitkit/ui/utils/Transitions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ object Transitions {
inline fun <reified T : Any> NavGraphBuilder.navigationWithDefaultTransitions(
startDestination: Any,
typeMap: Map<KType, @JvmSuppressWildcards NavType<*>> = emptyMap(),
deepLinks: List<NavDeepLink> = emptyList(),
deepLinks: List<NavDeepLink> = ScreenDeepLinks.linksFor(T::class),
noinline enterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? = defaultEnterTrans,
noinline exitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)? = defaultExitTrans,
noinline popEnterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? = defaultPopEnterTrans,
Expand All @@ -100,7 +100,7 @@ inline fun <reified T : Any> NavGraphBuilder.navigationWithDefaultTransitions(
@Suppress("LongParameterList", "MaxLineLength")
inline fun <reified T : Any> NavGraphBuilder.composableWithDefaultTransitions(
typeMap: Map<KType, NavType<*>> = emptyMap(),
deepLinks: List<NavDeepLink> = emptyList(),
deepLinks: List<NavDeepLink> = ScreenDeepLinks.linksFor(T::class),
Comment thread
ovitrif marked this conversation as resolved.
noinline enterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? = defaultEnterTrans,
noinline exitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)? = defaultExitTrans,
noinline popEnterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? = defaultPopEnterTrans,
Expand Down
18 changes: 18 additions & 0 deletions app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ import to.bitkit.ui.shared.toast.ToastQueueManager
import to.bitkit.ui.sheets.SendRoute
import to.bitkit.ui.sheets.hardware.HardwareRoute
import to.bitkit.ui.theme.TRANSITION_SCREEN_MS
import to.bitkit.ui.utils.ScreenDeepLinks
import to.bitkit.usecases.FormatMoneyValue
import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase
import to.bitkit.utils.AppError
Expand Down Expand Up @@ -259,6 +260,9 @@ class AppViewModel @Inject constructor(
private val _isAuthenticated = MutableStateFlow(false)
val isAuthenticated = _isAuthenticated.asStateFlow()

private val _pendingScreenDeepLink = MutableStateFlow<Uri?>(null)
val pendingScreenDeepLink = _pendingScreenDeepLink.asStateFlow()

private val _showForgotPinSheet = MutableStateFlow(false)
val showForgotPinSheet = _showForgotPinSheet.asStateFlow()

Expand Down Expand Up @@ -3279,6 +3283,16 @@ class AppViewModel @Inject constructor(
return@launch
}

if (ScreenDeepLinks.isScreenDeepLink(uri)) {
Comment thread
ovitrif marked this conversation as resolved.
Outdated
if (!settingsStore.data.first().isDevModeEnabled) {
Logger.warn("Ignoring screen deeplink, dev mode is off", context = TAG)
return@launch
}

_pendingScreenDeepLink.value = uri
return@launch
}

PubkyRingAuthCallback.parse(uri)?.let {
if (!isPaykitEnabled.value) return@launch
handlePubkyRingAuthCallback(it)
Expand All @@ -3296,6 +3310,10 @@ class AppViewModel @Inject constructor(
launchScan(source = ScanSource.DEEPLINK, data = value, startDelay = SCREEN_TRANSITION_DELAY)
}

fun consumeScreenDeepLink() {
_pendingScreenDeepLink.value = null
}

private fun Uri.isRecoveryModeDeeplink(): Boolean {
val normalizedScheme = scheme?.lowercase()
if (normalizedScheme != BITKIT_SCHEME) return false
Expand Down
140 changes: 140 additions & 0 deletions app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package to.bitkit.ui.utils

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 to.bitkit.ui.sheets.SendRoute
import kotlin.reflect.KClass
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue

@Config(sdk = [34])
@RunWith(RobolectricTestRunner::class)
class ScreenDeepLinksTest : BaseUnitTest() {
private companion object {
val SENSITIVE_ROUTES: List<KClass<out Routes>> = listOf(
Routes.AuthCheck::class,
Routes.CriticalUpdate::class,
Routes.LegacyRnRecovery::class,
Routes.LnurlChannel::class,
Routes.RecoveryMnemonic::class,
Routes.RecoveryMode::class,
)
}

@Test
fun `screen id is derived from the route name in kebab-case`() {
val home = ScreenDeepLinks.screenId(Routes.Home::class)
val activityDetail = ScreenDeepLinks.screenId(Routes.ActivityDetail::class)
val rgsServer = ScreenDeepLinks.screenId(Routes.RgsServer::class)

assertEquals("home", home)
assertEquals("activity-detail", activityDetail)
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 `sensitive routes are denied`() {
SENSITIVE_ROUTES.forEach { route ->
assertTrue(ScreenDeepLinks.isDenied(route), "expected ${route.simpleName} to be denied")
assertNull(ScreenDeepLinks.screenId(route))
assertTrue(ScreenDeepLinks.linksFor(route).isEmpty())
}
}

@Test
fun `every route is either denied or reachable by a unique screen id`() {
val ids = mutableMapOf<String, String>()

Routes::class.sealedSubclasses.forEach { route ->
val name = route.simpleName.orEmpty()
val id = ScreenDeepLinks.screenId(route)

if (ScreenDeepLinks.isDenied(route)) {
assertNull(id, "denied route $name must not expose a screen id")
return@forEach
}

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")
}
}

@Test
fun `denied set contains only routes that still exist`() {
val declared = Routes::class.sealedSubclasses.toSet()
val denied = SENSITIVE_ROUTES.filter { it in declared }

assertEquals(SENSITIVE_ROUTES.size, denied.size)
}

@Test
fun `sheet routes are not exposed as screen deeplinks`() {
val id = ScreenDeepLinks.screenId(SendRoute.Amount::class)
val links = ScreenDeepLinks.linksFor(SendRoute.Amount::class)

assertNull(id)
assertTrue(links.isEmpty())
}

@Test
fun `screen deeplinks are recognised regardless of case`() {
val lowercase = ScreenDeepLinks.isScreenDeepLink(Uri.parse("bitkit://screen/settings"))
val uppercase = ScreenDeepLinks.isScreenDeepLink(Uri.parse("BITKIT://SCREEN/settings"))

assertTrue(lowercase)
assertTrue(uppercase)
}

@Test
fun `other bitkit hosts are not screen deeplinks`() {
val recoveryMode = ScreenDeepLinks.isScreenDeepLink(Uri.parse("bitkit://recovery-mode"))
val pubkyAuth = ScreenDeepLinks.isScreenDeepLink(Uri.parse("bitkit://pubky-auth/callback"))
val lightning = ScreenDeepLinks.isScreenDeepLink(Uri.parse("lightning:lnbc1"))

assertFalse(recoveryMode)
assertFalse(pubkyAuth)
assertFalse(lightning)
}
}
Loading
Loading