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