Draft retained Cloud video picture-in-picture for companion
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user