diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 0881017a..c7cdd6fd 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -28,6 +28,7 @@
+
@@ -91,13 +92,14 @@
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/BufferedTransformingTrackOutput.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/BufferedTransformingTrackOutput.kt
index 4eb86361..07ff4905 100644
--- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/BufferedTransformingTrackOutput.kt
+++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/BufferedTransformingTrackOutput.kt
@@ -3,6 +3,7 @@ package com.edde746.plezy.exoplayer
import androidx.media3.common.C
import androidx.media3.common.DataReader
import androidx.media3.common.Format
+import androidx.media3.common.ParserException
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.extractor.TrackOutput
import java.io.EOFException
@@ -11,7 +12,8 @@ import java.io.EOFException
abstract class BufferedTransformingTrackOutput(
protected val delegate: TrackOutput,
initialBufferSize: Int,
- initialReadBufferSize: Int = initialBufferSize
+ initialReadBufferSize: Int = initialBufferSize,
+ private val maxBufferedSampleBytes: Int = Int.MAX_VALUE
) : TrackOutput {
protected var inputBuffer = ByteArray(initialBufferSize)
private set
@@ -26,6 +28,12 @@ abstract class BufferedTransformingTrackOutput(
/** Returns transformed length, or a negative value to drop the sample. */
protected abstract fun transformSample(inputLength: Int, flags: Int): Int
+
+ init {
+ require(initialBufferSize > 0)
+ require(initialReadBufferSize > 0)
+ require(maxBufferedSampleBytes >= initialBufferSize)
+ }
open override fun format(format: Format) = delegate.format(format)
override fun sampleData(
@@ -39,8 +47,10 @@ abstract class BufferedTransformingTrackOutput(
}
buffering = true
- if (readBuffer.size < length) readBuffer = ByteArray(length)
- val bytesRead = input.read(readBuffer, 0, length)
+ val remainingCapacity = maxBufferedSampleBytes - inputLength
+ if (length < 0 || remainingCapacity <= 0) throw sampleTooLarge()
+ val requested = minOf(length, remainingCapacity, readBuffer.size)
+ val bytesRead = input.read(readBuffer, 0, requested)
if (bytesRead == C.RESULT_END_OF_INPUT && !allowEndOfInput) throw EOFException()
if (bytesRead > 0) appendInput(readBuffer, bytesRead)
return bytesRead
@@ -53,7 +63,7 @@ abstract class BufferedTransformingTrackOutput(
}
buffering = true
- ensureInputCapacity(inputLength + length)
+ ensureInputCapacity(length)
data.readBytes(inputBuffer, inputLength, length)
inputLength += length
}
@@ -82,14 +92,24 @@ abstract class BufferedTransformingTrackOutput(
}
private fun appendInput(source: ByteArray, length: Int) {
- ensureInputCapacity(inputLength + length)
+ ensureInputCapacity(length)
System.arraycopy(source, 0, inputBuffer, inputLength, length)
inputLength += length
}
- private fun ensureInputCapacity(needed: Int) {
+ private fun ensureInputCapacity(additionalBytes: Int) {
+ if (additionalBytes < 0 || additionalBytes > maxBufferedSampleBytes - inputLength) {
+ throw sampleTooLarge()
+ }
+ val needed = inputLength + additionalBytes
if (inputBuffer.size < needed) {
- inputBuffer = inputBuffer.copyOf(maxOf(needed, inputBuffer.size * 2))
+ val doubledSize = minOf(maxBufferedSampleBytes.toLong(), inputBuffer.size.toLong() * 2).toInt()
+ inputBuffer = inputBuffer.copyOf(maxOf(needed, doubledSize))
}
}
+
+ private fun sampleTooLarge(): ParserException = ParserException.createForMalformedContainer(
+ "Buffered sample exceeds the maximum size of $maxBufferedSampleBytes bytes",
+ null
+ )
}
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt
index b0c690f1..0e82691e 100644
--- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt
+++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt
@@ -128,8 +128,6 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private val hwAudioDecoderCache = HashMap()
private val tunneledPlaybackCache = HashMap()
- private var assGlCrashHandlerInstalled = false
-
@Volatile private var cronetEngine: CronetEngine? = null
@Volatile private var cronetUnavailable = false
@@ -713,24 +711,6 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
// assView.requestRender directly from the listener below.
handler.init(exoPlayer!!)
- // Suppress ass-media GL thread crash when EGL init partially fails (e.g. Tegra).
- // AssRender.onSurfaceDestroyed() accesses uninitialized glProgram lateinit property
- // during error cleanup, which is a bug in the library. The render thread dying only
- // affects ASS subtitle GPU rendering; non-ASS subtitles are unaffected.
- if (!assGlCrashHandlerInstalled) {
- assGlCrashHandlerInstalled = true
- val previousHandler = Thread.getDefaultUncaughtExceptionHandler()
- Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
- if (thread.name.contains("AssTexRenderThread") &&
- throwable is UninitializedPropertyAccessException
- ) {
- Log.e(TAG, "ASS GL thread crash suppressed (EGL init failure)", throwable)
- } else {
- previousHandler?.uncaughtException(thread, throwable)
- }
- }
- }
-
exoPlayer!!.addListener(this)
exoPlayer!!.addAnalyticsListener(decoderHangListener)
exoPlayer!!.setVideoFrameMetadataListener { presentationTimeUs, releaseTimeNs, _, _ ->
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt
index a84b87ff..72067723 100644
--- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt
+++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt
@@ -31,6 +31,7 @@ class ExoPlayerPlugin :
private val channels = PlayerChannelBinding(METHOD_CHANNEL, this, this, TAG)
private val mainHandler get() = channels.mainHandler
+ private fun runOnMain(block: () -> Unit) = channels.runOnMain(block)
private var playerCore: ExoPlayerCore? = null
private var mpvCore: MpvPlayerCore? = null // MPV fallback player
private var usingMpvFallback: Boolean = false
@@ -65,9 +66,28 @@ class ExoPlayerPlugin :
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
+ teardownSession(clearActivity = true)
channels.detach()
}
+ private fun teardownSession(clearActivity: Boolean) {
+ ++sessionGeneration
+ val exoCore = playerCore
+ val fallbackCore = mpvCore
+ playerCore = null
+ mpvCore = null
+ usingMpvFallback = false
+ fallbackInProgress = false
+ currentExternalSubtitles = null
+ pendingMpvProperties.clear()
+ if (clearActivity) {
+ activity = null
+ activityBinding = null
+ }
+ exoCore?.dispose()
+ fallbackCore?.dispose()
+ }
+
// ActivityAware
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
@@ -77,17 +97,7 @@ class ExoPlayerPlugin :
}
override fun onDetachedFromActivity() {
- sessionGeneration++
- playerCore?.dispose()
- playerCore = null
- mpvCore?.dispose()
- mpvCore = null
- usingMpvFallback = false
- fallbackInProgress = false
- currentExternalSubtitles = null
- pendingMpvProperties.clear()
- activity = null
- activityBinding = null
+ teardownSession(clearActivity = true)
Log.d(TAG, "Detached from activity")
}
@@ -98,10 +108,10 @@ class ExoPlayerPlugin :
}
override fun onDetachedFromActivityForConfigChanges() {
- sessionGeneration++
- fallbackInProgress = false
- activity = null
- activityBinding = null
+ // MainActivity owns a self-created engine which is destroyed with the old
+ // Activity. There is no cached-engine transfer contract, so retaining an
+ // Activity-bound core here would orphan its views and native resources.
+ teardownSession(clearActivity = true)
Log.d(TAG, "Detached from activity for config changes")
}
@@ -180,6 +190,7 @@ class ExoPlayerPlugin :
return
}
+ val requestGeneration = sessionGeneration
if (playerCore?.isInitialized == true) {
Log.d(TAG, "Already initialized")
result.success(true)
@@ -198,7 +209,11 @@ class ExoPlayerPlugin :
AssHandler.setRenderScale(subtitleRenderScale)
currentActivity.runOnUiThread {
- sessionGeneration++
+ if (requestGeneration != sessionGeneration || activity !== currentActivity) {
+ result.success(false)
+ return@runOnUiThread
+ }
+ ++sessionGeneration
// Do NOT clear pendingMpvProperties here: Dart queues its startup
// properties (sub-ass, subtitle fonts, ...) before initialize, and the
// fallback replay in setupMpvFallback needs them. Dispose/detach clear.
@@ -245,19 +260,11 @@ class ExoPlayerPlugin :
}
private fun handleDispose(result: MethodChannel.Result) {
- activity?.runOnUiThread {
- sessionGeneration++
- playerCore?.dispose()
- playerCore = null
- mpvCore?.dispose()
- mpvCore = null
- usingMpvFallback = false
- fallbackInProgress = false
- currentExternalSubtitles = null
- pendingMpvProperties.clear()
+ runOnMain {
+ teardownSession(clearActivity = false)
Log.d(TAG, "Disposed")
result.success(null)
- } ?: result.success(null)
+ }
}
@Suppress("UNCHECKED_CAST")
@@ -331,9 +338,11 @@ class ExoPlayerPlugin :
appendExternalSubtitleOptions(options, externalSubtitles)
appendHttpHeaderOptions(options, headers)
val optionsStr = options.joinToString(",")
- mpvCore?.command(arrayOf("loadfile", uri, "replace", "-1", optionsStr)) { success ->
+ val core = mpvCore ?: return
+ core.setPauseIntentForLoad(paused = !autoPlay)
+ core.command(arrayOf("loadfile", uri, "replace", "-1", optionsStr)) { success ->
if (success && autoPlay) {
- mpvCore?.setProperty("pause", "no")
+ core.setProperty("pause", "no")
}
}
}
@@ -931,6 +940,7 @@ class ExoPlayerPlugin :
appendExternalSubtitleOptions(options, externalSubtitles)
appendHttpHeaderOptions(options, headers)
val optionsStr = options.joinToString(",")
+ core.setPauseIntentForLoad(paused = !playWhenReady)
notifyBackendSwitched()
core.command(arrayOf("loadfile", source.value, "replace", "-1", optionsStr))
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutput.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutput.kt
index b662358d..156a28c7 100644
--- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutput.kt
+++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutput.kt
@@ -1,6 +1,6 @@
package com.edde746.plezy.exoplayer
-import android.util.Log
+import androidx.media3.common.ParserException
import androidx.media3.extractor.TrackOutput
import java.util.zip.DataFormatException
import java.util.zip.Inflater
@@ -14,18 +14,27 @@ import java.util.zip.Inflater
*/
class ZlibInflatingTrackOutput(
delegate: TrackOutput
-) : BufferedTransformingTrackOutput(delegate, INITIAL_BUFFER_SIZE, INFLATE_CHUNK) {
+) : BufferedTransformingTrackOutput(
+ delegate,
+ INITIAL_BUFFER_SIZE,
+ INFLATE_CHUNK,
+ MAX_COMPRESSED_SAMPLE_SIZE
+) {
companion object {
- private const val TAG = "ZlibTrackOutput"
private const val INITIAL_BUFFER_SIZE = 256 * 1024
private const val INFLATE_CHUNK = 64 * 1024
+ private const val MAX_COMPRESSED_SAMPLE_SIZE = 16 * 1024 * 1024
+ private const val MAX_INFLATED_SAMPLE_SIZE = 16 * 1024 * 1024
+ private const val MAX_COMPRESSION_RATIO = 1024L
+ private const val MIN_RATIO_ALLOWANCE = 1024L * 1024
}
var active = false
private val inflater = Inflater()
private var inflateBuf = ByteArray(INITIAL_BUFFER_SIZE)
+ private val overflowProbe = ByteArray(1)
override val transformEnabled: Boolean
get() = active
@@ -33,31 +42,50 @@ class ZlibInflatingTrackOutput(
override val transformedBuffer: ByteArray
get() = inflateBuf
- override fun transformSample(inputLength: Int, flags: Int): Int = try {
+ override fun transformSample(inputLength: Int, flags: Int): Int {
inflater.reset()
inflater.setInput(inputBuffer, 0, inputLength)
var written = 0
- while (!inflater.finished()) {
- if (written == inflateBuf.size) growInflateBuf()
- val count = inflater.inflate(inflateBuf, written, inflateBuf.size - written)
- if (count == 0 && !inflater.finished()) break
- written += count
- }
- written
- } catch (e: DataFormatException) {
- Log.e(TAG, "Zlib inflate failed (${inputLength}B), passing raw", e)
- ensureInflateCapacity(inputLength)
- System.arraycopy(inputBuffer, 0, inflateBuf, 0, inputLength)
- inputLength
- }
+ val ratioBound = maxOf(MIN_RATIO_ALLOWANCE, inputLength.toLong() * MAX_COMPRESSION_RATIO)
- private fun ensureInflateCapacity(needed: Int) {
- if (inflateBuf.size < needed) {
- inflateBuf = ByteArray(maxOf(needed, inflateBuf.size * 2))
+ try {
+ while (true) {
+ if (written == inflateBuf.size) {
+ if (inflateBuf.size < MAX_INFLATED_SAMPLE_SIZE) {
+ val nextSize = minOf(MAX_INFLATED_SAMPLE_SIZE, inflateBuf.size * 2)
+ inflateBuf = inflateBuf.copyOf(nextSize)
+ } else {
+ val overflow = inflater.inflate(overflowProbe, 0, 1)
+ if (overflow > 0) {
+ throw malformed("Inflated sample exceeds the maximum size")
+ }
+ if (inflater.finished()) return written
+ throw stalledInflate()
+ }
+ }
+
+ val count = inflater.inflate(inflateBuf, written, inflateBuf.size - written)
+ written += count
+ if (written.toLong() > ratioBound) {
+ throw malformed("Inflated sample exceeds the maximum compression ratio")
+ }
+ if (inflater.finished()) return written
+ if (count == 0) throw stalledInflate()
+ }
+ } catch (_: DataFormatException) {
+ // Preserve playback for corrupt subtitle blocks; the raw sample remains bounded
+ // by MAX_COMPRESSED_SAMPLE_SIZE and matches the pre-hardening fallback.
+ if (inflateBuf.size < inputLength) inflateBuf = inflateBuf.copyOf(inputLength)
+ inputBuffer.copyInto(inflateBuf, endIndex = inputLength)
+ return inputLength
}
}
- private fun growInflateBuf() {
- inflateBuf = inflateBuf.copyOf(inflateBuf.size * 2)
+ private fun stalledInflate(): ParserException = when {
+ inflater.needsDictionary() -> malformed("Zlib-compressed sample requires a dictionary")
+ inflater.needsInput() -> malformed("Truncated zlib-compressed sample")
+ else -> malformed("Zlib decompressor made no progress")
}
+
+ private fun malformed(message: String, cause: Throwable? = null): ParserException = ParserException.createForMalformedContainer(message, cause)
}
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt
index 5a70c9fd..c81a093a 100644
--- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt
+++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt
@@ -113,8 +113,12 @@ class MpvPlayerCore private constructor(
@Volatile private var cachedPaused: Boolean = true
+ @Volatile private var desiredPaused: Boolean = true
+
@Volatile private var pausedForSurfaceLoss: Boolean = false
+ @Volatile private var pausedForAudioFocusLoss: Boolean = false
+
@Volatile private var hasAttachedSurface: Boolean = false
@Volatile private var attachedToPlaceholder: Boolean = false
@@ -125,6 +129,16 @@ class MpvPlayerCore private constructor(
@Volatile private var resumeBlockedByPublicPause: Boolean = false
+ private data class PublicPauseIntent(
+ val generation: Long,
+ val previousBlocked: Boolean,
+ val previousDesiredPaused: Boolean
+ )
+
+ private val publicPauseIntentLock = Any()
+ private var publicPauseIntentGeneration = 0L
+ private val publicPauseWriteMutex = Mutex()
+
@Volatile private var videoOutputEpoch: Long = 0L
private val videoOutputMutex = Mutex()
private var pendingVideoOutputDisableJob: Job? = null
@@ -199,14 +213,19 @@ class MpvPlayerCore private constructor(
disposing = false
endFileDiagnostics.onStartFile()
cachedPaused = true
+ desiredPaused = true
pausedForSurfaceLoss = false
+ pausedForAudioFocusLoss = false
pendingSurface = null
attachedSurface = null
attachedToPlaceholder = false
hasAttachedSurface = false
videoOutputRestoring = false
deferredResumeRequested = false
- resumeBlockedByPublicPause = false
+ synchronized(publicPauseIntentLock) {
+ publicPauseIntentGeneration += 1L
+ resumeBlockedByPublicPause = false
+ }
videoOutputEpoch = 0L
pendingVideoOutputDisableJob?.cancel()
pendingVideoOutputDisableJob = null
@@ -223,18 +242,12 @@ class MpvPlayerCore private constructor(
handler = handler,
contentType = if (audioOnly) AudioAttributes.CONTENT_TYPE_MUSIC else AudioAttributes.CONTENT_TYPE_MOVIE,
onPause = {
- scope.launch {
- try {
- player?.setProperty("pause", true)
- } catch (e: Exception) {
- Log.w(TAG, "Failed to pause on focus loss", e)
- }
- }
+ pauseForAudioFocusLoss()
},
onResume = {
- requestAutoResume("audio focus gain")
+ resumeAfterAudioFocusGain("audio focus gain")
},
- isPaused = { cachedPaused }
+ isPaused = { desiredPaused }
)
if (!audioOnly) {
frameRateManager = FrameRateManager(
@@ -392,7 +405,13 @@ class MpvPlayerCore private constructor(
// Audio Focus
- fun requestAudioFocus(): Boolean = audioFocusManager?.requestAudioFocus() ?: false
+ fun requestAudioFocus(): Boolean {
+ val granted = audioFocusManager?.requestAudioFocus() ?: false
+ if (granted && pausedForAudioFocusLoss) {
+ resumeAfterAudioFocusGain("audio focus request granted")
+ }
+ return granted
+ }
fun abandonAudioFocus() {
audioFocusManager?.abandonAudioFocus()
@@ -587,23 +606,25 @@ class MpvPlayerCore private constructor(
Log.d(TAG, "Skipping stale MPV placeholder attach ($reason, epoch=$epoch)")
return@withLock
}
- val wasPaused = try {
- p.getFlag("pause") == true
- } catch (e: Exception) {
- cachedPaused
- }
- if (!wasPaused) {
- try {
- p.setProperty("pause", true)
- cachedPaused = true
- pausedForSurfaceLoss = true
- Log.d(TAG, "Paused MPV for surface loss ($reason, epoch=$epoch)")
+ publicPauseWriteMutex.withLock {
+ val wasPaused = try {
+ p.getFlag("pause") == true
} catch (e: Exception) {
+ cachedPaused
+ }
+ if (!wasPaused) {
+ try {
+ p.setProperty("pause", true)
+ cachedPaused = true
+ pausedForSurfaceLoss = true
+ Log.d(TAG, "Paused MPV for surface loss ($reason, epoch=$epoch)")
+ } catch (e: Exception) {
+ pausedForSurfaceLoss = false
+ Log.w(TAG, "Failed to pause MPV before placeholder attach ($reason)", e)
+ }
+ } else {
pausedForSurfaceLoss = false
- Log.w(TAG, "Failed to pause MPV before placeholder attach ($reason)", e)
}
- } else {
- pausedForSurfaceLoss = false
}
val surface = placeholderSurface?.takeIf { it.isValid } ?: run {
Log.w(TAG, "No valid MPV placeholder surface available for $reason")
@@ -654,29 +675,88 @@ class MpvPlayerCore private constructor(
else -> null
}
+ private fun pauseForAudioFocusLoss() {
+ val shouldPause = synchronized(publicPauseIntentLock) {
+ (!desiredPaused).also { pausedForAudioFocusLoss = it }
+ }
+ if (!shouldPause) {
+ Log.d(TAG, "Skipping audio-focus pause because playback is already desirably paused")
+ return
+ }
+
+ scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
+ try {
+ publicPauseWriteMutex.withLock {
+ if (!pausedForAudioFocusLoss || disposing) return@withLock
+ writeProperty("pause", "yes")
+ cachedPaused = true
+ }
+ } catch (error: CancellationException) {
+ Log.d(TAG, "Canceled audio-focus pause")
+ } catch (error: Exception) {
+ Log.w(TAG, "Failed to pause on focus loss", error)
+ }
+ }
+ }
+
+ private fun resumeAfterAudioFocusGain(reason: String) {
+ pausedForAudioFocusLoss = false
+ requestAutoResume(reason)
+ }
+
+ private fun rollbackFailedPublicPauseIntent(intent: PublicPauseIntent) {
+ synchronized(publicPauseIntentLock) {
+ if (publicPauseIntentGeneration == intent.generation) {
+ resumeBlockedByPublicPause = intent.previousBlocked
+ desiredPaused = intent.previousDesiredPaused
+ }
+ }
+ }
+
private fun requestAutoResume(reason: String) {
- val p = player ?: return
+ val p = player
+ if (p == null && propertyWriterOverride == null) return
if (disposing) return
- if (resumeBlockedByPublicPause) {
- deferredResumeRequested = false
- Log.d(TAG, "Skipping auto-resume after $reason because playback is explicitly paused")
- return
+ val intentGeneration = synchronized(publicPauseIntentLock) {
+ if (resumeBlockedByPublicPause) {
+ deferredResumeRequested = false
+ Log.d(TAG, "Skipping auto-resume after $reason because playback is explicitly paused")
+ return
+ }
+
+ if (!hasReadyVideoOutput()) {
+ deferredResumeRequested = true
+ Log.d(TAG, "Deferring auto-resume after $reason until video output is ready")
+ return
+ }
+ publicPauseIntentGeneration
}
- if (!hasReadyVideoOutput()) {
- deferredResumeRequested = true
- Log.d(TAG, "Deferring auto-resume after $reason until video output is ready")
- return
- }
-
- scope.launch {
+ scope.launch(mpvWriteDispatcher) {
try {
- if (p.getFlag("pause") == true) {
- Log.d(TAG, "Auto-resuming playback after $reason")
- p.setProperty("pause", false)
- } else {
- Log.d(TAG, "Skipping auto-resume after $reason because playback is already running")
+ publicPauseWriteMutex.withLock {
+ val shouldResume = synchronized(publicPauseIntentLock) {
+ !pausedForAudioFocusLoss &&
+ !resumeBlockedByPublicPause &&
+ publicPauseIntentGeneration == intentGeneration
+ }
+ if (!shouldResume) {
+ Log.d(TAG, "Skipping stale auto-resume after $reason")
+ return@withLock
+ }
+ val isPaused = p?.getFlag("pause") ?: cachedPaused
+ if (isPaused) {
+ Log.d(TAG, "Auto-resuming playback after $reason")
+ if (p != null) {
+ p.setProperty("pause", false)
+ } else {
+ writeProperty("pause", "no")
+ }
+ cachedPaused = false
+ } else {
+ Log.d(TAG, "Skipping auto-resume after $reason because playback is already running")
+ }
}
} catch (e: Exception) {
Log.w(TAG, "Failed to resume after $reason", e)
@@ -685,41 +765,108 @@ class MpvPlayerCore private constructor(
}
private suspend fun applyDeferredResumeIfNeeded(p: MpvPlayer, reason: String) {
- if (!deferredResumeRequested) return
-
- if (resumeBlockedByPublicPause) {
- deferredResumeRequested = false
- Log.d(TAG, "Dropping deferred auto-resume after $reason because playback is explicitly paused")
- return
+ publicPauseWriteMutex.withLock {
+ val shouldResume = synchronized(publicPauseIntentLock) {
+ if (!deferredResumeRequested) {
+ false
+ } else if (pausedForAudioFocusLoss) {
+ Log.d(TAG, "Keeping deferred auto-resume pending after $reason until audio focus returns")
+ false
+ } else if (resumeBlockedByPublicPause) {
+ deferredResumeRequested = false
+ Log.d(TAG, "Dropping deferred auto-resume after $reason because playback is explicitly paused")
+ false
+ } else {
+ deferredResumeRequested = false
+ true
+ }
+ }
+ if (!shouldResume) return@withLock
+ if (p.getFlag("pause") == true) {
+ Log.d(TAG, "Applying deferred auto-resume after $reason")
+ p.setProperty("pause", false)
+ cachedPaused = false
+ } else {
+ Log.d(TAG, "Skipping deferred auto-resume after $reason because playback is already running")
+ }
}
+ }
- deferredResumeRequested = false
- if (p.getFlag("pause") == true) {
- Log.d(TAG, "Applying deferred auto-resume after $reason")
- p.setProperty("pause", false)
+ private suspend fun writeProperty(name: String, value: String) {
+ val writer = propertyWriterOverride
+ if (writer != null) {
+ writer(name, value)
} else {
- Log.d(TAG, "Skipping deferred auto-resume after $reason because playback is already running")
+ val currentPlayer = player ?: throw CancellationException("MPV player unavailable")
+ currentPlayer.setProperty(name, value)
}
}
// Public API
+ /**
+ * Atomically records the public pause intent applied by the next loadfile
+ * operation. The load owns the native state transition, so this deliberately
+ * does not enqueue a second pause property write.
+ */
+ fun setPauseIntentForLoad(paused: Boolean) {
+ if (!isInitialized || disposing || !scope.isActive) return
+
+ synchronized(publicPauseIntentLock) {
+ publicPauseIntentGeneration += 1L
+ desiredPaused = paused
+ resumeBlockedByPublicPause = paused
+ if (paused) {
+ cachedPaused = true
+ pausedForSurfaceLoss = false
+ pausedForAudioFocusLoss = false
+ deferredResumeRequested = false
+ } else if (!pausedForSurfaceLoss && !pausedForAudioFocusLoss && !deferredResumeRequested) {
+ cachedPaused = false
+ }
+ }
+ Log.d(TAG, "Load pause intent updated: paused=$paused")
+ }
fun setProperty(name: String, value: String, onComplete: ((Result) -> Unit)? = null) {
if (!isInitialized || disposing || !scope.isActive) {
- onComplete?.invoke(Result.failure(IllegalStateException("MPV core unavailable")))
+ onComplete?.invoke(Result.failure(CancellationException("MPV core unavailable")))
return
}
val paused = if (name == "pause") normalizePauseValue(value) else null
- if (paused == false && !hasReadyVideoOutput()) {
+ val pauseIntent = paused?.let {
+ synchronized(publicPauseIntentLock) {
+ PublicPauseIntent(
+ generation = ++publicPauseIntentGeneration,
+ previousBlocked = resumeBlockedByPublicPause,
+ previousDesiredPaused = desiredPaused
+ ).also {
+ resumeBlockedByPublicPause = paused
+ desiredPaused = paused
+ }
+ }
+ }
+
+ if (paused == false && (pausedForAudioFocusLoss || !hasReadyVideoOutput())) {
runOnMain {
if (!isInitialized || disposing || !scope.isActive) {
onComplete?.invoke(Result.failure(CancellationException("MPV core unavailable")))
return@runOnMain
}
- resumeBlockedByPublicPause = false
- deferredResumeRequested = true
- Log.d(TAG, "Deferring public resume until video output is ready")
+ val isCurrent = synchronized(publicPauseIntentLock) {
+ pauseIntent != null && publicPauseIntentGeneration == pauseIntent.generation
+ }
+ if (isCurrent) {
+ deferredResumeRequested = true
+ Log.d(
+ TAG,
+ if (pausedForAudioFocusLoss) {
+ "Deferring public resume until audio focus returns"
+ } else {
+ "Deferring public resume until video output is ready"
+ }
+ )
+ }
onComplete?.invoke(Result.success(Unit))
}
return
@@ -727,12 +874,15 @@ class MpvPlayerCore private constructor(
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
val writeResult = try {
- val writer = propertyWriterOverride
- if (writer != null) {
- writer(name, value)
+ if (pauseIntent == null) {
+ writeProperty(name, value)
} else {
- val currentPlayer = player ?: throw IllegalStateException("MPV player unavailable")
- currentPlayer.setProperty(name, value)
+ publicPauseWriteMutex.withLock {
+ val shouldWrite = synchronized(publicPauseIntentLock) {
+ publicPauseIntentGeneration == pauseIntent.generation
+ }
+ if (shouldWrite) writeProperty(name, value)
+ }
}
Result.success(Unit)
} catch (error: CancellationException) {
@@ -742,23 +892,29 @@ class MpvPlayerCore private constructor(
Result.failure(error)
}
+ if (writeResult.isFailure && pauseIntent != null) {
+ rollbackFailedPublicPauseIntent(pauseIntent)
+ }
+
withContext(NonCancellable + Dispatchers.Main) {
val completion = if (disposing || !isInitialized) {
Result.failure(CancellationException("MPV core unavailable"))
} else {
writeResult
}
- if (completion.isSuccess) {
+ val isCurrent = pauseIntent == null ||
+ synchronized(publicPauseIntentLock) {
+ publicPauseIntentGeneration == pauseIntent.generation
+ }
+ if (isCurrent && 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")
}
@@ -1042,10 +1198,15 @@ class MpvPlayerCore private constructor(
placeholderImageReader?.close()
placeholderImageReader = null
pausedForSurfaceLoss = false
+ pausedForAudioFocusLoss = false
attachedToPlaceholder = false
videoOutputRestoring = false
deferredResumeRequested = false
- resumeBlockedByPublicPause = false
+ synchronized(publicPauseIntentLock) {
+ publicPauseIntentGeneration += 1L
+ resumeBlockedByPublicPause = false
+ desiredPaused = true
+ }
videoOutputEpoch = 0L
pendingVideoOutputDisableJob = null
isInitialized = false
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt
index 18ef497b..f10d37cb 100644
--- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt
+++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt
@@ -12,20 +12,18 @@ import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
+import java.util.concurrent.CancellationException
internal fun completeMpvPropertyResult(
result: MethodChannel.Result,
outcome: Result,
successValue: Any? = null
) {
- if (outcome.isSuccess) {
- result.success(successValue)
- } else {
- result.error(
- "SET_PROPERTY_FAILED",
- "MPV property write was rejected or cancelled",
- null
- )
+ val failure = outcome.exceptionOrNull()
+ when {
+ failure == null -> result.success(successValue)
+ failure is CancellationException -> completeMpvPropertyNotInitialized(result)
+ else -> result.error("SET_PROPERTY_FAILED", "MPV property write was rejected", null)
}
}
@@ -73,6 +71,8 @@ open class MpvPlayerPlugin(
private val pendingInitResults = mutableListOf()
@Volatile private var isInitializing = false
+ private var initAttemptCounter = 0
+ private var activeInitAttempt: Int? = null
// FlutterPlugin
@@ -82,22 +82,26 @@ open class MpvPlayerPlugin(
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
- channels.detach()
- if (audioOnly) {
- // The audio core is not activity-bound; engine detach is its terminal
- // native lifecycle event (mirrors the video core's activity detach).
- disposeCoreForTeardown()
- }
+ // Engine detach is terminal for both video and audio plugin instances.
+ // Dispose before detaching channels so no native work can publish into a
+ // dead messenger.
+ disposeCoreForTeardown()
+ activity = null
+ activityBinding = null
applicationContext = null
+ channels.detach()
+ }
+
+ private fun takeCoreForTeardown(): MpvPlayerCore? {
+ ++sessionGeneration
+ val core = playerCore
+ playerCore = null
+ cancelPendingInits()
+ return core
}
private fun disposeCoreForTeardown() {
- ++sessionGeneration
- playerCore?.dispose()
- playerCore = null
- // Any in-flight init callback would never fire (its scope is cancelled
- // by dispose), so close out queued callers explicitly.
- completePendingInits(success = false)
+ takeCoreForTeardown()?.dispose()
}
// ActivityAware
@@ -126,6 +130,12 @@ open class MpvPlayerPlugin(
}
override fun onDetachedFromActivityForConfigChanges() {
+ // The video core owns views and window services from this Activity. Plezy
+ // does not retain the engine across configuration recreation, so there is
+ // no Activity-transfer contract under which that core may survive.
+ if (!audioOnly) {
+ disposeCoreForTeardown()
+ }
activity = null
activityBinding = null
Log.d(tag, "Detached from activity for config changes")
@@ -189,16 +199,29 @@ open class MpvPlayerPlugin(
// call's outcome instead of disposing the in-flight core. The Dart
// side memoizes too, but this is defense in depth for any direct
// `invoke('initialize')` that bypasses _ensureInitialized.
- synchronized(pendingInitResults) {
+ val attempt = synchronized(pendingInitResults) {
pendingInitResults += result
if (isInitializing) {
- Log.d(tag, "Init already in flight, queuing caller")
- return
+ null
+ } else {
+ isInitializing = true
+ (++initAttemptCounter).also { activeInitAttempt = it }
}
- isInitializing = true
+ }
+ if (attempt == null) {
+ Log.d(tag, "Init already in flight, queuing caller")
+ return
}
runOnMain {
+ if (!isCurrentInitAttempt(attempt) ||
+ (!audioOnly && activity !== coreContext) ||
+ (audioOnly && applicationContext !== coreContext)
+ ) {
+ completePendingInits(attempt, success = false)
+ return@runOnMain
+ }
+
val gen: Int
val core: MpvPlayerCore
try {
@@ -218,28 +241,58 @@ open class MpvPlayerPlugin(
playerCore = core
} catch (e: Exception) {
Log.e(tag, "Failed to initialize: ${e.message}", e)
- completePendingInits(success = false, errorMessage = e.message)
+ completePendingInits(attempt, success = false, errorMessage = e.message)
return@runOnMain
}
core.initialize { success ->
- val stale = gen != sessionGeneration || playerCore !== core
- if (stale) {
- Log.d(tag, "Stale init callback (gen=$gen, current=$sessionGeneration)")
+ val stale = gen != sessionGeneration ||
+ playerCore !== core ||
+ !isCurrentInitAttempt(attempt)
+ if (stale || !success) {
+ if (playerCore === core) playerCore = null
+ core.dispose()
+ if (stale) {
+ Log.d(tag, "Stale init callback (gen=$gen, current=$sessionGeneration)")
+ } else {
+ Log.d(tag, "Initialized: false")
+ }
} else {
// Start hidden - now safe because setVisible operates on the container,
// not the SurfaceView directly (matching ExoPlayer's approach).
// No-op on the audio-only core, which has no render layer.
core.setVisible(false)
- Log.d(tag, "Initialized: $success")
+ Log.d(tag, "Initialized: true")
}
- completePendingInits(success = !stale && success)
+ completePendingInits(attempt, success = !stale && success)
}
}
}
- private fun completePendingInits(success: Boolean, errorMessage: String? = null) {
+ private fun isCurrentInitAttempt(attempt: Int): Boolean = synchronized(pendingInitResults) {
+ isInitializing && activeInitAttempt == attempt
+ }
+
+ private fun cancelPendingInits() {
val pending = synchronized(pendingInitResults) {
+ ++initAttemptCounter
+ activeInitAttempt = null
+ isInitializing = false
+ val copy = pendingInitResults.toList()
+ pendingInitResults.clear()
+ copy
+ }
+ pending.forEach { it.success(false) }
+ }
+
+ internal fun completePendingInits(
+ attempt: Int,
+ success: Boolean,
+ errorMessage: String? = null
+ ) {
+ val pending = synchronized(pendingInitResults) {
+ if (activeInitAttempt != attempt) return
+ activeInitAttempt = null
isInitializing = false
val copy = pendingInitResults.toList()
pendingInitResults.clear()
@@ -256,14 +309,7 @@ open class MpvPlayerPlugin(
private fun handleDispose(result: MethodChannel.Result) {
runOnMain {
- val core = playerCore
- ++sessionGeneration
- playerCore = null
-
- // Any in-flight init callback is cancelled with the scope, so
- // close out queued callers here instead of leaking them.
- completePendingInits(success = false)
-
+ val core = takeCoreForTeardown()
core?.dispose {
Log.d(tag, "Disposed")
result.success(null)
@@ -287,6 +333,9 @@ open class MpvPlayerPlugin(
}
core.setProperty(name, value) { outcome ->
+ if (outcome.isFailure && outcome.exceptionOrNull() !is CancellationException) {
+ Log.w(tag, "MPV rejected property '$name'; keeping the previous value")
+ }
completeMpvPropertyResult(result, outcome)
}
}
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/shared/PlayerChannelBinding.kt b/android/app/src/main/kotlin/com/edde746/plezy/shared/PlayerChannelBinding.kt
index 9833955a..9a84706c 100644
--- a/android/app/src/main/kotlin/com/edde746/plezy/shared/PlayerChannelBinding.kt
+++ b/android/app/src/main/kotlin/com/edde746/plezy/shared/PlayerChannelBinding.kt
@@ -14,25 +14,28 @@ internal class PlayerChannelBinding(
private val streamHandler: EventChannel.StreamHandler,
private val logTag: String
) {
- private lateinit var methodChannel: MethodChannel
- private lateinit var eventChannel: EventChannel
+ private var methodChannel: MethodChannel? = null
+ private var eventChannel: EventChannel? = null
private var eventSink: EventChannel.EventSink? = null
val mainHandler = Handler(Looper.getMainLooper())
fun attach(binding: FlutterPlugin.FlutterPluginBinding) {
- methodChannel = MethodChannel(binding.binaryMessenger, channelBase)
- methodChannel.setMethodCallHandler(methodCallHandler)
-
- eventChannel = EventChannel(binding.binaryMessenger, "$channelBase/events")
- eventChannel.setStreamHandler(streamHandler)
+ methodChannel = MethodChannel(binding.binaryMessenger, channelBase).also {
+ it.setMethodCallHandler(methodCallHandler)
+ }
+ eventChannel = EventChannel(binding.binaryMessenger, "$channelBase/events").also {
+ it.setStreamHandler(streamHandler)
+ }
Log.d(logTag, "Attached to engine")
}
fun detach() {
- methodChannel.setMethodCallHandler(null)
- eventChannel.setStreamHandler(null)
+ methodChannel?.setMethodCallHandler(null)
+ eventChannel?.setStreamHandler(null)
+ methodChannel = null
+ eventChannel = null
eventSink = null
Log.d(logTag, "Detached from engine")
}
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/SystemShelfArtworkProvider.kt b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/SystemShelfArtworkProvider.kt
index bdb98fb1..8068b160 100644
--- a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/SystemShelfArtworkProvider.kt
+++ b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/SystemShelfArtworkProvider.kt
@@ -5,7 +5,9 @@ import android.content.ContentValues
import android.database.Cursor
import android.graphics.BitmapFactory
import android.net.Uri
+import android.os.Binder
import android.os.ParcelFileDescriptor
+import android.os.Process
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileNotFoundException
@@ -13,6 +15,9 @@ import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
import java.util.UUID
+import java.util.concurrent.Executors
+import java.util.concurrent.ScheduledExecutorService
+import java.util.concurrent.TimeUnit
class SystemShelfArtworkProvider : ContentProvider() {
companion object {
@@ -24,6 +29,19 @@ class SystemShelfArtworkProvider : ContentProvider() {
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor {
if (mode != "r") throw FileNotFoundException("Read-only artwork")
val appContext = context ?: throw FileNotFoundException("Provider unavailable")
+ val callingUid = Binder.getCallingUid()
+ if (callingUid != Process.myUid()) {
+ val homeIntent = android.content.Intent(android.content.Intent.ACTION_MAIN)
+ .addCategory(android.content.Intent.CATEGORY_HOME)
+ val homePackage = appContext.packageManager
+ .resolveActivity(homeIntent, android.content.pm.PackageManager.MATCH_DEFAULT_ONLY)
+ ?.activityInfo
+ ?.packageName
+ val callerPackages = appContext.packageManager.getPackagesForUid(callingUid)
+ if (homePackage == null || callerPackages == null || homePackage !in callerPackages) {
+ throw FileNotFoundException("Artwork caller is not the active HOME launcher")
+ }
+ }
val file = SystemShelfArtworkStore(appContext.cacheDir).resolve(uri)
?: throw FileNotFoundException("Unknown artwork")
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
@@ -42,54 +60,108 @@ class SystemShelfArtworkProvider : ContentProvider() {
override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0
}
+internal class SystemShelfSyncSession(
+ internal val ownership: SystemShelfLifecycle.Ownership,
+ durationMillis: Long,
+ private val nanoTime: () -> Long = System::nanoTime,
+ internal val budget: SystemShelfArtworkStore.Budget = SystemShelfArtworkStore.Budget()
+) {
+ private val deadlineNanos = nanoTime() + TimeUnit.MILLISECONDS.toNanos(durationMillis)
+
+ fun isExpired(): Boolean = nanoTime() >= deadlineNanos
+
+ fun isActive(): Boolean = !isExpired() && SystemShelfLifecycle.isCurrent(ownership)
+
+ fun remainingNanos(): Long = (deadlineNanos - nanoTime()).coerceAtLeast(0)
+
+ fun commitIfActive(block: () -> Boolean): Boolean = SystemShelfLifecycle.whileCurrent(ownership) {
+ if (isExpired()) false else block()
+ } ?: false
+}
+
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 MAX_SYNC_DURATION_MS = 10_000L
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$")
+ private val deadlineAborter: ScheduledExecutorService =
+ Executors.newSingleThreadScheduledExecutor { runnable ->
+ Thread(runnable, "system-shelf-deadline").apply { isDaemon = true }
+ }
}
data class Materialized(val key: String, val uri: Uri, val file: File)
- class Budget(var remaining: Int = MAX_SYNC_BYTES)
+ class Budget(var remaining: Int = MAX_SYNC_BYTES) {
+ var consumed: Long = 0
+ private set
+
+ fun charge(bytes: Int): Boolean {
+ if (bytes <= 0) return true
+ consumed += bytes
+ if (bytes > remaining) {
+ remaining = 0
+ return false
+ }
+ remaining -= bytes
+ return true
+ }
+ }
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
+ fun materialize(ownerId: String, source: String, session: SystemShelfSyncSession): Materialized? {
+ if (ownerId.isBlank() || session.budget.remaining <= 0 || !session.isActive()) 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
+ val remainingNanos = session.remainingNanos()
+ if (remainingNanos <= 0) return null
+ val abort = deadlineAborter.schedule(
+ { connection.disconnect() },
+ remainingNanos,
+ TimeUnit.NANOSECONDS
+ )
return try {
+ val remainingMillis = TimeUnit.NANOSECONDS.toMillis(remainingNanos).coerceIn(1, Int.MAX_VALUE.toLong()).toInt()
connection.instanceFollowRedirects = true
- connection.connectTimeout = CONNECT_TIMEOUT_MS
- connection.readTimeout = READ_TIMEOUT_MS
+ connection.connectTimeout = minOf(CONNECT_TIMEOUT_MS, remainingMillis)
+ connection.readTimeout = minOf(READ_TIMEOUT_MS, remainingMillis)
connection.useCaches = false
connection.setRequestProperty("Accept", "image/*")
val status = connection.responseCode
- if (status !in 200..299) return null
+ if (!session.isActive() || 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)
+ val cap = minOf(MAX_IMAGE_BYTES, session.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 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 (!session.isActive()) return null
+ val remaining = minOf(MAX_IMAGE_BYTES - total, session.budget.remaining)
+ if (remaining <= 0) {
+ if (contentLength >= 0 && total.toLong() == contentLength) break
+ return null
+ }
+ val read = input.read(buffer, 0, minOf(buffer.size, remaining))
if (read < 0) break
+ session.budget.charge(read)
total += read
- if (total > cap) return null
output.write(buffer, 0, read)
}
output.toByteArray()
}
- if (!isSupportedImage(bytes)) return null
+ if (!session.isActive() || !isSupportedImage(bytes)) return null
val ownerKey = sha256(ownerId)
val directory = File(root, ownerKey)
if (!directory.mkdirs() && !directory.isDirectory) return null
@@ -100,16 +172,20 @@ internal class SystemShelfArtworkStore(private val cacheDir: File) {
output.flush()
output.fd.sync()
}
+ if (!session.isActive()) {
+ staged.delete()
+ return null
+ }
val destination = File(directory, key)
- if (!staged.renameTo(destination)) {
+ if (!session.commitIfActive { staged.renameTo(destination) }) {
staged.delete()
return null
}
- budget.remaining -= bytes.size
Materialized(key, contentUri(ownerKey, key), destination)
} catch (_: Exception) {
null
} finally {
+ abort.cancel(false)
connection.disconnect()
}
}
@@ -145,6 +221,17 @@ internal class SystemShelfArtworkStore(private val cacheDir: File) {
}
}
+ fun delete(files: Set) {
+ val canonicalRoot = root.canonicalFile
+ files.forEach { file ->
+ val candidate = runCatching { file.canonicalFile }.getOrNull() ?: return@forEach
+ if (candidate.parentFile?.parentFile == canonicalRoot) candidate.delete()
+ }
+ root.listFiles()?.forEach { directory ->
+ if (directory.listFiles().isNullOrEmpty()) directory.delete()
+ }
+ }
+
fun deleteAll(): Boolean = !root.exists() || root.deleteRecursively()
private fun isSupportedImage(bytes: ByteArray): Boolean {
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/SystemShelfUpdateReceiver.kt b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/SystemShelfUpdateReceiver.kt
index f3a0bbef..fc09c7af 100644
--- a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/SystemShelfUpdateReceiver.kt
+++ b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/SystemShelfUpdateReceiver.kt
@@ -7,7 +7,7 @@ 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. */
+/** Migrates versioned shelf state after updates and restores volatile launcher grants after boot. */
class SystemShelfUpdateReceiver private constructor(
private val executor: Executor,
private val ownsExecutor: Boolean
@@ -16,11 +16,17 @@ class SystemShelfUpdateReceiver private constructor(
internal constructor(executor: Executor) : this(executor, false)
override fun onReceive(context: Context, intent: Intent) {
- if (intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) return
+ val action = intent.action
+ if (action != Intent.ACTION_MY_PACKAGE_REPLACED && action != Intent.ACTION_BOOT_COMPLETED) return
val pending = goAsync()
executor.execute {
try {
- WatchNextProvider(context.applicationContext).clearLegacyOnPackageUpdate()
+ val provider = WatchNextProvider.forMaintenance(context.applicationContext)
+ if (action == Intent.ACTION_MY_PACKAGE_REPLACED) {
+ provider.migrateShelfSchema()
+ } else {
+ provider.restoreReadGrants()
+ }
} finally {
pending?.finish()
if (ownsExecutor) (executor as ExecutorService).shutdown()
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextPlugin.kt
index 27720db5..1f8f0418 100644
--- a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextPlugin.kt
+++ b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextPlugin.kt
@@ -10,12 +10,17 @@ import androidx.tvprovider.media.tv.TvContractCompat
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
+import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
+import java.util.concurrent.atomic.AtomicBoolean
-/** Flutter bridge for profile-owned Android TV Watch Next mutations. */
-class WatchNextPlugin :
+class WatchNextPlugin() :
FlutterPlugin,
MethodChannel.MethodCallHandler {
+ internal constructor(executorFactory: () -> ExecutorService) : this() {
+ this.executorFactory = executorFactory
+ }
+
companion object {
private const val TAG = "WatchNextPlugin"
private const val METHOD_CHANNEL = "com.plezy/watch_next"
@@ -32,24 +37,64 @@ class WatchNextPlugin :
}
}
+ private var executorFactory: () -> ExecutorService = { Executors.newSingleThreadExecutor() }
+
+ private class EngineSession(val context: Context) {
+ private val closed = AtomicBoolean(false)
+ var lease: SystemShelfLifecycle.Lease? = null
+ var provider: WatchNextProvider? = null
+
+ fun close() {
+ closed.set(true)
+ }
+
+ fun isOpen(): Boolean = !closed.get()
+ }
+
private lateinit var methodChannel: MethodChannel
private var applicationContext: Context? = null
- private var watchNextProvider: WatchNextProvider? = null
- private val ioExecutor by lazy { Executors.newSingleThreadExecutor() }
+ private var engineSession: EngineSession? = null
+ private var ioExecutor: ExecutorService? = null
private val mainHandler = Handler(Looper.getMainLooper())
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
+ val executor = executorFactory()
+ val session = EngineSession(binding.applicationContext)
+ ioExecutor = executor
+ engineSession = session
applicationContext = binding.applicationContext
- watchNextProvider = WatchNextProvider(binding.applicationContext)
methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL)
methodChannel.setMethodCallHandler(this)
+ executor.execute {
+ val lease = SystemShelfLifecycle.acquireIf(session::isOpen) ?: return@execute
+ session.lease = lease
+ if (session.isOpen()) {
+ session.provider = WatchNextProvider(session.context, lease)
+ }
+ }
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel.setMethodCallHandler(null)
+ val session = engineSession
+ val executor = ioExecutor
+ session?.close()
+ engineSession = null
+ ioExecutor = null
applicationContext = null
- watchNextProvider = null
- ioExecutor.shutdown()
+ if (session != null && executor != null) {
+ try {
+ executor.execute {
+ session.lease?.let(SystemShelfLifecycle::invalidate)
+ session.lease = null
+ session.provider = null
+ }
+ } catch (_: java.util.concurrent.RejectedExecutionException) {
+ Log.e(TAG, "System shelf lifecycle executor rejected detach")
+ } finally {
+ executor.shutdown()
+ }
+ }
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
@@ -76,7 +121,8 @@ class WatchNextPlugin :
}
private fun handleSync(call: MethodCall, result: MethodChannel.Result) {
- val provider = watchNextProvider ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
+ val session = engineSession ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
+ val executor = ioExecutor ?: 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>>("items")
@@ -85,28 +131,48 @@ class WatchNextPlugin :
return result.error("INVALID_ARGS", "Too many items", null)
}
val items = itemsData.mapNotNull(::parseWatchNextItem)
- executeOnIo(result) { provider.syncWatchNextPrograms(owner, generation, items) }
+ executeOnIo(executor, result) {
+ if (!session.isOpen()) return@executeOnIo false
+ val provider = session.provider ?: return@executeOnIo false
+ val ownership = provider.claimOwnership(owner, generation) ?: return@executeOnIo false
+ if (!session.isOpen()) return@executeOnIo false
+ provider.syncWatchNextPrograms(owner, generation, items, ownership, session::isOpen)
+ }
}
private fun handleClear(call: MethodCall, result: MethodChannel.Result) {
- val provider = watchNextProvider ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
+ val session = engineSession ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
+ val executor = ioExecutor ?: 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) }
+ executeOnIo(executor, result) {
+ if (!session.isOpen()) return@executeOnIo false
+ val provider = session.provider ?: return@executeOnIo false
+ val ownership = provider.claimOwnership(owner, generation) ?: return@executeOnIo false
+ if (!session.isOpen()) return@executeOnIo false
+ provider.clearAll(owner, generation, ownership, session::isOpen)
+ }
}
private fun handleRemove(call: MethodCall, result: MethodChannel.Result) {
- val provider = watchNextProvider ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
+ val session = engineSession ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
+ val executor = ioExecutor ?: 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("contentId")
?: return result.error("INVALID_ARGS", "Missing contentId", null)
- executeOnIo(result) { provider.removeItem(owner, generation, contentId) }
+ executeOnIo(executor, result) {
+ if (!session.isOpen()) return@executeOnIo false
+ val provider = session.provider ?: return@executeOnIo false
+ val ownership = provider.claimOwnership(owner, generation) ?: return@executeOnIo false
+ if (!session.isOpen()) return@executeOnIo false
+ provider.removeItem(owner, generation, contentId, ownership, session::isOpen)
+ }
}
- private fun executeOnIo(result: MethodChannel.Result, block: () -> Any?) {
+ private fun executeOnIo(executor: ExecutorService, result: MethodChannel.Result, block: () -> Any?) {
try {
- ioExecutor.execute {
+ executor.execute {
try {
val value = block()
mainHandler.post { result.success(value) }
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextProvider.kt b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextProvider.kt
index ccb63104..2ebd4120 100644
--- a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextProvider.kt
+++ b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextProvider.kt
@@ -6,16 +6,99 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
+import android.os.Build
import android.util.Log
import androidx.tvprovider.media.tv.TvContractCompat
import androidx.tvprovider.media.tv.WatchNextProgram
+internal object SystemShelfLifecycle {
+ data class Lease internal constructor(internal val token: Long)
+ data class Ownership internal constructor(
+ internal val engineToken: Long,
+ internal val claimToken: Long
+ )
+
+ private val lock = Any()
+ private val operationLock = Any()
+ private var token = 0L
+ private var claimToken = 0L
+ private var currentOwner = ""
+ private var currentGeneration = 0L
+
+ fun acquire(): Lease = acquireIf { true }!!
+
+ fun acquireIf(isActive: () -> Boolean): Lease? = synchronized(operationLock) {
+ if (!isActive()) return@synchronized null
+ synchronized(lock) {
+ token += 1
+ claimToken += 1
+ currentOwner = ""
+ currentGeneration = 0
+ Lease(token)
+ }
+ }
+
+ fun invalidate(lease: Lease) {
+ synchronized(operationLock) {
+ synchronized(lock) {
+ if (token == lease.token) {
+ token += 1
+ claimToken += 1
+ }
+ }
+ }
+ }
+
+ fun claim(lease: Lease, ownerId: String, generation: Long): Ownership? = synchronized(operationLock) {
+ synchronized(lock) {
+ if (
+ token != lease.token ||
+ ownerId.isBlank() ||
+ generation <= 0 ||
+ generation < currentGeneration ||
+ generation == currentGeneration &&
+ currentOwner.isNotEmpty() &&
+ currentOwner != ownerId
+ ) {
+ null
+ } else {
+ currentOwner = ownerId
+ currentGeneration = generation
+ claimToken += 1
+ Ownership(token, claimToken)
+ }
+ }
+ }
+
+ fun isCurrent(ownership: Ownership): Boolean = synchronized(lock) {
+ token == ownership.engineToken && claimToken == ownership.claimToken
+ }
+
+ fun whileCurrent(ownership: Ownership, block: () -> T): T? = synchronized(operationLock) {
+ if (!isCurrent(ownership)) return@synchronized null
+ val result = block()
+ if (isCurrent(ownership)) result else null
+ }
+
+ fun exclusive(block: () -> T): T = synchronized(operationLock, block)
+}
+
/** Owns Plezy's durable Android TV Watch Next rows and their local artwork. */
-class WatchNextProvider(private val context: Context) {
+class WatchNextProvider internal constructor(
+ private val context: Context,
+ private val lifecycleLease: SystemShelfLifecycle.Lease?,
+ private val syncDurationMillis: Long = SystemShelfArtworkStore.MAX_SYNC_DURATION_MS
+) {
+ constructor(context: Context) : this(context, SystemShelfLifecycle.acquire())
companion object {
private const val TAG = "WatchNextProvider"
private const val PREFS = "system_shelf_state"
private const val GRANTED_URIS = "granted_uris"
+ private const val GRANTED_PACKAGES = "granted_packages"
+ internal const val SHELF_SCHEMA_VERSION = 1
+ private const val SHELF_SCHEMA_VERSION_KEY = "shelf_schema_version"
+
+ internal fun forMaintenance(context: Context) = WatchNextProvider(context, null)
}
data class WatchNextItem(
@@ -37,72 +120,157 @@ class WatchNextProvider(private val context: Context) {
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private val artwork = SystemShelfArtworkStore(context.cacheDir)
- private var currentOwner = ""
- private var currentGeneration = 0L
- /** Materializes transient art, then atomically replaces the durable rows. */
- fun syncWatchNextPrograms(ownerId: String, generation: Long, items: List): Boolean {
- if (!accepts(ownerId, generation) || items.size > SystemShelfArtworkStore.MAX_ITEMS) return false
+ internal fun claimOwnership(ownerId: String, generation: Long): SystemShelfLifecycle.Ownership? = lifecycleLease?.let { SystemShelfLifecycle.claim(it, ownerId, generation) }
+
+ internal fun syncWatchNextPrograms(
+ ownerId: String,
+ generation: Long,
+ items: List,
+ ownership: SystemShelfLifecycle.Ownership? = null,
+ isOperationActive: () -> Boolean = { true }
+ ): Boolean {
+ if (items.size > SystemShelfArtworkStore.MAX_ITEMS) return false
+ val operationOwnership = ownership ?: claimOwnership(ownerId, generation) ?: return false
+ if (!isOperationActive()) 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
- }
+ val oldPackages = storedPackages()
+ val oldSchemaVersion = prefs.getInt(SHELF_SCHEMA_VERSION_KEY, 0)
+ val session = SystemShelfSyncSession(
+ operationOwnership,
+ syncDurationMillis
+ )
+ val materializedFiles = LinkedHashSet()
+ var committed = false
+ try {
+ val prepared = items.map { item ->
+ if (!isOperationActive() || !session.isActive()) {
+ PreparedWatchNextItem(item, null)
+ } else {
+ val materialized = item.posterSourceUri?.let { artwork.materialize(ownerId, it, session) }
+ materialized?.file?.let(materializedFiles::add)
+ PreparedWatchNextItem(item, materialized?.uri)
+ }
+ }
+ val newUris = prepared.mapNotNullTo(LinkedHashSet()) { it.localPosterUri }
+ val newPackages = consumerPackages()
+ if (!isOperationActive() || !session.isActive()) return false
+ committed = SystemShelfLifecycle.whileCurrent(session.ownership) {
+ if (!isOperationActive() || session.isExpired()) return@whileCurrent false
+ reconcileReadAccess(oldUris, oldPackages, newUris, newPackages)
+ if (session.isExpired()) {
+ reconcileReadAccess(newUris, newPackages, oldUris, oldPackages)
+ return@whileCurrent false
+ }
+ val preferencesCommitted = prefs.edit()
+ .putStringSet(GRANTED_URIS, newUris.mapTo(LinkedHashSet(), Uri::toString))
+ .putStringSet(GRANTED_PACKAGES, newPackages)
+ .putInt(SHELF_SCHEMA_VERSION_KEY, SHELF_SCHEMA_VERSION)
+ .commit()
+ if (!preferencesCommitted) {
+ reconcileReadAccess(newUris, newPackages, oldUris, oldPackages)
+ return@whileCurrent false
+ }
+ if (session.isExpired() || !replaceRows(prepared)) {
+ val rollback = prefs.edit()
+ .putStringSet(GRANTED_URIS, oldUris.mapTo(LinkedHashSet(), Uri::toString))
+ .putStringSet(GRANTED_PACKAGES, oldPackages)
+ if (oldSchemaVersion > 0) {
+ rollback.putInt(SHELF_SCHEMA_VERSION_KEY, oldSchemaVersion)
+ } else {
+ rollback.remove(SHELF_SCHEMA_VERSION_KEY)
+ }
+ rollback.commit()
+ reconcileReadAccess(newUris, newPackages, oldUris, oldPackages)
+ return@whileCurrent false
+ }
- val newUris = prepared.mapNotNullTo(LinkedHashSet()) { it.localPosterUri }
- grantReadAccess(newUris)
- val committed = replaceRows(prepared)
- if (!committed) {
- revokeReadAccess(newUris - oldUris)
- artwork.deleteExcept(oldFiles)
- return false
+ artwork.deleteExcept(materializedFiles)
+ true
+ } ?: false
+ return committed
+ } finally {
+ if (!committed) {
+ materializedFiles.forEach { it.delete() }
+ }
}
+ }
+ /** Deletes rows first, then revokes grants and owned files. */
+ internal fun clearAll(
+ ownerId: String,
+ generation: Long,
+ ownership: SystemShelfLifecycle.Ownership? = null,
+ isOperationActive: () -> Boolean = { true }
+ ): Boolean {
+ val operationOwnership = ownership ?: claimOwnership(ownerId, generation) ?: return false
+ return SystemShelfLifecycle.whileCurrent(operationOwnership) {
+ if (!isOperationActive()) return@whileCurrent false
+ val rowsCleared = deleteRows()
+ if (!rowsCleared) return@whileCurrent false
+ val uris = storedUris()
+ val packages = storedPackages()
+ reconcileReadAccess(uris, packages, emptySet(), emptySet())
+ artwork.deleteAll()
+ prefs.edit()
+ .remove(GRANTED_URIS)
+ .remove(GRANTED_PACKAGES)
+ .putInt(SHELF_SCHEMA_VERSION_KEY, SHELF_SCHEMA_VERSION)
+ .commit()
+ true
+ } ?: false
+ }
+
+ /** Removes only data from a shelf schema older than the current on-device contract. */
+ fun migrateShelfSchema(): Boolean = SystemShelfLifecycle.exclusive {
+ if (prefs.getInt(SHELF_SCHEMA_VERSION_KEY, 0) >= SHELF_SCHEMA_VERSION) {
+ return@exclusive restoreReadGrantsOwned()
+ }
+ val rowsCleared = deleteRows()
+ if (!rowsCleared) return@exclusive false
+ val uris = storedUris()
+ val packages = storedPackages()
+ reconcileReadAccess(uris, packages, emptySet(), emptySet())
+ artwork.deleteAll()
prefs.edit()
- .putStringSet(GRANTED_URIS, newUris.mapTo(LinkedHashSet(), Uri::toString))
+ .clear()
+ .putInt(SHELF_SCHEMA_VERSION_KEY, SHELF_SCHEMA_VERSION)
.commit()
- currentOwner = ownerId
- currentGeneration = generation
- revokeReadAccess(oldUris - newUris)
- artwork.deleteExcept(prepared.mapNotNullTo(HashSet()) { it.localPosterUri?.let(artwork::resolve) })
- return true
}
- /** 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
+ /** Re-establishes reboot-volatile grants only for persisted, confined artwork files. */
+ fun restoreReadGrants(): Boolean = SystemShelfLifecycle.exclusive {
+ restoreReadGrantsOwned()
}
- /** 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
+ private fun restoreReadGrantsOwned(): Boolean {
+ val previousUris = storedUris()
+ val previousPackages = storedPackages()
+ val validUris = previousUris.filterTo(LinkedHashSet()) { artwork.resolve(it) != null }
+ val currentPackages = consumerPackages()
+ reconcileReadAccess(previousUris, previousPackages, validUris, currentPackages)
+ return prefs.edit()
+ .putStringSet(GRANTED_URIS, validUris.mapTo(LinkedHashSet(), Uri::toString))
+ .putStringSet(GRANTED_PACKAGES, currentPackages)
+ .commit()
}
- fun removeItem(ownerId: String, generation: Long, contentId: String): Boolean {
- if (!accepts(ownerId, generation)) return false
+ internal fun removeItem(
+ ownerId: String,
+ generation: Long,
+ contentId: String,
+ ownership: SystemShelfLifecycle.Ownership? = null,
+ isOperationActive: () -> Boolean = { true }
+ ): Boolean {
+ val operationOwnership = ownership ?: claimOwnership(ownerId, generation) ?: return false
+ return SystemShelfLifecycle.whileCurrent(operationOwnership) {
+ if (!isOperationActive()) return@whileCurrent false
+ removeItemOwned(contentId)
+ } ?: false
+ }
+
+ private fun removeItemOwned(contentId: String): Boolean {
return try {
val cursor = context.contentResolver.query(
TvContractCompat.WatchNextPrograms.CONTENT_URI,
@@ -144,16 +312,6 @@ class WatchNextProvider(private val context: Context) {
}
}
- 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): Boolean = try {
val operations = ArrayList(items.size + 1)
operations += ContentProviderOperation.newDelete(TvContractCompat.WatchNextPrograms.CONTENT_URI).build()
@@ -177,20 +335,39 @@ class WatchNextProvider(private val context: Context) {
false
}
- private fun consumerPackages(): Set {
- val packages = LinkedHashSet()
- 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
+ internal fun consumerPackages(): Set {
+ val homeIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
+ val packageManager = context.packageManager
+ val selectedHome = packageManager.resolveActivity(homeIntent, PackageManager.MATCH_DEFAULT_ONLY)
+ ?.activityInfo ?: return emptySet()
+ val packageName = selectedHome.packageName?.takeIf(String::isNotBlank) ?: return emptySet()
+ val activityName = selectedHome.name?.takeIf(String::isNotBlank) ?: return emptySet()
+ val isHomeHandler = packageManager.queryIntentActivities(homeIntent, PackageManager.MATCH_ALL).any { candidate ->
+ candidate.activityInfo?.let { it.packageName == packageName && it.name == activityName } == true
+ }
+ return if (isHomeHandler) setOf(packageName) else emptySet()
}
- private fun grantReadAccess(uris: Set) {
+ private fun reconcileReadAccess(
+ previousUris: Set,
+ previousPackages: Set,
+ currentUris: Set,
+ currentPackages: Set
+ ) {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
- consumerPackages().forEach { packageName ->
- uris.forEach { uri ->
+ if (previousPackages.isEmpty() && previousUris.isNotEmpty()) {
+ previousUris.forEach { uri -> runCatching { context.revokeUriPermission(uri, flags) } }
+ } else {
+ previousPackages.forEach { packageName ->
+ previousUris.forEach { uri ->
+ if (packageName !in currentPackages || uri !in currentUris) {
+ revokeReadAccess(packageName, uri, flags)
+ }
+ }
+ }
+ }
+ currentPackages.forEach { packageName ->
+ currentUris.forEach { uri ->
runCatching { context.grantUriPermission(packageName, uri, flags) }
}
}
@@ -198,9 +375,30 @@ class WatchNextProvider(private val context: Context) {
private fun revokeReadAccess(uris: Set) {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
- uris.forEach { uri -> runCatching { context.revokeUriPermission(uri, flags) } }
+ val packages = storedPackages()
+ if (packages.isEmpty()) {
+ uris.forEach { uri -> runCatching { context.revokeUriPermission(uri, flags) } }
+ return
+ }
+ packages.forEach { packageName ->
+ uris.forEach { uri -> revokeReadAccess(packageName, uri, flags) }
+ }
}
+ private fun revokeReadAccess(packageName: String, uri: Uri, flags: Int) {
+ runCatching {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.revokeUriPermission(packageName, uri, flags)
+ } else {
+ context.revokeUriPermission(uri, flags)
+ }
+ }
+ }
+
+ private fun storedUris(): Set = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty().mapNotNullTo(LinkedHashSet(), Uri::parse)
+
+ private fun storedPackages(): Set = prefs.getStringSet(GRANTED_PACKAGES, emptySet()).orEmpty()
+
internal fun buildProgram(item: PreparedWatchNextItem): WatchNextProgram {
val metadata = item.metadata
val watchNextType = if (metadata.lastPlaybackPosition > 0) {
diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/BufferedTransformingTrackOutputTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/BufferedTransformingTrackOutputTest.kt
index c215088a..7c15f86f 100644
--- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/BufferedTransformingTrackOutputTest.kt
+++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/BufferedTransformingTrackOutputTest.kt
@@ -3,6 +3,7 @@ package com.edde746.plezy.exoplayer
import androidx.media3.common.C
import androidx.media3.common.DataReader
import androidx.media3.common.Format
+import androidx.media3.common.ParserException
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.extractor.TrackOutput
import java.io.ByteArrayOutputStream
@@ -43,7 +44,39 @@ class BufferedTransformingTrackOutputTest {
)
}
- private class IncrementingTrackOutput(delegate: TrackOutput) : BufferedTransformingTrackOutput(delegate, initialBufferSize = 2) {
+ @Test
+ fun activeTransformRejectsParsableDataBeyondConfiguredBound() {
+ val output = IncrementingTrackOutput(RecordingTrackOutput(), maxBufferedSampleBytes = 4)
+
+ output.sampleData(ParsableByteArray(byteArrayOf(1, 2, 3)), 3, TrackOutput.SAMPLE_DATA_PART_MAIN)
+
+ assertThrows(ParserException::class.java) {
+ output.sampleData(ParsableByteArray(byteArrayOf(4, 5)), 2, TrackOutput.SAMPLE_DATA_PART_MAIN)
+ }
+ }
+
+ @Test
+ fun dataReaderRequestIsChunkedWithoutAllocatingTheDeclaredLength() {
+ val requestedLengths = mutableListOf()
+ val reader = DataReader { buffer, offset, length ->
+ requestedLengths += length
+ repeat(length) { buffer[offset + it] = it.toByte() }
+ length
+ }
+ val output = IncrementingTrackOutput(RecordingTrackOutput(), maxBufferedSampleBytes = 4)
+
+ assertEquals(2, output.sampleData(reader, Int.MAX_VALUE, false, TrackOutput.SAMPLE_DATA_PART_MAIN))
+ assertEquals(listOf(2), requestedLengths)
+ }
+
+ private class IncrementingTrackOutput(
+ delegate: TrackOutput,
+ maxBufferedSampleBytes: Int = Int.MAX_VALUE
+ ) : BufferedTransformingTrackOutput(
+ delegate,
+ initialBufferSize = 2,
+ maxBufferedSampleBytes = maxBufferedSampleBytes
+ ) {
private var transformed = ByteArray(2)
override val transformEnabled = true
diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerInitializationCleanupTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerInitializationCleanupTest.kt
index 95b25e68..dd27a692 100644
--- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerInitializationCleanupTest.kt
+++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerInitializationCleanupTest.kt
@@ -7,13 +7,17 @@ import android.view.ViewTreeObserver
import android.widget.FrameLayout
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
+import org.junit.Assert.assertSame
+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
+import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [28])
class ExoPlayerInitializationCleanupTest {
@Test
@@ -40,6 +44,34 @@ class ExoPlayerInitializationCleanupTest {
assertNull(core.getPrivateField("overlayLayoutListener"))
}
+ @Test
+ fun initializePreservesDefaultUncaughtExceptionHandlerAcrossPlayerLifecycles() {
+ val original = Thread.getDefaultUncaughtExceptionHandler()
+ val sentinel = Thread.UncaughtExceptionHandler { _, _ -> Unit }
+ Thread.setDefaultUncaughtExceptionHandler(sentinel)
+ val cores = mutableListOf()
+
+ try {
+ repeat(2) {
+ val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
+ activity.setContentView(FrameLayout(activity))
+ val core = ExoPlayerCore(activity)
+ cores += core
+
+ assertTrue(core.initialize())
+ assertSame(sentinel, Thread.getDefaultUncaughtExceptionHandler())
+
+ core.dispose()
+ shadowOf(Looper.getMainLooper()).idle()
+ assertSame(sentinel, Thread.getDefaultUncaughtExceptionHandler())
+ }
+ } finally {
+ cores.forEach { it.dispose() }
+ shadowOf(Looper.getMainLooper()).idle()
+ Thread.setDefaultUncaughtExceptionHandler(original)
+ }
+ }
+
private fun Any.setPrivateField(name: String, value: Any?) {
javaClass.getDeclaredField(name).apply {
isAccessible = true
diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPluginTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPluginTest.kt
index b7431392..26c13bc7 100644
--- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPluginTest.kt
+++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPluginTest.kt
@@ -2,15 +2,22 @@ package com.edde746.plezy.exoplayer
import android.app.Activity
import android.os.Looper
+import android.view.ViewGroup
+import android.view.ViewTreeObserver
+import android.widget.FrameLayout
import com.edde746.plezy.mpv.MpvPlayerCore
+import io.flutter.embedding.engine.plugins.FlutterPlugin
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.ConcurrentLinkedQueue
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.assertFalse
+import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@@ -78,7 +85,7 @@ class ExoPlayerPluginTest {
assertEquals(1, writes.get())
assertEquals(1, result.completionCount)
assertEquals("SET_PROPERTY_FAILED", result.errorCode)
- assertEquals("MPV property write was rejected or cancelled", result.errorMessage)
+ assertEquals("MPV property write was rejected", result.errorMessage)
assertTrue(result.errorMessage?.contains("secret-fallback-value") == false)
assertEquals(null, result.successValue)
assertEquals(null, result.errorDetails)
@@ -86,7 +93,7 @@ class ExoPlayerPluginTest {
}
@Test
- fun fallbackCancellationReturnsSetPropertyFailedOnce() {
+ fun fallbackCancellationReturnsNotInitializedOnce() {
val plugin = fallbackPlugin { _, _ ->
throw CancellationException("secret-cancellation")
}
@@ -99,7 +106,7 @@ class ExoPlayerPluginTest {
awaitCompletion(result)
assertEquals(1, result.completionCount)
- assertEquals("SET_PROPERTY_FAILED", result.errorCode)
+ assertEquals("NOT_INITIALIZED", result.errorCode)
assertTrue(result.errorMessage?.contains("secret") == false)
assertEquals(null, result.successValue)
}
@@ -158,6 +165,207 @@ class ExoPlayerPluginTest {
assertEquals(null, second.errorCode)
}
+ @Test
+ fun initialHeldFallbackSynchronouslyBlocksFocusAndSurfaceResumeWithoutPausePropertyWrite() {
+ val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
+ val writes = ConcurrentLinkedQueue>()
+ val core = MpvPlayerCore(activity, true) { name, value -> writes += name to value }
+ core.setPrivateField("desiredPaused", false)
+ core.setPrivateField("cachedPaused", false)
+ core.setPrivateField("resumeBlockedByPublicPause", false)
+ core.setPrivateField("pausedForSurfaceLoss", true)
+ core.setPrivateField("pausedForAudioFocusLoss", true)
+ core.setPrivateField("deferredResumeRequested", true)
+ val plugin = initialFallbackPlugin(activity, core)
+
+ invokeSetupMpvFallback(plugin, core, activity, playWhenReady = false)
+ invokeAutoResume(core, "audio focus gain")
+ invokeAutoResume(core, "surface attached")
+
+ assertEquals(true, core.getPrivateField("desiredPaused"))
+ assertEquals(true, core.getPrivateField("cachedPaused"))
+ assertEquals(true, core.getPrivateField("resumeBlockedByPublicPause"))
+ assertEquals(false, core.getPrivateField("pausedForSurfaceLoss"))
+ assertEquals(false, core.getPrivateField("pausedForAudioFocusLoss"))
+ assertEquals(false, core.getPrivateField("deferredResumeRequested"))
+ assertTrue(awaitQueueEntry(writes, "ao" to "audiotrack"))
+ assertFalse(awaitPauseWriteCount(writes, 1))
+ core.dispose()
+ }
+
+ @Test
+ fun initialAutoplayFallbackClearsPublicPauseBlockWithoutPausePropertyWrite() {
+ val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
+ val writes = ConcurrentLinkedQueue>()
+ val core = MpvPlayerCore(activity, true) { name, value -> writes += name to value }
+ core.setPrivateField("desiredPaused", true)
+ core.setPrivateField("cachedPaused", true)
+ core.setPrivateField("resumeBlockedByPublicPause", true)
+ val plugin = initialFallbackPlugin(activity, core)
+
+ invokeSetupMpvFallback(plugin, core, activity, playWhenReady = true)
+
+ assertEquals(false, core.getPrivateField("desiredPaused"))
+ assertEquals(false, core.getPrivateField("cachedPaused"))
+ assertEquals(false, core.getPrivateField("resumeBlockedByPublicPause"))
+ assertTrue(awaitQueueEntry(writes, "ao" to "audiotrack"))
+ assertFalse(awaitPauseWriteCount(writes, 1))
+ core.dispose()
+ }
+
+ @Test
+ fun reusedHeldFallbackSynchronouslyBlocksAutoResumeWithoutPausePropertyWrite() {
+ val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
+ val writes = ConcurrentLinkedQueue>()
+ val core = MpvPlayerCore(activity, true) { name, value -> writes += name to value }
+ core.setPrivateField("desiredPaused", false)
+ core.setPrivateField("cachedPaused", false)
+ core.setPrivateField("resumeBlockedByPublicPause", false)
+ core.setPrivateField("pausedForSurfaceLoss", true)
+ core.setPrivateField("deferredResumeRequested", true)
+ val plugin = reusedFallbackPlugin(activity, core)
+ val result = RecordingResult()
+
+ plugin.onMethodCall(
+ MethodCall(
+ "open",
+ mapOf("uri" to "https://example.test/video.mkv", "autoPlay" to false)
+ ),
+ result
+ )
+ invokeAutoResume(core, "surface attached")
+
+ assertEquals(1, result.completionCount)
+ assertNull(result.errorCode)
+ assertEquals(true, core.getPrivateField("desiredPaused"))
+ assertEquals(true, core.getPrivateField("cachedPaused"))
+ assertEquals(true, core.getPrivateField("resumeBlockedByPublicPause"))
+ assertEquals(false, core.getPrivateField("pausedForSurfaceLoss"))
+ assertEquals(false, core.getPrivateField("deferredResumeRequested"))
+ assertFalse(awaitPauseWriteCount(writes, 1))
+ core.dispose()
+ }
+
+ @Test
+ fun reusedAutoplayFallbackClearsIntentBeforeLoadAndClearsPersistedNativePauseAfterSuccess() {
+ val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
+ val writes = ConcurrentLinkedQueue>()
+ val core = MpvPlayerCore(activity, true) { name, value -> writes += name to value }
+ core.setPrivateField("desiredPaused", true)
+ core.setPrivateField("cachedPaused", true)
+ core.setPrivateField("resumeBlockedByPublicPause", true)
+ val plugin = reusedFallbackPlugin(activity, core)
+ val result = RecordingResult()
+
+ plugin.onMethodCall(
+ MethodCall(
+ "open",
+ mapOf("uri" to "https://example.test/video.mkv", "autoPlay" to true)
+ ),
+ result
+ )
+
+ assertEquals(1, result.completionCount)
+ assertNull(result.errorCode)
+ assertEquals(false, core.getPrivateField("desiredPaused"))
+ assertEquals(false, core.getPrivateField("cachedPaused"))
+ assertEquals(false, core.getPrivateField("resumeBlockedByPublicPause"))
+ assertTrue(awaitQueueEntry(writes, "pause" to "no"))
+ assertFalse(awaitPauseWriteCount(writes, 2))
+ assertEquals(listOf("pause" to "no"), writes.filter { it.first == "pause" })
+ core.dispose()
+ }
+
+ @Test
+ fun configDetachAndEngineDetachReleaseExoActivityOwnershipExactlyOnce() {
+ val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
+ activity.setContentView(FrameLayout(activity))
+ val content = activity.findViewById(android.R.id.content)
+ val container = FrameLayout(activity)
+ content.addView(container)
+ var layoutCallbacks = 0
+ val listener = ViewTreeObserver.OnGlobalLayoutListener { layoutCallbacks++ }
+ content.viewTreeObserver.addOnGlobalLayoutListener(listener)
+ val core = ExoPlayerCore(activity)
+ core.setPrivateField("surfaceContainer", container)
+ core.setPrivateField("overlayLayoutListener", listener)
+ val plugin = ExoPlayerPlugin()
+ setField(plugin, "activity", activity)
+ setField(plugin, "playerCore", core)
+ setField(plugin, "usingMpvFallback", true)
+ setField(plugin, "fallbackInProgress", true)
+ setField(plugin, "currentExternalSubtitles", listOf(mapOf("uri" to "content://subtitle")))
+ @Suppress("UNCHECKED_CAST")
+ (getField(plugin, "pendingMpvProperties") as MutableMap)["pause"] = "yes"
+
+ plugin.onDetachedFromActivityForConfigChanges()
+ content.viewTreeObserver.dispatchOnGlobalLayout()
+ shadowOf(Looper.getMainLooper()).idle()
+ plugin.onDetachedFromEngine(pluginBinding(activity))
+
+ assertEquals(0, layoutCallbacks)
+ assertNull(container.parent)
+ assertNull(getField(plugin, "playerCore"))
+ assertNull(getField(plugin, "mpvCore"))
+ assertFalse(getField(plugin, "usingMpvFallback") as Boolean)
+ assertFalse(getField(plugin, "fallbackInProgress") as Boolean)
+ assertNull(getField(plugin, "currentExternalSubtitles"))
+ assertTrue((getField(plugin, "pendingMpvProperties") as Map<*, *>).isEmpty())
+ assertNull(getField(plugin, "activity"))
+ }
+
+ @Test
+ fun engineDetachReleasesPublishedMpvFallbackActivityOwnership() {
+ val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
+ activity.setContentView(FrameLayout(activity))
+ val content = activity.findViewById(android.R.id.content)
+ val container = FrameLayout(activity)
+ content.addView(container)
+ var layoutCallbacks = 0
+ val listener = ViewTreeObserver.OnGlobalLayoutListener { layoutCallbacks++ }
+ content.viewTreeObserver.addOnGlobalLayoutListener(listener)
+ val core = MpvPlayerCore(activity)
+ core.setPrivateField("surfaceContainer", container)
+ core.setPrivateField("overlayLayoutListener", listener)
+ val plugin = ExoPlayerPlugin()
+ setField(plugin, "activity", activity)
+ setField(plugin, "mpvCore", core)
+ setField(plugin, "usingMpvFallback", true)
+
+ plugin.onDetachedFromEngine(pluginBinding(activity))
+ shadowOf(Looper.getMainLooper()).idle()
+ content.viewTreeObserver.dispatchOnGlobalLayout()
+
+ assertEquals(0, layoutCallbacks)
+ assertNull(container.parent)
+ assertNull(getField(plugin, "mpvCore"))
+ assertFalse(getField(plugin, "usingMpvFallback") as Boolean)
+ assertTrue(core.getPrivateField("disposing") as Boolean)
+ }
+
+ @Test
+ fun configDetachRejectsQueuedInitializationFromOldActivityGeneration() {
+ val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
+ activity.setContentView(FrameLayout(activity))
+ val plugin = ExoPlayerPlugin()
+ setField(plugin, "activity", activity)
+ val result = RecordingResult()
+
+ Thread {
+ plugin.onMethodCall(MethodCall("initialize", emptyMap()), result)
+ }.apply {
+ start()
+ join()
+ }
+ plugin.onDetachedFromActivityForConfigChanges()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertEquals(false, result.successValue)
+ assertEquals(1, result.completionCount)
+ assertNull(getField(plugin, "playerCore"))
+ assertNull(getField(plugin, "activity"))
+ }
+
@Test
fun eventCallbacksKeepTheSharedPlayerEnvelope() {
val plugin = ExoPlayerPlugin()
@@ -215,6 +423,87 @@ class ExoPlayerPluginTest {
}
}
+ private fun initialFallbackPlugin(
+ activity: Activity,
+ core: MpvPlayerCore
+ ): ExoPlayerPlugin = ExoPlayerPlugin().also { plugin ->
+ setField(plugin, "activity", activity)
+ setField(plugin, "mpvCore", core)
+ setField(plugin, "usingMpvFallback", true)
+ }
+
+ private fun reusedFallbackPlugin(
+ activity: Activity,
+ core: MpvPlayerCore
+ ): ExoPlayerPlugin = ExoPlayerPlugin().also { plugin ->
+ setField(plugin, "activity", activity)
+ setField(plugin, "mpvCore", core)
+ setField(plugin, "usingMpvFallback", true)
+ }
+
+ private fun invokeSetupMpvFallback(
+ plugin: ExoPlayerPlugin,
+ core: MpvPlayerCore,
+ activity: Activity,
+ playWhenReady: Boolean
+ ) {
+ ExoPlayerPlugin::class.java.getDeclaredMethod(
+ "setupMpvFallback",
+ MpvPlayerCore::class.java,
+ Activity::class.java,
+ String::class.java,
+ Map::class.java,
+ java.lang.Long.TYPE,
+ List::class.java,
+ java.lang.Boolean.TYPE,
+ java.lang.Integer.TYPE
+ ).apply {
+ isAccessible = true
+ invoke(
+ plugin,
+ core,
+ activity,
+ "https://example.test/video.mkv",
+ null,
+ 0L,
+ null,
+ playWhenReady,
+ 0
+ )
+ }
+ }
+
+ private fun invokeAutoResume(core: MpvPlayerCore, reason: String) {
+ MpvPlayerCore::class.java.getDeclaredMethod("requestAutoResume", String::class.java).apply {
+ isAccessible = true
+ invoke(core, reason)
+ }
+ }
+
+ private fun awaitQueueEntry(
+ queue: ConcurrentLinkedQueue>,
+ expected: Pair
+ ): Boolean {
+ repeat(100) {
+ shadowOf(Looper.getMainLooper()).idle()
+ if (queue.contains(expected)) return true
+ Thread.sleep(10)
+ }
+ return false
+ }
+
+ private fun awaitPauseWriteCount(
+ queue: ConcurrentLinkedQueue>,
+ expectedCount: Int
+ ): Boolean {
+ repeat(100) {
+ shadowOf(Looper.getMainLooper()).idle()
+ if (queue.count { it.first == "pause" } >= expectedCount) return true
+ Thread.sleep(10)
+ }
+ return false
+ }
+
private fun setField(plugin: ExoPlayerPlugin, name: String, value: Any?) {
plugin.javaClass.getDeclaredField(name).apply {
isAccessible = true
@@ -227,6 +516,23 @@ class ExoPlayerPluginTest {
get(plugin)
}
+ private fun Any.setPrivateField(name: String, value: Any?) {
+ javaClass.getDeclaredField(name).apply {
+ isAccessible = true
+ set(this@setPrivateField, value)
+ }
+ }
+
+ private fun Any.getPrivateField(name: String): Any? = javaClass.getDeclaredField(name).run {
+ isAccessible = true
+ get(this@getPrivateField)
+ }
+
+ private fun pluginBinding(activity: Activity): FlutterPlugin.FlutterPluginBinding {
+ val constructor = FlutterPlugin.FlutterPluginBinding::class.java.constructors.single()
+ return constructor.newInstance(activity, null, null, null, null, null, null) as FlutterPlugin.FlutterPluginBinding
+ }
+
private fun awaitCompletion(result: RecordingResult) {
var completed = false
repeat(100) {
diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutputTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutputTest.kt
new file mode 100644
index 00000000..b903e84b
--- /dev/null
+++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutputTest.kt
@@ -0,0 +1,217 @@
+package com.edde746.plezy.exoplayer
+
+import androidx.media3.common.C
+import androidx.media3.common.DataReader
+import androidx.media3.common.Format
+import androidx.media3.common.ParserException
+import androidx.media3.common.util.ParsableByteArray
+import androidx.media3.extractor.TrackOutput
+import java.io.ByteArrayOutputStream
+import java.util.zip.Deflater
+import java.util.zip.DeflaterOutputStream
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ZlibInflatingTrackOutputTest {
+
+ @Test
+ fun activeTransformInflatesChunkedSampleAndPreservesMetadata() {
+ val original = ByteArray(512 * 1024) { (it % 251).toByte() }
+ val compressed = deflate(original)
+ val delegate = RecordingTrackOutput(retainBytes = true)
+ val output = ZlibInflatingTrackOutput(delegate).apply { active = true }
+ val split = compressed.size / 2
+
+ output.sampleData(
+ ParsableByteArray(compressed.copyOfRange(0, split)),
+ split,
+ TrackOutput.SAMPLE_DATA_PART_MAIN
+ )
+ output.sampleData(
+ ParsableByteArray(compressed.copyOfRange(split, compressed.size)),
+ compressed.size - split,
+ TrackOutput.SAMPLE_DATA_PART_MAIN
+ )
+ output.sampleMetadata(42L, C.BUFFER_FLAG_KEY_FRAME, compressed.size, 7, null)
+
+ assertArrayEquals(original, delegate.retained.toByteArray())
+ assertEquals(42L, delegate.timeUs)
+ assertEquals(C.BUFFER_FLAG_KEY_FRAME, delegate.flags)
+ assertEquals(original.size, delegate.sampleSize)
+ assertEquals(0, delegate.offset)
+ assertEquals(1, delegate.metadataCount)
+ }
+
+ @Test
+ fun exactlySixteenMiBInflatedSampleIsAccepted() {
+ val size = 16 * 1024 * 1024
+ val compressed = deflateRepeated(size)
+ val delegate = RecordingTrackOutput()
+ val output = ZlibInflatingTrackOutput(delegate).apply { active = true }
+
+ feed(output, compressed)
+
+ assertEquals(size, delegate.byteCount)
+ assertEquals(size, delegate.sampleSize)
+ assertEquals(1, delegate.metadataCount)
+ assertTrue(compressed.size < 64 * 1024)
+ }
+
+ @Test
+ fun oneByteOverInflatedLimitFailsBeforeDelegation() {
+ val compressed = deflateRepeated(16 * 1024 * 1024 + 1)
+ val delegate = RecordingTrackOutput()
+ val output = ZlibInflatingTrackOutput(delegate).apply { active = true }
+
+ assertThrows(ParserException::class.java) { feed(output, compressed) }
+
+ assertEquals(0, delegate.byteCount)
+ assertEquals(0, delegate.metadataCount)
+ assertTrue(compressed.size < 64 * 1024)
+ }
+
+ @Test
+ fun excessiveCompressionRatioFailsBeforeInflatedSizeLimit() {
+ val compressed = deflate(ByteArray(8 * 1024 * 1024))
+ val delegate = RecordingTrackOutput()
+ val output = ZlibInflatingTrackOutput(delegate).apply { active = true }
+
+ assertThrows(ParserException::class.java) { feed(output, compressed) }
+
+ assertEquals(0, delegate.byteCount)
+ assertEquals(0, delegate.metadataCount)
+ }
+
+ @Test
+ fun corruptStreamPassesTheBoundedSampleThroughUnchanged() {
+ val corrupt = byteArrayOf(0, 1, 2, 3)
+ val delegate = RecordingTrackOutput(retainBytes = true)
+ val output = ZlibInflatingTrackOutput(delegate).apply { active = true }
+
+ feed(output, corrupt)
+
+ assertArrayEquals(corrupt, delegate.retained.toByteArray())
+ assertEquals(corrupt.size, delegate.sampleSize)
+ assertEquals(1, delegate.metadataCount)
+ }
+
+ @Test
+ fun truncatedAndDictionaryStreamsFailClosed() {
+ val valid = deflate(ByteArray(4096) { 7 })
+ val dictionary = "shared-zlib-dictionary".toByteArray()
+ val cases = listOf(
+ valid.copyOf(valid.size - 2),
+ deflate(ByteArray(4096) { 3 }, dictionary)
+ )
+
+ for (compressed in cases) {
+ val delegate = RecordingTrackOutput()
+ val output = ZlibInflatingTrackOutput(delegate).apply { active = true }
+
+ assertThrows(ParserException::class.java) { feed(output, compressed) }
+ assertEquals(0, delegate.byteCount)
+ assertEquals(0, delegate.metadataCount)
+ }
+ }
+
+ @Test
+ fun inactiveWrapperDelegatesBytesAndMetadataUnchanged() {
+ val bytes = byteArrayOf(9, 8, 7, 6)
+ val delegate = RecordingTrackOutput(retainBytes = true)
+ val output = ZlibInflatingTrackOutput(delegate)
+
+ output.sampleData(ParsableByteArray(bytes), bytes.size, TrackOutput.SAMPLE_DATA_PART_MAIN)
+ output.sampleMetadata(99L, 3, bytes.size, 2, null)
+
+ assertArrayEquals(bytes, delegate.retained.toByteArray())
+ assertEquals(bytes.size, delegate.sampleSize)
+ assertEquals(2, delegate.offset)
+ assertEquals(99L, delegate.timeUs)
+ }
+
+ private fun feed(output: ZlibInflatingTrackOutput, compressed: ByteArray) {
+ output.sampleData(
+ ParsableByteArray(compressed),
+ compressed.size,
+ TrackOutput.SAMPLE_DATA_PART_MAIN
+ )
+ output.sampleMetadata(1L, C.BUFFER_FLAG_KEY_FRAME, compressed.size, 0, null)
+ }
+
+ private fun deflate(bytes: ByteArray, dictionary: ByteArray? = null): ByteArray {
+ val target = ByteArrayOutputStream()
+ val deflater = Deflater().apply {
+ if (dictionary != null) setDictionary(dictionary)
+ }
+ DeflaterOutputStream(target, deflater).use { it.write(bytes) }
+ return target.toByteArray()
+ }
+
+ private fun deflateRepeated(size: Int): ByteArray {
+ val target = ByteArrayOutputStream()
+ val chunk = ByteArray(8192) { (it % 7).toByte() }
+ DeflaterOutputStream(target).use { stream ->
+ var remaining = size
+ while (remaining > 0) {
+ val count = minOf(remaining, chunk.size)
+ stream.write(chunk, 0, count)
+ remaining -= count
+ }
+ }
+ return target.toByteArray()
+ }
+
+ private class RecordingTrackOutput(
+ private val retainBytes: Boolean = false
+ ) : TrackOutput {
+ val retained = ByteArrayOutputStream()
+ var byteCount = 0
+ var metadataCount = 0
+ var timeUs = C.TIME_UNSET
+ var flags = 0
+ var sampleSize = -1
+ var offset = -1
+
+ override fun format(format: Format) = Unit
+
+ override fun sampleData(
+ input: DataReader,
+ length: Int,
+ allowEndOfInput: Boolean,
+ sampleDataPart: Int
+ ): Int {
+ val buffer = ByteArray(length)
+ val read = input.read(buffer, 0, length)
+ if (read > 0) record(buffer, read)
+ return read
+ }
+
+ override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
+ val buffer = ByteArray(length)
+ data.readBytes(buffer, 0, length)
+ record(buffer, length)
+ }
+
+ private fun record(buffer: ByteArray, length: Int) {
+ byteCount += length
+ if (retainBytes) retained.write(buffer, 0, length)
+ }
+
+ override fun sampleMetadata(
+ timeUs: Long,
+ flags: Int,
+ size: Int,
+ offset: Int,
+ cryptoData: TrackOutput.CryptoData?
+ ) {
+ metadataCount++
+ this.timeUs = timeUs
+ this.flags = flags
+ sampleSize = size
+ this.offset = offset
+ }
+ }
+}
diff --git a/android/app/src/test/kotlin/com/edde746/plezy/mpv/MpvPlayerPluginTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/mpv/MpvPlayerPluginTest.kt
index 6d430fac..3d167b78 100644
--- a/android/app/src/test/kotlin/com/edde746/plezy/mpv/MpvPlayerPluginTest.kt
+++ b/android/app/src/test/kotlin/com/edde746/plezy/mpv/MpvPlayerPluginTest.kt
@@ -1,20 +1,29 @@
package com.edde746.plezy.mpv
import android.app.Activity
+import android.media.AudioManager
+import android.os.Handler
import android.os.Looper
+import android.view.ViewGroup
+import android.view.ViewTreeObserver
+import android.widget.FrameLayout
+import com.edde746.plezy.shared.AudioFocusManager
import dev.jdtech.mpv.EndFileReason
import dev.jdtech.mpv.LogLevel
import dev.jdtech.mpv.LogMessage
import dev.jdtech.mpv.MpvEvent
+import io.flutter.embedding.engine.plugins.FlutterPlugin
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.ConcurrentLinkedQueue
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.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -70,7 +79,7 @@ class MpvPlayerPluginTest {
}
@Test
- fun rejectedSetPropertyFailsOnceForVideoAndAudioWithoutLeakingPayload() {
+ fun rejectedSetPropertyReportsBoundedErrorForVideoAndAudio() {
for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) {
installCore(plugin, testCore { _, _ -> error("secret-property-value") })
val result = RecordingResult()
@@ -80,15 +89,15 @@ class MpvPlayerPluginTest {
assertEquals(1, result.completionCount)
assertEquals("SET_PROPERTY_FAILED", result.errorCode)
- assertEquals("MPV property write was rejected or cancelled", result.errorMessage)
+ assertEquals("MPV property write was rejected", result.errorMessage)
assertTrue(result.errorMessage?.contains("secret-property-value") == false)
- assertNull(result.successValue)
assertNull(result.errorDetails)
+ assertNull(result.successValue)
}
}
@Test
- fun cancelledSetPropertyFailsOnceForVideoAndAudio() {
+ fun cancelledSetPropertyReportsNotInitializedOnceForVideoAndAudio() {
for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) {
installCore(plugin, testCore { _, _ -> throw CancellationException("secret-cancellation") })
val result = RecordingResult()
@@ -97,8 +106,9 @@ class MpvPlayerPluginTest {
awaitCompletion(result)
assertEquals(1, result.completionCount)
- assertEquals("SET_PROPERTY_FAILED", result.errorCode)
+ assertEquals("NOT_INITIALIZED", result.errorCode)
assertTrue(result.errorMessage?.contains("secret-cancellation") == false)
+ assertEquals("Player not initialized", result.errorMessage)
assertNull(result.successValue)
}
}
@@ -112,6 +122,7 @@ class MpvPlayerPluginTest {
awaitCondition { outcome != null }
assertTrue(outcome?.isFailure == true)
+ assertTrue(outcome?.exceptionOrNull() is CancellationException)
}
@Test
@@ -134,6 +145,7 @@ class MpvPlayerPluginTest {
assertEquals(2, outcomes.size)
assertTrue(outcomes.all { it.isFailure })
+ assertTrue(outcomes.all { it.exceptionOrNull() is CancellationException })
}
@Test
@@ -155,6 +167,175 @@ class MpvPlayerPluginTest {
assertEquals(true, getBoolean(core, "deferredResumeRequested"))
}
+ @Test
+ fun failedResumeRestoresThePreviousPublicPauseIntent() {
+ val core = testCore { _, _ -> error("rejected") }
+ setBoolean(core, "cachedPaused", true)
+ setBoolean(core, "resumeBlockedByPublicPause", true)
+ var outcome: Result? = null
+
+ core.setProperty("pause", "no") { outcome = it }
+ awaitCondition { outcome != null }
+
+ assertTrue(outcome?.isFailure == true)
+ assertEquals(true, getBoolean(core, "cachedPaused"))
+ assertEquals(true, getBoolean(core, "resumeBlockedByPublicPause"))
+ }
+
+ @Test
+ fun failedOlderPauseWriteDoesNotRollbackANewerResumeIntent() {
+ val firstStarted = CountDownLatch(1)
+ val releaseFirst = CountDownLatch(1)
+ val writes = AtomicInteger()
+ val core = testCore { _, _ ->
+ if (writes.incrementAndGet() == 1) {
+ firstStarted.countDown()
+ releaseFirst.await(1, TimeUnit.SECONDS)
+ error("rejected")
+ }
+ }
+ var firstOutcome: Result? = null
+ var secondOutcome: Result? = null
+
+ core.setProperty("pause", "yes") { firstOutcome = it }
+ assertTrue(firstStarted.await(1, TimeUnit.SECONDS))
+ core.setProperty("pause", "no") { secondOutcome = it }
+ releaseFirst.countDown()
+ awaitCondition { firstOutcome != null && secondOutcome != null }
+
+ assertTrue(firstOutcome?.isFailure == true)
+ assertTrue(secondOutcome?.isSuccess == true)
+ assertEquals(2, writes.get())
+ assertEquals(false, getBoolean(core, "cachedPaused"))
+ assertEquals(false, getBoolean(core, "resumeBlockedByPublicPause"))
+ }
+
+ @Test
+ fun pauseIntentBlocksAudioFocusAutoResumeBeforeNativeWriteCompletes() {
+ val writeStarted = CountDownLatch(1)
+ val releaseWrite = CountDownLatch(1)
+ val writes = AtomicInteger()
+ val unexpectedResumeWrite = CountDownLatch(1)
+ val core = testCore { name, value ->
+ if (writes.incrementAndGet() > 1) unexpectedResumeWrite.countDown()
+ if (name == "pause" && value == "yes") {
+ writeStarted.countDown()
+ releaseWrite.await(1, TimeUnit.SECONDS)
+ }
+ }
+ setBoolean(core, "resumeBlockedByPublicPause", false)
+ var outcome: Result? = null
+
+ core.setProperty("pause", "yes") { outcome = it }
+ assertTrue(writeStarted.await(1, TimeUnit.SECONDS))
+ invokeAutoResume(core, "audio focus gain")
+
+ assertEquals(true, getBoolean(core, "resumeBlockedByPublicPause"))
+ assertNull(outcome)
+ assertEquals(1, writes.get())
+
+ releaseWrite.countDown()
+ awaitCondition { outcome != null }
+ assertFalse(unexpectedResumeWrite.await(100, TimeUnit.MILLISECONDS))
+ assertTrue(outcome?.isSuccess == true)
+ assertEquals(1, writes.get())
+ assertEquals(true, getBoolean(core, "resumeBlockedByPublicPause"))
+ }
+
+ @Test
+ fun heldLoadPauseIntentSynchronouslyBlocksFocusAndSurfaceAutoResumeWithoutNativeWrite() {
+ val writes = ConcurrentLinkedQueue>()
+ val core = testVideoCore { name, value -> writes += name to value }
+ setBoolean(core, "desiredPaused", false)
+ setBoolean(core, "cachedPaused", false)
+ setBoolean(core, "resumeBlockedByPublicPause", false)
+ setBoolean(core, "pausedForSurfaceLoss", true)
+ setBoolean(core, "pausedForAudioFocusLoss", true)
+ setBoolean(core, "deferredResumeRequested", true)
+
+ core.setPauseIntentForLoad(paused = true)
+ invokeAutoResume(core, "audio focus gain")
+ invokeAutoResume(core, "surface attached")
+
+ assertEquals(true, getBoolean(core, "desiredPaused"))
+ assertEquals(true, getBoolean(core, "cachedPaused"))
+ assertEquals(true, getBoolean(core, "resumeBlockedByPublicPause"))
+ assertEquals(false, getBoolean(core, "pausedForSurfaceLoss"))
+ assertEquals(false, getBoolean(core, "pausedForAudioFocusLoss"))
+ assertEquals(false, getBoolean(core, "deferredResumeRequested"))
+ assertFalse(awaitQueueEntry(writes, "pause" to "no"))
+ assertTrue(writes.isEmpty())
+ }
+
+ @Test
+ fun autoplayLoadIntentClearsPublicPauseBlockWithoutPrematureNativeResumeWrite() {
+ val writes = ConcurrentLinkedQueue>()
+ val core = testCore { name, value -> writes += name to value }
+ setBoolean(core, "desiredPaused", true)
+ setBoolean(core, "cachedPaused", true)
+ setBoolean(core, "resumeBlockedByPublicPause", true)
+
+ core.setPauseIntentForLoad(paused = false)
+
+ assertEquals(false, getBoolean(core, "desiredPaused"))
+ assertEquals(false, getBoolean(core, "cachedPaused"))
+ assertEquals(false, getBoolean(core, "resumeBlockedByPublicPause"))
+ assertFalse(awaitQueueEntry(writes, "pause" to "no"))
+
+ invokeAudioFocusPause(core)
+ assertTrue(awaitQueueEntry(writes, "pause" to "yes"))
+ assertEquals(listOf("pause" to "yes"), writes.toList())
+ }
+
+ @Test
+ fun pausedFocusLossAndGainWithoutResumeCallbackAllowsOneExplicitResume() {
+ val writes = ConcurrentLinkedQueue>()
+ val focusResumeCallbacks = AtomicInteger()
+ val core = testCore { name, value -> writes += name to value }
+ val focusManager = testAudioFocusManager(core, focusResumeCallbacks)
+ var pauseOutcome: Result? = null
+ var resumeOutcome: Result? = null
+
+ core.setProperty("pause", "yes") { pauseOutcome = it }
+ awaitCondition { pauseOutcome != null }
+
+ dispatchAudioFocusChange(focusManager, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)
+ assertEquals(false, getBoolean(core, "pausedForAudioFocusLoss"))
+ dispatchAudioFocusChange(focusManager, AudioManager.AUDIOFOCUS_GAIN)
+ assertEquals(0, focusResumeCallbacks.get())
+
+ core.setProperty("pause", "no") { resumeOutcome = it }
+ awaitCondition { resumeOutcome != null }
+
+ assertTrue(pauseOutcome?.isSuccess == true)
+ assertTrue(resumeOutcome?.isSuccess == true)
+ assertEquals(listOf("pause" to "yes", "pause" to "no"), writes.toList())
+ assertEquals(1, writes.count { it == "pause" to "no" })
+ assertEquals(false, getBoolean(core, "pausedForAudioFocusLoss"))
+ }
+
+ @Test
+ fun synchronousFocusReacquisitionClearsLossMarkerAndResumesOnce() {
+ val writes = ConcurrentLinkedQueue>()
+ val focusResumeCallbacks = AtomicInteger()
+ val core = testCore { name, value -> writes += name to value }
+ val focusManager = testAudioFocusManager(core, focusResumeCallbacks)
+ setCoreField(core, "audioFocusManager", focusManager)
+ setBoolean(core, "desiredPaused", false)
+ setBoolean(core, "cachedPaused", false)
+
+ dispatchAudioFocusChange(focusManager, AudioManager.AUDIOFOCUS_LOSS)
+ awaitCondition { writes.contains("pause" to "yes") }
+ assertEquals(true, getBoolean(core, "pausedForAudioFocusLoss"))
+
+ assertTrue(core.requestAudioFocus())
+ awaitCondition { writes.count { it == "pause" to "no" } == 1 }
+
+ assertEquals(0, focusResumeCallbacks.get())
+ assertEquals(listOf("pause" to "yes", "pause" to "no"), writes.toList())
+ assertEquals(false, getBoolean(core, "pausedForAudioFocusLoss"))
+ }
+
@Test
fun resumeWithoutReadyVideoOutputIsAcceptedAndDeferredWithoutWriting() {
val writes = AtomicInteger()
@@ -201,6 +382,83 @@ class MpvPlayerPluginTest {
assertEquals(0, pending.size)
}
+ @Test
+ fun configDetachThenEngineDetachTearsDownVideoCoreAndPendingInitOnce() {
+ val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
+ activity.setContentView(FrameLayout(activity))
+ val content = activity.findViewById(android.R.id.content)
+ val container = FrameLayout(activity)
+ content.addView(container)
+ var layoutCallbacks = 0
+ val listener = ViewTreeObserver.OnGlobalLayoutListener { layoutCallbacks++ }
+ content.viewTreeObserver.addOnGlobalLayoutListener(listener)
+ val core = MpvPlayerCore(activity)
+ setCoreField(core, "surfaceContainer", container)
+ setCoreField(core, "overlayLayoutListener", listener)
+ val plugin = MpvPlayerPlugin()
+ installCore(plugin, core)
+ setPluginField(plugin, "activity", activity)
+ val pendingResult = RecordingResult()
+ pendingInitResults(plugin) += pendingResult
+ setPluginField(plugin, "isInitializing", true)
+ setPluginField(plugin, "activeInitAttempt", 7)
+ setPluginField(plugin, "initAttemptCounter", 7)
+
+ plugin.onDetachedFromActivityForConfigChanges()
+ shadowOf(Looper.getMainLooper()).idle()
+ plugin.onDetachedFromEngine(pluginBinding(activity))
+ content.viewTreeObserver.dispatchOnGlobalLayout()
+
+ assertEquals(false, pendingResult.successValue)
+ assertEquals(1, pendingResult.completionCount)
+ assertEquals(0, layoutCallbacks)
+ assertNull(container.parent)
+ assertNull(getPluginField(plugin, "playerCore"))
+ assertNull(getPluginField(plugin, "activity"))
+ assertTrue(getCoreField(core, "disposing") as Boolean)
+ assertFalse(getPluginField(plugin, "isInitializing") as Boolean)
+ }
+
+ @Test
+ fun staleInitCompletionCannotConsumeReplacementAttemptResults() {
+ val plugin = MpvPlayerPlugin()
+ val stale = RecordingResult()
+ pendingInitResults(plugin) += stale
+ setPluginField(plugin, "isInitializing", true)
+ setPluginField(plugin, "activeInitAttempt", 1)
+ setPluginField(plugin, "initAttemptCounter", 1)
+
+ plugin.onDetachedFromActivityForConfigChanges()
+ assertEquals(false, stale.successValue)
+ assertEquals(1, stale.completionCount)
+
+ val replacement = RecordingResult()
+ pendingInitResults(plugin) += replacement
+ setPluginField(plugin, "isInitializing", true)
+ setPluginField(plugin, "activeInitAttempt", 3)
+ setPluginField(plugin, "initAttemptCounter", 3)
+
+ plugin.completePendingInits(1, success = true)
+ assertEquals(0, replacement.completionCount)
+
+ plugin.completePendingInits(3, success = true)
+ assertEquals(true, replacement.successValue)
+ assertEquals(1, replacement.completionCount)
+ }
+
+ @Test
+ fun engineDetachAlsoTerminatesApplicationContextAudioCore() {
+ val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
+ val plugin = MpvAudioPlayerPlugin()
+ val core = testCore { _, _ -> Unit }
+ installCore(plugin, core)
+
+ plugin.onDetachedFromEngine(pluginBinding(activity))
+
+ assertNull(getPluginField(plugin, "playerCore"))
+ assertTrue(getCoreField(core, "disposing") as Boolean)
+ }
+
@Test
fun setLogLevelReportsUnsupported() {
val result = RecordingResult()
@@ -295,6 +553,84 @@ class MpvPlayerPluginTest {
}
}
+ @Suppress("UNCHECKED_CAST")
+ private fun pendingInitResults(plugin: MpvPlayerPlugin): MutableList = getPluginField(plugin, "pendingInitResults") as MutableList
+
+ private fun setPluginField(plugin: MpvPlayerPlugin, name: String, value: Any?) {
+ MpvPlayerPlugin::class.java.getDeclaredField(name).apply {
+ isAccessible = true
+ set(plugin, value)
+ }
+ }
+
+ private fun getPluginField(plugin: MpvPlayerPlugin, name: String): Any? = MpvPlayerPlugin::class.java.getDeclaredField(name).run {
+ isAccessible = true
+ get(plugin)
+ }
+
+ private fun setCoreField(core: MpvPlayerCore, name: String, value: Any?) {
+ MpvPlayerCore::class.java.getDeclaredField(name).apply {
+ isAccessible = true
+ set(core, value)
+ }
+ }
+
+ private fun getCoreField(core: MpvPlayerCore, name: String): Any? = MpvPlayerCore::class.java.getDeclaredField(name).run {
+ isAccessible = true
+ get(core)
+ }
+
+ private fun pluginBinding(activity: Activity): FlutterPlugin.FlutterPluginBinding {
+ val constructor = FlutterPlugin.FlutterPluginBinding::class.java.constructors.single()
+ return constructor.newInstance(activity, null, null, null, null, null, null) as FlutterPlugin.FlutterPluginBinding
+ }
+
+ private fun testAudioFocusManager(
+ core: MpvPlayerCore,
+ resumeCallbacks: AtomicInteger
+ ): AudioFocusManager = AudioFocusManager(
+ context = Robolectric.buildActivity(Activity::class.java).setup().get(),
+ handler = Handler(Looper.getMainLooper()),
+ onPause = { invokeAudioFocusPause(core) },
+ onResume = {
+ resumeCallbacks.incrementAndGet()
+ invokeAudioFocusResume(core, "audio focus gain")
+ },
+ isPaused = { getBoolean(core, "desiredPaused") }
+ )
+
+ private fun invokeAudioFocusPause(core: MpvPlayerCore) {
+ MpvPlayerCore::class.java.getDeclaredMethod("pauseForAudioFocusLoss").apply {
+ isAccessible = true
+ invoke(core)
+ }
+ }
+
+ private fun invokeAudioFocusResume(core: MpvPlayerCore, reason: String) {
+ MpvPlayerCore::class.java.getDeclaredMethod(
+ "resumeAfterAudioFocusGain",
+ String::class.java
+ ).apply {
+ isAccessible = true
+ invoke(core, reason)
+ }
+ }
+
+ private fun dispatchAudioFocusChange(manager: AudioFocusManager, focusChange: Int) {
+ val listener = AudioFocusManager::class.java.getDeclaredField("audioFocusChangeListener").run {
+ isAccessible = true
+ get(manager) as AudioManager.OnAudioFocusChangeListener
+ }
+ listener.onAudioFocusChange(focusChange)
+ }
+
+ private fun invokeAutoResume(core: MpvPlayerCore, reason: String) {
+ MpvPlayerCore::class.java.getDeclaredMethod("requestAutoResume", String::class.java).apply {
+ isAccessible = true
+ invoke(core, reason)
+ }
+ }
+
private fun setBoolean(core: MpvPlayerCore, name: String, value: Boolean) {
MpvPlayerCore::class.java.getDeclaredField(name).apply {
isAccessible = true
@@ -307,6 +643,18 @@ class MpvPlayerPluginTest {
getBoolean(core)
}
+ private fun awaitQueueEntry(
+ queue: ConcurrentLinkedQueue>,
+ expected: Pair
+ ): Boolean {
+ repeat(10) {
+ shadowOf(Looper.getMainLooper()).idle()
+ if (queue.contains(expected)) return true
+ Thread.sleep(10)
+ }
+ return false
+ }
+
private fun awaitCompletion(result: RecordingResult) {
awaitCondition { result.completed.await(10, TimeUnit.MILLISECONDS) }
shadowOf(Looper.getMainLooper()).idle()
diff --git a/android/app/src/test/kotlin/com/edde746/plezy/watchnext/WatchNextProviderTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/watchnext/WatchNextProviderTest.kt
index 36ab8ecc..39ac5995 100644
--- a/android/app/src/test/kotlin/com/edde746/plezy/watchnext/WatchNextProviderTest.kt
+++ b/android/app/src/test/kotlin/com/edde746/plezy/watchnext/WatchNextProviderTest.kt
@@ -1,18 +1,37 @@
package com.edde746.plezy.watchnext
+import android.content.ComponentName
import android.content.ContentProvider
import android.content.ContentProviderOperation
import android.content.ContentProviderResult
import android.content.ContentValues
+import android.content.Context
+import android.content.ContextWrapper
import android.content.Intent
+import android.content.IntentFilter
+import android.content.pm.ActivityInfo
+import android.content.pm.ApplicationInfo
+import android.content.pm.ResolveInfo
import android.database.Cursor
import android.net.Uri
import android.os.ParcelFileDescriptor.AutoCloseInputStream
import androidx.tvprovider.media.tv.TvContractCompat
+import io.flutter.embedding.engine.plugins.FlutterPlugin
+import io.flutter.plugin.common.BinaryMessenger
+import io.flutter.plugin.common.MethodCall
+import io.flutter.plugin.common.MethodChannel
+import java.lang.reflect.Proxy
import java.net.InetAddress
import java.net.ServerSocket
+import java.util.ArrayDeque
import java.util.Base64
+import java.util.concurrent.AbstractExecutorService
+import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executor
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.RejectedExecutionException
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicReference
import kotlin.concurrent.thread
import org.junit.After
import org.junit.Assert.assertArrayEquals
@@ -26,6 +45,8 @@ import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
import org.robolectric.shadows.ShadowContentResolver
@RunWith(RobolectricTestRunner::class)
@@ -101,6 +122,405 @@ class WatchNextProviderTest {
}
}
+ @Test
+ fun replacementEngineInvalidatesArtworkWorkBeforeRowsCanCommit() {
+ val requestReceived = CountDownLatch(1)
+ val releaseResponse = CountDownLatch(1)
+ val server = ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))
+ val responder = thread(start = true, name = "system-shelf-owner-test-http") {
+ server.accept().use { socket ->
+ val reader = socket.getInputStream().bufferedReader()
+ while (reader.readLine()?.isNotEmpty() == true) {
+ // Consume request headers before handing ownership to a replacement engine.
+ }
+ requestReceived.countDown()
+ releaseResponse.await(2, TimeUnit.SECONDS)
+ val headers = (
+ "HTTP/1.1 200 OK\r\n" +
+ "Content-Type: image/png\r\n" +
+ "Content-Length: ${imageBytes.size}\r\n" +
+ "Connection: close\r\n\r\n"
+ ).toByteArray()
+ socket.getOutputStream().use { output ->
+ output.write(headers)
+ output.write(imageBytes)
+ }
+ }
+ }
+ try {
+ val oldLease = SystemShelfLifecycle.acquire()
+ val oldProvider = WatchNextProvider(context, oldLease)
+ val result = AtomicReference()
+ val worker = thread(start = true, name = "system-shelf-stale-owner") {
+ result.set(
+ oldProvider.syncWatchNextPrograms(
+ "owner-old",
+ 1,
+ listOf(item("http://127.0.0.1:${server.localPort}/art"))
+ )
+ )
+ }
+ assertTrue(requestReceived.await(2, TimeUnit.SECONDS))
+ SystemShelfLifecycle.acquire()
+ releaseResponse.countDown()
+ worker.join(2_000)
+
+ assertFalse(worker.isAlive)
+ assertEquals(false, result.get())
+ assertTrue(tvProvider.inserted.isEmpty())
+ assertFalse(context.cacheDir.resolve("system_shelf_artwork").walkTopDown().any { it.isFile })
+ } finally {
+ releaseResponse.countDown()
+ server.close()
+ responder.join(2_000)
+ }
+ }
+
+ @Test
+ fun supersededSyncDeletesOnlyArtworkMaterializedByThatOperation() {
+ val secondRequestReceived = CountDownLatch(1)
+ val releaseSecondResponse = CountDownLatch(1)
+ val server = ServerSocket(0, 2, InetAddress.getByName("127.0.0.1"))
+ val responder = thread(start = true, name = "system-shelf-superseded-http") {
+ runCatching {
+ repeat(2) { requestIndex ->
+ server.accept().use { socket ->
+ val reader = socket.getInputStream().bufferedReader()
+ while (reader.readLine()?.isNotEmpty() == true) {
+ // Consume request headers.
+ }
+ if (requestIndex == 1) {
+ secondRequestReceived.countDown()
+ releaseSecondResponse.await(2, TimeUnit.SECONDS)
+ }
+ val headers = (
+ "HTTP/1.1 200 OK\r\n" +
+ "Content-Type: image/png\r\n" +
+ "Content-Length: ${imageBytes.size}\r\n" +
+ "Connection: close\r\n\r\n"
+ ).toByteArray()
+ socket.getOutputStream().use { output ->
+ output.write(headers)
+ output.write(imageBytes)
+ }
+ }
+ }
+ }
+ }
+ val provider = WatchNextProvider(context)
+ val result = AtomicReference()
+ val worker = thread(start = true, name = "system-shelf-superseded-sync") {
+ val source = "http://127.0.0.1:${server.localPort}"
+ result.set(
+ provider.syncWatchNextPrograms(
+ "owner-a",
+ 1,
+ listOf(item("$source/first"), item("$source/second").copy(contentId = "second"))
+ )
+ )
+ }
+ try {
+ assertTrue(secondRequestReceived.await(2, TimeUnit.SECONDS))
+ assertEquals(1, artworkFiles().size)
+ SystemShelfLifecycle.acquire()
+ releaseSecondResponse.countDown()
+ worker.join(2_000)
+
+ assertFalse(worker.isAlive)
+ assertEquals(false, result.get())
+ assertTrue(tvProvider.inserted.isEmpty())
+ assertTrue(artworkFiles().isEmpty())
+ } finally {
+ releaseSecondResponse.countDown()
+ server.close()
+ worker.join(2_000)
+ responder.join(2_000)
+ }
+ }
+
+ @Test
+ fun ownershipClaimWaitsForCurrentCommitBoundary() {
+ val lease = SystemShelfLifecycle.acquire()
+ val ownership = SystemShelfLifecycle.claim(lease, "owner-a", 1)
+ assertTrue(ownership != null)
+ val operationStarted = CountDownLatch(1)
+ val releaseOperation = CountDownLatch(1)
+ val claimAttempted = CountDownLatch(1)
+ val claimCompleted = CountDownLatch(1)
+ val replacement = AtomicReference()
+ val operation = thread(start = true, name = "system-shelf-blocked-commit") {
+ SystemShelfLifecycle.whileCurrent(ownership!!) {
+ operationStarted.countDown()
+ releaseOperation.await(2, TimeUnit.SECONDS)
+ }
+ }
+ assertTrue(operationStarted.await(1, TimeUnit.SECONDS))
+ val claimant = thread(start = true, name = "system-shelf-owner-claim") {
+ claimAttempted.countDown()
+ replacement.set(SystemShelfLifecycle.claim(lease, "owner-b", 2))
+ claimCompleted.countDown()
+ }
+
+ try {
+ assertTrue(claimAttempted.await(1, TimeUnit.SECONDS))
+ assertFalse("ownership changed during an active commit", claimCompleted.await(100, TimeUnit.MILLISECONDS))
+ releaseOperation.countDown()
+ assertTrue("claim did not resume after commit", claimCompleted.await(1, TimeUnit.SECONDS))
+ assertTrue(replacement.get() != null)
+ } finally {
+ releaseOperation.countDown()
+ operation.join(2_000)
+ claimant.join(2_000)
+ }
+ }
+
+ @Test
+ fun detachFencesQueuedSyncWithoutWaitingForBlockedActiveCommit() {
+ val plugin = WatchNextPlugin()
+ val binding = pluginBinding()
+ plugin.onAttachedToEngine(binding)
+ val executor = pluginIoExecutor(plugin)
+ tvProvider.blockNextBatch = true
+
+ val activeResult = RecordingResult()
+ plugin.onMethodCall(
+ MethodCall(
+ "sync",
+ mapOf(
+ "schemaVersion" to 2,
+ "ownerId" to "active-owner",
+ "generation" to 1L,
+ "items" to listOf(mapOf("contentId" to "active", "title" to "Active"))
+ )
+ ),
+ activeResult
+ )
+ assertTrue(tvProvider.batchStarted.await(1, TimeUnit.SECONDS))
+
+ val queuedResult = RecordingResult()
+ val channelReturned = CountDownLatch(1)
+ thread(start = true, name = "system-shelf-plugin-queued-channel") {
+ plugin.onMethodCall(
+ MethodCall(
+ "sync",
+ mapOf(
+ "schemaVersion" to 2,
+ "ownerId" to "queued-owner",
+ "generation" to 2L,
+ "items" to listOf(mapOf("contentId" to "queued", "title" to "Queued"))
+ )
+ ),
+ queuedResult
+ )
+ channelReturned.countDown()
+ }
+ assertTrue("queued channel claim waited for provider work", channelReturned.await(500, TimeUnit.MILLISECONDS))
+
+ val detachReturned = CountDownLatch(1)
+ thread(start = true, name = "system-shelf-plugin-detach") {
+ plugin.onDetachedFromEngine(binding)
+ detachReturned.countDown()
+ }
+ try {
+ assertTrue("engine detach waited for provider work", detachReturned.await(500, TimeUnit.MILLISECONDS))
+ } finally {
+ tvProvider.releaseBatch.countDown()
+ }
+ assertTrue(executor.awaitTermination(2, TimeUnit.SECONDS))
+ shadowOf(android.os.Looper.getMainLooper()).idle()
+
+ assertTrue(activeResult.completed.await(1, TimeUnit.SECONDS))
+ assertTrue(queuedResult.completed.await(1, TimeUnit.SECONDS))
+ assertEquals(true, activeResult.successValue)
+ assertEquals(false, queuedResult.successValue)
+ assertEquals(
+ listOf("Active"),
+ tvProvider.inserted.map { it.getAsString(TvContractCompat.WatchNextPrograms.COLUMN_TITLE) }
+ )
+ }
+
+ @Test
+ fun closedStaleEngineInitializationCannotInvalidateNewEngineLease() {
+ val staleExecutor = ManualExecutorService()
+ val stalePlugin = WatchNextPlugin { staleExecutor }
+ val staleBinding = pluginBinding()
+ stalePlugin.onAttachedToEngine(staleBinding)
+ stalePlugin.onDetachedFromEngine(staleBinding)
+
+ val currentPlugin = WatchNextPlugin()
+ val currentBinding = pluginBinding()
+ currentPlugin.onAttachedToEngine(currentBinding)
+ val firstResult = RecordingResult()
+ currentPlugin.onMethodCall(syncCall("current-owner", 1), firstResult)
+ awaitResult(firstResult)
+ assertEquals(true, firstResult.successValue)
+
+ staleExecutor.runNext()
+
+ val secondResult = RecordingResult()
+ currentPlugin.onMethodCall(syncCall("current-owner", 2), secondResult)
+ awaitResult(secondResult)
+ assertEquals(true, secondResult.successValue)
+
+ staleExecutor.runNext()
+ val currentExecutor = pluginIoExecutor(currentPlugin)
+ currentPlugin.onDetachedFromEngine(currentBinding)
+ assertTrue(currentExecutor.awaitTermination(2, TimeUnit.SECONDS))
+ }
+
+ @Test
+ fun oneSynchronizationDeadlineCoversSerialArtworkAndAbortsDripResponses() {
+ val server = ServerSocket(0, 2, InetAddress.getByName("127.0.0.1"))
+ val responder = thread(start = true, name = "system-shelf-drip-test-http") {
+ runCatching {
+ repeat(2) { requestIndex ->
+ server.accept().use { socket ->
+ val reader = socket.getInputStream().bufferedReader()
+ while (reader.readLine()?.isNotEmpty() == true) {
+ // Consume request headers.
+ }
+ val output = socket.getOutputStream()
+ if (requestIndex == 0) {
+ Thread.sleep(500)
+ output.write(
+ (
+ "HTTP/1.1 200 OK\r\n" +
+ "Content-Type: image/png\r\n" +
+ "Content-Length: ${imageBytes.size}\r\n" +
+ "Connection: close\r\n\r\n"
+ ).toByteArray()
+ )
+ output.write(imageBytes)
+ output.flush()
+ } else {
+ output.write(
+ (
+ "HTTP/1.1 200 OK\r\n" +
+ "Content-Type: image/png\r\n" +
+ "Transfer-Encoding: chunked\r\n" +
+ "Connection: close\r\n\r\n"
+ ).toByteArray()
+ )
+ repeat(100) {
+ output.write("1\r\nX\r\n".toByteArray())
+ output.flush()
+ Thread.sleep(50)
+ }
+ }
+ }
+ }
+ }
+ }
+ try {
+ val lease = SystemShelfLifecycle.acquire()
+ val provider = WatchNextProvider(context, lease, syncDurationMillis = 800)
+ val source = "http://127.0.0.1:${server.localPort}"
+ val started = System.nanoTime()
+ val committed = provider.syncWatchNextPrograms(
+ "owner",
+ 1,
+ listOf(item("$source/first"), item("$source/second").copy(contentId = "second"))
+ )
+ val elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started)
+
+ assertFalse(committed)
+ assertTrue(tvProvider.inserted.isEmpty())
+ assertTrue("shared deadline took ${elapsedMillis}ms", elapsedMillis < 1_100)
+ } finally {
+ server.close()
+ responder.join(2_000)
+ }
+ }
+
+ @Test
+ fun rejectedOrUnwritableImageBytesStillConsumeTheSharedSyncBudget() {
+ val malformed = "not an image".toByteArray()
+ withServer("image/png", malformed) { source ->
+ val budget = SystemShelfArtworkStore.Budget(100)
+ val ownership = SystemShelfLifecycle.claim(SystemShelfLifecycle.acquire(), "owner", 1)!!
+ val session = SystemShelfSyncSession(ownership, 2_000, budget = budget)
+
+ assertNull(SystemShelfArtworkStore(context.cacheDir).materialize("owner", source, session))
+ assertEquals(malformed.size.toLong(), budget.consumed)
+ assertEquals(100 - malformed.size, budget.remaining)
+ }
+
+ val blockedRoot = context.cacheDir.resolve("system_shelf_artwork")
+ blockedRoot.writeText("not a directory")
+ withServer("image/png", imageBytes) { source ->
+ val budget = SystemShelfArtworkStore.Budget(100)
+ val ownership = SystemShelfLifecycle.claim(SystemShelfLifecycle.acquire(), "owner", 1)!!
+ val session = SystemShelfSyncSession(ownership, 2_000, budget = budget)
+
+ assertNull(SystemShelfArtworkStore(context.cacheDir).materialize("owner", source, session))
+ assertEquals(imageBytes.size.toLong(), budget.consumed)
+ assertEquals(100 - imageBytes.size, budget.remaining)
+ }
+ blockedRoot.delete()
+ }
+
+ @Test
+ fun onlySelectedHomeHandlerIsAnArtworkGrantConsumer() {
+ val homeIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
+ val selected = registerHandler(homeIntent, "selected.home.launcher")
+ val inactive = registerHandler(homeIntent, "inactive.home.launcher")
+ selectDefaultHome(selected, selected, inactive)
+ registerHandler(
+ Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LEANBACK_LAUNCHER),
+ "unrelated.leanback.app"
+ )
+
+ assertEquals(setOf("selected.home.launcher"), WatchNextProvider(context).consumerPackages())
+ }
+
+ @Test
+ fun bootRestoresConfinedArtworkGrantOnlyToSelectedHomeLauncher() {
+ val homeIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
+ val selected = registerHandler(homeIntent, "selected.home.launcher")
+ val inactive = registerHandler(homeIntent, "inactive.home.launcher")
+ selectDefaultHome(selected, selected, inactive)
+ val owner = "a".repeat(64)
+ val key = "${"b".repeat(32)}.art"
+ context.cacheDir.resolve("system_shelf_artwork/$owner").mkdirs()
+ context.cacheDir.resolve("system_shelf_artwork/$owner/$key").writeBytes(imageBytes)
+ val valid = SystemShelfArtworkStore(context.cacheDir).contentUri(owner, key)
+ val invalid = Uri.parse("content://${SystemShelfArtworkProvider.AUTHORITY}/art/not/confined.art")
+ context.getSharedPreferences("system_shelf_state", 0).edit()
+ .putStringSet("granted_uris", setOf(valid.toString(), invalid.toString()))
+ .putInt("shelf_schema_version", WatchNextProvider.SHELF_SCHEMA_VERSION)
+ .commit()
+ val recordingContext = RecordingGrantContext(context)
+
+ SystemShelfUpdateReceiver(Executor { command -> command.run() })
+ .onReceive(recordingContext, Intent(Intent.ACTION_BOOT_COMPLETED))
+
+ assertEquals(
+ listOf(Grant("selected.home.launcher", valid, Intent.FLAG_GRANT_READ_URI_PERMISSION)),
+ recordingContext.grants
+ )
+ assertEquals(
+ setOf(valid.toString()),
+ context.getSharedPreferences("system_shelf_state", 0).getStringSet("granted_uris", emptySet())
+ )
+ }
+
+ @Test
+ @Config(sdk = [25])
+ fun api25RevocationUsesUriWideFallback() {
+ val stale = Uri.parse("content://${SystemShelfArtworkProvider.AUTHORITY}/art/stale/file.art")
+ context.getSharedPreferences("system_shelf_state", 0).edit()
+ .putStringSet("granted_uris", setOf(stale.toString()))
+ .putStringSet("granted_packages", setOf("selected.home.launcher"))
+ .putInt("shelf_schema_version", WatchNextProvider.SHELF_SCHEMA_VERSION)
+ .commit()
+ val recordingContext = RecordingGrantContext(context)
+
+ assertTrue(WatchNextProvider.forMaintenance(recordingContext).restoreReadGrants())
+
+ assertEquals(listOf(stale), recordingContext.uriWideRevocations)
+ assertTrue(recordingContext.packageRevocations.isEmpty())
+ }
+
@Test
fun staleGenerationCannotCommitAndClearRemovesRowsGrantsAndFiles() {
withServer("image/png", imageBytes) { source ->
@@ -116,6 +536,22 @@ class WatchNextProviderTest {
}
}
+ @Test
+ fun packageUpdatePreservesRowsAndArtworkAtCurrentShelfSchema() {
+ val file = context.cacheDir.resolve("system_shelf_artwork/${"a".repeat(64)}/${"b".repeat(32)}.art")
+ file.parentFile?.mkdirs()
+ file.writeBytes(imageBytes)
+ context.getSharedPreferences("system_shelf_state", 0).edit()
+ .putInt("shelf_schema_version", WatchNextProvider.SHELF_SCHEMA_VERSION)
+ .commit()
+
+ val receiver = SystemShelfUpdateReceiver(Executor { command -> command.run() })
+ receiver.onReceive(context, Intent(Intent.ACTION_MY_PACKAGE_REPLACED))
+
+ assertEquals(0, tvProvider.deleteCount)
+ assertTrue(file.isFile)
+ }
+
@Test
fun packageUpdateCleanupDeletesLegacyRowsAndOwnedFiles() {
context.cacheDir.resolve("system_shelf_artwork/legacy").apply { mkdirs() }.resolve("legacy.art").writeBytes(imageBytes)
@@ -126,6 +562,80 @@ class WatchNextProviderTest {
assertFalse(context.cacheDir.resolve("system_shelf_artwork").exists())
}
+ @Test
+ fun providerFailureDeletesNewArtworkAndPreservesCommittedArtwork() {
+ val provider = WatchNextProvider(context)
+ withServer("image/png", imageBytes) { source ->
+ assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(source))))
+ }
+ val committedArtwork = artworkFiles().single().canonicalFile
+ tvProvider.failBatch = true
+
+ withServer("image/png", imageBytes) { source ->
+ assertFalse(provider.syncWatchNextPrograms("owner-a", 2, listOf(item(source))))
+ }
+
+ assertEquals(setOf(committedArtwork), artworkFiles().mapTo(HashSet()) { it.canonicalFile })
+ }
+
+ private fun registerHandler(intent: Intent, packageName: String): ComponentName {
+ val component = ComponentName(packageName, "$packageName.HomeActivity")
+ val result = ResolveInfo().apply {
+ activityInfo = ActivityInfo().apply {
+ this.packageName = component.packageName
+ name = component.className
+ applicationInfo = ApplicationInfo().apply { this.packageName = component.packageName }
+ }
+ }
+ shadowOf(context.packageManager).addResolveInfoForIntent(intent, result)
+ return component
+ }
+
+ private fun selectDefaultHome(selected: ComponentName, vararg candidates: ComponentName) {
+ val filter = IntentFilter(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_HOME) }
+ context.packageManager.addPreferredActivity(filter, IntentFilter.MATCH_CATEGORY_EMPTY, candidates, selected)
+ }
+
+ private fun artworkFiles() = context.cacheDir.resolve("system_shelf_artwork").walkTopDown().filter { it.isFile }.toList()
+
+ private fun pluginBinding(): FlutterPlugin.FlutterPluginBinding {
+ val messenger = Proxy.newProxyInstance(
+ BinaryMessenger::class.java.classLoader,
+ arrayOf(BinaryMessenger::class.java)
+ ) { _, _, _ -> null } as BinaryMessenger
+ val constructor = FlutterPlugin.FlutterPluginBinding::class.java.constructors.single()
+ val arguments = constructor.parameterTypes.map { type ->
+ when {
+ Context::class.java.isAssignableFrom(type) -> context
+ BinaryMessenger::class.java.isAssignableFrom(type) -> messenger
+ else -> null
+ }
+ }.toTypedArray()
+ return constructor.newInstance(*arguments) as FlutterPlugin.FlutterPluginBinding
+ }
+ private fun syncCall(ownerId: String, generation: Long) = MethodCall(
+ "sync",
+ mapOf(
+ "schemaVersion" to 2,
+ "ownerId" to ownerId,
+ "generation" to generation,
+ "items" to emptyList