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