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>() + ) + ) + + private fun awaitResult(result: RecordingResult) { + repeat(100) { + shadowOf(android.os.Looper.getMainLooper()).idle() + if (result.completed.await(10, TimeUnit.MILLISECONDS)) return + } + assertTrue("Watch Next result never completed", false) + } + + private fun pluginIoExecutor(plugin: WatchNextPlugin): ExecutorService = WatchNextPlugin::class.java.getDeclaredField("ioExecutor").run { + isAccessible = true + get(plugin) as ExecutorService + } + private fun item(source: String) = WatchNextProvider.WatchNextItem( contentId = "plezy_server_item", title = "Private title", @@ -177,10 +687,39 @@ class WatchNextProviderTest { } } +private data class Grant(val packageName: String, val uri: Uri, val modeFlags: Int) + +private data class PackageRevocation(val packageName: String, val uri: Uri, val modeFlags: Int) + +private class RecordingGrantContext(base: Context) : ContextWrapper(base) { + val grants = mutableListOf() + val uriWideRevocations = mutableListOf() + val packageRevocations = mutableListOf() + + override fun getApplicationContext(): Context = this + + override fun grantUriPermission(toPackage: String?, uri: Uri?, modeFlags: Int) { + if (toPackage != null && uri != null) grants += Grant(toPackage, uri, modeFlags) + } + + override fun revokeUriPermission(uri: Uri?, modeFlags: Int) { + if (uri != null) uriWideRevocations += uri + } + + override fun revokeUriPermission(targetPackage: String?, uri: Uri?, modeFlags: Int) { + if (targetPackage != null && uri != null) { + packageRevocations += PackageRevocation(targetPackage, uri, modeFlags) + } + } +} + private class CapturingTvProvider : ContentProvider() { val inserted = mutableListOf() var deleteCount = 0 - + var failBatch = false + var blockNextBatch = false + val batchStarted = CountDownLatch(1) + val releaseBatch = CountDownLatch(1) override fun onCreate(): Boolean = true override fun insert(uri: Uri, values: ContentValues?): Uri { inserted += ContentValues(values) @@ -191,8 +730,63 @@ private class CapturingTvProvider : ContentProvider() { inserted.clear() return 1 } - override fun applyBatch(operations: ArrayList): Array = super.applyBatch(operations) + override fun applyBatch(operations: ArrayList): Array { + if (failBatch) throw IllegalStateException("Injected provider failure") + if (blockNextBatch) { + blockNextBatch = false + batchStarted.countDown() + releaseBatch.await(2, TimeUnit.SECONDS) + } + return super.applyBatch(operations) + } override fun getType(uri: Uri): String? = null override fun query(uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String?): Cursor? = null override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int = 0 } + +private class RecordingResult : MethodChannel.Result { + val completed = CountDownLatch(1) + var successValue: Any? = null + + override fun success(result: Any?) { + successValue = result + completed.countDown() + } + + override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + completed.countDown() + } + + override fun notImplemented() { + completed.countDown() + } +} + +private class ManualExecutorService : AbstractExecutorService() { + private val tasks = ArrayDeque() + private var shutdown = false + + override fun execute(command: Runnable) { + if (shutdown) throw RejectedExecutionException() + tasks.addLast(command) + } + + override fun shutdown() { + shutdown = true + } + + override fun shutdownNow(): MutableList { + shutdown = true + return tasks.toMutableList().also { tasks.clear() } + } + + override fun isShutdown(): Boolean = shutdown + + override fun isTerminated(): Boolean = shutdown && tasks.isEmpty() + + override fun awaitTermination(timeout: Long, unit: TimeUnit): Boolean = isTerminated + + fun runNext() { + tasks.removeFirst().run() + } +} diff --git a/android/libass/src/main/cpp/AssKt.c b/android/libass/src/main/cpp/AssKt.c index 25dea8fd..f76992e7 100644 --- a/android/libass/src/main/cpp/AssKt.c +++ b/android/libass/src/main/cpp/AssKt.c @@ -56,6 +56,12 @@ JNIEXPORT void JNICALL Java_com_edde746_plezy_libass_Ass_nativeAssAddFont( } } +JNIEXPORT void JNICALL Java_com_edde746_plezy_libass_Ass_nativeAssClearFonts(JNIEnv* env, jclass clazz, jlong ass) { + if (ass) { + ass_clear_fonts((ASS_Library*)ass); + } +} + JNIEXPORT void JNICALL Java_com_edde746_plezy_libass_Ass_nativeAssDeinit(JNIEnv* env, jclass clazz, jlong ass) { if (ass) { ass_library_done((ASS_Library*)ass); diff --git a/android/libass/src/main/java/com/edde746/plezy/libass/Ass.kt b/android/libass/src/main/java/com/edde746/plezy/libass/Ass.kt index a3b1b117..f603b423 100644 --- a/android/libass/src/main/java/com/edde746/plezy/libass/Ass.kt +++ b/android/libass/src/main/java/com/edde746/plezy/libass/Ass.kt @@ -17,6 +17,9 @@ class Ass { @JvmStatic external fun nativeAssAddFont(ptr: Long, name: String, buffer: ByteArray) + @JvmStatic + external fun nativeAssClearFonts(ptr: Long) + @JvmStatic external fun nativeAssDeinit(ptr: Long) } @@ -45,6 +48,12 @@ class Ass { } } + internal fun clearFonts() { + lock.withLock { + if (!released && nativeAss != 0L) nativeAssClearFonts(nativeAss) + } + } + fun release() { lock.withLock { if (released) return diff --git a/android/libass/src/main/java/com/edde746/plezy/libass/media/AssHandler.kt b/android/libass/src/main/java/com/edde746/plezy/libass/media/AssHandler.kt index 1a0cf98c..e588f462 100644 --- a/android/libass/src/main/java/com/edde746/plezy/libass/media/AssHandler.kt +++ b/android/libass/src/main/java/com/edde746/plezy/libass/media/AssHandler.kt @@ -19,6 +19,39 @@ import com.edde746.plezy.libass.AssTrack import com.edde746.plezy.libass.media.parser.AssHeaderParser import com.edde746.plezy.libass.media.widget.AssAtlasPipelineConfig +internal class AssFontStore { + private val pendingFonts = mutableListOf>() + + @Synchronized + fun add( + name: String, + data: ByteArray, + nativeReady: Boolean, + addToNative: (String, ByteArray) -> Unit + ) { + if (nativeReady) { + addToNative(name, data) + } else { + pendingFonts.add(name to data) + } + } + + @Synchronized + fun flush(addToNative: (String, ByteArray) -> Unit) { + pendingFonts.forEach { (name, data) -> addToNative(name, data) } + pendingFonts.clear() + } + + @Synchronized + fun reset(nativeInitialized: Boolean, clearNative: () -> Unit) { + pendingFonts.clear() + if (nativeInitialized) clearNative() + } + + @Synchronized + internal fun pendingSnapshot(): List> = pendingFonts.toList() +} + /** * Handles ASS subtitle rendering and integration with ExoPlayer. * @@ -52,8 +85,8 @@ class AssHandler( /** The available ASS tracks in the current media. */ private val availableTracks = mutableMapOf() - /** Fonts encountered before any ASS track was created. Flushed in [createTrack]. */ - private val pendingFonts = mutableListOf>() + /** Owns pre-track Java font buffers and the per-media native clear boundary. */ + internal val fontStore = AssFontStore() /** The size of the video track. */ var videoSize = Size.ZERO @@ -119,6 +152,7 @@ class AssHandler( resetMediaState(releaseNative = true) } + @Synchronized private fun resetMediaState(releaseNative: Boolean) { val oldRender = render val oldTracks = availableTracks.values.toList() @@ -127,7 +161,6 @@ class AssHandler( track = null format = null availableTracks.clear() - pendingFonts.clear() videoSize = Size.ZERO renderCallback?.invoke(null) @@ -135,6 +168,7 @@ class AssHandler( oldRender?.release() oldTracks.forEach { it.release() } } + fontStore.reset(assDelegate.isInitialized()) { ass.clearFonts() } } /** @@ -274,10 +308,8 @@ class AssHandler( */ @Synchronized fun addFont(name: String, data: ByteArray) { - if (hasTracks()) { - ass.addFont(name, data) - } else { - pendingFonts.add(name to data) + fontStore.add(name, data, hasTracks()) { fontName, fontData -> + ass.addFont(fontName, fontData) } } @@ -294,12 +326,7 @@ class AssHandler( createRenderIfNeeded() // Flush any fonts that were buffered before the first track was created. - if (pendingFonts.isNotEmpty()) { - for ((name, data) in pendingFonts) { - ass.addFont(name, data) - } - pendingFonts.clear() - } + fontStore.flush { name, data -> ass.addFont(name, data) } val track = ass.createTrack() if (format.initializationData.size > 0) { @@ -375,6 +402,7 @@ class AssHandler( /** * Releases all native resources held by this handler. */ + @Synchronized fun release() { videoFrameCallback = null player?.clearVideoFrameMetadataListener(videoFrameMetadataListener) diff --git a/android/libass/src/main/java/com/edde746/plezy/libass/media/extractor/AssMatroskaExtractor.kt b/android/libass/src/main/java/com/edde746/plezy/libass/media/extractor/AssMatroskaExtractor.kt index 2eb5ea3d..9a7bdb48 100644 --- a/android/libass/src/main/java/com/edde746/plezy/libass/media/extractor/AssMatroskaExtractor.kt +++ b/android/libass/src/main/java/com/edde746/plezy/libass/media/extractor/AssMatroskaExtractor.kt @@ -1,6 +1,8 @@ package com.edde746.plezy.libass.media.extractor +import android.util.Log import androidx.annotation.OptIn +import androidx.media3.common.ParserException import androidx.media3.common.util.ParsableByteArray import androidx.media3.common.util.UnstableApi import androidx.media3.extractor.ExtractorInput @@ -20,6 +22,8 @@ open class AssMatroskaExtractor( private var currentAttachmentName: String? = null private var currentAttachmentMime: String? = null + internal var acceptedFontBytes = 0L + private set internal val subtitleSample = subtitleSampleField.get(this) as ParsableByteArray @@ -75,27 +79,65 @@ open class AssMatroskaExtractor( override fun binaryElement(id: Int, contentSize: Int, input: ExtractorInput) { when (id) { ID_FILE_DATA -> { + if (contentSize < 0) { + throw ParserException.createForMalformedContainer( + "Negative Matroska attachment size", + null + ) + } + val attachmentName = requireNotNull(currentAttachmentName) val attachmentMime = requireNotNull(currentAttachmentMime) - - if (attachmentMime in fontMimeTypes) { - val data = ByteArray(contentSize) - input.readFully(data, 0, contentSize) - assHandler.addFont(attachmentName, data) - } else { + if (attachmentMime !in fontMimeTypes) { input.skipFully(contentSize) + return } + if (contentSize == 0) { + input.skipFully(0) + return + } + + val size = contentSize.toLong() + val rejectionReason = when { + size > MAX_FONT_BYTES -> "per-font limit" + size > MAX_TOTAL_FONT_BYTES - acceptedFontBytes -> "aggregate limit" + else -> null + } + if (rejectionReason != null) { + onFontRejected(contentSize, acceptedFontBytes, rejectionReason) + input.skipFully(contentSize) + return + } + + val data = ByteArray(contentSize) + input.readFully(data, 0, contentSize) + acceptedFontBytes += size + assHandler.addFont(attachmentName, data) } else -> super.binaryElement(id, contentSize, input) } } + protected open fun onFontRejected( + contentSize: Int, + acceptedBytes: Long, + reason: String + ) { + Log.w( + TAG, + "Skipping embedded font: $reason (bytes=$contentSize, accepted=$acceptedBytes)" + ) + } + private fun clearAttachment() { currentAttachmentName = null currentAttachmentMime = null } companion object { + private const val TAG = "AssMatroskaExtractor" + internal const val MAX_FONT_BYTES = 16L * 1024 * 1024 + internal const val MAX_TOTAL_FONT_BYTES = 32L * 1024 * 1024 const val ID_EBML = 0x1A45DFA3 const val ID_VIDEO = 0xE0 const val ID_ATTACHMENTS = 0x1941A469 diff --git a/android/libass/src/test/java/com/edde746/plezy/libass/media/AssFontStoreTest.kt b/android/libass/src/test/java/com/edde746/plezy/libass/media/AssFontStoreTest.kt new file mode 100644 index 00000000..2c3d0194 --- /dev/null +++ b/android/libass/src/test/java/com/edde746/plezy/libass/media/AssFontStoreTest.kt @@ -0,0 +1,48 @@ +package com.edde746.plezy.libass.media + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +class AssFontStoreTest { + + @Test + fun queuedFontsFlushOnceAndResetDropsPendingBuffers() { + val store = AssFontStore() + val delivered = mutableListOf>() + val deliver: (String, ByteArray) -> Unit = { name, data -> delivered.add(name to data) } + store.add("first", byteArrayOf(1, 2), nativeReady = false, deliver) + store.add("second", byteArrayOf(3), nativeReady = false, deliver) + + store.reset(nativeInitialized = false) { error("native clear must stay lazy") } + store.flush(deliver) + assertEquals(0, delivered.size) + + store.add("third", byteArrayOf(4, 5), nativeReady = false, deliver) + store.flush(deliver) + store.flush(deliver) + + assertEquals(1, delivered.size) + assertEquals("third", delivered.single().first) + assertArrayEquals(byteArrayOf(4, 5), delivered.single().second) + } + + @Test + fun resetClearsNativeFontsAndNewMediaCanAddAfterward() { + val store = AssFontStore() + val delivered = mutableListOf>() + val deliver: (String, ByteArray) -> Unit = { name, data -> delivered.add(name to data) } + var clearCount = 0 + + store.add("old", byteArrayOf(1), nativeReady = false, deliver) + store.flush(deliver) + store.reset(nativeInitialized = true) { clearCount++ } + + store.add("new", byteArrayOf(2), nativeReady = true, deliver) + store.reset(nativeInitialized = true) { clearCount++ } + + assertEquals(listOf("old", "new"), delivered.map { it.first }) + assertEquals(2, clearCount) + assertEquals(0, store.pendingSnapshot().size) + } +} diff --git a/android/libass/src/test/java/com/edde746/plezy/libass/media/extractor/AssMatroskaExtractorTest.kt b/android/libass/src/test/java/com/edde746/plezy/libass/media/extractor/AssMatroskaExtractorTest.kt new file mode 100644 index 00000000..9a8e2ee7 --- /dev/null +++ b/android/libass/src/test/java/com/edde746/plezy/libass/media/extractor/AssMatroskaExtractorTest.kt @@ -0,0 +1,156 @@ +package com.edde746.plezy.libass.media.extractor + +import androidx.media3.common.C +import androidx.media3.common.DataReader +import androidx.media3.common.ParserException +import androidx.media3.extractor.DefaultExtractorInput +import androidx.media3.extractor.ExtractorInput +import androidx.media3.extractor.text.DefaultSubtitleParserFactory +import com.edde746.plezy.libass.media.AssHandler +import java.io.EOFException +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class AssMatroskaExtractorTest { + + @Test + fun smallFontIsDeliveredAndNonFontDoesNotConsumeBudget() { + val handler = AssHandler() + val extractor = extractor(handler) + + attachment(extractor, "font/ttf") + extractor.fileData(4, input(4, seed = 11)) + attachment(extractor, "application/octet-stream") + extractor.fileData(9, input(9, seed = 22)) + + val pending = handler.fontStore.pendingSnapshot() + assertEquals(1, pending.size) + assertEquals("fixture-font", pending.single().first) + assertArrayEquals(byteArrayOf(11, 12, 13, 14), pending.single().second) + assertEquals(4L, extractor.acceptedFontBytes) + } + + @Test + fun perFontLimitAcceptsExactBoundaryAndSkipsOneByteOver() { + val handler = AssHandler() + val extractor = extractor(handler) + val limit = AssMatroskaExtractor.MAX_FONT_BYTES.toInt() + + attachment(extractor, "font/otf") + extractor.fileData(limit, input(limit)) + attachment(extractor, "font/otf") + extractor.fileData(limit + 1, input(limit + 1)) + + assertEquals(limit.toLong(), extractor.acceptedFontBytes) + assertEquals(1, handler.fontStore.pendingSnapshot().size) + } + + @Test + fun aggregateLimitIsDeterministicAcrossAttachmentEntries() { + val handler = AssHandler() + val extractor = extractor(handler) + val perFont = AssMatroskaExtractor.MAX_FONT_BYTES.toInt() + + repeat(2) { + attachment(extractor, "font/ttf") + extractor.fileData(perFont, input(perFont, seed = it)) + extractor.endAttachment() + } + attachment(extractor, "font/ttf") + extractor.fileData(1, input(1)) + + assertEquals(AssMatroskaExtractor.MAX_TOTAL_FONT_BYTES, extractor.acceptedFontBytes) + assertEquals(2, handler.fontStore.pendingSnapshot().size) + } + + @Test + fun negativeAndZeroSizesAllocateAndDeliverNothing() { + val handler = AssHandler() + val extractor = extractor(handler) + attachment(extractor, "font/woff2") + + assertThrows(ParserException::class.java) { + extractor.fileData(-1, input(0)) + } + attachment(extractor, "font/woff2") + extractor.fileData(0, input(0)) + + assertEquals(0L, extractor.acceptedFontBytes) + assertEquals(0, handler.fontStore.pendingSnapshot().size) + } + + @Test + fun failedReadDoesNotChargeAggregateBudgetOrDeliverPartialFont() { + val handler = AssHandler() + val extractor = extractor(handler) + attachment(extractor, "font/ttf") + + assertThrows(EOFException::class.java) { + extractor.fileData(1024, input(1024, available = 4)) + } + assertEquals(0L, extractor.acceptedFontBytes) + assertEquals(0, handler.fontStore.pendingSnapshot().size) + + attachment(extractor, "font/ttf") + extractor.fileData(1024, input(1024)) + assertEquals(1024L, extractor.acceptedFontBytes) + assertEquals(1, handler.fontStore.pendingSnapshot().size) + } + + private fun extractor(handler: AssHandler) = TestExtractor(handler) + + private fun attachment(extractor: TestExtractor, mime: String) { + extractor.setAttachment(mime) + } + + private class TestExtractor(handler: AssHandler) : + AssMatroskaExtractor( + DefaultSubtitleParserFactory(), + handler + ) { + fun setAttachment(mime: String) { + startMasterElement(ID_ATTACHED_FILE, 0, 0) + stringElement(ID_FILE_NAME, "fixture-font") + stringElement(ID_FILE_MIME_TYPE, mime) + } + + fun fileData(contentSize: Int, input: ExtractorInput) { + binaryElement(ID_FILE_DATA, contentSize, input) + } + + fun endAttachment() { + endMasterElement(ID_ATTACHED_FILE) + } + + override fun onFontRejected(contentSize: Int, acceptedBytes: Long, reason: String) = Unit + } + + private fun input( + declared: Int, + available: Int = declared, + seed: Int = 0 + ): ExtractorInput = DefaultExtractorInput( + PatternDataReader(available, seed), + 0, + declared.toLong() + ) + + private class PatternDataReader( + private val size: Int, + private val seed: Int + ) : DataReader { + private var position = 0 + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + if (position >= size) return C.RESULT_END_OF_INPUT + val count = minOf(length, size - position) + for (index in 0 until count) { + buffer[offset + index] = ((seed + position + index) and 0xFF).toByte() + } + position += count + return count + } + } +} diff --git a/ios/Runner/MpvPlayer/MpvPipController.swift b/ios/Runner/MpvPlayer/MpvPipController.swift index 45d052e5..1923ecf0 100644 --- a/ios/Runner/MpvPlayer/MpvPipController.swift +++ b/ios/Runner/MpvPlayer/MpvPipController.swift @@ -54,6 +54,20 @@ import UIKit /// Get total duration in seconds var pipDuration: Double { get } } + protocol MpvPictureInPictureControlling: AnyObject { + var isPictureInPicturePossible: Bool { get } + func startPictureInPicture() + func stopPictureInPicture() + func setAutomaticStart(_ enabled: Bool) + func invalidatePlaybackState() + } + + @available(iOS 15.0, *) + extension AVPictureInPictureController: MpvPictureInPictureControlling { + func setAutomaticStart(_ enabled: Bool) { + canStartPictureInPictureAutomaticallyFromInline = enabled + } + } /// Encapsulates all iOS Picture-in-Picture logic using AVSampleBufferDisplayLayer. /// Requires iOS 15+ for the ContentSource API; on older versions, `setup()` is a no-op. @@ -61,18 +75,69 @@ import UIKit // MARK: - Properties - private var pipController: AVPictureInPictureController? + private var pipController: MpvPictureInPictureControlling? private weak var sampleBufferLayer: AVSampleBufferDisplayLayer? weak var delegate: MpvPipDelegate? + private var startGeneration = 0 + private var pendingStartCompletion: ((Bool) -> Void)? + private var startRequested = false + private var systemStartExpected = false + private var hasActiveSession = false + private var restoreRequested = false + private var isTornDown = false + private let readinessOverride: (() -> (possible: Bool, timebase: Bool, frame: Bool))? + private let retryScheduler: (@escaping () -> Void) -> Void + private let startTimeoutScheduler: (@escaping () -> Void) -> Void + private let replacementControllerFactory: ((AVSampleBufferDisplayLayer?) -> MpvPictureInPictureControlling)? + private var autoStartEnabled = false // MARK: - Initialization init(sampleBufferDisplayLayer: AVSampleBufferDisplayLayer) { self.sampleBufferLayer = sampleBufferDisplayLayer + self.readinessOverride = nil + self.retryScheduler = { work in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: work) + } + self.startTimeoutScheduler = { work in + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: work) + } + self.replacementControllerFactory = nil super.init() setup() } + init( + controller: MpvPictureInPictureControlling, + sampleBufferDisplayLayer: AVSampleBufferDisplayLayer? = nil, + readiness: @escaping () -> (possible: Bool, timebase: Bool, frame: Bool), + retryScheduler: @escaping (@escaping () -> Void) -> Void, + startTimeoutScheduler: @escaping (@escaping () -> Void) -> Void = { work in + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: work) + }, + replacementControllerFactory: + ((AVSampleBufferDisplayLayer?) -> MpvPictureInPictureControlling)? = nil + ) { + self.pipController = controller + self.sampleBufferLayer = sampleBufferDisplayLayer + self.readinessOverride = readiness + self.retryScheduler = retryScheduler + self.startTimeoutScheduler = startTimeoutScheduler + self.replacementControllerFactory = replacementControllerFactory + super.init() + } + + deinit { + if let completion = pendingStartCompletion { + pendingStartCompletion = nil + if Thread.isMainThread { + completion(false) + } else { + DispatchQueue.main.async { completion(false) } + } + } + } + private func setup() { guard #available(iOS 15.0, *) else { return } @@ -98,14 +163,17 @@ import UIKit ) self.delegateHelper = helper pipController = AVPictureInPictureController(contentSource: contentSource) - pipController?.delegate = helper - pipController?.canStartPictureInPictureAutomaticallyFromInline = false + (pipController as? AVPictureInPictureController)?.delegate = helper + pipController?.setAutomaticStart(autoStartEnabled) } /// Enable/disable system auto-PiP (starts PiP automatically on background transition) func setAutoStart(_ enabled: Bool) { - guard #available(iOS 14.2, *) else { return } - pipController?.canStartPictureInPictureAutomaticallyFromInline = enabled + guard !isTornDown else { return } + let wasEnabled = autoStartEnabled + autoStartEnabled = enabled + if enabled && !wasEnabled { systemStartExpected = false } + pipController?.setAutomaticStart(enabled) } /// MPVKit owns the sample-buffer layer timebase. PiP only reads it. @@ -126,63 +194,219 @@ import UIKit return AVPictureInPictureController.isPictureInPictureSupported() } - /// Start PiP. When `waitForFrame` is false (auto-PiP), skips the frame - /// readiness check since the scene is about to deactivate. + fileprivate func isCurrentController(_ controller: MpvPictureInPictureControlling) -> Bool { + guard let pipController else { return false } + return ObjectIdentifier(pipController) == ObjectIdentifier(controller) + } + + private func retireCurrentPipController() { + if #available(iOS 15.0, *) { + (delegateHelper as? PipDelegateHelper)?.controller = nil + (pipController as? AVPictureInPictureController)?.delegate = nil + } + pipController?.setAutomaticStart(false) + pipController?.stopPictureInPicture() + pipController = nil + delegateHelper = nil + } + + private func recreatePipController() { + if let replacementControllerFactory { + pipController = replacementControllerFactory(sampleBufferLayer) + pipController?.setAutomaticStart(autoStartEnabled) + } else if sampleBufferLayer != nil { + createPipController() + } + } + + private func finishStart(generation: Int, success: Bool) { + guard generation == startGeneration, let completion = pendingStartCompletion else { return } + pendingStartCompletion = nil + startRequested = false + completion(success) + } + + private func cancelPendingStart() { + startGeneration &+= 1 + startRequested = false + guard let completion = pendingStartCompletion else { return } + pendingStartCompletion = nil + completion(false) + } + + private func readiness(waitForFrame: Bool) -> (possible: Bool, timebase: Bool, frame: Bool) { + if let readinessOverride { + return readinessOverride() + } + let possible = pipController?.isPictureInPicturePossible ?? false + let timebase = sampleBufferLayer?.controlTimebase != nil + let frame: Bool + if !waitForFrame { + frame = true + } else if #available(iOS 17.4, *) { + frame = sampleBufferLayer?.isReadyForDisplay ?? false + } else { + frame = true + } + return (possible, timebase, frame) + } + + private func scheduleStartTimeout( + generation: Int, + controllerIdentifier: ObjectIdentifier + ) { + startTimeoutScheduler { [weak self] in + guard let self, !isTornDown, generation == startGeneration, + startRequested, let completion = pendingStartCompletion, + let pipController, + ObjectIdentifier(pipController) == controllerIdentifier + else { return } + print("[MpvPipController] PiP start produced no delegate outcome before the deadline") + pendingStartCompletion = nil + startRequested = false + systemStartExpected = false + retireCurrentPipController() + recreatePipController() + completion(false) + } + } + + private func retryStart(generation: Int, waitForFrame: Bool, attempts: Int) { + guard !isTornDown, generation == startGeneration, pendingStartCompletion != nil, + let pipController + else { return } + + let readiness = readiness(waitForFrame: waitForFrame) + if readiness.possible && readiness.timebase && readiness.frame { + guard !startRequested else { return } + startRequested = true + print("[MpvPipController] vo_avfoundation ready after \(attempts) retries, starting PiP") + pipController.startPictureInPicture() + scheduleStartTimeout( + generation: generation, + controllerIdentifier: ObjectIdentifier(pipController) + ) + } else if attempts < 40 { + retryScheduler { [weak self] in + self?.retryStart( + generation: generation, waitForFrame: waitForFrame, attempts: attempts + 1) + } + } else { + print( + "[MpvPipController] PiP not ready after \(attempts) retries " + + "(possible=\(readiness.possible), timebase=\(readiness.timebase))" + ) + finishStart(generation: generation, success: false) + } + } + + func pictureInPictureWillStart(from controller: MpvPictureInPictureControlling) { + guard !isTornDown, isCurrentController(controller) else { return } + systemStartExpected = true + delegate?.pipWillStart() + } + + func pictureInPictureWillStart() { + guard let pipController else { return } + pictureInPictureWillStart(from: pipController) + } + + func pictureInPictureDidStart(from controller: MpvPictureInPictureControlling) { + guard !isTornDown, isCurrentController(controller) else { return } + guard systemStartExpected || pendingStartCompletion != nil else { + controller.stopPictureInPicture() + return + } + hasActiveSession = true + systemStartExpected = false + // Resolve the pending manual method call before the delegate publishes + // PiP state: the plugin's delegate path may suspend the application. + finishStart(generation: startGeneration, success: true) + delegate?.pipDidStart() + } + + func pictureInPictureDidStart() { + guard let pipController else { return } + pictureInPictureDidStart(from: pipController) + } + + func pictureInPictureFailedToStart( + from controller: MpvPictureInPictureControlling, + error: Error + ) { + guard !isTornDown, isCurrentController(controller), + systemStartExpected || pendingStartCompletion != nil + else { return } + systemStartExpected = false + delegate?.pipDidFailToStart(error: error) + finishStart(generation: startGeneration, success: false) + } + + func pictureInPictureFailedToStart(error: Error) { + guard let pipController else { return } + pictureInPictureFailedToStart(from: pipController, error: error) + } + + func pictureInPictureDidStop(from controller: MpvPictureInPictureControlling) { + guard !isTornDown, isCurrentController(controller), hasActiveSession else { return } + hasActiveSession = false + let restored = restoreRequested + restoreRequested = false + delegate?.pipDidStop(restored: restored) + } + + func pictureInPictureDidStop() { + guard let pipController else { return } + pictureInPictureDidStop(from: pipController) + } + + func restoreUserInterface(completion: @escaping (Bool) -> Void) { + let canRestore = !isTornDown && hasActiveSession && delegate != nil + restoreRequested = canRestore + completion(canRestore) + } + + /// Start PiP. Completion reports the delegate-confirmed terminal outcome. func startPip(waitForFrame: Bool = true, completion: @escaping (Bool) -> Void) { - guard let pipController = pipController else { + guard !isTornDown, pipController != nil else { completion(false) return } - - var attempts = 0 - func tryStart() { - let possible = pipController.isPictureInPicturePossible - let hasTimebase = self.sampleBufferLayer?.controlTimebase != nil - - let hasFrame: Bool - if !waitForFrame { - hasFrame = true // Skip frame check for auto-PiP - } else if #available(iOS 17.4, *) { - hasFrame = self.sampleBufferLayer?.isReadyForDisplay ?? false - } else { - hasFrame = true - } - - if possible && hasTimebase && hasFrame { - print("[MpvPipController] vo_avfoundation ready after \(attempts) retries, starting PiP") - pipController.startPictureInPicture() - completion(true) - } else if attempts < 40 { - attempts += 1 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { tryStart() } - } else { - print( - "[MpvPipController] PiP not ready after \(attempts) retries (possible=\(possible), timebase=\(hasTimebase))" - ) - completion(false) - } + guard pendingStartCompletion == nil else { + completion(false) + return } - tryStart() + startGeneration &+= 1 + let generation = startGeneration + pendingStartCompletion = completion + startRequested = false + systemStartExpected = false + retryStart(generation: generation, waitForFrame: waitForFrame, attempts: 0) } func stopPip() { + cancelPendingStart() + systemStartExpected = false + restoreRequested = false pipController?.stopPictureInPicture() } /// Invalidate the playback state so PiP updates its UI (play/pause button) func invalidatePlaybackState() { - guard #available(iOS 15.0, *) else { return } + guard !isTornDown else { return } pipController?.invalidatePlaybackState() } /// Fully tear down PiP without touching the shared inline display layer. func teardown() { - pipController?.stopPictureInPicture() - if #available(iOS 14.2, *) { - pipController?.canStartPictureInPictureAutomaticallyFromInline = false - } - pipController = nil - delegateHelper = nil + guard !isTornDown else { return } + cancelPendingStart() + isTornDown = true + systemStartExpected = false + hasActiveSession = false + restoreRequested = false + retireCurrentPipController() + delegate = nil } } @@ -197,7 +421,6 @@ import UIKit AVPictureInPictureSampleBufferPlaybackDelegate { weak var controller: MpvPipController? - private var isRestoring = false init(controller: MpvPipController) { self.controller = controller @@ -210,23 +433,21 @@ import UIKit _ pictureInPictureController: AVPictureInPictureController ) { print("[MpvPipController] PiP will start") - controller?.delegate?.pipWillStart() + controller?.pictureInPictureWillStart(from: pictureInPictureController) } func pictureInPictureControllerDidStartPictureInPicture( _ pictureInPictureController: AVPictureInPictureController ) { print("[MpvPipController] PiP did start") - controller?.delegate?.pipDidStart() + controller?.pictureInPictureDidStart(from: pictureInPictureController) } func pictureInPictureControllerDidStopPictureInPicture( _ pictureInPictureController: AVPictureInPictureController ) { - let restored = isRestoring - isRestoring = false - print("[MpvPipController] PiP did stop (restored: \(restored))") - controller?.delegate?.pipDidStop(restored: restored) + print("[MpvPipController] PiP did stop") + controller?.pictureInPictureDidStop(from: pictureInPictureController) } func pictureInPictureController( @@ -234,7 +455,10 @@ import UIKit failedToStartPictureInPictureWithError error: Error ) { print("[MpvPipController] PiP failed to start: \(error)") - controller?.delegate?.pipDidFailToStart(error: error) + controller?.pictureInPictureFailedToStart( + from: pictureInPictureController, + error: error + ) } func pictureInPictureController( @@ -243,30 +467,43 @@ import UIKit @escaping (Bool) -> Void ) { print("[MpvPipController] PiP restore user interface") - isRestoring = true - completionHandler(true) + guard let controller, + controller.isCurrentController(pictureInPictureController) + else { + completionHandler(false) + return + } + controller.restoreUserInterface(completion: completionHandler) } func pictureInPictureControllerWillStopPictureInPicture( _ pictureInPictureController: AVPictureInPictureController ) { + guard controller?.isCurrentController(pictureInPictureController) == true else { return } print("[MpvPipController] PiP will stop") } - // MARK: - AVPictureInPictureSampleBufferPlaybackDelegate func pictureInPictureController( _ pictureInPictureController: AVPictureInPictureController, setPlaying playing: Bool ) { + guard let controller, + controller.isCurrentController(pictureInPictureController) + else { return } print("[MpvPipController] PiP setPlaying: \(playing)") - controller?.delegate?.pipSetPlaying(playing) + controller.delegate?.pipSetPlaying(playing) } func pictureInPictureControllerTimeRangeForPlayback( _ pictureInPictureController: AVPictureInPictureController ) -> CMTimeRange { - let duration = controller?.delegate?.pipDuration ?? 0 + guard let controller, + controller.isCurrentController(pictureInPictureController) + else { + return CMTimeRange(start: .zero, duration: CMTime(seconds: 1, preferredTimescale: 1)) + } + let duration = controller.delegate?.pipDuration ?? 0 if duration > 0 { return CMTimeRange( start: .zero, @@ -279,7 +516,10 @@ import UIKit func pictureInPictureControllerIsPlaybackPaused( _ pictureInPictureController: AVPictureInPictureController ) -> Bool { - return !(controller?.delegate?.isPipPlaying ?? false) + guard let controller, + controller.isCurrentController(pictureInPictureController) + else { return true } + return !(controller.delegate?.isPipPlaying ?? false) } func pictureInPictureController( @@ -292,9 +532,15 @@ import UIKit skipByInterval skipInterval: CMTime, completion completionHandler: @escaping () -> Void ) { + guard let controller, + controller.isCurrentController(pictureInPictureController) + else { + completionHandler() + return + } let seconds = CMTimeGetSeconds(skipInterval) print("[MpvPipController] PiP skip by \(seconds)s") - guard let delegate = controller?.delegate else { + guard let delegate = controller.delegate else { completionHandler() return } diff --git a/ios/Runner/MpvPlayer/MpvPlayerCore.swift b/ios/Runner/MpvPlayer/MpvPlayerCore.swift index 0d3a5d12..d7b3f52c 100644 --- a/ios/Runner/MpvPlayer/MpvPlayerCore.swift +++ b/ios/Runner/MpvPlayer/MpvPlayerCore.swift @@ -257,8 +257,8 @@ class MpvPlayerCore: MpvPlayerCoreBase { guard let window = containerView?.window ?? self.window else { return false } let displayManager = window.avDisplayManager - if width <= 0 || height <= 0 { - clearDisplayCriteria(displayManager, reason: "no video dimensions") + if !self.validateSideDataDimensions(width: Int64(width), height: Int64(height)) { + clearDisplayCriteria(displayManager, reason: "invalid video dimensions") return false } diff --git a/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift b/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift index a6417a00..78b70563 100644 --- a/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift +++ b/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift @@ -48,7 +48,13 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS registrar.addMethodCallDelegate(instance, channel: methodChannel) eventChannel.setStreamHandler(instance) - pipChannel.setMethodCallHandler(instance.handlePipCall) + pipChannel.setMethodCallHandler { [weak instance] call, result in + guard let instance else { + result(nil) + return + } + instance.handlePipCall(call, result: result) + } } // MARK: - FlutterStreamHandler @@ -210,6 +216,13 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS ]) return } + if manual && isManualPipRequest { + result?([ + "success": false, "errorCode": "failed", + "errorMessage": "A PiP start request is already pending", + ]) + return + } guard let pip = preparePip() else { result?([ "success": false, "errorCode": "pip_prepare_failed", @@ -220,10 +233,18 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS isManualPipRequest = manual pip.startPip(waitForFrame: manual) { [weak self] started in + guard let self else { + result?([ + "success": false, "errorCode": "failed", "errorMessage": "Player disposed", + ]) + return + } if started { result?(["success": true]) } else { - self?.cleanupPip(notify: false) + if self.playerCore?.isPipStarting == true { + self.cleanupPip(notify: false) + } result?([ "success": false, "errorCode": "failed", "errorMessage": "PiP failed to start", ]) @@ -310,6 +331,14 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS return } + // A partially torn-down core must not survive a rapid route replacement. + self.pipController?.teardown() + self.pipController = nil + self.pendingInlineRestoreAfterPip = false + self.stopPipTimebaseSync() + self.playerCore?.dispose() + self.playerCore = nil + let core = MpvPlayerCore() core.delegate = self diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift index f631f7e0..87e46c53 100644 --- a/ios/RunnerTests/RunnerTests.swift +++ b/ios/RunnerTests/RunnerTests.swift @@ -1,3 +1,6 @@ +import AVFoundation +import Libmpv +import UIKit import Flutter import XCTest @@ -47,6 +50,97 @@ final class RecordingMpvPlugin: MpvPluginShared { } } +final class RecordingLifecycleDelegate: MpvPlayerDelegate { + private(set) var events: [String] = [] + private(set) var properties: [String] = [] + + func onPropertyChange(name: String, value: Any?) { + properties.append(name) + } + + func onEvent(name: String, data: [String: Any]?) { + events.append(name) + } +} + +final class FakePictureInPictureController: MpvPictureInPictureControlling { + var isPictureInPicturePossible = false + private(set) var startCount = 0 + private(set) var stopCount = 0 + private(set) var automaticStartValues: [Bool] = [] + private(set) var invalidateCount = 0 + + func startPictureInPicture() { startCount += 1 } + func stopPictureInPicture() { stopCount += 1 } + func setAutomaticStart(_ enabled: Bool) { automaticStartValues.append(enabled) } + func invalidatePlaybackState() { invalidateCount += 1 } +} + +final class RecordingPipDelegate: MpvPipDelegate { + private(set) var events: [String] = [] + var onDidStart: (() -> Void)? + func pipWillStart() { events.append("willStart") } + func pipDidStart() { + onDidStart?() + events.append("didStart") + } + func pipDidStop(restored: Bool) { events.append("didStop:\(restored)") } + func pipDidFailToStart(error: Error?) { events.append("failed") } + func pipSetPlaying(_ playing: Bool) {} + func pipSkip(byInterval seconds: Double, completion: @escaping () -> Void) { completion() } + var isPipPlaying: Bool { true } + var pipDuration: Double { 60 } +} + +final class ReleaseTrackingCore: MpvPlayerCoreBase { + let onDeinit: () -> Void + init(onDeinit: @escaping () -> Void) { + self.onDeinit = onDeinit + super.init() + } + deinit { onDeinit() } +} + +final class ProbeURLProtocol: URLProtocol { + private static let lock = NSLock() + private static var startHandler: ((ProbeURLProtocol) -> Void)? + private static var stopHandler: (() -> Void)? + + static func configure( + start: @escaping (ProbeURLProtocol) -> Void, + stop: (() -> Void)? = nil + ) { + lock.lock() + startHandler = start + stopHandler = stop + lock.unlock() + } + + static func reset() { + lock.lock() + startHandler = nil + stopHandler = nil + lock.unlock() + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + Self.lock.lock() + let handler = Self.startHandler + Self.lock.unlock() + handler?(self) + } + + override func stopLoading() { + Self.lock.lock() + let handler = Self.stopHandler + Self.lock.unlock() + handler?() + } +} + final class MpvPlayerContractTests: XCTestCase { private let failure = NSError( domain: "MpvPlayerContractTests", @@ -108,6 +202,103 @@ final class MpvPlayerContractTests: XCTestCase { XCTAssertFalse(core.isPaused, "The accepted pause write must commit before completion") } + func testPauseIntentUpdatesCacheBeforeAsyncWriteCompletes() { + let core = MpvAudioPlayerCore() + XCTAssertTrue(core.initialize()) + defer { + core.dispose() + core.queue.sync {} + } + + let queueEntered = expectation(description: "mpv queue blocked") + let releaseQueue = DispatchSemaphore(value: 0) + core.queue.async { + queueEntered.fulfill() + releaseQueue.wait() + } + wait(for: [queueEntered], timeout: 2) + + let completion = expectation(description: "pause write completed") + core.setPropertyAsync("pause", value: "no") { result in + if case .failure(let error) = result { + XCTFail("Pause write failed: \(error)") + } + completion.fulfill() + } + + XCTAssertFalse(core.isPaused, "The public pause intent must be visible before the native write completes") + releaseQueue.signal() + wait(for: [completion], timeout: 2) + } + + func testOlderPauseReplyCannotOverwriteNewerUserIntent() { + let core = ControllablePropertyCore() + let olderResume = core.beginCachedPauseIntent(false) + let newerPause = core.beginCachedPauseIntent(true) + XCTAssertTrue(core.isPaused) + + core.finishCachedPauseIntent(olderResume, result: .success(())) + XCTAssertTrue( + core.isPaused, + "An older resume reply must not overwrite a newer pending pause intent" + ) + + core.finishCachedPauseIntent(newerPause, result: .success(())) + XCTAssertTrue(core.isPaused) + } + + func testPauseObservationAndUserIntentResolveInEventOrder() { + let core = ControllablePropertyCore() + let olderResume = core.beginCachedPauseIntent(false) + + core.observeCachedPauseForTesting(true) + core.finishCachedPauseIntent(olderResume, result: .success(())) + XCTAssertTrue( + core.isPaused, + "A native pause observation must invalidate the older resume write's delayed reply" + ) + + let newerResume = core.beginCachedPauseIntent(false) + core.finishCachedPauseIntent(newerResume, result: .success(())) + XCTAssertFalse( + core.isPaused, + "A user intent created after the native observation must remain authoritative" + ) + } + + func testPauseObservationRetiresOutOfOrderIntentsForSuccessAndFailure() { + let newerResults: [Result] = [ + .success(()), + .failure(failure), + ] + + for newerResult in newerResults { + let core = ControllablePropertyCore() + let generationOneResume = core.beginCachedPauseIntent(false) + let generationTwoPause = core.beginCachedPauseIntent(true) + + core.observeCachedPauseForTesting(true) + core.finishCachedPauseIntent(generationTwoPause, result: newerResult) + XCTAssertTrue( + core.isPaused, + "The observed native pause must survive the newer pending pause's completion" + ) + + core.finishCachedPauseIntent(generationOneResume, result: .success(())) + XCTAssertTrue( + core.isPaused, + "A late older resume must be inert after a newer intent resolves" + ) + + let postObservationResume = core.beginCachedPauseIntent(false) + core.finishCachedPauseIntent(postObservationResume, result: .success(())) + XCTAssertFalse( + core.isPaused, + "A resume created after the native observation must remain authoritative" + ) + } + } + func testPendingSetPropertyIsCancelledExactlyOnceOnDispose() { let core = MpvAudioPlayerCore() XCTAssertTrue(core.initialize()) @@ -152,6 +343,401 @@ final class MpvPlayerContractTests: XCTestCase { } } + func testQueuedDelegateDeliveryIsDroppedAfterTerminalTransition() { + let core = MpvPlayerCoreBase() + let delegate = RecordingLifecycleDelegate() + core.delegate = delegate + core.dispatchDelegateEvent(name: "file-loaded", data: nil) + core.dispatchDelegateProperty(name: "time-pos", value: 1.0) + XCTAssertTrue(core.beginDisposal()) + + let drained = expectation(description: "main delivery drained") + DispatchQueue.main.async { drained.fulfill() } + wait(for: [drained], timeout: 2) + XCTAssertTrue(delegate.events.isEmpty) + XCTAssertTrue(delegate.properties.isEmpty) + } + + func testWakeupContextDoesNotRetainCallbackTarget() { + let released = expectation(description: "callback target released") + var core: ReleaseTrackingCore? = ReleaseTrackingCore { released.fulfill() } + weak var weakCore = core + let context = MpvWakeupCallbackContext(core: core!) + + core = nil + wait(for: [released], timeout: 2) + XCTAssertNil(weakCore) + context.dispatchWakeup() + context.detach() + } + + func testUnavailablePropertyCompletionRunsExactlyOnceOnMainThread() { + let core = MpvAudioPlayerCore() + XCTAssertTrue(core.initialize()) + core.dispose() + core.queue.sync {} + + let completed = expectation(description: "unavailable property completed") + completed.assertForOverFulfill = true + var completionCount = 0 + DispatchQueue.global().async { + core.getPropertyAsync("volume") { result in + XCTAssertTrue(Thread.isMainThread) + if case .success = result { XCTFail("Expected unavailable property failure") } + completionCount += 1 + completed.fulfill() + } + } + wait(for: [completed], timeout: 2) + XCTAssertEqual(completionCount, 1) + } + + func testNormalizedPlaybackDelayStringsPassThroughUnchanged() { + let core = ControllablePropertyCore() + let plugin = RecordingMpvPlugin(core: core) + let values = ["0.25", "-0.5", "0", "0.25"] + + for value in values { + core.nextResult = .success(()) + let result = invokeSetProperty(plugin, name: "audio-delay", value: value) + XCTAssertEqual(result.count, 1) + XCTAssertNil(result[0]) + } + XCTAssertEqual(core.propertyCalls.map(\.1), values) + } + + func testNodeConversionBoundsAndDiscardsMalformedSiblings() { + let core = MpvPlayerCoreBase() + var valid = mpv_node() + valid.format = MPV_FORMAT_INT64 + valid.u.int64 = 7 + var malformed = mpv_node() + malformed.format = MPV_FORMAT_NONE + var values = [valid, malformed, valid] + var decoded: Any? + + let valueCount = values.count + values.withUnsafeMutableBufferPointer { valuesPointer in + var list = mpv_node_list() + list.num = Int32(valueCount) + list.values = valuesPointer.baseAddress + withUnsafeMutablePointer(to: &list) { listPointer in + var root = mpv_node() + root.format = MPV_FORMAT_NODE_ARRAY + root.u.list = listPointer + decoded = core.convertNode(root) + } + } + XCTAssertEqual(decoded as? [Int64], [7, 7]) + + var oversizedBytes = mpv_byte_array() + oversizedBytes.size = 16 * 1_024 * 1_024 + 1 + withUnsafeMutablePointer(to: &oversizedBytes) { bytePointer in + var root = mpv_node() + root.format = MPV_FORMAT_BYTE_ARRAY + root.u.ba = bytePointer + XCTAssertNil(core.convertNode(root)) + } + + var invalidList = mpv_node_list() + invalidList.num = -1 + withUnsafeMutablePointer(to: &invalidList) { listPointer in + var root = mpv_node() + root.format = MPV_FORMAT_NODE_ARRAY + root.u.list = listPointer + XCTAssertNil(core.convertNode(root)) + } + XCTAssertTrue(core.validateSideDataDimensions(width: 3_840, height: 2_160)) + XCTAssertFalse(core.validateSideDataDimensions(width: 0, height: 2_160)) + XCTAssertFalse(core.validateSideDataDimensions(width: 65_536, height: 2_160)) + XCTAssertFalse(core.validateSideDataDimensions(width: 16_384, height: 16_384)) + } + + func testRawEc3LoaderBoundsAndIgnoresLateCallbacksForBothModes() { + for finiteLength in [false, true] { + let loader = RawEc3Loader( + source: URL(string: "https://example.invalid/test.ec3")!, + finiteLength: finiteLength, + maximumBufferedBytes: 8, + sessionConfiguration: .ephemeral + ) + let session = URLSession(configuration: .ephemeral) + let task = session.dataTask(with: URL(string: "https://example.invalid/test.ec3")!) + + loader.urlSession(session, dataTask: task, didReceive: Data([1, 2, 3, 4])) + var snapshot = loader.statusSnapshot() + XCTAssertEqual(snapshot.bytesReceived, 4) + XCTAssertEqual(snapshot.retainedBytes, 4) + XCTAssertNil(snapshot.errorCode) + + loader.urlSession(session, dataTask: task, didReceive: Data([5, 6, 7, 8, 9])) + snapshot = loader.statusSnapshot() + XCTAssertEqual(snapshot.bytesReceived, 4) + XCTAssertEqual(snapshot.retainedBytes, 0) + XCTAssertEqual(snapshot.errorCode, "response_too_large") + + loader.urlSession(session, dataTask: task, didReceive: Data([10])) + let lateSnapshot = loader.statusSnapshot() + XCTAssertEqual(lateSnapshot.bytesReceived, snapshot.bytesReceived) + XCTAssertEqual(lateSnapshot.retainedBytes, 0) + loader.cancel() + loader.cancel() + XCTAssertEqual(loader.statusSnapshot().pendingRequestCount, 0) + let cancelledLoader = RawEc3Loader( + source: URL(string: "https://example.invalid/cancel.ec3")!, + finiteLength: finiteLength, + maximumBufferedBytes: 8, + sessionConfiguration: .ephemeral + ) + cancelledLoader.urlSession(session, dataTask: task, didReceive: Data([1, 2, 3, 4])) + XCTAssertEqual(cancelledLoader.statusSnapshot().retainedBytes, 4) + cancelledLoader.cancel() + cancelledLoader.cancel() + let cancelledSnapshot = cancelledLoader.statusSnapshot() + XCTAssertEqual(cancelledSnapshot.retainedBytes, 0) + XCTAssertEqual(cancelledSnapshot.pendingRequestCount, 0) + session.invalidateAndCancel() + } + } + + func testRawEc3LoaderCompletesThroughInjectedURLProtocolForBothModes() { + defer { ProbeURLProtocol.reset() } + for finiteLength in [false, true] { + let requestStarted = expectation(description: "probe request started") + let loaderFinished = expectation(description: "probe loader finished") + ProbeURLProtocol.configure { protocolInstance in + let response = URLResponse( + url: protocolInstance.request.url!, + mimeType: "audio/eac3", + expectedContentLength: -1, + textEncodingName: nil + ) + protocolInstance.client?.urlProtocol( + protocolInstance, + didReceive: response, + cacheStoragePolicy: .notAllowed + ) + protocolInstance.client?.urlProtocol(protocolInstance, didLoad: Data([1, 2, 3, 4])) + protocolInstance.client?.urlProtocolDidFinishLoading(protocolInstance) + requestStarted.fulfill() + } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [ProbeURLProtocol.self] + let loader = RawEc3Loader( + source: URL(string: "https://probe.test/audio.ec3")!, + finiteLength: finiteLength, + maximumBufferedBytes: 8, + sessionConfiguration: configuration, + terminalHandlerForTesting: { loaderFinished.fulfill() } + ) + loader.begin() + wait(for: [requestStarted, loaderFinished], timeout: 2) + + let snapshot = loader.statusSnapshot() + XCTAssertTrue(snapshot.isFinished) + XCTAssertEqual(snapshot.bytesReceived, 4) + XCTAssertEqual(snapshot.retainedBytes, 4) + XCTAssertNil(snapshot.errorCode) + + loader.cancel() + let cancelled = loader.statusSnapshot() + XCTAssertEqual(cancelled.retainedBytes, 0) + XCTAssertEqual(cancelled.pendingRequestCount, 0) + } + } + + func testPipStartWaitsForDelegateAndCompletesOnce() { + let fake = FakePictureInPictureController() + fake.isPictureInPicturePossible = true + let controller = MpvPipController( + controller: fake, + readiness: { (true, true, true) }, + retryScheduler: { $0() } + ) + let delegate = RecordingPipDelegate() + controller.delegate = delegate + var results: [Bool] = [] + + delegate.onDidStart = { + XCTAssertEqual(results, [true], "Manual result must resolve before delegate suspension") + } + controller.startPip { results.append($0) } + XCTAssertEqual(fake.startCount, 1) + XCTAssertTrue(results.isEmpty) + controller.pictureInPictureWillStart() + controller.pictureInPictureDidStart() + XCTAssertEqual(Array(delegate.events.prefix(2)), ["willStart", "didStart"]) + XCTAssertEqual(results, [true]) + + controller.pictureInPictureDidStart() + controller.teardown() + XCTAssertEqual(results, [true]) + } + + func testRepeatedAutoStartDuringCurrentControllerStartDoesNotRejectDidStart() { + let fake = FakePictureInPictureController() + let controller = MpvPipController( + controller: fake, + readiness: { (true, true, true) }, + retryScheduler: { $0() } + ) + let delegate = RecordingPipDelegate() + controller.delegate = delegate + + controller.setAutoStart(true) + controller.pictureInPictureWillStart(from: fake) + controller.setAutoStart(true) + controller.pictureInPictureDidStart(from: fake) + + XCTAssertEqual(fake.automaticStartValues, [true, true]) + XCTAssertEqual(delegate.events, ["willStart", "didStart"]) + XCTAssertEqual( + fake.stopCount, + 0, + "Reasserting an enabled auto-start setting must not reject the in-flight system start" + ) + } + + func testPipStartTimesOutWithoutDelegateOutcome() { + let fake = FakePictureInPictureController() + var timeouts: [() -> Void] = [] + let controller = MpvPipController( + controller: fake, + readiness: { (true, true, true) }, + retryScheduler: { $0() }, + startTimeoutScheduler: { timeouts.append($0) } + ) + var results: [Bool] = [] + + controller.startPip { results.append($0) } + XCTAssertEqual(fake.startCount, 1) + XCTAssertTrue(results.isEmpty) + XCTAssertEqual(timeouts.count, 1) + + timeouts[0]() + XCTAssertEqual(results, [false]) + XCTAssertEqual(fake.stopCount, 1) + + controller.pictureInPictureDidStart() + controller.pictureInPictureFailedToStart(error: NSError(domain: "late", code: 1)) + XCTAssertEqual(results, [false]) + } + + func testPipTimeoutRecreatesControllerAndRejectsRetiredCallbacks() { + let displayLayer = AVSampleBufferDisplayLayer() + let retired = FakePictureInPictureController() + let replacement = FakePictureInPictureController() + retired.isPictureInPicturePossible = true + replacement.isPictureInPicturePossible = true + var timeouts: [() -> Void] = [] + var replacementLayers: [AVSampleBufferDisplayLayer?] = [] + let controller = MpvPipController( + controller: retired, + sampleBufferDisplayLayer: displayLayer, + readiness: { (true, true, true) }, + retryScheduler: { $0() }, + startTimeoutScheduler: { timeouts.append($0) }, + replacementControllerFactory: { layer in + replacementLayers.append(layer) + return replacement + } + ) + let delegate = RecordingPipDelegate() + controller.delegate = delegate + controller.setAutoStart(true) + var results: [Bool] = [] + + controller.startPip { results.append($0) } + XCTAssertEqual(retired.startCount, 1) + XCTAssertEqual(timeouts.count, 1) + + timeouts[0]() + XCTAssertEqual(results, [false]) + XCTAssertEqual(retired.stopCount, 1) + XCTAssertEqual(retired.automaticStartValues, [true, false]) + XCTAssertEqual(replacementLayers.count, 1) + XCTAssertTrue(replacementLayers[0] === displayLayer) + XCTAssertEqual(replacement.automaticStartValues, [true]) + + controller.startPip { results.append($0) } + XCTAssertEqual(replacement.startCount, 1) + XCTAssertEqual(timeouts.count, 2) + + controller.pictureInPictureWillStart(from: retired) + controller.pictureInPictureDidStart(from: retired) + controller.pictureInPictureFailedToStart( + from: retired, + error: NSError(domain: "late-retired-controller", code: 1) + ) + controller.pictureInPictureDidStop(from: retired) + XCTAssertEqual(results, [false]) + XCTAssertTrue(delegate.events.isEmpty) + XCTAssertEqual(retired.stopCount, 1) + XCTAssertEqual( + replacement.startCount, + 1, + "A retired controller callback must not disturb the replacement's pending start" + ) + + controller.pictureInPictureWillStart(from: replacement) + controller.pictureInPictureDidStart(from: replacement) + XCTAssertEqual(results, [false, true]) + XCTAssertEqual(delegate.events, ["willStart", "didStart"]) + XCTAssertEqual(replacementLayers.count, 1) + } + + func testPipTeardownCancelsRetryAndLateWork() { + let fake = FakePictureInPictureController() + var possible = false + var retries: [() -> Void] = [] + let controller = MpvPipController( + controller: fake, + readiness: { (possible, true, true) }, + retryScheduler: { retries.append($0) } + ) + var results: [Bool] = [] + + controller.startPip { results.append($0) } + XCTAssertEqual(retries.count, 1) + controller.teardown() + XCTAssertEqual(results, [false]) + possible = true + retries.forEach { $0() } + XCTAssertEqual(fake.startCount, 0) + XCTAssertEqual(results, [false]) + } + + func testPipFailureAndLateRestoreRemainSingleShot() { + let fake = FakePictureInPictureController() + let controller = MpvPipController( + controller: fake, + readiness: { (true, true, true) }, + retryScheduler: { $0() } + ) + let delegate = RecordingPipDelegate() + controller.delegate = delegate + var startResults: [Bool] = [] + + controller.startPip { startResults.append($0) } + controller.pictureInPictureWillStart() + controller.pictureInPictureFailedToStart(error: NSError(domain: "test", code: 1)) + controller.pictureInPictureFailedToStart(error: NSError(domain: "test", code: 2)) + XCTAssertEqual(startResults, [false]) + XCTAssertEqual(delegate.events.filter { $0 == "failed" }.count, 1) + + controller.startPip { startResults.append($0) } + controller.pictureInPictureWillStart() + controller.pictureInPictureDidStart() + XCTAssertEqual(startResults, [false, true]) + + var restoreResults: [Bool] = [] + controller.restoreUserInterface { restoreResults.append($0) } + controller.teardown() + controller.restoreUserInterface { restoreResults.append($0) } + XCTAssertEqual(restoreResults, [true, false]) + } + private func invokeSetProperty( _ plugin: RecordingMpvPlugin, name: String, diff --git a/lib/mpv/player/mpv_node_decoder.dart b/lib/mpv/player/mpv_node_decoder.dart index 17fc600a..f8f306f4 100644 --- a/lib/mpv/player/mpv_node_decoder.dart +++ b/lib/mpv/player/mpv_node_decoder.dart @@ -1,7 +1,15 @@ import 'dart:convert'; /// Decodes an mpv node delivered either as a platform-channel value or JSON. +/// +/// Native payloads are bounded before traversal so a malformed backend cannot +/// turn a property update into unbounded allocation or recursion on the UI +/// isolate. abstract final class MpvNodeDecoder { + static const _maximumDepth = 32; + static const _maximumEntries = 16384; + static const _maximumStringBytes = 16 * 1024 * 1024; + static List? decodeList(Object? value) { final decoded = _decode(value); return decoded is List ? decoded : null; @@ -13,13 +21,112 @@ abstract final class MpvNodeDecoder { } static Object? _decode(Object? value) { - if (value is List || value is Map) return value; - if (value is! String || value.isEmpty) return null; + if (value is List || value is Map) { + return _isBoundedStructure(value) ? value : null; + } + if (value is! String || value.isEmpty || !_isPlausiblyBoundedJson(value)) return null; try { - return jsonDecode(value); + final decoded = jsonDecode(value); + return _isBoundedStructure(decoded) ? decoded : null; } on FormatException { return null; } } + + static bool _isPlausiblyBoundedJson(String value) { + if (value.length > _maximumStringBytes) return false; + + var depth = 0; + var separators = 0; + var inString = false; + var escaped = false; + for (var i = 0; i < value.length; i++) { + final codeUnit = value.codeUnitAt(i); + if (inString) { + if (escaped) { + escaped = false; + } else if (codeUnit == 0x5c) { + escaped = true; + } else if (codeUnit == 0x22) { + inString = false; + } + continue; + } + if (codeUnit == 0x22) { + inString = true; + } else if (codeUnit == 0x5b || codeUnit == 0x7b) { + depth++; + if (depth > _maximumDepth) return false; + } else if (codeUnit == 0x5d || codeUnit == 0x7d) { + depth--; + if (depth < 0) return false; + } else if (codeUnit == 0x2c) { + separators++; + if (separators >= _maximumEntries) return false; + } + } + return !inString && depth == 0; + } + + static bool _isBoundedStructure(Object? root) { + var remainingEntries = _maximumEntries; + var remainingStringBytes = _maximumStringBytes; + final pending = <(Object?, int)>[(root, 0)]; + + while (pending.isNotEmpty) { + final (value, depth) = pending.removeLast(); + if (remainingEntries == 0 || depth >= _maximumDepth) return false; + remainingEntries--; + + if (value is String) { + final byteLength = _utf8LengthAtMost(value, remainingStringBytes); + if (byteLength == null) return false; + remainingStringBytes -= byteLength; + } else if (value is num) { + if (value is double && !value.isFinite) return false; + } else if (value is List) { + if (value.length > remainingEntries) return false; + for (var i = value.length - 1; i >= 0; i--) { + pending.add((value[i], depth + 1)); + } + } else if (value is Map) { + if (value.length > remainingEntries) return false; + for (final entry in value.entries) { + final key = entry.key; + if (key is! String) return false; + final byteLength = _utf8LengthAtMost(key, remainingStringBytes); + if (byteLength == null) return false; + remainingStringBytes -= byteLength; + pending.add((entry.value, depth + 1)); + } + } else if (value != null && value is! bool) { + return false; + } + } + return true; + } + + static int? _utf8LengthAtMost(String value, int limit) { + var length = 0; + for (var i = 0; i < value.length; i++) { + final codeUnit = value.codeUnitAt(i); + if (codeUnit <= 0x7f) { + length++; + } else if (codeUnit <= 0x7ff) { + length += 2; + } else if (codeUnit >= 0xd800 && + codeUnit <= 0xdbff && + i + 1 < value.length && + value.codeUnitAt(i + 1) >= 0xdc00 && + value.codeUnitAt(i + 1) <= 0xdfff) { + length += 4; + i++; + } else { + length += 3; + } + if (length > limit) return null; + } + return length; + } } diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index de9d71a2..518e090f 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -114,6 +114,7 @@ class PlayerAndroid extends PlayerBase { .read(SettingsService.subtitleRenderResolution) .androidRenderScale, }); + if (disposed) throw StateError('Player was disposed during initialization'); if (result != true) { throw Exception('Failed to initialize ExoPlayer'); } @@ -123,11 +124,23 @@ class PlayerAndroid extends PlayerBase { // future would falsely treat as ready. await observeCoreProperties(trackListFormat: 'string'); await observeProperty('demuxer-cache-time', 'double'); + if (disposed) throw StateError('Player was disposed during initialization'); + + // These settings can be queued before any operation initializes the + // native core. Apply the latest requested values now so ExoPlayer and + // the already-queued mpv fallback properties start in the same state. + await invoke('setAudioNormalization', {'enabled': _audioNormalizationEnabled}); + await invoke('setAudioDownmix', { + 'enabled': _downmixEnabled, + 'centerBoostDb': _downmixCenterBoostDb, + 'normalize': _downmixNormalize, + }); + if (disposed) throw StateError('Player was disposed during initialization'); initialized = true; } catch (e) { _initFuture = null; - errorController.add(PlayerError('Initialization failed: $e')); + if (!disposed) errorController.add(PlayerError('Initialization failed: $e')); rethrow; } } diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index 40e859f1..501e21a6 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:math' as math; import 'package:collection/collection.dart'; -import 'package:flutter/foundation.dart' show protected; +import 'package:flutter/foundation.dart' show ValueListenable, ValueNotifier, protected, visibleForTesting; import 'package:flutter/services.dart'; import '../../media/media_display_criteria.dart'; @@ -48,12 +48,23 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { @override PlayerStreams get streams => _streams; + final ValueNotifier _textureId = ValueNotifier(null); + @override - int? get textureId => null; + int? get textureId => _textureId.value; + + ValueListenable get textureIdListenable => _textureId; + + @protected + void setTextureId(int? value) { + if (!_disposed) _textureId.value = value; + } StreamSubscription? _eventSubscription; StreamSubscription? _logSubscription; bool _disposed = false; + late final Future? _nativeOwnershipReady; + final Completer _nativeRelease = Completer(); final _throttleSw = Stopwatch()..start(); int _lastEmitMs = 0; int _lastCacheStateMs = 0; @@ -65,6 +76,36 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { bool _primaryMediaLoadStarted = false; bool _primaryMediaReadyEmitted = false; + @visibleForTesting + static Duration debugNativeOwnershipDisposeTimeout = const Duration(seconds: 3); + + static const _maximumDurationMilliseconds = 9223372036854775; + + static double? _finiteDouble(Object? value) { + if (value is! num) return null; + final result = value.toDouble(); + return result.isFinite ? result : null; + } + + static int? _millisecondsFromSeconds(Object? value, {bool round = false}) { + final seconds = _finiteDouble(value); + if (seconds == null) return null; + final milliseconds = seconds * Duration.millisecondsPerSecond; + if (!milliseconds.isFinite || + milliseconds < -_maximumDurationMilliseconds || + milliseconds > _maximumDurationMilliseconds) { + return null; + } + return round ? milliseconds.round() : milliseconds.toInt(); + } + + static int? _finiteInt(Object? value) { + if (value is int) return value; + final result = _finiteDouble(value); + if (result == null || result < -9007199254740991 || result > 9007199254740991) return null; + return result.toInt(); + } + @protected bool initialized = false; @@ -78,6 +119,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { String get logPrefix; PlayerBase() { + _nativeOwnershipReady = _eventChannelOwners[eventChannel.name]?._nativeRelease.future; _streams = createStreams(); _setupEventListener(); _logSubscription = logController.stream.listen(_forwardToAppLogger); @@ -161,15 +203,18 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { void _handleEvent(dynamic event) { if (_disposed) return; if (event is List && event.length == 2) { - final name = _propIdToName[event.first as int]; + final propertyId = event.first; + if (propertyId is! int) return; + final name = _propIdToName[propertyId]; if (name != null) { handlePropertyChange(name, event[1]); } } else if (event is Map) { - final type = event['type'] as String?; - final name = event['name'] as String?; - if (type == 'event' && name != null) { - handlePlayerEvent(name, event['data'] as Map?); + final type = event['type']; + final name = event['name']; + if (type == 'event' && name is String) { + final rawData = event['data']; + handlePlayerEvent(name, rawData is Map ? rawData : null); } } } @@ -196,11 +241,12 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { break; case 'time-pos': - if (value is num) { - final pos = Duration(milliseconds: (value * 1000).round()); - _positionMs = pos.inMilliseconds; - // Only allocate Duration + copyWith + emit at ~4Hz (250ms). - // Raw int is stored every tick so synchronous reads via _positionMs stay current. + final positionMs = _millisecondsFromSeconds(value, round: true); + if (positionMs != null) { + final pos = Duration(milliseconds: positionMs); + _positionMs = positionMs; + // Only allocate PlayerState + emit at ~4Hz (250ms). The raw integer + // remains current for synchronous position reads on every tick. final nowMs = _throttleSw.elapsedMilliseconds; if (nowMs - _lastEmitMs >= 250) { _lastEmitMs = nowMs; @@ -211,8 +257,9 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { break; case 'duration': - if (value is num) { - final duration = _timelineDuration ?? Duration(milliseconds: (value * 1000).toInt()); + final durationMs = _millisecondsFromSeconds(value); + if (durationMs != null) { + final duration = _timelineDuration ?? Duration(milliseconds: durationMs); _state = _state.copyWith(duration: duration); durationController.add(duration); } @@ -225,11 +272,12 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { break; case 'demuxer-cache-time': - if (value is num) { + final bufferMs = _millisecondsFromSeconds(value); + if (bufferMs != null) { final nowMs = _throttleSw.elapsedMilliseconds; if (nowMs - _lastCacheStateMs < 250) break; _lastCacheStateMs = nowMs; - final buffer = Duration(milliseconds: (value * 1000).toInt()); + final buffer = Duration(milliseconds: bufferMs); _state = _state.copyWith(buffer: buffer); bufferController.add(buffer); // Synthesize a single range for players without demuxer-cache-state (ExoPlayer). @@ -245,14 +293,15 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { break; case 'volume': - if (value is num) { - setVolumeState(value.toDouble()); + final volume = _finiteDouble(value); + if (volume != null) { + setVolumeState(volume); } break; case 'speed': - if (value is num) { - final rate = value.toDouble(); + final rate = _finiteDouble(value); + if (rate != null) { _state = _state.copyWith(rate: rate); rateController.add(rate); } @@ -295,10 +344,14 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { case 'audio-device-list': final deviceList = MpvNodeDecoder.decodeList(value); if (deviceList != null) { - final devices = deviceList - .whereType() - .map((d) => AudioDevice(name: d['name'] as String? ?? '', description: d['description'] as String? ?? '')) - .toList(); + final devices = []; + for (final entry in deviceList) { + if (entry is! Map) continue; + final name = entry['name']; + final description = entry['description']; + if (name is! String) continue; + devices.add(AudioDevice(name: name, description: description is String ? description : '')); + } _state = _state.copyWith(audioDevices: devices); audioDevicesController.add(devices); } @@ -326,9 +379,9 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { if (cacheState == null) return; // Extract cache-end for the single buffer duration (replaces demuxer-cache-time) - final cacheEnd = cacheState['cache-end'] as num?; - if (cacheEnd != null) { - final buffer = Duration(milliseconds: (cacheEnd * 1000).toInt()); + final cacheEndMs = _millisecondsFromSeconds(cacheState['cache-end']); + if (cacheEndMs != null) { + final buffer = Duration(milliseconds: cacheEndMs); _state = _state.copyWith(buffer: buffer); bufferController.add(buffer); } @@ -338,17 +391,16 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { if (seekableRanges is List) { final ranges = []; for (final range in seekableRanges) { - if (range is Map) { - final start = range['start'] as num?; - final end = range['end'] as num?; - if (start != null && end != null) { - ranges.add( - BufferRange( - start: Duration(milliseconds: (start * 1000).toInt()), - end: Duration(milliseconds: (end * 1000).toInt()), - ), - ); - } + if (range is! Map) continue; + final startMs = _millisecondsFromSeconds(range['start']); + final endMs = _millisecondsFromSeconds(range['end']); + if (startMs != null && endMs != null) { + ranges.add( + BufferRange( + start: Duration(milliseconds: startMs), + end: Duration(milliseconds: endMs), + ), + ); } } _state = _state.copyWith(bufferRanges: ranges); @@ -383,8 +435,13 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { completedController.add(true); } else if (reason == 'error') { fileLoadFailedController.add(null); + final rawMessage = data?['message']; + final rawCause = data?['cause']; errorController.add( - PlayerError(data?['message'] as String? ?? 'Playback error', cause: data?['cause'] as String?), + PlayerError( + rawMessage is String ? rawMessage : 'Playback error', + cause: rawCause is String ? rawCause : null, + ), ); } break; @@ -400,10 +457,12 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { break; case 'log-message': - final prefix = data?['prefix'] as String? ?? ''; - final levelStr = data?['level'] as String? ?? 'info'; - final text = data?['text'] as String? ?? ''; - final level = parseLogLevel(levelStr); + final rawPrefix = data?['prefix']; + final rawLevel = data?['level']; + final rawText = data?['text']; + final prefix = rawPrefix is String ? rawPrefix : ''; + final level = parseLogLevel(rawLevel is String ? rawLevel : 'info'); + final text = rawText is String ? rawText : ''; logController.add(PlayerLog(level: level, prefix: prefix, text: text)); break; } @@ -444,37 +503,44 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { for (final track in trackList) { if (track is! Map) continue; - final type = track['type'] as String?; - final id = track['id']?.toString() ?? ''; - final selected = track['selected'] as bool? ?? false; + final rawType = track['type']; + if (rawType is! String) continue; + final type = rawType; + final rawId = track['id']; + final id = rawId is String || rawId is num ? rawId.toString() : ''; + final selected = track['selected'] == true; if (type == 'audio') { if (selected) selectedAudioId = id; audioTracks.add( AudioTrack( id: id, - title: cleanTrackMetadataValue(track['title'] as String?), - language: cleanTrackMetadataValue(track['lang'] as String?), - codec: track['codec'] as String?, - channels: (track['demux-channel-count'] as num?)?.toInt(), - sampleRate: (track['demux-samplerate'] as num?)?.toInt(), - isDefault: track['default'] as bool? ?? false, + title: cleanTrackMetadataValue(track['title'] is String ? track['title'] as String : null), + language: cleanTrackMetadataValue(track['lang'] is String ? track['lang'] as String : null), + codec: track['codec'] is String ? track['codec'] as String : null, + channels: _finiteInt(track['demux-channel-count']), + sampleRate: _finiteInt(track['demux-samplerate']), + isDefault: track['default'] == true, ), ); } else if (type == 'sub') { if (selected) selectedSubtitleId = id; - final codec = track['codec'] as String?; - final externalFilename = track['external-filename'] as String?; + final rawCodec = track['codec']; + final codec = rawCodec is String ? rawCodec : null; + final rawExternalFilename = track['external-filename']; + final externalFilename = rawExternalFilename is String ? rawExternalFilename : null; final externalMetadata = externalFilename == null ? null : _externalSubtitleMetadataByUri[externalFilename]; + final rawTitle = track['title']; + final rawLanguage = track['lang']; subtitleTracks.add( SubtitleTrack( id: id, - title: externalMetadata?.title ?? cleanSubtitleTitle(track['title'] as String?, codec: codec), - language: externalMetadata?.language ?? cleanTrackMetadataValue(track['lang'] as String?), + title: externalMetadata?.title ?? cleanSubtitleTitle(rawTitle is String ? rawTitle : null, codec: codec), + language: externalMetadata?.language ?? cleanTrackMetadataValue(rawLanguage is String ? rawLanguage : null), codec: externalMetadata?.codec ?? codec, - isDefault: externalMetadata?.isDefault ?? (track['default'] as bool? ?? false), - isForced: externalMetadata?.isForced ?? (track['forced'] as bool? ?? false), - isExternal: track['external'] as bool? ?? false, + isDefault: externalMetadata?.isDefault ?? (track['default'] == true), + isForced: externalMetadata?.isForced ?? (track['forced'] == true), + isExternal: track['external'] == true, uri: externalFilename, ), ); @@ -490,13 +556,11 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { void updateSelectedAudioTrack(dynamic trackId) { final id = trackId?.toString(); - AudioTrack? selectedTrack; + final selectedTrack = (id == null || id == 'no') + ? null + : _state.tracks.audio.firstWhereOrNull((track) => track.id == id); + if (id != null && id != 'no' && selectedTrack == null) return; - if (id != null && id != 'no') { - selectedTrack = _state.tracks.audio.firstWhereOrNull((t) => t.id == id); - } - - if (selectedTrack == null) return; _state = _state.copyWith(track: _state.track.copyWith(audio: selectedTrack)); trackController.add(_state.track); } @@ -505,7 +569,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { final id = trackId?.toString(); final selectedTrack = (id == null || id == 'no') ? SubtitleTrack.off - : _state.tracks.subtitle.firstWhereOrNull((t) => t.id == id); + : _state.tracks.subtitle.firstWhereOrNull((track) => track.id == id); if (selectedTrack == null) return; _state = _state.copyWith(track: _state.track.copyWith(subtitle: selectedTrack)); @@ -584,6 +648,14 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { @protected Future invoke(String method, [dynamic args]) async { + if (_disposed) return null; + if (_nativeOwnershipReady case final ready?) { + try { + await ready.timeout(debugNativeOwnershipDisposeTimeout); + } on TimeoutException { + return null; + } + } if (_disposed) return null; return methodChannel.invokeMethod(method, args); } @@ -800,13 +872,35 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { errorController.add(const PlayerError('HTTP 500', cause: PlayerError.serverHttp500)); } + Future _waitForNativeOwnershipForDispose() async { + final ready = _nativeOwnershipReady; + if (ready == null) return true; + try { + await ready.timeout(debugNativeOwnershipDisposeTimeout); + return true; + } on TimeoutException catch (error, stackTrace) { + appLogger.w( + 'Timed out waiting for the previous player to release the native channel; skipping native dispose', + error: error, + stackTrace: stackTrace, + ); + if (!_nativeRelease.isCompleted) _nativeRelease.complete(ready); + return false; + } + } + @override Future dispose({bool preserveDisplayMode = false}) async { if (_disposed) return; _disposed = true; + _textureId.value = null; - if (identical(_eventChannelOwners[eventChannel.name], this)) { - _eventChannelOwners.remove(eventChannel.name); + final channelName = eventChannel.name; + if (identical(_eventChannelOwners[channelName], this)) { + // Keep this owner registered while its native release is pending so a + // player created during disposal inherits the complete release chain. + // The newer listen cannot interleave before cancel() is invoked on this + // isolate; after the first await, ownership is checked again at removal. try { await _eventSubscription?.cancel(); } on PlatformException catch (e, st) { @@ -822,15 +916,33 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { } _eventSubscription = null; await _logSubscription?.cancel(); + final ownsNativeChannel = await _waitForNativeOwnershipForDispose(); try { - await methodChannel.invokeMethod('dispose', { - 'preserveDisplayMode': preserveDisplayMode, - }); // Direct call — already guarded by _disposed check above + if (ownsNativeChannel) { + await methodChannel.invokeMethod('dispose', { + 'preserveDisplayMode': preserveDisplayMode, + }); // Direct call — invoke() is disabled once _disposed is set. + } } on PlatformException catch (e, st) { appLogger.w('Player native dispose failed during teardown', error: e, stackTrace: st); } on MissingPluginException catch (e, st) { appLogger.w('Player native dispose plugin missing during teardown', error: e, stackTrace: st); + } finally { + if (ownsNativeChannel && !_nativeRelease.isCompleted) _nativeRelease.complete(); + } + + // A timed-out predecessor is still represented by this release future. + // Do not expose an empty ownership slot until that chained release settles. + if (_nativeRelease.isCompleted) { + unawaited( + _nativeRelease.future.whenComplete(() { + if (identical(_eventChannelOwners[channelName], this)) { + _eventChannelOwners.remove(channelName); + } + }), + ); } await closeStreamControllers(); + _textureId.dispose(); } } diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index 7963163d..6c303c00 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -10,6 +10,17 @@ import '../../utils/app_logger.dart'; import '../models.dart'; import 'player_base.dart'; +typedef _AudioStateRequest = ({ + bool passthrough, + bool normalization, + bool downmix, + int downmixCenterBoostDb, + bool downmixNormalize, + double rate, +}); + +typedef _AudioStateGenerations = ({int passthrough, int normalization, int downmix, int rate}); + /// MPV-backed player for platforms where AetherEngine is not the native route. class PlayerNative extends PlayerBase { /// Video player on the default mpv channels/core. @@ -27,7 +38,6 @@ class PlayerNative extends PlayerBase { eventChannel = const EventChannel('com.plezy/mpv_audio_player/events'), audioOnly = true; - int? _textureIdValue; String _dvConversionMode = 'auto'; String _dvConversionLog = 'no'; @@ -45,13 +55,14 @@ class PlayerNative extends PlayerBase { @visibleForTesting static bool debugForceContentFdConversion = false; + /// Overrides the Linux-only video readiness handshake in host tests. + @visibleForTesting + static bool? debugUseLinuxVideoBootstrap; + // Set by open() and consumed by that load's file-loaded event, so it is // not mistaken for a gapless advance (see _handleAudioFileLoaded). bool _expectOpenFileLoad = false; - @override - int? get textureId => _textureIdValue; - /// Whether this instance drives the audio-only core. final bool audioOnly; @@ -168,13 +179,29 @@ class PlayerNative extends PlayerBase { ); } + /// Whether the UI must mount the provisional texture before initialization + /// can complete its first render/bootstrap handshake. + bool get requiresProvisionalTextureSurface => !audioOnly && (debugUseLinuxVideoBootstrap ?? Platform.isLinux); + // Memoizes the in-flight init Future so concurrent callers (e.g. the // parallel `requestAudioFocus()` and `setProperty()` paths kicked off in // VideoPlayerScreen._initializePlayer) share one `invoke('initialize')`. // Two concurrent invokes on Android caused MpvPlayerPlugin.handleInitialize // to dispose-and-recreate the in-flight core, hanging playback (#930). Future? _initFuture; - Future _rateChangeTail = Future.value(); + Future _audioStateTail = Future.value(); + Future? _disposeFuture; + bool _disposing = false; + + bool get _nativeCoreUnavailable => disposed || _disposing; + + @override + Future invoke(String method, [dynamic args]) { + if (_nativeCoreUnavailable) return Future.value(); + return super.invoke(method, args); + } + + double _requestedRate = 1.0; Future _ensureInitialized() async { if (initialized) return; @@ -186,8 +213,13 @@ class PlayerNative extends PlayerBase { final result = await invoke('initialize'); final bool ok; if (result is int) { - // Linux: initialize returns the texture ID - _textureIdValue = result; + // Linux publishes a provisional texture so Flutter can invoke + // FlTextureGL::populate. Playback stays gated until native GPU + // bootstrap reports that the texture is usable. + setTextureId(result); + if (debugUseLinuxVideoBootstrap ?? Platform.isLinux) { + await invoke('waitForVideoReady'); + } ok = true; } else { ok = result == true; @@ -195,6 +227,7 @@ class PlayerNative extends PlayerBase { if (!ok) { throw Exception('Failed to initialize player'); } + if (_nativeCoreUnavailable) throw StateError('Player was disposed during initialization'); // Subscribe to MPV properties before flipping `initialized` so partial // failures don't leave us in a half-initialized state that the memoized @@ -217,10 +250,14 @@ class PlayerNative extends PlayerBase { await invoke('setProperty', {'name': 'gapless-audio', 'value': 'weak'}); } + if (_nativeCoreUnavailable) throw StateError('Player was disposed during initialization'); initialized = true; } catch (e) { + setTextureId(null); _initFuture = null; - errorController.add(PlayerError('Initialization failed: $e')); + if (!_nativeCoreUnavailable) { + errorController.add(PlayerError('Initialization failed: $e')); + } rethrow; } } @@ -235,9 +272,13 @@ class PlayerNative extends PlayerBase { /// Closes a detached content fd that mpv will never consume. Fire-and-forget /// safe: a failure only leaks one fd. - Future _closeContentFd(int fd) async { + Future _closeContentFd(int fd, {bool duringDispose = false}) async { try { - await invoke('closeContentFd', {'fd': fd}); + if (duringDispose) { + await super.invoke('closeContentFd', {'fd': fd}); + } else { + await invoke('closeContentFd', {'fd': fd}); + } } catch (e) { appLogger.d('$logPrefix: closeContentFd($fd) failed', error: e); } @@ -269,8 +310,9 @@ class PlayerNative extends PlayerBase { List? externalSubtitles, Duration? timelineDuration, }) async { - if (disposed) return; + if (_nativeCoreUnavailable) return; await _ensureInitialized(); + if (_nativeCoreUnavailable) return; // `loadfile replace` (below) clears the native playlist, dropping any // gapless entry armed via setNext — settle its content-fd claim first. // No transition is surfaced: the caller is replacing playback anyway. @@ -338,16 +380,19 @@ class PlayerNative extends PlayerBase { @override Future play() async { + if (_nativeCoreUnavailable) return; await setProperty('pause', 'no'); } @override Future pause() async { + if (_nativeCoreUnavailable) return; await setProperty('pause', 'yes'); } @override Future stop() async { + if (_nativeCoreUnavailable) return; // `stop` tears down the playlist without mpv opening the armed entry — // settle its content-fd claim first. No transition: playback is ending. await _clearArmedNext(adoptIfRolledIn: false); @@ -358,12 +403,13 @@ class PlayerNative extends PlayerBase { @override Future seek(Duration position) async { + if (_nativeCoreUnavailable) return; await runSeek(position, () => command(['seek', (position.inMilliseconds / 1000.0).toString(), 'absolute'])); } @override Future setNext(Media? media) async { - if (!audioOnly || disposed || !initialized) return; + if (_nativeCoreUnavailable || !audioOnly || !initialized) return; await _clearArmedNext(); if (media == null) return; @@ -409,7 +455,7 @@ class PlayerNative extends PlayerBase { /// exactly at the gapless boundary desyncs the music service from the /// audio for the whole next track. Callers that replace or stop playback /// pass false: no one is listening for that entry anymore. - Future _clearArmedNext({bool adoptIfRolledIn = true}) async { + Future _clearArmedNext({bool adoptIfRolledIn = true, bool duringDispose = false}) async { if (!_hasArmedNext) return; final uri = _armedNextUri; final fd = _armedNextFd; @@ -419,7 +465,9 @@ class PlayerNative extends PlayerBase { String? pos; try { - pos = await getProperty('playlist-pos'); + pos = duringDispose + ? await super.invoke('getProperty', {'name': 'playlist-pos'}) + : await getProperty('playlist-pos'); } catch (_) { // Unknown state — fall through to the remove, never close the fd. } @@ -431,7 +479,13 @@ class PlayerNative extends PlayerBase { appLogger.d('MPV-audio: clearing armed entry (playlist-remove 1)'); try { - await command(['playlist-remove', '1']); + if (duringDispose) { + await super.invoke('command', { + 'args': ['playlist-remove', '1'], + }); + } else { + await command(['playlist-remove', '1']); + } } on PlatformException { // Entry 1 vanished in the arm/advance race — mpv rolled into it and // the file-loaded handler already rebased. The fd (if any) is mpv's. @@ -440,10 +494,12 @@ class PlayerNative extends PlayerBase { if (fd == null) return; String? postPos; try { - postPos = await getProperty('playlist-pos'); + postPos = duringDispose + ? await super.invoke('getProperty', {'name': 'playlist-pos'}) + : await getProperty('playlist-pos'); } catch (_) {} if (pos == '0' && postPos == '0') { - unawaited(_closeContentFd(fd)); + unawaited(_closeContentFd(fd, duringDispose: duringDispose)); } // Any other combination is ambiguous (mpv advanced mid-clear, idle // playlist, property error): leak on doubt. @@ -516,38 +572,52 @@ class PlayerNative extends PlayerBase { } @override - Future dispose({bool preserveDisplayMode = false}) async { + Future dispose({bool preserveDisplayMode = false}) { + final existing = _disposeFuture; + if (existing != null) return existing; + _disposing = true; + final disposal = _disposeNative(preserveDisplayMode: preserveDisplayMode); + _disposeFuture = disposal; + return disposal; + } + + Future _disposeNative({required bool preserveDisplayMode}) async { if (disposed) return; // Settle an armed-but-unconsumed content fd before the base teardown // disables invoke() — the playlist is torn down without mpv ever opening // the entry. if (_hasArmedNext) { try { - await _clearArmedNext(adoptIfRolledIn: false); + await _clearArmedNext(adoptIfRolledIn: false, duringDispose: true); } catch (_) { // Leak on doubt. } } + await _audioStateTail; await super.dispose(preserveDisplayMode: preserveDisplayMode); } @override Future selectAudioTrack(AudioTrack track) async { + if (_nativeCoreUnavailable) return; await setProperty('aid', track.id); } @override Future selectSubtitleTrack(SubtitleTrack track) async { + if (_nativeCoreUnavailable) return; await setProperty('sid', track.id); } @override Future selectSecondarySubtitleTrack(SubtitleTrack track) async { + if (_nativeCoreUnavailable) return; await setProperty('secondary-sid', track.id); } @override Future addSubtitleTrack({required String uri, String? title, String? language, bool select = false}) async { + if (_nativeCoreUnavailable) return; final args = ['sub-add', uri, select ? 'select' : 'auto']; if (title != null) args.add('title=$title'); if (language != null) args.add('lang=$language'); @@ -556,53 +626,64 @@ class PlayerNative extends PlayerBase { @override Future setVolume(double volume) async { + if (_nativeCoreUnavailable) return; await setProperty('volume', volume.toString()); - if (!disposed) setVolumeState(volume); + if (!_nativeCoreUnavailable) setVolumeState(volume); } @override Future setRate(double rate) { - _currentRate = rate; - final operation = _rateChangeTail.then((_) => _applyRateChange(rate)); - _rateChangeTail = operation.catchError((Object _, StackTrace _) {}); - return operation; - } - - Future _applyRateChange(double rate) async { - // mpv cannot scaletempo compressed (spdif) audio and silently keeps - // playing at 1x, so serialize passthrough and speed transitions. - if (_passthroughActive && rate != 1.0) { - await _applyPassthrough(false); - } - await setProperty('speed', rate.toString()); - if (_passthroughRequested && !_passthroughActive && rate == 1.0 && !_downmixEnabled) { - await _applyPassthrough(true); - } + if (_nativeCoreUnavailable) return Future.value(); + _requestedRate = rate; + return _enqueueAudioStateReconciliation(_rateAudioField); } @override Future setAudioDevice(AudioDevice device) async { + if (_nativeCoreUnavailable) return; await setProperty('audio-device', device.name); } @override - Future setProperty(String name, String value) async { - if (disposed) return; - if ((Platform.isIOS || Platform.isMacOS) && name == 'dv-conversion-mode') { - value = _normalizeDvConversionMode(value); - _dvConversionMode = value; - } - if ((Platform.isIOS || Platform.isMacOS) && name == 'dv-conversion-log') { - value = _normalizeBoolProperty(value); - _dvConversionLog = value; - } + Future setProperty(String name, String value) => _setProperty(name, value, synchronizeRate: true); + + Future _setProperty(String name, String value, {required bool synchronizeRate}) async { + if (_nativeCoreUnavailable) return; + final updatesDvMode = (Platform.isIOS || Platform.isMacOS) && name == 'dv-conversion-mode'; + final updatesDvLog = (Platform.isIOS || Platform.isMacOS) && name == 'dv-conversion-log'; + if (updatesDvMode) value = _normalizeDvConversionMode(value); + if (updatesDvLog) value = _normalizeBoolProperty(value); + await _ensureInitialized(); await invoke('setProperty', {'name': name, 'value': value}); + if (_nativeCoreUnavailable) return; + if (updatesDvMode) _dvConversionMode = value; + if (updatesDvLog) _dvConversionLog = value; + if (synchronizeRate && name == 'speed') { + final rate = double.tryParse(value); + if (rate != null && rate.isFinite) { + _currentRate = rate; + _requestedRate = rate; + final accepted = _acceptedAudioState; + _acceptedAudioState = ( + passthrough: accepted.passthrough, + normalization: accepted.normalization, + downmix: accepted.downmix, + downmixCenterBoostDb: accepted.downmixCenterBoostDb, + downmixNormalize: accepted.downmixNormalize, + rate: rate, + ); + } else { + // The native bridge may accept custom mpv speed syntax. Its numeric + // value is unknown, so the next typed setRate must write explicitly. + _currentRate = double.nan; + } + } } @override Future getProperty(String name) async { - if (disposed) return null; + if (_nativeCoreUnavailable) return null; if ((Platform.isIOS || Platform.isMacOS) && name == 'dv-conversion-mode') { return _dvConversionMode; } @@ -615,7 +696,7 @@ class PlayerNative extends PlayerBase { @override Future> getStats() async { - if (disposed || !Platform.isAndroid) return super.getStats(); + if (_nativeCoreUnavailable || !Platform.isAndroid) return super.getStats(); await _ensureInitialized(); final result = await invoke('getStats'); return Map.from(result ?? const {}); @@ -623,7 +704,7 @@ class PlayerNative extends PlayerBase { @override Future command(List args) async { - if (disposed) return; + if (_nativeCoreUnavailable) return; await _ensureInitialized(); await invoke('command', {'args': args}); } @@ -633,7 +714,7 @@ class PlayerNative extends PlayerBase { @override Future setDisplayCriteria(MediaDisplayCriteria? criteria, {int extraDelayMs = 0}) async { - if (disposed || audioOnly || !Platform.isIOS) return; + if (_nativeCoreUnavailable || audioOnly || !Platform.isIOS) return; await _ensureInitialized(); await invoke('setDisplayCriteria', { 'criteria': _effectiveDisplayCriteria(criteria)?.toJson(), @@ -643,16 +724,46 @@ class PlayerNative extends PlayerBase { @override Future setLogLevel(String level) async { - if (disposed) return; + if (_nativeCoreUnavailable) return; await _ensureInitialized(); await invoke('setLogLevel', {'level': level}); } + @override + Future setVisible(bool visible, {bool restoreOnWindowVisible = false}) async { + if (_nativeCoreUnavailable) return false; + final changed = await super.setVisible(visible, restoreOnWindowVisible: restoreOnWindowVisible); + return changed && !_nativeCoreUnavailable; + } + + static const int _passthroughAudioField = 1 << 0; + static const int _normalizationAudioField = 1 << 1; + static const int _downmixAudioField = 1 << 2; + static const int _rateAudioField = 1 << 3; + bool _passthroughRequested = false; bool _passthroughActive = false; bool _normalizationRequested = false; - bool _downmixEnabled = false; + bool _normalizationActive = false; + bool _downmixRequested = false; + bool _downmixActive = false; + int _downmixCenterBoostDb = 0; + int _activeDownmixCenterBoostDb = 0; + bool _downmixNormalize = false; + bool _activeDownmixNormalize = false; double _currentRate = 1.0; + int _passthroughGeneration = 0; + int _normalizationGeneration = 0; + int _downmixGeneration = 0; + int _rateGeneration = 0; + _AudioStateRequest _acceptedAudioState = const ( + passthrough: false, + normalization: false, + downmix: false, + downmixCenterBoostDb: 0, + downmixNormalize: false, + rate: 1.0, + ); @override bool get audioPassthroughActive => _passthroughActive; @@ -662,58 +773,177 @@ class PlayerNative extends PlayerBase { /// Digital (Plus); desktop does real device passthrough for the full list. static final String _passthroughCodecs = Platform.isIOS ? 'ac3,eac3' : 'ac3,eac3,dts,dts-hd,truehd'; - @override - Future setAudioPassthrough(bool enabled) async { - _passthroughRequested = enabled; - // Deferred until the rate returns to 1.0 (see setRate) and the stereo - // downmix ends (see setAudioDownmix). - if (enabled && (_currentRate != 1.0 || _downmixEnabled)) return; - await _applyPassthrough(enabled); - } + _AudioStateRequest get _requestedAudioState => ( + passthrough: _passthroughRequested, + normalization: _normalizationRequested, + downmix: _downmixRequested, + downmixCenterBoostDb: _downmixCenterBoostDb, + downmixNormalize: _downmixNormalize, + rate: _requestedRate, + ); - Future _applyPassthrough(bool enabled) async { - _passthroughActive = enabled; - // loudnorm decodes to PCM, which defeats bitstream passthrough; the - // filter yields while passthrough is active and returns when it ends. - if (enabled && _normalizationRequested) { - await super.setAudioNormalization(false); + _AudioStateRequest _rebaseAudioState(_AudioStateRequest accepted, _AudioStateRequest requested, int fields) => ( + passthrough: fields & _passthroughAudioField != 0 ? requested.passthrough : accepted.passthrough, + normalization: fields & _normalizationAudioField != 0 ? requested.normalization : accepted.normalization, + downmix: fields & _downmixAudioField != 0 ? requested.downmix : accepted.downmix, + downmixCenterBoostDb: fields & _downmixAudioField != 0 + ? requested.downmixCenterBoostDb + : accepted.downmixCenterBoostDb, + downmixNormalize: fields & _downmixAudioField != 0 ? requested.downmixNormalize : accepted.downmixNormalize, + rate: fields & _rateAudioField != 0 ? requested.rate : accepted.rate, + ); + + void _restoreFailedRequestedFields(_AudioStateRequest previous, int fields, _AudioStateGenerations generations) { + if (fields & _passthroughAudioField != 0 && generations.passthrough == _passthroughGeneration) { + _passthroughRequested = previous.passthrough; } - await setProperty('audio-spdif', enabled ? _passthroughCodecs : ''); - // audio-exclusive redirects coreaudio to coreaudio_exclusive on macOS - // (and exclusive WASAPI on Windows); on iOS/tvOS it is set once at - // playback start and must not be clobbered here. - if (!Platform.isIOS) { - await setProperty('audio-exclusive', enabled ? 'yes' : 'no'); + if (fields & _normalizationAudioField != 0 && generations.normalization == _normalizationGeneration) { + _normalizationRequested = previous.normalization; } - if (!enabled && _normalizationRequested) { - await super.setAudioNormalization(true); + if (fields & _downmixAudioField != 0 && generations.downmix == _downmixGeneration) { + _downmixRequested = previous.downmix; + _downmixCenterBoostDb = previous.downmixCenterBoostDb; + _downmixNormalize = previous.downmixNormalize; + } + if (fields & _rateAudioField != 0 && generations.rate == _rateGeneration) { + _requestedRate = previous.rate; } } - @override - Future setAudioNormalization(bool enabled) async { - _normalizationRequested = enabled; - if (enabled && _passthroughActive) return; // deferred until passthrough ends - await super.setAudioNormalization(enabled); + Future _enqueueAudioStateReconciliation(int fields) { + final requested = _requestedAudioState; + if (fields & _passthroughAudioField != 0) ++_passthroughGeneration; + if (fields & _normalizationAudioField != 0) ++_normalizationGeneration; + if (fields & _downmixAudioField != 0) ++_downmixGeneration; + if (fields & _rateAudioField != 0) ++_rateGeneration; + final generations = ( + passthrough: _passthroughGeneration, + normalization: _normalizationGeneration, + downmix: _downmixGeneration, + rate: _rateGeneration, + ); + final operation = _audioStateTail.then((_) => _reconcileAudioState(requested, fields, generations)); + _audioStateTail = operation.catchError((Object _, StackTrace _) {}); + return operation; } - @override - Future setAudioDownmix({required bool enabled, required int centerBoostDb, required bool normalize}) async { - _downmixEnabled = enabled; - // spdif bypasses the filter chain entirely; passthrough yields while a - // stereo downmix is forced and returns when it is disabled. - if (enabled && _passthroughActive) { + Future _reconcileAudioState( + _AudioStateRequest requested, + int fields, + _AudioStateGenerations generations, + ) async { + if (_nativeCoreUnavailable) return; + final previous = _acceptedAudioState; + final target = _rebaseAudioState(previous, requested, fields); + try { + await _applyAudioState(target); + if (_nativeCoreUnavailable) return; + _acceptedAudioState = target; + } catch (error, stackTrace) { + try { + await _applyAudioState( + previous, + forceDownmix: fields & _downmixAudioField != 0, + forceNormalization: fields & _downmixAudioField != 0, + ); + } catch (rollbackError, rollbackStackTrace) { + appLogger.e( + 'MPV: failed to restore accepted audio state', + error: rollbackError, + stackTrace: rollbackStackTrace, + ); + } + _restoreFailedRequestedFields(previous, fields, generations); + Error.throwWithStackTrace(error, stackTrace); + } + } + + Future _applyAudioState( + _AudioStateRequest target, { + bool forceDownmix = false, + bool forceNormalization = false, + }) async { + if (_nativeCoreUnavailable) return; + final passthroughShouldBeActive = target.passthrough && target.rate == 1.0 && !target.downmix; + + // mpv cannot scaletempo compressed audio and filters cannot process a + // bitstream. Always leave passthrough before applying either state. + if (_passthroughActive && !passthroughShouldBeActive) { await _applyPassthrough(false); } - await super.setAudioDownmix(enabled: enabled, centerBoostDb: centerBoostDb, normalize: normalize); - if (!enabled && _passthroughRequested && !_passthroughActive && _currentRate == 1.0) { + if (_currentRate != target.rate) { + await _setProperty('speed', target.rate.toString(), synchronizeRate: false); + _currentRate = target.rate; + } + if (forceDownmix || + _downmixActive != target.downmix || + (target.downmix && + (_activeDownmixCenterBoostDb != target.downmixCenterBoostDb || + _activeDownmixNormalize != target.downmixNormalize))) { + await super.setAudioDownmix( + enabled: target.downmix, + centerBoostDb: target.downmixCenterBoostDb, + normalize: target.downmixNormalize, + ); + _downmixActive = target.downmix; + _activeDownmixCenterBoostDb = target.downmixCenterBoostDb; + _activeDownmixNormalize = target.downmixNormalize; + } + final normalizationShouldBeActive = target.normalization && !passthroughShouldBeActive; + if (forceNormalization || _normalizationActive != normalizationShouldBeActive) { + await super.setAudioNormalization(normalizationShouldBeActive); + _normalizationActive = normalizationShouldBeActive; + } + if (passthroughShouldBeActive && !_passthroughActive) { await _applyPassthrough(true); } } + @override + Future setAudioPassthrough(bool enabled) { + if (_nativeCoreUnavailable) return Future.value(); + _passthroughRequested = enabled; + return _enqueueAudioStateReconciliation(_passthroughAudioField); + } + + Future _applyPassthrough(bool enabled) async { + await setProperty('audio-spdif', enabled ? _passthroughCodecs : ''); + if (_nativeCoreUnavailable) return; + + // audio-spdif is the authoritative transition. Publish only after mpv + // accepts it; audio-exclusive below is an independent device-mode hint. + _passthroughActive = enabled; + // audio-exclusive redirects coreaudio to coreaudio_exclusive on macOS + // (and exclusive WASAPI on Windows); on iOS/tvOS it is set once at + // playback start and must not be clobbered here. + if (!Platform.isIOS) { + try { + await setProperty('audio-exclusive', enabled ? 'yes' : 'no'); + } catch (error, stackTrace) { + appLogger.w('MPV: failed to update exclusive-audio hint', error: error, stackTrace: stackTrace); + } + } + } + + @override + Future setAudioNormalization(bool enabled) { + if (_nativeCoreUnavailable) return Future.value(); + _normalizationRequested = enabled; + return _enqueueAudioStateReconciliation(_normalizationAudioField); + } + + @override + Future setAudioDownmix({required bool enabled, required int centerBoostDb, required bool normalize}) { + if (_nativeCoreUnavailable) return Future.value(); + _downmixRequested = enabled; + _downmixCenterBoostDb = centerBoostDb; + _downmixNormalize = normalize; + return _enqueueAudioStateReconciliation(_downmixAudioField); + } + @override Future updateFrame() async { - if (disposed || !initialized) return; + if (_nativeCoreUnavailable || !initialized) return; if (Platform.isAndroid || Platform.isIOS || Platform.isMacOS || Platform.isLinux) { await invoke('updateFrame'); } @@ -727,7 +957,7 @@ class PlayerNative extends PlayerBase { int videoWidth = 0, int videoHeight = 0, }) async { - if (!Platform.isAndroid || disposed || !initialized) return false; + if (_nativeCoreUnavailable || !Platform.isAndroid || !initialized) return false; final result = await invoke('setVideoFrameRate', { 'fps': fps, 'duration': durationMs, @@ -740,13 +970,13 @@ class PlayerNative extends PlayerBase { @override Future clearVideoFrameRate() async { - if (!Platform.isAndroid || disposed || !initialized) return; + if (_nativeCoreUnavailable || !Platform.isAndroid || !initialized) return; await invoke('clearVideoFrameRate'); } @override Future requestAudioFocus() async { - if (disposed) return false; + if (_nativeCoreUnavailable) return false; if (!Platform.isAndroid) return true; await _ensureInitialized(); return await invoke('requestAudioFocus') ?? false; @@ -754,7 +984,7 @@ class PlayerNative extends PlayerBase { @override Future abandonAudioFocus() async { - if (!Platform.isAndroid || disposed || !initialized) return; + if (_nativeCoreUnavailable || !Platform.isAndroid || !initialized) return; await invoke('abandonAudioFocus'); } } diff --git a/lib/mpv/video.dart b/lib/mpv/video.dart index b7995395..be0ac42a 100644 --- a/lib/mpv/video.dart +++ b/lib/mpv/video.dart @@ -108,7 +108,17 @@ class _VideoState extends State