Draft retained Cloud video picture-in-picture for companion

This commit is contained in:
archipelago
2026-10-07 11:31:10 -04:00
parent 4d938cd5aa
commit 0d8decb366
10 changed files with 418 additions and 4 deletions
+3 -1
View File
@@ -37,11 +37,13 @@
<activity
android:name=".MainActivity"
android:supportsPictureInPicture="true"
android:exported="true"
android:launchMode="singleTask"
android:resizeableActivity="true"
android:theme="@style/Theme.Archipelago.Splash"
android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden">
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -14,6 +14,13 @@ import com.archipelago.app.ui.theme.ArchipelagoTheme
import kotlinx.coroutines.flow.MutableStateFlow
class MainActivity : ComponentActivity() {
internal var cloudVideoPip: com.archipelago.app.ui.screens.CloudVideoPip? = null
override fun onPictureInPictureModeChanged(active: Boolean, config: android.content.res.Configuration) {
super.onPictureInPictureModeChanged(active, config)
cloudVideoPip?.modeChanged(active)
}
override fun onStop() { cloudVideoPip?.stopped(); super.onStop() }
// Pairing deep link (archipelago://pair?...) from the launch intent or a
// later one (launchMode=singleTask). Consumed by AppNavHost.
@@ -0,0 +1,193 @@
package com.archipelago.app.ui.screens
import android.app.PendingIntent
import android.app.PictureInPictureParams
import android.app.RemoteAction
import android.content.BroadcastReceiver
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.graphics.Rect
import android.graphics.drawable.Icon
import android.net.Uri
import android.util.Rational
import android.webkit.WebView
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.webkit.JavaScriptReplyProxy
import androidx.webkit.WebMessageCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import com.archipelago.app.MainActivity
import org.json.JSONObject
import java.net.URI
import java.util.UUID
internal fun cloudVideoOrigin(value: String?): String? = runCatching {
val uri = URI(value ?: return null)
val scheme = uri.scheme?.lowercase() ?: return null
if (scheme !in setOf("http", "https") || uri.userInfo != null) return null
val host = uri.host?.lowercase() ?: return null
val port = uri.port.takeUnless { it == -1 || it == if (scheme == "https") 443 else 80 }
"$scheme://$host${port?.let { ":$it" } ?: ""}"
}.getOrNull()
internal fun cloudVideoSenderAllowed(currentUrl: String?, source: String, allowed: Set<String>, mainFrame: Boolean): Boolean {
val origin = cloudVideoOrigin(source)
return mainFrame && origin != null && origin in allowed && cloudVideoOrigin(currentUrl) == origin
}
/** Only the dashboard's origin-restricted main-frame channel can arm Cloud PiP.
* No URL, cookies, bearer token or second media player enters native storage. */
internal class CloudVideoPip(private val activity: MainActivity?, private val fullscreen: WebViewFullscreen) {
private class Binding(val origins: Set<String>, var owner: java.lang.ref.WeakReference<CloudVideoPip>)
companion object {
private val bindings = java.util.WeakHashMap<WebView, Binding>()
}
private var webView: WebView? = null
private var session: String? = null
private var reply: JavaScriptReplyProxy? = null
private var playing = false
private var ratio = Rational(16, 9)
private var entered = false
private var registered = false
private val action = "com.archipelago.app.CLOUD_VIDEO_PIP.${UUID.randomUUID()}"
private val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (!entered || intent?.action != action || intent.getStringExtra("session") != session) return
event("command", if (playing) "pause" else "play")
}
}
private fun supported() = activity?.packageManager?.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) == true
private fun event(state: String, command: String? = null) {
val message = JSONObject().put("type", "event").put("session", session).put("state", state)
if (command != null) message.put("command", command)
runCatching { reply?.postMessage(message.toString()) }
}
private fun params(): PictureInPictureParams {
val owner = requireNotNull(activity)
val intent = Intent(action).setPackage(owner.packageName).putExtra("session", session)
val pending = PendingIntent.getBroadcast(owner, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val control = RemoteAction(Icon.createWithResource(owner, if (playing) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play),
if (playing) "Pause" else "Play", if (playing) "Pause video" else "Play video", pending)
val bounds = Rect()
val builder = PictureInPictureParams.Builder().setAspectRatio(ratio).setActions(listOf(control))
if (fullscreen.bounds(bounds)) builder.setSourceRectHint(bounds)
return builder.build()
}
fun attach(view: WebView, allowedUrls: List<String>) {
if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) return
val origins = allowedUrls.mapNotNull(::cloudVideoOrigin).toSet()
if (origins.isEmpty()) return
webView = view
val existing = bindings[view]
if (existing != null) {
// Rebind the retained document; removing/re-adding a listener would
// require a reload and strand the page's existing JS bridge.
if (existing.origins != origins) {
existing.owner.clear(); webView = null
return // The page receives a bounded unavailable response; reconnect reloads policy.
}
existing.owner = java.lang.ref.WeakReference(this)
return
}
val binding = Binding(origins, java.lang.ref.WeakReference(this))
bindings[view] = binding
WebViewCompat.addWebMessageListener(view, "ArchipelagoCloudVideo", origins,
object : WebViewCompat.WebMessageListener {
override fun onPostMessage(web: WebView, message: WebMessageCompat, sourceOrigin: Uri, isMainFrame: Boolean, proxy: JavaScriptReplyProxy) {
binding.owner.get()?.receive(web, message, sourceOrigin, isMainFrame, proxy, binding.origins)
}
})
}
private fun receive(web: WebView, message: WebMessageCompat, sourceOrigin: Uri, isMainFrame: Boolean, proxy: JavaScriptReplyProxy, origins: Set<String>) {
if (web !== webView || !cloudVideoSenderAllowed(web.url, sourceOrigin.toString(), origins, isMainFrame)) return
val raw = runCatching { message.data }.getOrNull() ?: return
if (raw.length > 2048) return
val request = runCatching { JSONObject(raw) }.getOrNull() ?: return
val id = request.optString("id")
if (!id.matches(Regex("[0-9a-f-]{36}"))) return
val response = JSONObject().put("id", id)
runCatching {
when (request.optString("action")) {
"capabilities" -> response.put("supported", supported()).put("version", 1)
"arm" -> {
check(supported()) { "Picture-in-picture is unavailable on this device." }
check(!entered) { "A video is already in picture-in-picture." }
val width = request.optInt("width", 0); val height = request.optInt("height", 0)
check(width in 1..16384 && height in 1..16384) { "Video dimensions are not ready." }
ratio = Rational(((width.toDouble() / height).coerceIn(1.0 / 2.39, 2.39) * 10000).toInt(), 10000)
session = id; reply = proxy; playing = request.optBoolean("playing", false)
response.put("session", id)
}
"enter" -> {
check(request.optString("session") == session && session != null && fullscreen.isActive) { "Open the selected Cloud video fullscreen first." }
val owner = requireNotNull(activity)
if (!registered) {
ContextCompat.registerReceiver(owner, receiver, IntentFilter(action), ContextCompat.RECEIVER_NOT_EXPORTED)
registered = true
}
check(owner.enterPictureInPictureMode(params())) { "Picture-in-picture is disabled or unavailable. Check this app's system setting." }
entered = true
response.put("active", true)
}
"state" -> {
check(request.optString("session") == session && session != null) { "Video session changed." }
playing = request.optBoolean("playing", false)
if (entered) activity?.setPictureInPictureParams(params())
}
"release" -> {
check(request.optString("session") == session && session != null) { "Video session changed." }
reset()
}
else -> error("Unsupported Cloud video action.")
}
}.onFailure { response.put("error", it.message ?: "Picture-in-picture is unavailable.") }
proxy.postMessage(response.toString())
}
fun modeChanged(active: Boolean) {
if (active) { entered = true; event("entered") }
else if (entered) {
entered = false
event("restored")
// Restoring the viewer is not a stop request. Retire the native
// session before Chromium's hide callback can recursively reset it.
session = null; reply = null
fullscreen.hide()
}
}
fun stopped() { if (entered) { event("command", "pause"); event("closed") } }
fun reset() {
event("command", "pause")
event("closed")
session = null; reply = null
fullscreen.hide()
}
fun dispose() {
reset()
if (registered) runCatching { activity?.unregisterReceiver(receiver) }
registered = false
webView?.let { view -> bindings[view]?.takeIf { it.owner.get() === this }?.owner?.clear() }
webView = null
}
}
private fun Context.pipActivity(): MainActivity? = when(this) {
is MainActivity -> this
is ContextWrapper -> baseContext.takeIf { it !== this }?.pipActivity()
else -> null
}
@Composable
internal fun rememberCloudVideoPip(fullscreen: WebViewFullscreen): CloudVideoPip {
val owner = LocalContext.current.pipActivity()
val pip = remember(owner, fullscreen) { CloudVideoPip(owner, fullscreen) }
DisposableEffect(pip) {
owner?.cloudVideoPip = pip
onDispose { if (owner != null && owner.cloudVideoPip === pip) owner.cloudVideoPip = null; pip.dispose() }
}
return pip
}
@@ -29,6 +29,8 @@ internal class WebViewFullscreen(private val activity: ComponentActivity?) {
private var overlay: FrameLayout? = null
private var callback: WebChromeClient.CustomViewCallback? = null
private var back: OnBackPressedCallback? = null
val isActive: Boolean get() = overlay != null
fun bounds(rect: android.graphics.Rect): Boolean = overlay?.getGlobalVisibleRect(rect) == true
private var visibleBars = 0
private var originalBehavior = 0
@@ -71,6 +73,22 @@ internal class WebViewFullscreen(private val activity: ComponentActivity?) {
fun hide() {
val host = overlay ?: return
if (activity?.isInPictureInPictureMode == true) {
// A navigation/logout/custom-view exit must never expose the node
// management UI in the small OS window. Keep a black cover until
// the activity leaves PiP; the ordinary hide then removes it.
val notify = callback; callback = null
back?.remove(); back = null
host.keepScreenOn = false; host.removeAllViews()
host.addView(android.widget.TextView(host.context).apply {
text = "Video paused. Expand to return."
setTextColor(Color.WHITE)
gravity = android.view.Gravity.CENTER
contentDescription = "Video paused. Use picture-in-picture controls to expand or close."
}, FrameLayout.LayoutParams(-1, -1))
notify?.onCustomViewHidden()
return
}
// Clear first: Chromium may synchronously call onHideCustomView again.
overlay = null
val notify = callback
@@ -612,6 +612,7 @@ fun WebViewScreen(
meshFallbackUrl: String? = null,
) {
val fullscreen = rememberWebViewFullscreen()
val cloudPip = rememberCloudVideoPip(fullscreen)
val downloads = rememberWebViewDownloads()
var isLoading by remember { mutableStateOf(true) }
// First kiosk load (often over the FIPS mesh) gets the full branded
@@ -915,6 +916,7 @@ fun WebViewScreen(
cookieManager.setAcceptThirdPartyCookies(this, true)
applyArchipelagoSettings()
cloudPip.attach(this, listOfNotNull(serverUrl, meshFallbackUrl))
setDownloadListener(downloads)
settings.apply {
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
@@ -1089,6 +1091,7 @@ fun WebViewScreen(
webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
cloudPip.reset()
isLoading = true
hasError = false
// New document — the injected safe-area style is
@@ -1188,7 +1191,7 @@ fun WebViewScreen(
fullscreen.show(view, callback)
}
override fun onHideCustomView() = fullscreen.hide()
override fun onHideCustomView() { cloudPip.reset(); fullscreen.hide() }
override fun onProgressChanged(view: WebView?, newProgress: Int) {
loadProgress = newProgress
@@ -0,0 +1,26 @@
package com.archipelago.app.ui.screens
import org.junit.Assert.*
import org.junit.Test
class CloudVideoPipTest {
@Test fun nativeChannelRejectsSiblingFrameAndStaleOrForeignPage() {
val allowed = setOf("https://node.test", "http://[fd00::1]")
assertTrue(cloudVideoSenderAllowed("https://node.test/cloud", "https://node.test", allowed, true))
assertFalse(cloudVideoSenderAllowed("https://node.test/cloud", "https://node.test", allowed, false))
assertFalse(cloudVideoSenderAllowed("https://other.test", "https://node.test", allowed, true))
assertFalse(cloudVideoSenderAllowed("https://node.test:7778", "https://node.test:7778", allowed, true))
assertFalse(cloudVideoSenderAllowed("https://node.test", "http://node.test", allowed, true))
}
@Test fun exactOriginIncludesSchemeAndNonDefaultPort() {
assertEquals("https://node.test", cloudVideoOrigin("https://NODE.test:443/cloud"))
assertEquals("http://node.test:8080", cloudVideoOrigin("http://node.test:8080/cloud?file=video"))
assertEquals("http://[fd00::1]", cloudVideoOrigin("http://[fd00::1]/cloud"))
assertNotEquals(cloudVideoOrigin("https://node.test"), cloudVideoOrigin("http://node.test"))
assertNotEquals(cloudVideoOrigin("https://node.test"), cloudVideoOrigin("https://node.test:7778"))
}
@Test fun unsupportedAndCredentialOriginsCannotReceiveBridge() {
for (url in listOf("javascript:alert(1)", "file:///video", "data:text/plain,video", "https://user:password@node.test/video", "not-a-url")) assertNull(cloudVideoOrigin(url))
}
}
@@ -13,6 +13,25 @@ import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, sdk = [28, 35])
class WebViewFullscreenTest {
@Test fun closingVideoInPipKeepsOpaqueCoverUntilActivityReturns() {
val lifecycle = Robolectric.buildActivity(ComponentActivity::class.java).setup()
try {
val activity = lifecycle.get()
val fullscreen = WebViewFullscreen(activity)
val video = View(activity)
var hidden = 0
fullscreen.show(video) { hidden++ }
assertTrue(activity.enterPictureInPictureMode(android.app.PictureInPictureParams.Builder().build()))
assertTrue(activity.isInPictureInPictureMode)
fullscreen.hide()
assertNull(video.parent)
assertTrue(fullscreen.isActive)
assertEquals(1, hidden)
fullscreen.hide()
assertEquals(1, hidden)
} finally { lifecycle.pause().stop().destroy() }
}
@Test fun backExitsFullscreenWithoutFinishingActivityAndNotifiesOnce() {
val lifecycle = Robolectric.buildActivity(ComponentActivity::class.java).setup()
try {