fix(native): bound cross-platform lifecycle ownership
This commit is contained in:
@@ -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>
|
||||
|
||||
+27
-7
@@ -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))
|
||||
|
||||
|
||||
+50
-22
@@ -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")
|
||||
}
|
||||
|
||||
+100
-13
@@ -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 {
|
||||
|
||||
+9
-3
@@ -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) {
|
||||
|
||||
+34
-1
@@ -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
|
||||
|
||||
+32
@@ -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) {
|
||||
|
||||
+217
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+48
-6
@@ -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)
|
||||
}
|
||||
}
|
||||
+156
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user