diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index bb2b532..4b3d5a5 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -145,6 +145,9 @@ dependencies { implementation(libs.androidx.material.icons.extended) implementation(libs.androidx.navigation.compose) implementation(libs.compose.icons.lucide) + // Chrome Custom Tabs — keeps explorer-link round-trip inside the app's + // task so the OEM auth gate doesn't re-engage on return (#138). + implementation(libs.androidx.browser) debugImplementation(libs.androidx.ui.tooling) // Hilt diff --git a/android/app/src/main/java/com/rjnr/pocketnode/ui/screens/activity/ActivityScreen.kt b/android/app/src/main/java/com/rjnr/pocketnode/ui/screens/activity/ActivityScreen.kt index 30d3ca6..2389290 100644 --- a/android/app/src/main/java/com/rjnr/pocketnode/ui/screens/activity/ActivityScreen.kt +++ b/android/app/src/main/java/com/rjnr/pocketnode/ui/screens/activity/ActivityScreen.kt @@ -278,11 +278,7 @@ fun ActivityScreen( retryDialogTx = failed }, onOpenExplorer = { url -> - try { - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) - } catch (e: android.content.ActivityNotFoundException) { - // No browser available — silently ignore - } + com.rjnr.pocketnode.ui.util.openInBrowser(context, url) } ) } diff --git a/android/app/src/main/java/com/rjnr/pocketnode/ui/screens/home/HomeScreen.kt b/android/app/src/main/java/com/rjnr/pocketnode/ui/screens/home/HomeScreen.kt index 378e5c0..ed43d57 100644 --- a/android/app/src/main/java/com/rjnr/pocketnode/ui/screens/home/HomeScreen.kt +++ b/android/app/src/main/java/com/rjnr/pocketnode/ui/screens/home/HomeScreen.kt @@ -178,9 +178,8 @@ fun HomeScreen( val onLookupBlockHeight: (() -> Unit)? = uiState.address.takeIf { it.isNotBlank() }?.let { addr -> { val url = buildExplorerAddressUrl(addr, uiState.currentNetwork) - try { - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) - } catch (_: android.content.ActivityNotFoundException) { + // Custom Tabs first, falls back to ACTION_VIEW under the hood (#138). + if (!com.rjnr.pocketnode.ui.util.openInBrowser(context, url)) { scope.launch { snackbarHostState.showSnackbar("No browser available to open the explorer") } @@ -362,11 +361,7 @@ fun HomeScreen( } }, onOpenExplorer = { url -> - try { - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) - } catch (_: android.content.ActivityNotFoundException) { - // No browser available - } + com.rjnr.pocketnode.ui.util.openInBrowser(context, url) }, onRetry = { tx -> selectedTransaction = null diff --git a/android/app/src/main/java/com/rjnr/pocketnode/ui/screens/send/SendScreen.kt b/android/app/src/main/java/com/rjnr/pocketnode/ui/screens/send/SendScreen.kt index f24910a..d2e440b 100644 --- a/android/app/src/main/java/com/rjnr/pocketnode/ui/screens/send/SendScreen.kt +++ b/android/app/src/main/java/com/rjnr/pocketnode/ui/screens/send/SendScreen.kt @@ -821,16 +821,9 @@ fun TransactionStatusDialog( } else { "https://explorer.nervos.org/transaction/" } - try { - context.startActivity( - Intent( - Intent.ACTION_VIEW, - Uri.parse("$explorerBase$txHash") - ) - ) - } catch (e: android.content.ActivityNotFoundException) { - // No browser available — silently ignore - } + com.rjnr.pocketnode.ui.util.openInBrowser( + context, "$explorerBase$txHash", + ) }, contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp) ) { diff --git a/android/app/src/main/java/com/rjnr/pocketnode/ui/screens/settings/SettingsScreen.kt b/android/app/src/main/java/com/rjnr/pocketnode/ui/screens/settings/SettingsScreen.kt index b181fc9..4fee0aa 100644 --- a/android/app/src/main/java/com/rjnr/pocketnode/ui/screens/settings/SettingsScreen.kt +++ b/android/app/src/main/java/com/rjnr/pocketnode/ui/screens/settings/SettingsScreen.kt @@ -179,9 +179,7 @@ fun SettingsScreen( val url = com.rjnr.pocketnode.ui.screens.home.buildExplorerAddressUrl( addr, uiState.currentNetwork ) - try { - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) - } catch (_: android.content.ActivityNotFoundException) { + if (!com.rjnr.pocketnode.ui.util.openInBrowser(context, url)) { scope.launch { snackbarHostState.showSnackbar("No browser available to open the explorer") } @@ -534,8 +532,9 @@ private fun SettingsScreenUI( title = "Open Source", badgeText = "Github", onClick = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(GITHUB_URL)) - context.startActivity(intent) + // Custom Tabs keeps the GitHub round-trip inside the app + // task, same reasoning as the explorer launches (#138). + com.rjnr.pocketnode.ui.util.openInBrowser(context, GITHUB_URL) } ) } diff --git a/android/app/src/main/java/com/rjnr/pocketnode/ui/util/CustomTabsLauncher.kt b/android/app/src/main/java/com/rjnr/pocketnode/ui/util/CustomTabsLauncher.kt new file mode 100644 index 0000000..e633b44 --- /dev/null +++ b/android/app/src/main/java/com/rjnr/pocketnode/ui/util/CustomTabsLauncher.kt @@ -0,0 +1,52 @@ +package com.rjnr.pocketnode.ui.util + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.browser.customtabs.CustomTabsIntent + +/** + * Open [url] in a Chrome Custom Tab when one is available, falling back to + * [Intent.ACTION_VIEW] otherwise. + * + * Why Custom Tabs over `ACTION_VIEW`: + * - The Custom Tab runs inside Pocket Node's task instead of starting Chrome's + * own task. The OS treats the round-trip as part of Pocket Node, so the auth + * gate does not re-engage on return and the user is not bounced through PIN + * re-entry. + * - Process death by aggressive memory managers (Tecno / Infinix / MIUI) is + * much less likely because the Custom Tab and host app share a task. + * - Browser back arrow drops the user straight back where they were. + * + * Returns `true` if either path launched successfully, `false` if neither did + * (rare: a stripped ROM with no browser at all). Callers can surface a + * snackbar in that case. + * + * Issue: #138 + */ +fun openInBrowser(context: Context, url: String): Boolean { + val uri = Uri.parse(url) + return try { + CustomTabsIntent.Builder() + .setShowTitle(true) + .setUrlBarHidingEnabled(false) + .build() + .launchUrl(context, uri) + true + } catch (_: ActivityNotFoundException) { + // No Custom Tabs provider AND no fallback browser — Custom Tabs lib + // resolves through ACTION_VIEW under the hood when no provider exists, + // so reaching here means the device truly has no web browser. + false + } catch (_: Throwable) { + // Defensive: if the Custom Tabs launch crashes for any other reason + // (e.g. a misconfigured browser), try plain ACTION_VIEW one more time. + try { + context.startActivity(Intent(Intent.ACTION_VIEW, uri)) + true + } catch (_: ActivityNotFoundException) { + false + } + } +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index fc3519f..a80fb27 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -25,6 +25,7 @@ lucide = "1.1.0" mockk = "1.13.16" coroutinesTest = "1.10.1" paging = "3.3.6" +androidxBrowser = "1.8.0" [libraries] kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serializationJson" } @@ -77,6 +78,7 @@ camerax-view = { group = "androidx.camera", name = "camera-view", version.ref = accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistPermissions" } zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" } compose-icons-lucide = { group = "com.composables", name = "icons-lucide", version.ref = "lucide" } +androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "androidxBrowser" } # Testing mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }