mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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.
This commit is contained in:
1 parent
47b33cdb91
commit
8a1807c262
6 files changed
+124
-4
No files matched your search
@@ -39,6 +39,23 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!--
|
||||
Proton Drive public links. Not autoVerify: the domain is Proton's,
|
||||
so Android treats these as unverified and only opens them here
|
||||
once "Open supported links" is allowed in the app's settings.
|
||||
Sharing a link to the app works without that.
|
||||
-->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="drive.proton.me" android:pathPrefix="/urls/" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
|
||||
@@ -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))
|
||||
@@ -48,6 +48,9 @@ import uniffi.pstr_android.PaletteRecord
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val playerHost = mutableStateOf<NativeMpvHost?>(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<String?>(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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user