fix(runtime): harden application service boundaries

This commit is contained in:
edde746
2026-07-24 03:46:46 +02:00
parent 658da37b48
commit e0bf66eea8
309 changed files with 32574 additions and 4369 deletions
+12
View File
@@ -88,6 +88,18 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider_paths" />
</provider>
<provider
android:name=".watchnext.SystemShelfArtworkProvider"
android:authorities="com.edde746.plezy.systemshelf.artwork"
android:exported="false"
android:grantUriPermissions="true" />
<receiver
android:name=".watchnext.SystemShelfUpdateReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
@@ -6,6 +6,8 @@ import android.content.Context
import android.util.Log
import com.edde746.plezy.libass.media.AssHandler
import com.edde746.plezy.mpv.MpvPlayerCore
import com.edde746.plezy.mpv.completeMpvPropertyNotInitialized
import com.edde746.plezy.mpv.completeMpvPropertyResult
import com.edde746.plezy.shared.MpvContentUriResolver
import com.edde746.plezy.shared.PlayerChannelBinding
import io.flutter.embedding.engine.plugins.FlutterPlugin
@@ -336,24 +338,64 @@ class ExoPlayerPlugin :
}
}
private fun handlePlay(result: MethodChannel.Result) {
activity?.runOnUiThread {
if (usingMpvFallback) {
mpvCore?.setProperty("pause", "no")
} else {
playerCore?.play()
private fun handleFallbackMpvProperty(
name: String,
value: String,
result: MethodChannel.Result,
successValue: Any? = null
) {
val currentActivity = activity
val core = mpvCore
if (currentActivity == null || core?.isInitialized != true) {
completeMpvPropertyNotInitialized(result)
return
}
val generation = sessionGeneration
currentActivity.runOnUiThread {
if (
!usingMpvFallback ||
generation != sessionGeneration ||
activity !== currentActivity ||
mpvCore !== core ||
!core.isInitialized
) {
completeMpvPropertyNotInitialized(result)
return@runOnUiThread
}
core.setProperty(name, value) { outcome ->
val currentOutcome = if (
usingMpvFallback &&
generation == sessionGeneration &&
activity === currentActivity &&
mpvCore === core
) {
outcome
} else {
Result.failure(IllegalStateException("MPV fallback unavailable"))
}
completeMpvPropertyResult(result, currentOutcome, successValue)
}
}
}
private fun handlePlay(result: MethodChannel.Result) {
if (usingMpvFallback) {
handleFallbackMpvProperty("pause", "no", result)
return
}
activity?.runOnUiThread {
playerCore?.play()
result.success(null)
} ?: result.success(null)
}
private fun handlePause(result: MethodChannel.Result) {
if (usingMpvFallback) {
handleFallbackMpvProperty("pause", "yes", result)
return
}
activity?.runOnUiThread {
if (usingMpvFallback) {
mpvCore?.setProperty("pause", "yes")
} else {
playerCore?.pause()
}
playerCore?.pause()
result.success(null)
} ?: result.success(null)
}
@@ -397,12 +439,12 @@ class ExoPlayerPlugin :
return
}
if (usingMpvFallback) {
handleFallbackMpvProperty("volume", volume.toString(), result)
return
}
activity?.runOnUiThread {
if (usingMpvFallback) {
mpvCore?.setProperty("volume", volume.toString())
} else {
playerCore?.setVolume(volume / 100f) // Convert 0-100 to 0-1
}
playerCore?.setVolume(volume / 100f) // Convert 0-100 to 0-1
result.success(null)
} ?: result.success(null)
}
@@ -415,12 +457,12 @@ class ExoPlayerPlugin :
return
}
if (usingMpvFallback) {
handleFallbackMpvProperty("speed", rate.toString(), result)
return
}
activity?.runOnUiThread {
if (usingMpvFallback) {
mpvCore?.setProperty("speed", rate.toString())
} else {
playerCore?.setPlaybackSpeed(rate)
}
playerCore?.setPlaybackSpeed(rate)
result.success(null)
} ?: result.success(null)
}
@@ -433,13 +475,13 @@ class ExoPlayerPlugin :
return
}
if (usingMpvFallback) {
// After fallback, track IDs come from mpv's track-list (already 1-indexed)
handleFallbackMpvProperty("aid", trackId, result)
return
}
activity?.runOnUiThread {
if (usingMpvFallback) {
// After fallback, track IDs come from mpv's track-list (already 1-indexed)
mpvCore?.setProperty("aid", trackId)
} else {
playerCore?.selectAudioTrack(trackId)
}
playerCore?.selectAudioTrack(trackId)
result.success(null)
} ?: result.success(null)
}
@@ -448,12 +490,12 @@ class ExoPlayerPlugin :
val trackId = call.argument<String>("trackId")
// trackId can be null or "no" to disable subtitles
if (usingMpvFallback) {
handleFallbackMpvProperty("sid", trackId ?: "no", result)
return
}
activity?.runOnUiThread {
if (usingMpvFallback) {
mpvCore?.setProperty("sid", trackId ?: "no")
} else {
playerCore?.selectSubtitleTrack(trackId)
}
playerCore?.selectSubtitleTrack(trackId)
result.success(null)
} ?: result.success(null)
}
@@ -705,8 +747,7 @@ class ExoPlayerPlugin :
val audioSpdif = if (enabled) "ac3,eac3,dts,dts-hd,truehd" else ""
pendingMpvProperties["audio-spdif"] = audioSpdif
if (usingMpvFallback) {
mpvCore?.setProperty("audio-spdif", audioSpdif)
result.success(true)
handleFallbackMpvProperty("audio-spdif", audioSpdif, result, true)
return
}
activity?.runOnUiThread {
@@ -724,24 +765,23 @@ class ExoPlayerPlugin :
return
}
// Apply sync offsets to ExoPlayer when active
if (!usingMpvFallback) {
when (name) {
"audio-delay" -> playerCore?.setAudioDelay(value.toDoubleOrNull() ?: 0.0)
"sub-delay" -> playerCore?.setSubtitleDelay(value.toDoubleOrNull() ?: 0.0)
// mpv semantics mirrored on the libass overlay: anchor non-positioned ASS
// events to the visible screen (Dart sets 'yes' for cover mode / zoom > 1)
"sub-ass-force-margins" -> playerCore?.setAssForceMargins(value == "yes")
"force-seekable" -> playerCore?.setForceSeekable(value == "yes")
}
if (usingMpvFallback) {
handleFallbackMpvProperty(name, value, result)
return
}
if (usingMpvFallback) {
mpvCore?.setProperty(name, value)
} else {
// Store for later application if ExoPlayer falls back to MPV
pendingMpvProperties[name] = value
// Apply sync offsets to ExoPlayer when active
when (name) {
"audio-delay" -> playerCore?.setAudioDelay(value.toDoubleOrNull() ?: 0.0)
"sub-delay" -> playerCore?.setSubtitleDelay(value.toDoubleOrNull() ?: 0.0)
// mpv semantics mirrored on the libass overlay: anchor non-positioned ASS
// events to the visible screen (Dart sets 'yes' for cover mode / zoom > 1)
"sub-ass-force-margins" -> playerCore?.setAssForceMargins(value == "yes")
"force-seekable" -> playerCore?.setForceSeekable(value == "yes")
}
// Before fallback this is queue acceptance, not a completed MPV write.
pendingMpvProperties[name] = value
result.success(null)
}
@@ -867,7 +907,11 @@ class ExoPlayerPlugin :
}
for ((propName, propValue) in pendingProps) {
core.setProperty(propName, propValue)
core.setProperty(propName, propValue) { outcome ->
if (outcome.isFailure) {
Log.w(TAG, "Failed to replay queued MPV property")
}
}
}
// Re-observe exactly what Dart registered via observeProperty, so the
@@ -35,10 +35,19 @@ import kotlinx.coroutines.sync.withLock
* configured before init to never open a video output (`vid=no`,
* `force-window=no`, `audio-display=no`, plus `gapless-audio=weak`).
*/
class MpvPlayerCore(
class MpvPlayerCore private constructor(
private val context: Context,
private val audioOnly: Boolean = false
private val audioOnly: Boolean,
private val propertyWriterOverride: (suspend (String, String) -> Unit)?,
initializedForTesting: Boolean
) : SurfaceHolder.Callback {
constructor(context: Context, audioOnly: Boolean = false) : this(context, audioOnly, null, false)
internal constructor(
context: Context,
audioOnly: Boolean,
propertyWriter: (suspend (String, String) -> Unit)?
) : this(context, audioOnly, propertyWriter, true)
companion object {
private const val TAG = "MpvPlayerCore"
@@ -71,6 +80,10 @@ class MpvPlayerCore(
var isInitialized: Boolean = false
private set
init {
if (initializedForTesting) isInitialized = true
}
@Volatile private var player: MpvPlayer? = null
private var scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val endFileDiagnostics = MpvEndFileDiagnostics()
@@ -691,43 +704,66 @@ class MpvPlayerCore(
// Public API
fun setProperty(name: String, value: String, onComplete: ((Boolean) -> Unit)? = null) {
fun setProperty(name: String, value: String, onComplete: ((Result<Unit>) -> Unit)? = null) {
if (!isInitialized || disposing || !scope.isActive) {
onComplete?.invoke(false)
onComplete?.invoke(Result.failure(IllegalStateException("MPV core unavailable")))
return
}
if (name == "pause") {
val paused = normalizePauseValue(value)
if (paused == true) {
cachedPaused = true
pausedForSurfaceLoss = false
resumeBlockedByPublicPause = true
deferredResumeRequested = false
Log.d(TAG, "Public pause state updated: paused=true")
} else if (paused == false) {
val paused = if (name == "pause") normalizePauseValue(value) else null
if (paused == false && !hasReadyVideoOutput()) {
runOnMain {
if (!isInitialized || disposing || !scope.isActive) {
onComplete?.invoke(Result.failure(CancellationException("MPV core unavailable")))
return@runOnMain
}
resumeBlockedByPublicPause = false
if (!hasReadyVideoOutput()) {
deferredResumeRequested = true
Log.d(TAG, "Deferring public resume until video output is ready")
onComplete?.invoke(true)
return
}
cachedPaused = false
pausedForSurfaceLoss = false
Log.d(TAG, "Public pause state updated: paused=false")
deferredResumeRequested = true
Log.d(TAG, "Deferring public resume until video output is ready")
onComplete?.invoke(Result.success(Unit))
}
return
}
scope.launch(mpvWriteDispatcher) {
var success = false
try {
player?.setProperty(name, value)
success = true
} catch (e: Exception) {
Log.w(TAG, "setProperty($name) failed", e)
} finally {
withContext(NonCancellable + Dispatchers.Main) {
onComplete?.invoke(success)
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
val writeResult = try {
val writer = propertyWriterOverride
if (writer != null) {
writer(name, value)
} else {
val currentPlayer = player ?: throw IllegalStateException("MPV player unavailable")
currentPlayer.setProperty(name, value)
}
Result.success(Unit)
} catch (error: CancellationException) {
Result.failure(error)
} catch (error: Exception) {
Log.w(TAG, "MPV property write failed")
Result.failure(error)
}
withContext(NonCancellable + Dispatchers.Main) {
val completion = if (disposing || !isInitialized) {
Result.failure(CancellationException("MPV core unavailable"))
} else {
writeResult
}
if (completion.isSuccess) {
if (paused == true) {
cachedPaused = true
pausedForSurfaceLoss = false
resumeBlockedByPublicPause = true
deferredResumeRequested = false
Log.d(TAG, "Public pause state updated: paused=true")
} else if (paused == false) {
cachedPaused = false
pausedForSurfaceLoss = false
resumeBlockedByPublicPause = false
deferredResumeRequested = false
Log.d(TAG, "Public pause state updated: paused=false")
}
}
onComplete?.invoke(completion)
}
}
}
@@ -13,6 +13,26 @@ import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
internal fun completeMpvPropertyResult(
result: MethodChannel.Result,
outcome: Result<Unit>,
successValue: Any? = null
) {
if (outcome.isSuccess) {
result.success(successValue)
} else {
result.error(
"SET_PROPERTY_FAILED",
"MPV property write was rejected or cancelled",
null
)
}
}
internal fun completeMpvPropertyNotInitialized(result: MethodChannel.Result) {
result.error("NOT_INITIALIZED", "Player not initialized", null)
}
/**
* Channel plumbing for [MpvPlayerCore]. The default instance is the video
* player; the [audioOnly] instance (see [MpvAudioPlayerPlugin]) drives the
@@ -261,13 +281,13 @@ open class MpvPlayerPlugin(
}
val core = playerCore
if (core == null) {
result.success(null)
if (core?.isInitialized != true) {
completeMpvPropertyNotInitialized(result)
return
}
core.setProperty(name, value) {
result.success(null)
core.setProperty(name, value) { outcome ->
completeMpvPropertyResult(result, outcome)
}
}
@@ -0,0 +1,174 @@
package com.edde746.plezy.watchnext
import android.content.ContentProvider
import android.content.ContentValues
import android.database.Cursor
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.ParcelFileDescriptor
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileNotFoundException
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
import java.util.UUID
class SystemShelfArtworkProvider : ContentProvider() {
companion object {
const val AUTHORITY = "com.edde746.plezy.systemshelf.artwork"
}
override fun onCreate(): Boolean = context != null
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor {
if (mode != "r") throw FileNotFoundException("Read-only artwork")
val appContext = context ?: throw FileNotFoundException("Provider unavailable")
val file = SystemShelfArtworkStore(appContext.cacheDir).resolve(uri)
?: throw FileNotFoundException("Unknown artwork")
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
}
override fun getType(uri: Uri): String? = if (uri.authority == AUTHORITY) "image/*" else null
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?
): Cursor? = null
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int = 0
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
}
internal class SystemShelfArtworkStore(private val cacheDir: File) {
companion object {
const val MAX_IMAGE_BYTES = 2 * 1024 * 1024
const val MAX_SYNC_BYTES = 8 * 1024 * 1024
const val MAX_ITEMS = 20
const val CONNECT_TIMEOUT_MS = 2_500
const val READ_TIMEOUT_MS = 2_500
private val opaquePart = Regex("^[a-f0-9]{64}$")
private val artworkKey = Regex("^[a-f0-9]{32}\\.art$")
}
data class Materialized(val key: String, val uri: Uri, val file: File)
class Budget(var remaining: Int = MAX_SYNC_BYTES)
private val root: File get() = File(cacheDir, "system_shelf_artwork")
fun materialize(ownerId: String, source: String, budget: Budget): Materialized? {
if (ownerId.isBlank() || budget.remaining <= 0) return null
val url = runCatching { URL(source) }.getOrNull() ?: return null
if (url.protocol != "https" && url.protocol != "http") return null
val connection = (url.openConnection() as? HttpURLConnection) ?: return null
return try {
connection.instanceFollowRedirects = true
connection.connectTimeout = CONNECT_TIMEOUT_MS
connection.readTimeout = READ_TIMEOUT_MS
connection.useCaches = false
connection.setRequestProperty("Accept", "image/*")
val status = connection.responseCode
if (status !in 200..299) return null
if (connection.url.protocol != "https" && connection.url.protocol != "http") return null
if (!connection.contentType.orEmpty().substringBefore(';').trim().startsWith("image/")) return null
val contentLength = connection.contentLengthLong
val cap = minOf(MAX_IMAGE_BYTES, budget.remaining)
if (contentLength > cap) return null
val bytes = connection.inputStream.use { input ->
val output = ByteArrayOutputStream(minOf(if (contentLength > 0) contentLength.toInt() else 32 * 1024, cap))
val buffer = ByteArray(16 * 1024)
var total = 0
while (true) {
val read = input.read(buffer)
if (read < 0) break
total += read
if (total > cap) return null
output.write(buffer, 0, read)
}
output.toByteArray()
}
if (!isSupportedImage(bytes)) return null
val ownerKey = sha256(ownerId)
val directory = File(root, ownerKey)
if (!directory.mkdirs() && !directory.isDirectory) return null
val key = UUID.randomUUID().toString().replace("-", "") + ".art"
val staged = File(directory, ".$key.tmp")
staged.outputStream().use { output ->
output.write(bytes)
output.flush()
output.fd.sync()
}
val destination = File(directory, key)
if (!staged.renameTo(destination)) {
staged.delete()
return null
}
budget.remaining -= bytes.size
Materialized(key, contentUri(ownerKey, key), destination)
} catch (_: Exception) {
null
} finally {
connection.disconnect()
}
}
fun contentUri(ownerKey: String, key: String): Uri = Uri.Builder()
.scheme("content")
.authority(SystemShelfArtworkProvider.AUTHORITY)
.appendPath("art")
.appendPath(ownerKey)
.appendPath(key)
.build()
fun resolve(uri: Uri): File? {
if (uri.scheme != "content" || uri.authority != SystemShelfArtworkProvider.AUTHORITY) return null
val segments = uri.pathSegments
if (segments.size != 3 || segments[0] != "art") return null
val owner = segments[1]
val key = segments[2]
if (!opaquePart.matches(owner) || !artworkKey.matches(key)) return null
val canonicalRoot = root.canonicalFile
val candidate = File(File(canonicalRoot, owner), key).canonicalFile
if (candidate.parentFile?.parentFile != canonicalRoot || !candidate.isFile) return null
return candidate
}
fun deleteExcept(keep: Set<File>) {
val canonicalKeep = keep.mapTo(HashSet()) { it.canonicalFile }
root.listFiles()?.forEach { ownerDirectory ->
ownerDirectory.listFiles()?.forEach { file ->
if (file.canonicalFile !in canonicalKeep) file.delete()
}
if (ownerDirectory.listFiles().isNullOrEmpty()) ownerDirectory.delete()
}
}
fun deleteAll(): Boolean = !root.exists() || root.deleteRecursively()
private fun isSupportedImage(bytes: ByteArray): Boolean {
if (bytes.size < 4) return false
val png = bytes.size >= 8 &&
bytes[0] == 0x89.toByte() &&
bytes[1] == 0x50.toByte() &&
bytes[2] == 0x4e.toByte() &&
bytes[3] == 0x47.toByte()
val jpeg = bytes[0] == 0xff.toByte() && bytes[1] == 0xd8.toByte() && bytes[2] == 0xff.toByte()
val gif = bytes[0] == 0x47.toByte() && bytes[1] == 0x49.toByte() && bytes[2] == 0x46.toByte()
val webp = bytes.size >= 12 &&
bytes.copyOfRange(0, 4).contentEquals("RIFF".toByteArray()) &&
bytes.copyOfRange(8, 12).contentEquals("WEBP".toByteArray())
if (!png && !jpeg && !gif && !webp) return false
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
val width = options.outWidth
val height = options.outHeight
return width in 1..4096 && height in 1..4096 && width.toLong() * height <= 16_777_216L
}
private fun sha256(value: String): String = MessageDigest.getInstance("SHA-256")
.digest(value.toByteArray(Charsets.UTF_8))
.joinToString("") { byte -> "%02x".format(byte) }
}
@@ -0,0 +1,30 @@
package com.edde746.plezy.watchnext
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import java.util.concurrent.Executor
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
/** Scrubs unversioned rows that may contain legacy authenticated poster URLs. */
class SystemShelfUpdateReceiver private constructor(
private val executor: Executor,
private val ownsExecutor: Boolean
) : BroadcastReceiver() {
constructor() : this(Executors.newSingleThreadExecutor(), true)
internal constructor(executor: Executor) : this(executor, false)
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) return
val pending = goAsync()
executor.execute {
try {
WatchNextProvider(context.applicationContext).clearLegacyOnPackageUpdate()
} finally {
pending?.finish()
if (ownsExecutor) (executor as ExecutorService).shutdown()
}
}
}
}
@@ -12,30 +12,23 @@ import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.Executors
/**
* Flutter plugin for Android TV Watch Next integration.
* Syncs Plex "On Deck" content to the Android TV launcher's Watch Next row.
*/
/** Flutter bridge for profile-owned Android TV Watch Next mutations. */
class WatchNextPlugin :
FlutterPlugin,
MethodChannel.MethodCallHandler {
companion object {
private const val TAG = "WatchNextPlugin"
private const val METHOD_CHANNEL = "com.plezy/watch_next"
private const val SCHEMA_VERSION = 2
private var pendingDeepLink: String? = null
/**
* Parse a Watch Next deep link intent.
* Returns the content ID if this was a Watch Next intent, null otherwise.
*/
fun handleIntent(intent: Intent?): String? {
val data = intent?.data ?: return null
if (data.scheme == "plezy" && data.authority == "play") {
return data.getQueryParameter("content_id")
return if (data.scheme == "plezy" && data.authority == "play") {
data.getQueryParameter("content_id")
} else {
null
}
return null
}
}
@@ -63,7 +56,7 @@ class WatchNextPlugin :
when (call.method) {
"isSupported" -> handleIsSupported(result)
"sync" -> handleSync(call, result)
"clear" -> handleClear(result)
"clear" -> handleClear(call, result)
"remove" -> handleRemove(call, result)
"getInitialDeepLink" -> handleGetInitialDeepLink(result)
else -> result.notImplemented()
@@ -72,52 +65,43 @@ class WatchNextPlugin :
private fun handleIsSupported(result: MethodChannel.Result) {
val context = applicationContext
if (context == null) {
result.success(false)
return
}
result.success(context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
result.success(context?.packageManager?.hasSystemFeature(PackageManager.FEATURE_LEANBACK) == true)
}
private fun ownerArguments(call: MethodCall): Pair<String, Long>? {
if (call.argument<Number>("schemaVersion")?.toInt() != SCHEMA_VERSION) return null
val owner = call.argument<String>("ownerId")?.takeIf(String::isNotBlank) ?: return null
val generation = call.argument<Number>("generation")?.toLong()?.takeIf { it > 0 } ?: return null
return owner to generation
}
private fun handleSync(call: MethodCall, result: MethodChannel.Result) {
val provider = watchNextProvider
if (provider == null) {
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
return
}
val provider = watchNextProvider ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
val (owner, generation) = ownerArguments(call)
?: return result.error("INVALID_ARGS", "Invalid shelf envelope", null)
val itemsData = call.argument<List<Map<String, Any?>>>("items")
if (itemsData == null) {
result.error("INVALID_ARGS", "Missing 'items' argument", null)
return
?: return result.error("INVALID_ARGS", "Missing items", null)
if (itemsData.size > SystemShelfArtworkStore.MAX_ITEMS) {
return result.error("INVALID_ARGS", "Too many items", null)
}
val items = itemsData.mapNotNull { parseWatchNextItem(it) }
executeOnIo(result) { provider.syncWatchNextPrograms(items) }
val items = itemsData.mapNotNull(::parseWatchNextItem)
executeOnIo(result) { provider.syncWatchNextPrograms(owner, generation, items) }
}
private fun handleClear(result: MethodChannel.Result) {
val provider = watchNextProvider
if (provider == null) {
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
return
}
executeOnIo(result) { provider.clearAll() }
private fun handleClear(call: MethodCall, result: MethodChannel.Result) {
val provider = watchNextProvider ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
val (owner, generation) = ownerArguments(call)
?: return result.error("INVALID_ARGS", "Invalid shelf envelope", null)
executeOnIo(result) { provider.clearAll(owner, generation) }
}
private fun handleRemove(call: MethodCall, result: MethodChannel.Result) {
val provider = watchNextProvider
if (provider == null) {
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
return
}
val provider = watchNextProvider ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
val (owner, generation) = ownerArguments(call)
?: return result.error("INVALID_ARGS", "Invalid shelf envelope", null)
val contentId = call.argument<String>("contentId")
if (contentId == null) {
result.error("INVALID_ARGS", "Missing 'contentId' argument", null)
return
}
executeOnIo(result) { provider.removeItem(contentId) }
?: return result.error("INVALID_ARGS", "Missing contentId", null)
executeOnIo(result) { provider.removeItem(owner, generation, contentId) }
}
private fun executeOnIo(result: MethodChannel.Result, block: () -> Any?) {
@@ -126,12 +110,12 @@ class WatchNextPlugin :
try {
val value = block()
mainHandler.post { result.success(value) }
} catch (e: Exception) {
Log.e(TAG, "IO operation failed: ${e.message}", e)
mainHandler.post { result.error("IO_ERROR", e.message, null) }
} catch (_: Exception) {
Log.e(TAG, "System shelf IO operation failed")
mainHandler.post { result.error("IO_ERROR", "System shelf operation failed", null) }
}
}
} catch (e: java.util.concurrent.RejectedExecutionException) {
} catch (_: java.util.concurrent.RejectedExecutionException) {
result.error("SHUTDOWN", "Plugin is shutting down", null)
}
}
@@ -143,22 +127,18 @@ class WatchNextPlugin :
}
private fun parseWatchNextItem(data: Map<String, Any?>): WatchNextProvider.WatchNextItem? {
val contentId = data["contentId"] as? String ?: return null
val contentId = (data["contentId"] as? String)?.takeIf(String::isNotBlank) ?: return null
val title = data["title"] as? String ?: return null
val typeString = data["type"] as? String ?: "movie"
val type = when (typeString.lowercase()) {
val type = when ((data["type"] as? String)?.lowercase()) {
"episode" -> TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE
"movie" -> TvContractCompat.WatchNextPrograms.TYPE_MOVIE
else -> TvContractCompat.WatchNextPrograms.TYPE_MOVIE
}
return WatchNextProvider.WatchNextItem(
contentId = contentId,
title = title,
episodeTitle = data["episodeTitle"] as? String,
description = data["description"] as? String,
posterUri = data["posterUri"] as? String,
posterSourceUri = data["posterSourceUri"] as? String,
type = type,
duration = (data["duration"] as? Number)?.toLong() ?: 0L,
lastPlaybackPosition = (data["lastPlaybackPosition"] as? Number)?.toLong() ?: 0L,
@@ -169,16 +149,12 @@ class WatchNextPlugin :
)
}
/**
* Store a deep link content ID for delivery to Flutter.
* Called from MainActivity on intent receipt.
*/
fun notifyDeepLink(contentId: String) {
pendingDeepLink = contentId
try {
methodChannel.invokeMethod("onWatchNextTap", mapOf("contentId" to contentId))
} catch (e: Exception) {
Log.d(TAG, "Method channel not ready, stored as pending deep link")
} catch (_: Exception) {
Log.d(TAG, "Method channel not ready; deep link retained")
}
}
}
@@ -3,19 +3,19 @@ package com.edde746.plezy.watchnext
import android.content.ContentProviderOperation
import android.content.ContentUris
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.util.Log
import androidx.tvprovider.media.tv.TvContractCompat
import androidx.tvprovider.media.tv.WatchNextProgram
/**
* Wraps Android TvProvider API for Watch Next row integration.
* Manages WatchNextProgram entries for Plex "On Deck" content.
*/
/** Owns Plezy's durable Android TV Watch Next rows and their local artwork. */
class WatchNextProvider(private val context: Context) {
companion object {
private const val TAG = "WatchNextProvider"
private const val PREFS = "system_shelf_state"
private const val GRANTED_URIS = "granted_uris"
}
data class WatchNextItem(
@@ -23,7 +23,7 @@ class WatchNextProvider(private val context: Context) {
val title: String,
val episodeTitle: String?,
val description: String?,
val posterUri: String?,
val posterSourceUri: String?,
val type: Int,
val duration: Long,
val lastPlaybackPosition: Long,
@@ -33,132 +33,208 @@ class WatchNextProvider(private val context: Context) {
val episodeNumber: Int?
)
/**
* Sync items to Watch Next row.
* Uses applyBatch to delete + insert in a single transaction so the
* launcher receives one content-change notification with the full set.
*/
fun syncWatchNextPrograms(items: List<WatchNextItem>): Boolean = try {
val ops = ArrayList<ContentProviderOperation>()
internal data class PreparedWatchNextItem(val metadata: WatchNextItem, val localPosterUri: Uri?)
ops.add(
ContentProviderOperation.newDelete(
TvContractCompat.WatchNextPrograms.CONTENT_URI
).build()
)
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private val artwork = SystemShelfArtworkStore(context.cacheDir)
private var currentOwner = ""
private var currentGeneration = 0L
for (item in items) {
val program = buildProgram(item)
ops.add(
ContentProviderOperation.newInsert(
TvContractCompat.WatchNextPrograms.CONTENT_URI
).withValues(program.toContentValues()).build()
)
/** Materializes transient art, then atomically replaces the durable rows. */
fun syncWatchNextPrograms(ownerId: String, generation: Long, items: List<WatchNextItem>): Boolean {
if (!accepts(ownerId, generation) || items.size > SystemShelfArtworkStore.MAX_ITEMS) return false
val oldUris = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty().mapNotNull(Uri::parse).toSet()
val oldFiles = oldUris.mapNotNullTo(HashSet()) { artwork.resolve(it) }
val budget = SystemShelfArtworkStore.Budget()
val prepared = items.map { item ->
val materialized = item.posterSourceUri?.let { artwork.materialize(ownerId, it, budget) }
PreparedWatchNextItem(item, materialized?.uri)
}
if (!accepts(ownerId, generation)) {
artwork.deleteExcept(oldFiles)
return false
}
context.contentResolver.applyBatch(TvContractCompat.AUTHORITY, ops)
Log.d(TAG, "Synced ${items.size} Watch Next entries")
true
} catch (e: Exception) {
Log.e(TAG, "Failed to sync Watch Next programs", e)
false
val newUris = prepared.mapNotNullTo(LinkedHashSet()) { it.localPosterUri }
grantReadAccess(newUris)
val committed = replaceRows(prepared)
if (!committed) {
revokeReadAccess(newUris - oldUris)
artwork.deleteExcept(oldFiles)
return false
}
prefs.edit()
.putStringSet(GRANTED_URIS, newUris.mapTo(LinkedHashSet(), Uri::toString))
.commit()
currentOwner = ownerId
currentGeneration = generation
revokeReadAccess(oldUris - newUris)
artwork.deleteExcept(prepared.mapNotNullTo(HashSet()) { it.localPosterUri?.let(artwork::resolve) })
return true
}
fun clearAll(): Boolean = try {
context.contentResolver.delete(
TvContractCompat.WatchNextPrograms.CONTENT_URI,
null,
null
)
true
} catch (e: Exception) {
Log.e(TAG, "Failed to clear Watch Next entries", e)
false
/** Deletes rows first, then grants, then owned files. */
fun clearAll(ownerId: String, generation: Long): Boolean {
if (!acceptsClear(ownerId, generation)) return false
val rowsCleared = deleteRows()
if (!rowsCleared) return false
val uris = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty().mapNotNull(Uri::parse).toSet()
revokeReadAccess(uris)
artwork.deleteAll()
prefs.edit().remove(GRANTED_URIS).commit()
currentOwner = ""
currentGeneration = generation
return true
}
fun removeItem(contentId: String): Boolean {
/** Package replacement is a clean cutover: remote legacy rows cannot survive. */
fun clearLegacyOnPackageUpdate(): Boolean {
val rowsCleared = deleteRows()
val uris = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty().mapNotNull(Uri::parse).toSet()
revokeReadAccess(uris)
artwork.deleteAll()
prefs.edit().clear().commit()
currentOwner = ""
currentGeneration = 0
return rowsCleared
}
fun removeItem(ownerId: String, generation: Long, contentId: String): Boolean {
if (!accepts(ownerId, generation)) return false
return try {
val cursor = context.contentResolver.query(
TvContractCompat.WatchNextPrograms.CONTENT_URI,
arrayOf(
TvContractCompat.WatchNextPrograms._ID,
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID,
TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI
),
null,
null,
null
)
cursor?.use {
val idIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms._ID)
val providerIdIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID)
val posterIndex = it.getColumnIndex(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI)
if (idIndex < 0 || providerIdIndex < 0) return false
while (it.moveToNext()) {
if (it.getString(providerIdIndex) == contentId) {
val id = it.getLong(idIndex)
val deleteUri = ContentUris.withAppendedId(
TvContractCompat.WatchNextPrograms.CONTENT_URI,
id
)
val deleteUri = ContentUris.withAppendedId(TvContractCompat.WatchNextPrograms.CONTENT_URI, it.getLong(idIndex))
context.contentResolver.delete(deleteUri, null, null)
if (posterIndex >= 0) {
val poster = it.getString(posterIndex)?.let(Uri::parse)
if (poster != null) {
revokeReadAccess(setOf(poster))
artwork.resolve(poster)?.delete()
val remaining = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty() - poster.toString()
prefs.edit().putStringSet(GRANTED_URIS, remaining).commit()
}
}
return true
}
}
}
false
} catch (e: Exception) {
Log.e(TAG, "Failed to remove Watch Next item: $contentId", e)
} catch (_: Exception) {
Log.e(TAG, "Failed to remove Watch Next item")
false
}
}
private fun buildProgram(item: WatchNextItem): WatchNextProgram {
val watchNextType = if (item.lastPlaybackPosition > 0) {
private fun accepts(ownerId: String, generation: Long): Boolean {
if (ownerId.isBlank() || generation <= 0) return false
return generation > currentGeneration || generation == currentGeneration && currentOwner == ownerId
}
private fun acceptsClear(ownerId: String, generation: Long): Boolean {
if (ownerId.isBlank() || generation <= 0 || generation < currentGeneration) return false
return generation > currentGeneration || currentOwner.isEmpty() || currentOwner == ownerId
}
private fun replaceRows(items: List<PreparedWatchNextItem>): Boolean = try {
val operations = ArrayList<ContentProviderOperation>(items.size + 1)
operations += ContentProviderOperation.newDelete(TvContractCompat.WatchNextPrograms.CONTENT_URI).build()
items.forEach { item ->
operations += ContentProviderOperation.newInsert(TvContractCompat.WatchNextPrograms.CONTENT_URI)
.withValues(buildProgram(item).toContentValues())
.build()
}
context.contentResolver.applyBatch(TvContractCompat.AUTHORITY, operations)
true
} catch (_: Exception) {
Log.e(TAG, "Failed to sync Watch Next programs")
false
}
private fun deleteRows(): Boolean = try {
context.contentResolver.delete(TvContractCompat.WatchNextPrograms.CONTENT_URI, null, null)
true
} catch (_: Exception) {
Log.e(TAG, "Failed to clear Watch Next entries")
false
}
private fun consumerPackages(): Set<String> {
val packages = LinkedHashSet<String>()
context.packageManager.resolveContentProvider(TvContractCompat.AUTHORITY, PackageManager.MATCH_ALL)?.packageName
?.let(packages::add)
val launcherIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LEANBACK_LAUNCHER)
context.packageManager.queryIntentActivities(launcherIntent, PackageManager.MATCH_ALL)
.mapTo(packages) { it.activityInfo.packageName }
return packages
}
private fun grantReadAccess(uris: Set<Uri>) {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
consumerPackages().forEach { packageName ->
uris.forEach { uri ->
runCatching { context.grantUriPermission(packageName, uri, flags) }
}
}
}
private fun revokeReadAccess(uris: Set<Uri>) {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
uris.forEach { uri -> runCatching { context.revokeUriPermission(uri, flags) } }
}
internal fun buildProgram(item: PreparedWatchNextItem): WatchNextProgram {
val metadata = item.metadata
val watchNextType = if (metadata.lastPlaybackPosition > 0) {
TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE
} else {
TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_NEXT
}
val builder = WatchNextProgram.Builder()
.setType(item.type)
.setType(metadata.type)
.setWatchNextType(watchNextType)
.setTitle(item.title)
.setInternalProviderId(item.contentId)
.setLastEngagementTimeUtcMillis(item.lastEngagementTime)
.setTitle(metadata.title)
.setInternalProviderId(metadata.contentId)
.setLastEngagementTimeUtcMillis(metadata.lastEngagementTime)
item.description?.let { builder.setDescription(it) }
item.posterUri?.let { uri ->
try {
builder.setPosterArtUri(Uri.parse(uri))
builder.setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9)
} catch (e: Exception) {
Log.w(TAG, "Failed to parse poster URI: $uri", e)
metadata.description?.let(builder::setDescription)
item.localPosterUri?.let { uri ->
builder.setPosterArtUri(uri)
builder.setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9)
}
if (metadata.duration > 0) {
builder.setDurationMillis(metadata.duration.coerceAtMost(Int.MAX_VALUE.toLong()).toInt())
if (metadata.lastPlaybackPosition > 0) {
builder.setLastPlaybackPositionMillis(metadata.lastPlaybackPosition.coerceAtMost(Int.MAX_VALUE.toLong()).toInt())
}
}
if (item.duration > 0) {
builder.setDurationMillis(item.duration.toInt())
if (item.lastPlaybackPosition > 0) {
builder.setLastPlaybackPositionMillis(item.lastPlaybackPosition.toInt())
}
if (metadata.type == TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE) {
metadata.episodeTitle?.let(builder::setEpisodeTitle)
metadata.seasonNumber?.let(builder::setSeasonNumber)
metadata.episodeNumber?.let(builder::setEpisodeNumber)
}
if (item.type == TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE) {
item.episodeTitle?.let { builder.setEpisodeTitle(it) }
item.seasonNumber?.let { builder.setSeasonNumber(it) }
item.episodeNumber?.let { builder.setEpisodeNumber(it) }
}
val intentUri = Uri.Builder()
.scheme("plezy")
.authority("play")
.appendQueryParameter("content_id", item.contentId)
.build()
builder.setIntentUri(intentUri)
builder.setIntentUri(
Uri.Builder().scheme("plezy").authority("play")
.appendQueryParameter("content_id", metadata.contentId).build()
)
return builder.build()
}
}
@@ -1,15 +1,20 @@
package com.edde746.plezy.exoplayer
import android.app.Activity
import android.os.Looper
import com.edde746.plezy.mpv.MpvPlayerCore
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.CancellationException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
@@ -40,6 +45,119 @@ class ExoPlayerPluginTest {
assertEquals(mapOf("playerType" to "mpv"), result.successValue)
}
@Test
fun fallbackPropertyHandlersWaitForAcceptedWritesAndReplyOnce() {
for (case in fallbackPropertyCases()) {
val writes = mutableListOf<Pair<String, String>>()
val plugin = fallbackPlugin { name, value -> writes += name to value }
val result = RecordingResult()
plugin.onMethodCall(MethodCall(case.method, case.arguments), result)
awaitCompletion(result)
assertEquals(listOf(case.expectedWrite), writes)
assertEquals(case.successValue, result.successValue)
assertEquals(1, result.completionCount)
assertEquals(null, result.errorCode)
}
}
@Test
fun fallbackPropertyHandlersMapRejectedWritesToBoundedErrorsOnce() {
for (case in fallbackPropertyCases()) {
val writes = AtomicInteger()
val plugin = fallbackPlugin { _, _ ->
writes.incrementAndGet()
error("secret-fallback-value")
}
val result = RecordingResult()
plugin.onMethodCall(MethodCall(case.method, case.arguments), result)
awaitCompletion(result)
assertEquals(1, writes.get())
assertEquals(1, result.completionCount)
assertEquals("SET_PROPERTY_FAILED", result.errorCode)
assertEquals("MPV property write was rejected or cancelled", result.errorMessage)
assertTrue(result.errorMessage?.contains("secret-fallback-value") == false)
assertEquals(null, result.successValue)
assertEquals(null, result.errorDetails)
}
}
@Test
fun fallbackCancellationReturnsSetPropertyFailedOnce() {
val plugin = fallbackPlugin { _, _ ->
throw CancellationException("secret-cancellation")
}
val result = RecordingResult()
plugin.onMethodCall(
MethodCall("setMpvProperty", mapOf("name" to "custom", "value" to "secret")),
result
)
awaitCompletion(result)
assertEquals(1, result.completionCount)
assertEquals("SET_PROPERTY_FAILED", result.errorCode)
assertTrue(result.errorMessage?.contains("secret") == false)
assertEquals(null, result.successValue)
}
@Test
fun fallbackWithoutCoreReturnsNotInitializedOnce() {
val plugin = ExoPlayerPlugin()
setField(plugin, "usingMpvFallback", true)
setField(plugin, "activity", Robolectric.buildActivity(Activity::class.java).setup().get())
val result = RecordingResult()
plugin.onMethodCall(MethodCall("pause", null), result)
assertEquals(1, result.completionCount)
assertEquals("NOT_INITIALIZED", result.errorCode)
assertEquals(null, result.successValue)
}
@Test
fun fallbackWithoutActivityReturnsNotInitializedOnce() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val core = MpvPlayerCore(activity, true) { _, _ -> Unit }
val plugin = ExoPlayerPlugin()
setField(plugin, "usingMpvFallback", true)
setField(plugin, "mpvCore", core)
val result = RecordingResult()
plugin.onMethodCall(MethodCall("play", null), result)
assertEquals(1, result.completionCount)
assertEquals("NOT_INITIALIZED", result.errorCode)
assertEquals(null, result.successValue)
}
@Test
fun genericPropertyBeforeFallbackIsAcceptedIntoLastWriteWinsPendingMap() {
val plugin = ExoPlayerPlugin()
val first = RecordingResult()
val second = RecordingResult()
plugin.onMethodCall(
MethodCall("setMpvProperty", mapOf("name" to "custom", "value" to "first")),
first
)
plugin.onMethodCall(
MethodCall("setMpvProperty", mapOf("name" to "custom", "value" to "second")),
second
)
@Suppress("UNCHECKED_CAST")
val pending = getField(plugin, "pendingMpvProperties") as Map<String, String>
assertEquals(mapOf("custom" to "second"), pending)
assertEquals(1, first.completionCount)
assertEquals(1, second.completionCount)
assertEquals(null, first.errorCode)
assertEquals(null, second.errorCode)
}
@Test
fun eventCallbacksKeepTheSharedPlayerEnvelope() {
val plugin = ExoPlayerPlugin()
@@ -58,20 +176,95 @@ class ExoPlayerPluginTest {
)
}
private data class FallbackPropertyCase(
val method: String,
val arguments: Any?,
val expectedWrite: Pair<String, String>,
val successValue: Any? = null
)
private fun fallbackPropertyCases() = listOf(
FallbackPropertyCase("play", null, "pause" to "no"),
FallbackPropertyCase("pause", null, "pause" to "yes"),
FallbackPropertyCase("setVolume", mapOf("volume" to 25), "volume" to "25.0"),
FallbackPropertyCase("setRate", mapOf("rate" to 1.5), "speed" to "1.5"),
FallbackPropertyCase("selectAudioTrack", mapOf("trackId" to "2"), "aid" to "2"),
FallbackPropertyCase("selectSubtitleTrack", emptyMap<String, Any?>(), "sid" to "no"),
FallbackPropertyCase(
"setAudioPassthrough",
mapOf("enabled" to true),
"audio-spdif" to "ac3,eac3,dts,dts-hd,truehd",
true
),
FallbackPropertyCase(
"setMpvProperty",
mapOf("name" to "custom", "value" to "value"),
"custom" to "value"
)
)
private fun fallbackPlugin(
writer: suspend (String, String) -> Unit
): ExoPlayerPlugin {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val core = MpvPlayerCore(activity, true, writer)
return ExoPlayerPlugin().also { plugin ->
setField(plugin, "activity", activity)
setField(plugin, "mpvCore", core)
setField(plugin, "usingMpvFallback", true)
}
}
private fun setField(plugin: ExoPlayerPlugin, name: String, value: Any?) {
plugin.javaClass.getDeclaredField(name).apply {
isAccessible = true
set(plugin, value)
}
}
private fun getField(plugin: ExoPlayerPlugin, name: String): Any? = plugin.javaClass.getDeclaredField(name).run {
isAccessible = true
get(plugin)
}
private fun awaitCompletion(result: RecordingResult) {
var completed = false
repeat(100) {
shadowOf(Looper.getMainLooper()).idle()
if (result.completed.await(10, TimeUnit.MILLISECONDS)) {
completed = true
return@repeat
}
}
shadowOf(Looper.getMainLooper()).idle()
assertTrue("fallback property result never completed", completed)
assertEquals(1, result.completionCount)
}
private class RecordingResult : MethodChannel.Result {
val completed = CountDownLatch(1)
var successValue: Any? = null
var errorCode: String? = null
var errorMessage: String? = null
var errorDetails: Any? = null
var completionCount: Int = 0
override fun success(result: Any?) {
completionCount++
successValue = result
completed.countDown()
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
completionCount++
this.errorCode = errorCode
this.errorMessage = errorMessage
this.errorDetails = errorDetails
completed.countDown()
}
override fun notImplemented() {
completionCount++
completed.countDown()
}
}
@@ -1,5 +1,7 @@
package com.edde746.plezy.mpv
import android.app.Activity
import android.os.Looper
import dev.jdtech.mpv.EndFileReason
import dev.jdtech.mpv.LogLevel
import dev.jdtech.mpv.LogMessage
@@ -7,12 +9,19 @@ import dev.jdtech.mpv.MpvEvent
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.CancellationException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.suspendCancellableCoroutine
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
@RunWith(RobolectricTestRunner::class)
class MpvPlayerPluginTest {
@@ -30,6 +39,139 @@ class MpvPlayerPluginTest {
assertNull(result.successValue)
}
@Test
fun setPropertyWithoutCoreReportsNotInitializedForVideoAndAudio() {
for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) {
val result = RecordingResult()
plugin.onMethodCall(propertyCall(), result)
assertEquals("NOT_INITIALIZED", result.errorCode)
assertEquals(1, result.completionCount)
assertNull(result.successValue)
}
}
@Test
fun acceptedSetPropertyCompletesOnceForVideoAndAudio() {
for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) {
val writes = AtomicInteger()
installCore(plugin, testCore { _, _ -> writes.incrementAndGet() })
val result = RecordingResult()
plugin.onMethodCall(propertyCall(), result)
awaitCompletion(result)
assertEquals(1, writes.get())
assertEquals(1, result.completionCount)
assertNull(result.errorCode)
assertNull(result.successValue)
}
}
@Test
fun rejectedSetPropertyFailsOnceForVideoAndAudioWithoutLeakingPayload() {
for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) {
installCore(plugin, testCore { _, _ -> error("secret-property-value") })
val result = RecordingResult()
plugin.onMethodCall(propertyCall(), result)
awaitCompletion(result)
assertEquals(1, result.completionCount)
assertEquals("SET_PROPERTY_FAILED", result.errorCode)
assertEquals("MPV property write was rejected or cancelled", result.errorMessage)
assertTrue(result.errorMessage?.contains("secret-property-value") == false)
assertNull(result.successValue)
assertNull(result.errorDetails)
}
}
@Test
fun cancelledSetPropertyFailsOnceForVideoAndAudio() {
for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) {
installCore(plugin, testCore { _, _ -> throw CancellationException("secret-cancellation") })
val result = RecordingResult()
plugin.onMethodCall(propertyCall(), result)
awaitCompletion(result)
assertEquals(1, result.completionCount)
assertEquals("SET_PROPERTY_FAILED", result.errorCode)
assertTrue(result.errorMessage?.contains("secret-cancellation") == false)
assertNull(result.successValue)
}
}
@Test
fun coreReportsMissingPlayerDuringWriteAsFailure() {
val core = testCore(null)
var outcome: Result<Unit>? = null
core.setProperty("volume", "50") { outcome = it }
awaitCondition { outcome != null }
assertTrue(outcome?.isFailure == true)
}
@Test
fun disposeCancelsQueuedPropertyWritesAndCompletesEachCallbackOnce() {
val firstStarted = CountDownLatch(1)
val core = testCore { name, _ ->
if (name == "first") {
suspendCancellableCoroutine<Unit> {
firstStarted.countDown()
}
}
}
val outcomes = mutableListOf<Result<Unit>>()
core.setProperty("first", "value") { outcomes += it }
assertTrue(firstStarted.await(1, TimeUnit.SECONDS))
core.setProperty("second", "value") { outcomes += it }
core.dispose()
awaitCondition { outcomes.size == 2 }
assertEquals(2, outcomes.size)
assertTrue(outcomes.all { it.isFailure })
}
@Test
fun failedPauseLeavesAllPauseBookkeepingUnchanged() {
val core = testVideoCore { _, _ -> error("rejected") }
setBoolean(core, "cachedPaused", false)
setBoolean(core, "pausedForSurfaceLoss", true)
setBoolean(core, "resumeBlockedByPublicPause", false)
setBoolean(core, "deferredResumeRequested", true)
var outcome: Result<Unit>? = null
core.setProperty("pause", "yes") { outcome = it }
awaitCondition { outcome != null }
assertTrue(outcome?.isFailure == true)
assertEquals(false, getBoolean(core, "cachedPaused"))
assertEquals(true, getBoolean(core, "pausedForSurfaceLoss"))
assertEquals(false, getBoolean(core, "resumeBlockedByPublicPause"))
assertEquals(true, getBoolean(core, "deferredResumeRequested"))
}
@Test
fun resumeWithoutReadyVideoOutputIsAcceptedAndDeferredWithoutWriting() {
val writes = AtomicInteger()
val core = testVideoCore { _, _ -> writes.incrementAndGet() }
setBoolean(core, "resumeBlockedByPublicPause", true)
var outcome: Result<Unit>? = null
core.setProperty("pause", "no") { outcome = it }
awaitCondition { outcome != null }
assertTrue(outcome?.isSuccess == true)
assertEquals(0, writes.get())
assertEquals(false, getBoolean(core, "resumeBlockedByPublicPause"))
assertEquals(true, getBoolean(core, "deferredResumeRequested"))
assertEquals(true, getBoolean(core, "cachedPaused"))
}
@Test
fun disposeCompletesEveryPendingInitialization() {
val plugin = MpvPlayerPlugin()
@@ -53,9 +195,9 @@ class MpvPlayerPluginTest {
assertEquals(false, first.successValue)
assertEquals(false, second.successValue)
assertNull(dispose.successValue)
assertTrue(first.completed)
assertTrue(second.completed)
assertTrue(dispose.completed)
assertEquals(1, first.completionCount)
assertEquals(1, second.completionCount)
assertEquals(1, dispose.completionCount)
assertEquals(0, pending.size)
}
@@ -125,22 +267,91 @@ class MpvPlayerPluginTest {
)
}
private fun propertyCall() = MethodCall(
"setProperty",
mapOf("name" to "volume", "value" to "50")
)
private fun testCore(
writer: (suspend (String, String) -> Unit)?
): MpvPlayerCore = MpvPlayerCore(
Robolectric.buildActivity(Activity::class.java).setup().get(),
true,
writer
)
private fun testVideoCore(
writer: suspend (String, String) -> Unit
): MpvPlayerCore = MpvPlayerCore(
Robolectric.buildActivity(Activity::class.java).setup().get(),
false,
writer
)
private fun installCore(plugin: MpvPlayerPlugin, core: MpvPlayerCore) {
MpvPlayerPlugin::class.java.getDeclaredField("playerCore").apply {
isAccessible = true
set(plugin, core)
}
}
private fun setBoolean(core: MpvPlayerCore, name: String, value: Boolean) {
MpvPlayerCore::class.java.getDeclaredField(name).apply {
isAccessible = true
setBoolean(core, value)
}
}
private fun getBoolean(core: MpvPlayerCore, name: String): Boolean = MpvPlayerCore::class.java.getDeclaredField(name).run {
isAccessible = true
getBoolean(core)
}
private fun awaitCompletion(result: RecordingResult) {
awaitCondition { result.completed.await(10, TimeUnit.MILLISECONDS) }
shadowOf(Looper.getMainLooper()).idle()
assertEquals(1, result.completionCount)
}
private fun awaitCondition(condition: () -> Boolean) {
var completed = false
repeat(100) {
shadowOf(Looper.getMainLooper()).idle()
if (condition()) {
completed = true
return@repeat
}
Thread.sleep(10)
}
assertTrue("asynchronous operation never completed", completed)
}
private class RecordingResult : MethodChannel.Result {
val completed = CountDownLatch(1)
var successValue: Any? = null
var errorCode: String? = null
var completed: Boolean = false
var errorMessage: String? = null
var errorDetails: Any? = null
var completionCount: Int = 0
override fun success(result: Any?) {
completed = true
completionCount++
successValue = result
completed.countDown()
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
completed = true
completionCount++
this.errorCode = errorCode
this.errorMessage = errorMessage
this.errorDetails = errorDetails
completed.countDown()
}
override fun notImplemented() = Unit
override fun notImplemented() {
completionCount++
completed.countDown()
}
}
private class RecordingEventSink : EventChannel.EventSink {
@@ -0,0 +1,198 @@
package com.edde746.plezy.watchnext
import android.content.ContentProvider
import android.content.ContentProviderOperation
import android.content.ContentProviderResult
import android.content.ContentValues
import android.content.Intent
import android.database.Cursor
import android.net.Uri
import android.os.ParcelFileDescriptor.AutoCloseInputStream
import androidx.tvprovider.media.tv.TvContractCompat
import java.net.InetAddress
import java.net.ServerSocket
import java.util.Base64
import java.util.concurrent.Executor
import kotlin.concurrent.thread
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.shadows.ShadowContentResolver
@RunWith(RobolectricTestRunner::class)
class WatchNextProviderTest {
private val context get() = RuntimeEnvironment.getApplication()
private lateinit var tvProvider: CapturingTvProvider
private val imageBytes = Base64.getDecoder().decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
)
@Before
fun setUp() {
context.cacheDir.resolve("system_shelf_artwork").deleteRecursively()
context.getSharedPreferences("system_shelf_state", 0).edit().clear().commit()
tvProvider = CapturingTvProvider()
ShadowContentResolver.registerProviderInternal(TvContractCompat.AUTHORITY, tvProvider)
}
@After
fun tearDown() {
context.cacheDir.resolve("system_shelf_artwork").deleteRecursively()
}
@Test
fun syncPersistsOnlyGrantedLocalUriAndProviderReturnsValidatedBytes() {
withServer("image/png", imageBytes) { source ->
val provider = WatchNextProvider(context)
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(source))))
assertEquals(1, tvProvider.inserted.size)
val stored = tvProvider.inserted.single()
val poster = stored.getAsString(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI)
assertTrue(poster.startsWith("content://${SystemShelfArtworkProvider.AUTHORITY}/art/"))
assertFalse(poster.contains("http"))
val artworkProvider = Robolectric.buildContentProvider(SystemShelfArtworkProvider::class.java).create().get()
val localBytes = AutoCloseInputStream(artworkProvider.openFile(Uri.parse(poster), "r")).use { it.readBytes() }
assertArrayEquals(imageBytes, localBytes)
}
}
@Test
fun traversalUnknownOversizeAndMalformedArtworkAreRejectedWithoutDroppingMetadata() {
val store = SystemShelfArtworkStore(context.cacheDir)
assertNull(store.resolve(Uri.parse("content://${SystemShelfArtworkProvider.AUTHORITY}/art/../../private")))
assertNull(store.resolve(Uri.parse("content://${SystemShelfArtworkProvider.AUTHORITY}/art/${"a".repeat(64)}/${"b".repeat(32)}.art")))
withServer("image/png", ByteArray(SystemShelfArtworkStore.MAX_IMAGE_BYTES + 1)) { source ->
val provider = WatchNextProvider(context)
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(source))))
assertNull(tvProvider.inserted.single().getAsString(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI))
assertEquals("Private title", tvProvider.inserted.single().getAsString(TvContractCompat.WatchNextPrograms.COLUMN_TITLE))
}
tvProvider.inserted.clear()
withServer("image/png", "not an image".toByteArray()) { source ->
val provider = WatchNextProvider(context)
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(source))))
assertNull(tvProvider.inserted.single().getAsString(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI))
}
tvProvider.inserted.clear()
withServer("text/plain", imageBytes) { source ->
val provider = WatchNextProvider(context)
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(source))))
assertNull(tvProvider.inserted.single().getAsString(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI))
}
tvProvider.inserted.clear()
withServer("image/png", imageBytes, delayMillis = 3_000) { source ->
val provider = WatchNextProvider(context)
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(source))))
assertNull(tvProvider.inserted.single().getAsString(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI))
}
}
@Test
fun staleGenerationCannotCommitAndClearRemovesRowsGrantsAndFiles() {
withServer("image/png", imageBytes) { source ->
val provider = WatchNextProvider(context)
assertTrue(provider.syncWatchNextPrograms("owner-a", 3, listOf(item(source))))
assertFalse(provider.syncWatchNextPrograms("owner-old", 2, listOf(item(source))))
assertTrue(context.cacheDir.resolve("system_shelf_artwork").walkTopDown().any { it.isFile })
assertTrue(provider.clearAll("owner-a", 4))
assertTrue(tvProvider.deleteCount >= 2)
assertFalse(context.cacheDir.resolve("system_shelf_artwork").exists())
assertTrue(context.getSharedPreferences("system_shelf_state", 0).getStringSet("granted_uris", null).isNullOrEmpty())
}
}
@Test
fun packageUpdateCleanupDeletesLegacyRowsAndOwnedFiles() {
context.cacheDir.resolve("system_shelf_artwork/legacy").apply { mkdirs() }.resolve("legacy.art").writeBytes(imageBytes)
val receiver = SystemShelfUpdateReceiver(Executor { command -> command.run() })
receiver.onReceive(context, Intent(Intent.ACTION_MY_PACKAGE_REPLACED))
assertEquals(1, tvProvider.deleteCount)
assertFalse(context.cacheDir.resolve("system_shelf_artwork").exists())
}
private fun item(source: String) = WatchNextProvider.WatchNextItem(
contentId = "plezy_server_item",
title = "Private title",
episodeTitle = null,
description = "Private summary",
posterSourceUri = source,
type = TvContractCompat.WatchNextPrograms.TYPE_MOVIE,
duration = 100,
lastPlaybackPosition = 10,
lastEngagementTime = 1,
seriesTitle = null,
seasonNumber = null,
episodeNumber = null
)
private fun withServer(
contentType: String,
body: ByteArray,
delayMillis: Long = 0,
block: (String) -> Unit
) {
val server = ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))
val responder = thread(start = true, name = "system-shelf-test-http") {
server.accept().use { socket ->
val reader = socket.getInputStream().bufferedReader()
while (reader.readLine()?.isNotEmpty() == true) {
// Consume the local deterministic request headers.
}
if (delayMillis > 0) Thread.sleep(delayMillis)
val headers = (
"HTTP/1.1 200 OK\r\n" +
"Content-Type: $contentType\r\n" +
"Content-Length: ${body.size}\r\n" +
"Connection: close\r\n\r\n"
).toByteArray()
socket.getOutputStream().use { output ->
output.write(headers)
output.write(body)
output.flush()
}
}
}
try {
block("http://127.0.0.1:${server.localPort}/art")
responder.join(5_000)
} finally {
server.close()
}
}
}
private class CapturingTvProvider : ContentProvider() {
val inserted = mutableListOf<ContentValues>()
var deleteCount = 0
override fun onCreate(): Boolean = true
override fun insert(uri: Uri, values: ContentValues?): Uri {
inserted += ContentValues(values)
return uri.buildUpon().appendPath(inserted.size.toString()).build()
}
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
deleteCount++
inserted.clear()
return 1
}
override fun applyBatch(operations: ArrayList<ContentProviderOperation>): Array<ContentProviderResult> = super.applyBatch(operations)
override fun getType(uri: Uri): String? = null
override fun query(uri: Uri, projection: Array<out String>?, selection: String?, selectionArgs: Array<out String>?, sortOrder: String?): Cursor? = null
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int = 0
}