From 8a1807c262f8ac45be7db38be964b80c200834f2 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sat, 3 Oct 2026 03:59:41 +0200 Subject: [PATCH] feat(android): open share links from a browser or the share sheet A drive.proton.me/urls/ link opened from a browser, or text containing one shared to the app, switches to Shares and opens the Add form with the link filled in, fragment included. A playing episode moves to the mini transport instead of covering the form. A recreated activity does not offer the same link again. The view filter is not autoVerify, since the domain is Proton's, so Android only routes browser links here once "Open supported links" is allowed in the app's settings; sharing works without that. --- android/app/src/main/AndroidManifest.xml | 17 +++++++++ .../io/narl/protonstream/IncomingShareLink.kt | 22 +++++++++++ .../java/io/narl/protonstream/MainActivity.kt | 20 +++++++++- .../narl/protonstream/ui/ProtonStreamApp.kt | 14 +++++++ .../io/narl/protonstream/ui/SharesScreen.kt | 18 +++++++-- .../protonstream/IncomingShareLinkTest.kt | 37 +++++++++++++++++++ 6 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 android/app/src/main/java/io/narl/protonstream/IncomingShareLink.kt create mode 100644 android/app/src/test/java/io/narl/protonstream/IncomingShareLinkTest.kt diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a49049d..cd9090d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -39,6 +39,23 @@ + + + + + + + + + + + + diff --git a/android/app/src/main/java/io/narl/protonstream/IncomingShareLink.kt b/android/app/src/main/java/io/narl/protonstream/IncomingShareLink.kt new file mode 100644 index 0000000..1976ed6 --- /dev/null +++ b/android/app/src/main/java/io/narl/protonstream/IncomingShareLink.kt @@ -0,0 +1,22 @@ +package io.narl.protonstream + +import android.content.Intent + +/** + * A Proton Drive public link, wherever in a piece of text it sits. + * + * The fragment is kept: it is the share's secret, and a link without it cannot + * be opened. Matching stops at whitespace and at the quotes and brackets a + * message app wraps a link in, which never occur in a link itself. + */ +private val SHARE_LINK = Regex("""https://drive\.proton\.me/urls/[^\s"'<>()\[\]]+""") + +/** The share link an `ACTION_VIEW` URL or an `ACTION_SEND` text carries, if any. */ +internal fun shareLinkFrom(action: String?, url: String?, text: CharSequence?): String? = when (action) { + Intent.ACTION_VIEW -> url?.let { SHARE_LINK.matchEntire(it)?.value } + Intent.ACTION_SEND -> text?.let { SHARE_LINK.find(it)?.value } + else -> null +} + +internal fun Intent.shareLink(): String? = + shareLinkFrom(action, dataString, getCharSequenceExtra(Intent.EXTRA_TEXT)) diff --git a/android/app/src/main/java/io/narl/protonstream/MainActivity.kt b/android/app/src/main/java/io/narl/protonstream/MainActivity.kt index aceade7..a177b9c 100644 --- a/android/app/src/main/java/io/narl/protonstream/MainActivity.kt +++ b/android/app/src/main/java/io/narl/protonstream/MainActivity.kt @@ -48,6 +48,9 @@ import uniffi.pstr_android.PaletteRecord class MainActivity : ComponentActivity() { private val playerHost = mutableStateOf(null) private val inPictureInPicture = mutableStateOf(false) + + /** A share link handed over by a browser or the share sheet, until the Add form takes it. */ + private val incomingShareLink = mutableStateOf(null) /** * Whether a bind is outstanding, which is what has to be unbound. * @@ -97,6 +100,9 @@ class MainActivity : ComponentActivity() { // Infallible by construction — an unreadable theme file resolves to the // shipped default rather than throwing. runCatching { NativeRuntime.storedPalette() }.onSuccess(AppearanceState::seed) + // Only on a fresh start: a recreated activity is handed the intent it + // was launched with again, and would offer to add the same share twice. + if (savedInstanceState == null) incomingShareLink.value = intent.shareLink() enableEdgeToEdge() // Re-applied on every change, not just at startup: picking Latte from // the settings page has to turn the status bar's icons dark in the same @@ -134,7 +140,12 @@ class MainActivity : ComponentActivity() { } setContent { ProtonStreamTheme { - ProtonStreamApp(playerHost.value, inPictureInPicture.value) + ProtonStreamApp( + playerHost.value, + inPictureInPicture.value, + shareLink = incomingShareLink.value, + onShareLinkTaken = { incomingShareLink.value = null }, + ) } } } @@ -159,6 +170,13 @@ class MainActivity : ComponentActivity() { } } + // singleTask: a link opened while the app is running arrives here, not in + // a second activity. + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + intent.shareLink()?.let { incomingShareLink.value = it } + } + override fun onUserLeaveHint() { super.onUserLeaveHint() // The backstop for launchers that still send this instead of arming diff --git a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt index dc4fff6..79c1817 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt @@ -87,6 +87,8 @@ private enum class Destination(val label: String, val icon: ImageVector) { fun ProtonStreamApp( playerHost: LibmpvHost? = null, inPictureInPicture: Boolean = false, + shareLink: String? = null, + onShareLinkTaken: () -> Unit = {}, model: AppViewModel = viewModel( factory = AppViewModel.Factory( LocalContext.current, @@ -118,6 +120,16 @@ fun ProtonStreamApp( PlayRequest(title, episodes, playingIndex.coerceIn(0, (episodes.size - 1).coerceAtLeast(0))) } + // A link opened from a browser or shared from another app: show the Add + // form with it filled in. A playing episode keeps going in the mini + // transport rather than covering the form. + LaunchedEffect(shareLink) { + if (shareLink != null) { + destination = Destination.Shares + selectedTitleKey = null + if (playingTitleKey != null) playerMinimized = true + } + } LaunchedEffect(state.message) { state.message?.let { snackbars.showSnackbar(it) @@ -213,6 +225,8 @@ fun ProtonStreamApp( model::repairShare, model::refreshShare, model::removeShare, + shareLink, + onShareLinkTaken, body, ) Destination.Downloads -> DownloadsScreen( diff --git a/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt index f62004a..a077718 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt @@ -45,6 +45,8 @@ internal fun SharesScreen( onRepair: (String, String, String?) -> Unit, onRefresh: (String) -> Unit, onRemove: (String) -> Unit, + incomingLink: String?, + onIncomingLinkTaken: () -> Unit, padding: PaddingValues, ) { var showAdd by remember { mutableStateOf(false) } @@ -79,7 +81,13 @@ internal fun SharesScreen( } } } - if (showAdd) AddShareDialog(onDismiss = { showAdd = false }, onAdd = onAdd) + if (showAdd || incomingLink != null) { + AddShareDialog( + initialUrl = incomingLink.orEmpty(), + onDismiss = { showAdd = false; onIncomingLinkTaken() }, + onAdd = onAdd, + ) + } repairing?.let { share -> RepairShareDialog( share = share, @@ -145,9 +153,13 @@ private fun RepairShareDialog( } @Composable -private fun AddShareDialog(onDismiss: () -> Unit, onAdd: (String, String, String?) -> Unit) { +private fun AddShareDialog( + initialUrl: String, + onDismiss: () -> Unit, + onAdd: (String, String, String?) -> Unit, +) { var name by remember { mutableStateOf("") } - var url by remember { mutableStateOf("") } + var url by remember(initialUrl) { mutableStateOf(initialUrl) } var password by remember { mutableStateOf("") } AlertDialog( onDismissRequest = onDismiss, diff --git a/android/app/src/test/java/io/narl/protonstream/IncomingShareLinkTest.kt b/android/app/src/test/java/io/narl/protonstream/IncomingShareLinkTest.kt new file mode 100644 index 0000000..e90a5af --- /dev/null +++ b/android/app/src/test/java/io/narl/protonstream/IncomingShareLinkTest.kt @@ -0,0 +1,37 @@ +package io.narl.protonstream + +import android.content.Intent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class IncomingShareLinkTest { + private val link = "https://drive.proton.me/urls/ABC123#s3cr3t" + + @Test + fun `a viewed share link is taken whole, fragment included`() { + assertEquals(link, shareLinkFrom(Intent.ACTION_VIEW, link, null)) + } + + @Test + fun `a viewed url that is not an https share link is ignored`() { + assertNull(shareLinkFrom(Intent.ACTION_VIEW, "https://drive.proton.me/u/0/", null)) + assertNull(shareLinkFrom(Intent.ACTION_VIEW, "http://drive.proton.me/urls/ABC123#s3cr3t", null)) + } + + @Test + fun `a shared message yields the link inside it`() { + assertEquals(link, shareLinkFrom(Intent.ACTION_SEND, null, "Season two is up: $link enjoy")) + assertEquals(link, shareLinkFrom(Intent.ACTION_SEND, null, "(<$link>)")) + } + + @Test + fun `a shared message without a link yields nothing`() { + assertNull(shareLinkFrom(Intent.ACTION_SEND, null, "no link here")) + } + + @Test + fun `other actions are ignored`() { + assertNull(shareLinkFrom(Intent.ACTION_MAIN, link, link)) + } +}