feat(companion): retain audio with native media controls
This commit is contained in:
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "com.archipelago.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 55
|
||||
versionName = "0.5.35"
|
||||
versionCode = 56
|
||||
versionName = "0.5.36"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<!-- Embedded FIPS mesh tunnel (ArchyVpnService) runs as a foreground service. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
@@ -67,6 +68,12 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".ui.screens.CompanionAudioService"
|
||||
android:exported="false"
|
||||
android:stopWithTask="false"
|
||||
android:foregroundServiceType="mediaPlayback" />
|
||||
|
||||
<!-- Embedded FIPS mesh node: split-tunnel VpnService (fd00::/8 only),
|
||||
configured entirely by scanning the node's pairing QR. -->
|
||||
<service
|
||||
|
||||
@@ -9,7 +9,7 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import com.archipelago.app.ui.navigation.AppNavHost
|
||||
import com.archipelago.app.ui.screens.releaseKioskWebView
|
||||
import com.archipelago.app.ui.screens.finishKioskActivity
|
||||
import com.archipelago.app.ui.theme.ArchipelagoTheme
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
@@ -56,11 +56,8 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
// Swiped out of recents (or otherwise finished) — let go of the
|
||||
// retained kiosk WebView so the next launch starts clean. Without
|
||||
// this the FIPS service keeps the process (and the static WebView)
|
||||
// alive, and "close the app" no longer restarted it. isFinishing
|
||||
// keeps config changes (rotation) on the fast reattach path.
|
||||
if (isFinishing) releaseKioskWebView()
|
||||
// Keep an authorized playing WebView owned by the media service;
|
||||
// discard ordinary dashboard state when the task is finished.
|
||||
if (isFinishing) finishKioskActivity()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.archipelago.app.ui.screens
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.SystemClock
|
||||
import android.webkit.WebView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.webkit.JavaScriptReplyProxy
|
||||
import androidx.webkit.WebMessageCompat
|
||||
import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import org.json.JSONObject
|
||||
|
||||
/** Metadata/control only: the authorized WebView owns the stream and queue. */
|
||||
internal data class CompanionAudioState(
|
||||
val session: String, val sequence: Long, val title: String,
|
||||
val playing: Boolean, val position: Double, val duration: Double,
|
||||
val previous: Boolean, val next: Boolean, val shuffle: Boolean,
|
||||
val shuffled: Boolean, val artwork: String,
|
||||
) {
|
||||
companion object {
|
||||
fun parse(value: JSONObject): CompanionAudioState {
|
||||
require(value.optInt("version") == 1 && value.getString("action") == "state")
|
||||
val session = value.getString("session")
|
||||
require(session.matches(Regex("[0-9a-f-]{36}")))
|
||||
val sequence = value.getLong("sequence")
|
||||
require(sequence >= 0 && sequence <= 9007199254740991L)
|
||||
val position = value.getDouble("position"); val duration = value.getDouble("duration")
|
||||
require(position.isFinite() && duration.isFinite() && duration in 0.0..604800.0 && position in 0.0..duration)
|
||||
val title = value.getString("title"); require(title.length <= 512)
|
||||
val artwork = value.optString("artwork", "")
|
||||
require(artwork.length <= 90000 && (artwork.isEmpty() || artwork.startsWith("data:image/jpeg;base64,")))
|
||||
return CompanionAudioState(session, sequence, title, value.getBoolean("playing"), position, duration,
|
||||
value.optBoolean("previous"), value.optBoolean("next"), value.optBoolean("shuffle"),
|
||||
value.optBoolean("shuffled"), artwork)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal object CompanionAudioBridge {
|
||||
private val bindings = java.util.WeakHashMap<WebView, Set<String>>()
|
||||
private val retired = linkedSetOf<String>()
|
||||
private var view: WebView? = null
|
||||
private var origin: String? = null
|
||||
private var reply: ((String) -> Unit)? = null
|
||||
var state: CompanionAudioState? = null
|
||||
private set
|
||||
var updatedAt: Long = 0
|
||||
private set
|
||||
fun retains(web: WebView?) = web != null && view === web && state != null
|
||||
fun attach(web: WebView, urls: List<String>) {
|
||||
if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) return
|
||||
val origins = urls.mapNotNull(::cloudVideoOrigin).toSet()
|
||||
if (origins.isEmpty()) return
|
||||
val existing = bindings[web]
|
||||
if (existing != null) {
|
||||
if (existing != origins) { bindings[web] = emptySet(); release(web) }
|
||||
return
|
||||
}
|
||||
bindings[web] = origins
|
||||
WebViewCompat.addWebMessageListener(web, "ArchipelagoAudio", origins,
|
||||
object : WebViewCompat.WebMessageListener {
|
||||
override fun onPostMessage(web: WebView, message: WebMessageCompat, source: Uri, main: Boolean, proxy: JavaScriptReplyProxy) {
|
||||
if (bindings[web] != origins) return
|
||||
val raw = runCatching { message.data }.getOrNull() ?: return
|
||||
receive(web, raw, source.toString(), main, origins) { proxy.postMessage(it) }
|
||||
}
|
||||
})
|
||||
}
|
||||
internal fun receive(web: WebView, raw: String, source: String, main: Boolean,
|
||||
origins: Set<String>, proxy: (String) -> Unit) {
|
||||
if (!cloudVideoSenderAllowed(web.url, source, origins, main)) return
|
||||
if (raw.length > 96000) return
|
||||
val data = runCatching { JSONObject(raw) }.getOrNull() ?: return
|
||||
val session = data.optString("session")
|
||||
if (data.optString("action") == "release") {
|
||||
if (web === view && session == state?.session) terminate()
|
||||
return
|
||||
}
|
||||
val incoming = runCatching { CompanionAudioState.parse(data) }.getOrNull() ?: return
|
||||
if (session in retired) return
|
||||
val old = state
|
||||
if (old != null && old.session == session) {
|
||||
if (view !== web || incoming.sequence <= old.sequence) return
|
||||
} else {
|
||||
if (!incoming.playing) return // Do not start a service for idle metadata.
|
||||
if (old != null) { command("pause"); retire(old.session) }
|
||||
}
|
||||
view = web; origin = cloudVideoOrigin(source); reply = proxy
|
||||
state = if (old != null && old.session == session && !data.has("artwork")) incoming.copy(artwork = old.artwork) else incoming; updatedAt = SystemClock.elapsedRealtime()
|
||||
runCatching {
|
||||
val service = CompanionAudioService.instance
|
||||
if (service != null) service.refresh()
|
||||
else ContextCompat.startForegroundService(web.context.applicationContext,
|
||||
Intent(web.context.applicationContext, CompanionAudioService::class.java))
|
||||
}.onFailure {
|
||||
event("error", "Background playback could not start. Reopen the companion and press Play.")
|
||||
command("pause"); terminate()
|
||||
}
|
||||
}
|
||||
private fun retire(session: String) {
|
||||
retired.add(session)
|
||||
while (retired.size > 64) retired.remove(retired.first())
|
||||
}
|
||||
private fun event(type: String, value: String? = null, position: Double? = null) {
|
||||
val current = state ?: return
|
||||
if (cloudVideoOrigin(view?.url) != origin) { terminate(); return }
|
||||
val message = JSONObject().put("version", 1).put("session", current.session).put("type", type)
|
||||
if (value != null) message.put(if (type == "error") "error" else "command", value)
|
||||
if (position != null) message.put("position", position)
|
||||
runCatching { reply?.invoke(message.toString()) }
|
||||
}
|
||||
fun command(name: String, position: Double? = null) = event("command", name, position)
|
||||
fun release(web: WebView) { if (view === web) { command("stop"); terminate() } }
|
||||
fun terminate() {
|
||||
state?.session?.let(::retire)
|
||||
state = null; view = null; origin = null; reply = null
|
||||
CompanionAudioService.instance?.finishPlayback()
|
||||
releaseDetachedKioskWebView()
|
||||
}
|
||||
fun stop() { command("stop"); terminate() }
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package com.archipelago.app.ui.screens
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.media.AudioManager
|
||||
import android.media.MediaMetadata
|
||||
import android.media.session.MediaSession
|
||||
import android.media.session.PlaybackState
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.util.Base64
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.archipelago.app.MainActivity
|
||||
|
||||
/** Foreground ownership of the existing authenticated WebView player. No stream
|
||||
* URL, auth token or cookie is copied into native playback or notifications. */
|
||||
class CompanionAudioService : Service() {
|
||||
companion object {
|
||||
internal var instance: CompanionAudioService? = null
|
||||
private const val CHANNEL = "companion-audio"
|
||||
private const val NOTIFICATION = 4056
|
||||
}
|
||||
private lateinit var media: MediaSession
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var lastArtwork = ""
|
||||
private var bitmap: Bitmap? = null
|
||||
private var finishing = false
|
||||
private val noisy = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) CompanionAudioBridge.command("pause")
|
||||
}
|
||||
}
|
||||
private val watchdog = object : Runnable {
|
||||
override fun run() {
|
||||
val state = CompanionAudioBridge.state ?: return
|
||||
val age = SystemClock.elapsedRealtime() - CompanionAudioBridge.updatedAt
|
||||
if (age > 90000) CompanionAudioBridge.stop()
|
||||
else {
|
||||
if (age > 15000) CompanionAudioBridge.command("sync")
|
||||
handler.postDelayed(this, 5000)
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun onCreate() {
|
||||
super.onCreate(); instance = this
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||
NotificationChannel(CHANNEL, "Audio playback", NotificationManager.IMPORTANCE_LOW))
|
||||
media = MediaSession(this, "Archipelago audio")
|
||||
media.setCallback(object : MediaSession.Callback() {
|
||||
override fun onPlay() = CompanionAudioBridge.command("play")
|
||||
override fun onPause() = CompanionAudioBridge.command("pause")
|
||||
override fun onStop() = CompanionAudioBridge.stop()
|
||||
override fun onSkipToNext() { if (CompanionAudioBridge.state?.next == true) CompanionAudioBridge.command("next") }
|
||||
override fun onSkipToPrevious() { if (CompanionAudioBridge.state?.previous == true) CompanionAudioBridge.command("previous") }
|
||||
override fun onSeekTo(pos: Long) {
|
||||
val duration = CompanionAudioBridge.state?.duration ?: return
|
||||
CompanionAudioBridge.command("seek", (pos / 1000.0).coerceIn(0.0, duration))
|
||||
}
|
||||
override fun onCustomAction(action: String, extras: Bundle?) {
|
||||
if (action == "shuffle" && CompanionAudioBridge.state?.shuffle == true) CompanionAudioBridge.command("shuffle")
|
||||
}
|
||||
}, handler)
|
||||
media.setFlags(MediaSession.FLAG_HANDLES_MEDIA_BUTTONS or MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS)
|
||||
media.setSessionActivity(openPlayer())
|
||||
media.isActive = true
|
||||
ContextCompat.registerReceiver(this, noisy, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY), ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
handler.postDelayed(watchdog, 5000)
|
||||
}
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val state = CompanionAudioBridge.state
|
||||
if (state == null) { finishPlayback(); return START_NOT_STICKY }
|
||||
finishing = false; instance = this
|
||||
if (intent?.action != null && intent.getStringExtra("session") == state.session) {
|
||||
when (intent.action) {
|
||||
"stop" -> CompanionAudioBridge.stop()
|
||||
"play", "pause" -> CompanionAudioBridge.command(intent.action!!)
|
||||
"next" -> if (state.next) CompanionAudioBridge.command("next")
|
||||
"previous" -> if (state.previous) CompanionAudioBridge.command("previous")
|
||||
"shuffle" -> if (state.shuffle) CompanionAudioBridge.command("shuffle")
|
||||
}
|
||||
}
|
||||
if (!finishing) refresh()
|
||||
return START_NOT_STICKY // Never reconstruct an authorized stream after process death.
|
||||
}
|
||||
private fun openPlayer() = PendingIntent.getActivity(this, 0,
|
||||
Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
private fun action(name: String, label: String, icon: Int, session: String): Notification.Action {
|
||||
val intent = Intent(this, CompanionAudioService::class.java).setAction(name).putExtra("session", session)
|
||||
val pending = PendingIntent.getService(this, name.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
return Notification.Action.Builder(icon, label, pending).build()
|
||||
}
|
||||
internal fun refresh() {
|
||||
if (finishing) return
|
||||
val state = CompanionAudioBridge.state ?: return
|
||||
if (state.artwork != lastArtwork) {
|
||||
lastArtwork = state.artwork
|
||||
bitmap = decodeArtwork(state.artwork)
|
||||
}
|
||||
val metadata = MediaMetadata.Builder().putString(MediaMetadata.METADATA_KEY_TITLE, state.title)
|
||||
.putString(MediaMetadata.METADATA_KEY_ARTIST, "Archipelago")
|
||||
.putLong(MediaMetadata.METADATA_KEY_DURATION, (state.duration * 1000).toLong())
|
||||
bitmap?.let { metadata.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, it) }
|
||||
media.setMetadata(metadata.build())
|
||||
var actions = PlaybackState.ACTION_PLAY or PlaybackState.ACTION_PAUSE or PlaybackState.ACTION_PLAY_PAUSE or PlaybackState.ACTION_STOP
|
||||
if (state.duration > 0) actions = actions or PlaybackState.ACTION_SEEK_TO
|
||||
if (state.previous) actions = actions or PlaybackState.ACTION_SKIP_TO_PREVIOUS
|
||||
if (state.next) actions = actions or PlaybackState.ACTION_SKIP_TO_NEXT
|
||||
val playback = PlaybackState.Builder().setActions(actions)
|
||||
.setState(if (state.playing) PlaybackState.STATE_PLAYING else PlaybackState.STATE_PAUSED,
|
||||
(state.position * 1000).toLong(), if (state.playing) 1f else 0f, SystemClock.elapsedRealtime())
|
||||
if (state.shuffle) playback.addCustomAction("shuffle", if (state.shuffled) "Shuffle on" else "Shuffle off", android.R.drawable.ic_menu_rotate)
|
||||
media.setPlaybackState(playback.build())
|
||||
val controls = mutableListOf<Notification.Action>()
|
||||
if (state.previous) controls.add(action("previous", "Previous", android.R.drawable.ic_media_previous, state.session))
|
||||
controls.add(action(if (state.playing) "pause" else "play", if (state.playing) "Pause" else "Play",
|
||||
if (state.playing) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play, state.session))
|
||||
if (state.next) controls.add(action("next", "Next", android.R.drawable.ic_media_next, state.session))
|
||||
val compact = controls.indices.toList().toIntArray()
|
||||
if (state.shuffle) controls.add(action("shuffle", if (state.shuffled) "Shuffle on" else "Shuffle off", android.R.drawable.ic_menu_rotate, state.session))
|
||||
controls.add(action("stop", "Stop", android.R.drawable.ic_menu_close_clear_cancel, state.session))
|
||||
val notification = Notification.Builder(this, CHANNEL)
|
||||
.setSmallIcon(android.R.drawable.ic_media_play).setContentTitle(state.title).setContentText("Archipelago")
|
||||
.setContentIntent(openPlayer()).setOnlyAlertOnce(true).setOngoing(state.playing)
|
||||
.setVisibility(Notification.VISIBILITY_PUBLIC).setCategory(Notification.CATEGORY_TRANSPORT)
|
||||
.setStyle(Notification.MediaStyle().setMediaSession(media.sessionToken).setShowActionsInCompactView(*compact))
|
||||
.setActions(*controls.toTypedArray())
|
||||
bitmap?.let { notification.setLargeIcon(it) }
|
||||
startForeground(NOTIFICATION, notification.build())
|
||||
}
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
if (CompanionAudioBridge.state?.playing != true) CompanionAudioBridge.stop()
|
||||
super.onTaskRemoved(rootIntent)
|
||||
}
|
||||
internal fun finishPlayback() {
|
||||
if (finishing) return
|
||||
finishing = true
|
||||
if (instance === this) instance = null
|
||||
stopForeground(STOP_FOREGROUND_REMOVE); stopSelf()
|
||||
}
|
||||
override fun onDestroy() {
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
runCatching { unregisterReceiver(noisy) }
|
||||
media.isActive = false; media.release(); bitmap = null
|
||||
if (instance === this) { instance = null; CompanionAudioBridge.stop() }
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun decodeArtwork(data: String): Bitmap? = runCatching {
|
||||
if (!data.startsWith("data:image/jpeg;base64,") || data.length > 90000) return null
|
||||
val bytes = Base64.decode(data.substringAfter(','), Base64.NO_WRAP)
|
||||
if (bytes.size < 4 || bytes[0] != 0xff.toByte() || bytes[1] != 0xd8.toByte() || bytes[2] != 0xff.toByte()) return null
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||
if (bounds.outWidth !in 1..512 || bounds.outHeight !in 1..512) return null
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
}.getOrNull()
|
||||
@@ -144,6 +144,29 @@ private fun openExternalUrl(context: android.content.Context, url: String) {
|
||||
* this when the task is genuinely finishing. */
|
||||
fun releaseKioskWebView() = KioskWebView.drop()
|
||||
|
||||
/** A playing session is owned by the foreground media service after task close. */
|
||||
fun finishKioskActivity() {
|
||||
val view = KioskWebView.instance ?: return
|
||||
if (!CompanionAudioBridge.retains(view)) { KioskWebView.drop(); return }
|
||||
(view.parent as? ViewGroup)?.removeView(view)
|
||||
KioskWebView.backgroundOwned = true
|
||||
KioskWebView.clearDelegates()
|
||||
view.setOnTouchListener(null)
|
||||
view.setOnApplyWindowInsetsListener(null)
|
||||
view.setDownloadListener(null)
|
||||
view.webChromeClient = null
|
||||
view.webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(web: WebView?, url: String?, favicon: Bitmap?) {
|
||||
web?.let { CompanionAudioBridge.release(it) }
|
||||
}
|
||||
}
|
||||
(view.context as? android.content.MutableContextWrapper)?.baseContext = view.context.applicationContext
|
||||
}
|
||||
|
||||
internal fun releaseDetachedKioskWebView() {
|
||||
if (KioskWebView.backgroundOwned && !CompanionAudioBridge.retains(KioskWebView.instance)) KioskWebView.drop()
|
||||
}
|
||||
|
||||
/** Restart the app in place: throw away the retained page and relaunch the
|
||||
* task from scratch. The mesh/VPN service is deliberately left running — this
|
||||
* is the "give me a clean app" button (hub menu), not a process kill. */
|
||||
@@ -294,6 +317,7 @@ private fun isSameHost(url: String, base: String): Boolean {
|
||||
data class InAppLaunch(val url: String, val icon: String? = null, val name: String? = null)
|
||||
|
||||
private object KioskWebView {
|
||||
var backgroundOwned = false
|
||||
var instance: WebView? = null
|
||||
var url: String? = null
|
||||
|
||||
@@ -306,13 +330,19 @@ private object KioskWebView {
|
||||
var onQrStatus: (String, Boolean) -> Unit = { _, _ -> }
|
||||
var onQrClose: () -> Unit = {}
|
||||
|
||||
fun clearDelegates() {
|
||||
onRouteOutbound = {}; onOpenInApp = {}; onQrOpen = {}
|
||||
onQrStatus = { _, _ -> }; onQrClose = {}
|
||||
}
|
||||
fun drop() {
|
||||
instance?.let {
|
||||
val old = instance
|
||||
instance = null; url = null; backgroundOwned = false
|
||||
clearDelegates()
|
||||
old?.let {
|
||||
CompanionAudioBridge.release(it)
|
||||
(it.parent as? ViewGroup)?.removeView(it)
|
||||
it.destroy()
|
||||
}
|
||||
instance = null
|
||||
url = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -901,7 +931,9 @@ fun WebViewScreen(
|
||||
// stale closures from the previous visit are replaced.
|
||||
if (KioskWebView.url != serverUrl) KioskWebView.drop()
|
||||
val reused = KioskWebView.instance
|
||||
(reused ?: WebView(context)).apply {
|
||||
(reused ?: WebView(android.content.MutableContextWrapper(context))).apply {
|
||||
(this.context as? android.content.MutableContextWrapper)?.baseContext = context
|
||||
KioskWebView.backgroundOwned = false
|
||||
(parent as? ViewGroup)?.removeView(this)
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
@@ -917,6 +949,7 @@ fun WebViewScreen(
|
||||
|
||||
applyArchipelagoSettings()
|
||||
cloudPip.attach(this, listOfNotNull(serverUrl, meshFallbackUrl))
|
||||
CompanionAudioBridge.attach(this, listOfNotNull(serverUrl, meshFallbackUrl))
|
||||
setDownloadListener(downloads)
|
||||
settings.apply {
|
||||
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
|
||||
@@ -1091,6 +1124,7 @@ fun WebViewScreen(
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
CompanionAudioBridge.release(view ?: return)
|
||||
cloudPip.reset()
|
||||
isLoading = true
|
||||
hasError = false
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.archipelago.app.ui.screens
|
||||
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.Before
|
||||
import org.robolectric.Shadows
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.Robolectric
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(manifest = Config.NONE, sdk = [28, 35])
|
||||
class CompanionAudioTest {
|
||||
@Before fun compatReceiverPermission() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
// The real merged manifest contributes this AndroidX permission.
|
||||
Shadows.shadowOf(app).grantPermissions(app.packageName + ".DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION")
|
||||
}
|
||||
@Test fun actualBridgeBindsOriginSessionAndSequenceAndReleasesStoppedPlayback() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val web = android.webkit.WebView(app)
|
||||
web.loadUrl("https://node.test/cloud")
|
||||
val events = mutableListOf<JSONObject>()
|
||||
fun send(message: JSONObject, origin: String = "https://node.test", main: Boolean = true) {
|
||||
CompanionAudioBridge.receive(web, message.toString(), origin, main, setOf("https://node.test")) { events.add(JSONObject(it)) }
|
||||
}
|
||||
try {
|
||||
send(state(), main = false); assertNull(CompanionAudioBridge.state)
|
||||
send(state(), origin = "https://foreign.test"); assertNull(CompanionAudioBridge.state)
|
||||
send(state()); assertTrue(CompanionAudioBridge.retains(web))
|
||||
send(state().put("sequence", 0).put("playing", false)); assertTrue(CompanionAudioBridge.state!!.playing)
|
||||
CompanionAudioBridge.command("seek", 32.0)
|
||||
assertEquals("seek", events.last().getString("command")); assertEquals(32.0, events.last().getDouble("position"), 0.0)
|
||||
send(state().put("sequence", 2).put("playing", false)); assertFalse(CompanionAudioBridge.state!!.playing)
|
||||
CompanionAudioBridge.stop(); assertNull(CompanionAudioBridge.state)
|
||||
assertEquals("stop", events.last().getString("command"))
|
||||
send(state().put("sequence", 3)); assertNull(CompanionAudioBridge.state) // delayed state cannot revive a stopped session
|
||||
} finally { CompanionAudioBridge.release(web); web.destroy() }
|
||||
}
|
||||
@Test fun liveBridgeBuildsForegroundMediaNotificationForSameSession() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val web = android.webkit.WebView(app); web.loadUrl("https://node.test/cloud")
|
||||
val payload = state().put("session", "22345678-1234-1234-1234-123456789abc")
|
||||
CompanionAudioBridge.receive(web, payload.toString(), "https://node.test", true, setOf("https://node.test")) {}
|
||||
val lifecycle = Robolectric.buildService(CompanionAudioService::class.java).create()
|
||||
try {
|
||||
lifecycle.get().onStartCommand(null, 0, 1)
|
||||
val notification = Shadows.shadowOf(lifecycle.get()).lastForegroundNotification
|
||||
assertNotNull(notification)
|
||||
assertEquals("Current song", notification.extras.getString(android.app.Notification.EXTRA_TITLE))
|
||||
assertEquals(5, notification.actions.size)
|
||||
assertNotNull(notification.extras.getParcelable<android.media.session.MediaSession.Token>(android.app.Notification.EXTRA_MEDIA_SESSION))
|
||||
lifecycle.get().onTaskRemoved(null)
|
||||
assertTrue(CompanionAudioBridge.retains(web))
|
||||
} finally { CompanionAudioBridge.release(web); lifecycle.destroy(); web.destroy() }
|
||||
}
|
||||
|
||||
private fun state() = JSONObject("""{"version":1,"action":"state","session":"12345678-1234-1234-1234-123456789abc","sequence":1,"title":"Current song","playing":true,"position":10,"duration":120,"previous":true,"next":true,"shuffle":true,"shuffled":false}""")
|
||||
@Test fun malformedOrUnboundedMetadataCannotBecomeNativePlayback() {
|
||||
for ((key, value) in listOf("version" to 2, "session" to "foreign", "sequence" to -1,
|
||||
"position" to -1, "position" to 121, "duration" to 604801, "title" to "x".repeat(513),
|
||||
"artwork" to "https://node.test/protected?token=secret")) {
|
||||
assertTrue("Must reject $key", runCatching { CompanionAudioState.parse(state().put(key, value)) }.isFailure)
|
||||
}
|
||||
}
|
||||
@Test fun currentMetadataPreservesPauseSeekAndQueueCapabilities() {
|
||||
val parsed = CompanionAudioState.parse(state().put("playing", false).put("shuffled", true))
|
||||
assertFalse(parsed.playing); assertTrue(parsed.shuffled)
|
||||
assertTrue(parsed.previous && parsed.next && parsed.shuffle)
|
||||
assertEquals(10.0, parsed.position, 0.0)
|
||||
assertEquals(120.0, parsed.duration, 0.0)
|
||||
assertEquals("", parsed.artwork)
|
||||
}
|
||||
@Test fun artworkNeverFetchesProtectedUrlsAndRejectsInvalidBytes() {
|
||||
assertNull(decodeArtwork("https://node.test/protected"))
|
||||
assertNull(decodeArtwork("data:image/jpeg;base64,AAAA"))
|
||||
assertNull(decodeArtwork("data:image/jpeg;base64," + "A".repeat(90000)))
|
||||
}
|
||||
@Test fun serviceRestartWithoutLiveAuthorizedSessionDoesNotResumePlayback() {
|
||||
val lifecycle = Robolectric.buildService(CompanionAudioService::class.java).create()
|
||||
try {
|
||||
assertEquals(android.app.Service.START_NOT_STICKY, lifecycle.get().onStartCommand(null, 0, 1))
|
||||
assertNull(CompanionAudioBridge.state)
|
||||
assertNull(CompanionAudioService.instance)
|
||||
} finally { lifecycle.destroy() }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user