diff --git a/Android/app/src/main/AndroidManifest.xml b/Android/app/src/main/AndroidManifest.xml index 335a8837..14b4895f 100644 --- a/Android/app/src/main/AndroidManifest.xml +++ b/Android/app/src/main/AndroidManifest.xml @@ -37,11 +37,13 @@ + android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden"> diff --git a/Android/app/src/main/java/com/archipelago/app/MainActivity.kt b/Android/app/src/main/java/com/archipelago/app/MainActivity.kt index 7456061f..025c006a 100644 --- a/Android/app/src/main/java/com/archipelago/app/MainActivity.kt +++ b/Android/app/src/main/java/com/archipelago/app/MainActivity.kt @@ -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. diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/CloudVideoPip.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/CloudVideoPip.kt new file mode 100644 index 00000000..16898ceb --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/CloudVideoPip.kt @@ -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, 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, var owner: java.lang.ref.WeakReference) + companion object { + private val bindings = java.util.WeakHashMap() + } + 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) { + 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) { + 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 +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewFullscreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewFullscreen.kt index 5b9c2e91..d5fad1be 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewFullscreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewFullscreen.kt @@ -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 diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt index dea0e713..ec6dcb13 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt @@ -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 diff --git a/Android/app/src/test/java/com/archipelago/app/ui/screens/CloudVideoPipTest.kt b/Android/app/src/test/java/com/archipelago/app/ui/screens/CloudVideoPipTest.kt new file mode 100644 index 00000000..a73b84cb --- /dev/null +++ b/Android/app/src/test/java/com/archipelago/app/ui/screens/CloudVideoPipTest.kt @@ -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)) + } +} diff --git a/Android/app/src/test/java/com/archipelago/app/ui/screens/WebViewFullscreenTest.kt b/Android/app/src/test/java/com/archipelago/app/ui/screens/WebViewFullscreenTest.kt index 59e32775..bee9190f 100644 --- a/Android/app/src/test/java/com/archipelago/app/ui/screens/WebViewFullscreenTest.kt +++ b/Android/app/src/test/java/com/archipelago/app/ui/screens/WebViewFullscreenTest.kt @@ -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 { diff --git a/neode-ui/src/components/cloud/MediaLightbox.vue b/neode-ui/src/components/cloud/MediaLightbox.vue index 3221804e..4cef6cf6 100644 --- a/neode-ui/src/components/cloud/MediaLightbox.vue +++ b/neode-ui/src/components/cloud/MediaLightbox.vue @@ -31,16 +31,18 @@ + {{ companionPip.error.value }}