fix(native): bound cross-platform lifecycle ownership

This commit is contained in:
edde746
2026-07-24 03:56:40 +02:00
parent 09656fa4d3
commit 9f2e050797
98 changed files with 12908 additions and 1599 deletions
+8 -1
View File
@@ -28,6 +28,7 @@
<uses-feature android:name="android.software.leanback" android:required="false" />
<!-- Android TV Watch Next integration -->
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<!-- Touchscreen not required for TV -->
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
@@ -91,13 +92,14 @@
<provider
android:name=".watchnext.SystemShelfArtworkProvider"
android:authorities="com.edde746.plezy.systemshelf.artwork"
android:exported="false"
android:exported="true"
android:grantUriPermissions="true" />
<receiver
android:name=".watchnext.SystemShelfUpdateReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<!-- Don't delete the meta-data below.
@@ -130,5 +132,10 @@
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="video/*" />
</intent>
<!-- Required to identify only the active HOME launcher as a shelf artwork consumer. -->
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.HOME" />
</intent>
</queries>
</manifest>
@@ -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
)
}
@@ -128,8 +128,6 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private val hwAudioDecoderCache = HashMap<String, Boolean>()
private val tunneledPlaybackCache = HashMap<String, Boolean>()
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, _, _ ->
@@ -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))
@@ -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)
}
@@ -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>) -> 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
@@ -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<Unit>,
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<MethodChannel.Result>()
@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)
}
}
@@ -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")
}
@@ -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<out String>?): 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<File>) {
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 {
@@ -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()
@@ -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<List<Map<String, Any?>>>("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<String>("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) }
@@ -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 <T> whileCurrent(ownership: Ownership, block: () -> T): T? = synchronized(operationLock) {
if (!isCurrent(ownership)) return@synchronized null
val result = block()
if (isCurrent(ownership)) result else null
}
fun <T> 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<WatchNextItem>): 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<WatchNextItem>,
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<java.io.File>()
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<PreparedWatchNextItem>): Boolean = try {
val operations = ArrayList<ContentProviderOperation>(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<String> {
val packages = LinkedHashSet<String>()
context.packageManager.resolveContentProvider(TvContractCompat.AUTHORITY, PackageManager.MATCH_ALL)?.packageName
?.let(packages::add)
val launcherIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LEANBACK_LAUNCHER)
context.packageManager.queryIntentActivities(launcherIntent, PackageManager.MATCH_ALL)
.mapTo(packages) { it.activityInfo.packageName }
return packages
internal fun consumerPackages(): Set<String> {
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<Uri>) {
private fun reconcileReadAccess(
previousUris: Set<Uri>,
previousPackages: Set<String>,
currentUris: Set<Uri>,
currentPackages: Set<String>
) {
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<Uri>) {
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<Uri> = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty().mapNotNullTo(LinkedHashSet(), Uri::parse)
private fun storedPackages(): Set<String> = prefs.getStringSet(GRANTED_PACKAGES, emptySet()).orEmpty()
internal fun buildProgram(item: PreparedWatchNextItem): WatchNextProgram {
val metadata = item.metadata
val watchNextType = if (metadata.lastPlaybackPosition > 0) {
@@ -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<Int>()
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
@@ -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<ExoPlayerCore>()
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
@@ -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<Pair<String, String>>()
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<Pair<String, String>>()
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<Pair<String, String>>()
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<Pair<String, String>>()
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<ViewGroup>(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<String, String>)["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<ViewGroup>(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<String, Any?>()), 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<Pair<String, String>>,
expected: Pair<String, String>
): Boolean {
repeat(100) {
shadowOf(Looper.getMainLooper()).idle()
if (queue.contains(expected)) return true
Thread.sleep(10)
}
return false
}
private fun awaitPauseWriteCount(
queue: ConcurrentLinkedQueue<Pair<String, String>>,
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) {
@@ -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
}
}
}
@@ -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<Unit>? = 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<Unit>? = null
var secondOutcome: Result<Unit>? = 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<Unit>? = 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<Pair<String, String>>()
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<Pair<String, String>>()
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<Pair<String, String>>()
val focusResumeCallbacks = AtomicInteger()
val core = testCore { name, value -> writes += name to value }
val focusManager = testAudioFocusManager(core, focusResumeCallbacks)
var pauseOutcome: Result<Unit>? = null
var resumeOutcome: Result<Unit>? = 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<Pair<String, String>>()
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<ViewGroup>(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<MethodChannel.Result> = getPluginField(plugin, "pendingInitResults") as MutableList<MethodChannel.Result>
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<Pair<String, String>>,
expected: Pair<String, String>
): 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()
@@ -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<Boolean>()
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<Boolean>()
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<SystemShelfLifecycle.Ownership?>()
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<Map<String, Any?>>()
)
)
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<Grant>()
val uriWideRevocations = mutableListOf<Uri>()
val packageRevocations = mutableListOf<PackageRevocation>()
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<ContentValues>()
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<ContentProviderOperation>): Array<ContentProviderResult> = super.applyBatch(operations)
override fun applyBatch(operations: ArrayList<ContentProviderOperation>): Array<ContentProviderResult> {
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<out String>?, selection: String?, selectionArgs: Array<out String>?, sortOrder: String?): Cursor? = null
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int = 0
}
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<Runnable>()
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<Runnable> {
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()
}
}
+6
View File
@@ -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);
@@ -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
@@ -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<Pair<String, ByteArray>>()
@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<Pair<String, ByteArray>> = 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<String, AssTrack>()
/** Fonts encountered before any ASS track was created. Flushed in [createTrack]. */
private val pendingFonts = mutableListOf<Pair<String, ByteArray>>()
/** 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)
@@ -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
@@ -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<Pair<String, ByteArray>>()
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<Pair<String, ByteArray>>()
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)
}
}
@@ -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
}
}
}