fix(native): harden player teardown and shelf recovery

This commit is contained in:
edde746
2026-07-25 16:16:24 +02:00
parent 4af77f4696
commit f8f366a513
26 changed files with 2895 additions and 302 deletions
@@ -18,6 +18,7 @@ import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.CancellationException
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
class ExoPlayerPlugin : class ExoPlayerPlugin :
@@ -774,15 +775,16 @@ class ExoPlayerPlugin :
} }
pendingMpvProperties[name] = value pendingMpvProperties[name] = value
core.setProperty(name, value) { outcome -> core.setProperty(name, value) { outcome ->
val currentOutcome = if ( val fallbackIsCurrent =
usingMpvFallback && usingMpvFallback &&
generation == sessionGeneration && generation == sessionGeneration &&
activity === currentActivity && activity === currentActivity &&
mpvCore === core mpvCore === core
) { val currentOutcome =
if (fallbackIsCurrent || outcome.isFailure) {
outcome outcome
} else { } else {
Result.failure(IllegalStateException("MPV fallback unavailable")) Result.failure(CancellationException("MPV fallback unavailable"))
} }
completeMpvPropertyResult(result, currentOutcome, successValue) completeMpvPropertyResult(result, currentOutcome, successValue)
} }
@@ -700,8 +700,15 @@ class MpvPlayerCore private constructor(
} }
private fun resumeAfterAudioFocusGain(reason: String) { private fun resumeAfterAudioFocusGain(reason: String) {
val shouldResume = synchronized(publicPauseIntentLock) {
if (!pausedForAudioFocusLoss) {
false
} else {
pausedForAudioFocusLoss = false pausedForAudioFocusLoss = false
requestAutoResume(reason) true
}
}
if (shouldResume) requestAutoResume(reason)
} }
private fun rollbackFailedPublicPauseIntent(intent: PublicPauseIntent) { private fun rollbackFailedPublicPauseIntent(intent: PublicPauseIntent) {
@@ -713,6 +720,22 @@ class MpvPlayerCore private constructor(
} }
} }
private fun completeFailedPublicResume(
intent: PublicPauseIntent,
failure: Throwable,
onComplete: ((Result<Unit>) -> Unit)?
) {
rollbackFailedPublicPauseIntent(intent)
runOnMain {
val completion: Result<Unit> = if (disposing || !isInitialized || !scope.isActive) {
Result.failure(CancellationException("MPV core unavailable"))
} else {
Result.failure(failure)
}
onComplete?.invoke(completion)
}
}
private fun requestAutoResume(reason: String) { private fun requestAutoResume(reason: String) {
val p = player val p = player
if (p == null && propertyWriterOverride == null) return if (p == null && propertyWriterOverride == null) return
@@ -847,44 +870,89 @@ class MpvPlayerCore private constructor(
} }
} }
if (paused == false && (pausedForAudioFocusLoss || !hasReadyVideoOutput())) { if (paused == false && pauseIntent != null) {
val shouldReclaimAudioFocus = synchronized(publicPauseIntentLock) {
publicPauseIntentGeneration == pauseIntent.generation && pausedForAudioFocusLoss
}
if (shouldReclaimAudioFocus) {
val focusGranted = audioFocusManager?.requestAudioFocus() == true
if (!focusGranted) {
Log.w(TAG, "Audio focus request denied for public resume")
completeFailedPublicResume(
pauseIntent,
IllegalStateException("Audio focus unavailable for resume"),
onComplete
)
return
}
synchronized(publicPauseIntentLock) {
if (publicPauseIntentGeneration == pauseIntent.generation && pausedForAudioFocusLoss) {
pausedForAudioFocusLoss = false
}
}
}
}
if (paused == false && pauseIntent != null && !hasReadyVideoOutput()) {
runOnMain { runOnMain {
if (!isInitialized || disposing || !scope.isActive) { if (!isInitialized || disposing || !scope.isActive) {
onComplete?.invoke(Result.failure(CancellationException("MPV core unavailable"))) onComplete?.invoke(Result.failure(CancellationException("MPV core unavailable")))
return@runOnMain return@runOnMain
} }
val isCurrent = synchronized(publicPauseIntentLock) { var deferredForSurface = false
pauseIntent != null && publicPauseIntentGeneration == pauseIntent.generation val interruptedAgain = synchronized(publicPauseIntentLock) {
} if (publicPauseIntentGeneration != pauseIntent.generation) {
if (isCurrent) { false
deferredResumeRequested = true } else if (pausedForAudioFocusLoss) {
Log.d( true
TAG,
if (pausedForAudioFocusLoss) {
"Deferring public resume until audio focus returns"
} else { } else {
"Deferring public resume until video output is ready" deferredResumeRequested = true
deferredForSurface = true
false
} }
}
if (interruptedAgain) {
Log.d(TAG, "Public resume interrupted by a newer audio-focus loss")
completeFailedPublicResume(
pauseIntent,
IllegalStateException("Audio focus unavailable for resume"),
onComplete
) )
} else {
if (deferredForSurface) {
Log.d(TAG, "Deferring public resume until video output is ready")
} }
onComplete?.invoke(Result.success(Unit)) onComplete?.invoke(Result.success(Unit))
} }
}
return return
} }
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) { scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
var interruptedBeforeWrite = false
val writeResult = try { val writeResult = try {
if (pauseIntent == null) { if (pauseIntent == null) {
writeProperty(name, value) writeProperty(name, value)
} else { } else {
publicPauseWriteMutex.withLock { publicPauseWriteMutex.withLock {
val shouldWrite = synchronized(publicPauseIntentLock) { val shouldWrite = synchronized(publicPauseIntentLock) {
publicPauseIntentGeneration == pauseIntent.generation val isCurrent = publicPauseIntentGeneration == pauseIntent.generation
if (isCurrent && paused == false && pausedForAudioFocusLoss) {
interruptedBeforeWrite = true
false
} else {
isCurrent
}
} }
if (shouldWrite) writeProperty(name, value) if (shouldWrite) writeProperty(name, value)
} }
} }
if (interruptedBeforeWrite) {
Result.failure(IllegalStateException("Audio focus unavailable for resume"))
} else {
Result.success(Unit) Result.success(Unit)
}
} catch (error: CancellationException) { } catch (error: CancellationException) {
Result.failure(error) Result.failure(error)
} catch (error: Exception) { } catch (error: Exception) {
@@ -8,13 +8,14 @@ import android.net.Uri
import android.os.Binder import android.os.Binder
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.os.Process import android.os.Process
import android.system.Os
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.io.RandomAccessFile
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.security.MessageDigest import java.security.MessageDigest
import java.util.UUID
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -88,14 +89,28 @@ internal class SystemShelfArtworkStore(private val cacheDir: File) {
const val CONNECT_TIMEOUT_MS = 2_500 const val CONNECT_TIMEOUT_MS = 2_500
const val READ_TIMEOUT_MS = 2_500 const val READ_TIMEOUT_MS = 2_500
private val opaquePart = Regex("^[a-f0-9]{64}$") private val opaquePart = Regex("^[a-f0-9]{64}$")
private val artworkKey = Regex("^[a-f0-9]{32}\\.art$") private val artworkKey = Regex("^(?:[a-f0-9]{32}|[a-f0-9]{64})\\.art$")
private val deadlineAborter: ScheduledExecutorService = private val deadlineAborter: ScheduledExecutorService =
Executors.newSingleThreadScheduledExecutor { runnable -> Executors.newSingleThreadScheduledExecutor { runnable ->
Thread(runnable, "system-shelf-deadline").apply { isDaemon = true } Thread(runnable, "system-shelf-deadline").apply { isDaemon = true }
} }
} }
data class Materialized(val key: String, val uri: Uri, val file: File) data class Materialized(val uri: Uri, val file: File)
sealed class Prepared {
abstract val materialized: Materialized
data class Existing(override val materialized: Materialized) : Prepared()
data class Staged(
override val materialized: Materialized,
val stagingFile: File
) : Prepared()
}
data class Publication(val materialized: Materialized, val newlyPublished: Boolean)
class Budget(var remaining: Int = MAX_SYNC_BYTES) { class Budget(var remaining: Int = MAX_SYNC_BYTES) {
var consumed: Long = 0 var consumed: Long = 0
private set private set
@@ -114,20 +129,37 @@ internal class SystemShelfArtworkStore(private val cacheDir: File) {
private val root: File get() = File(cacheDir, "system_shelf_artwork") private val root: File get() = File(cacheDir, "system_shelf_artwork")
fun materialize(ownerId: String, source: String, session: SystemShelfSyncSession): Materialized? { fun prepare(ownerId: String, source: String, session: SystemShelfSyncSession): Prepared? {
if (ownerId.isBlank() || session.budget.remaining <= 0 || !session.isActive()) return null if (ownerId.isBlank()) return null
val url = runCatching { URL(source) }.getOrNull() ?: return null val url = runCatching { URL(source) }.getOrNull() ?: return null
if (url.protocol != "https" && url.protocol != "http") return null if (url.protocol != "https" && url.protocol != "http") return null
val connection = (url.openConnection() as? HttpURLConnection) ?: return null if (!session.isActive()) return null
val ownerKey = sha256(ownerId)
val key = sha256(ownerId + "\u0000" + source) + ".art"
val uri = contentUri(ownerKey, key)
val destination = File(File(root, ownerKey), key)
val materialized = Materialized(uri, destination)
resolveOwned(ownerId, uri)?.let { existing ->
return Prepared.Existing(materialized.copy(file = existing))
}
if (session.budget.remaining <= 0) return null
val remainingNanos = session.remainingNanos() val remainingNanos = session.remainingNanos()
if (remainingNanos <= 0) return null if (remainingNanos <= 0) return null
val connection = runCatching { url.openConnection() as? HttpURLConnection }.getOrNull()
?: return null
val abort = deadlineAborter.schedule( val abort = deadlineAborter.schedule(
{ connection.disconnect() }, { connection.disconnect() },
remainingNanos, remainingNanos,
TimeUnit.NANOSECONDS TimeUnit.NANOSECONDS
) )
var stagingFile: File? = null
var retainStagingFile = false
return try { return try {
val remainingMillis = TimeUnit.NANOSECONDS.toMillis(remainingNanos).coerceIn(1, Int.MAX_VALUE.toLong()).toInt() val remainingMillis = TimeUnit.NANOSECONDS.toMillis(remainingNanos)
.coerceIn(1, Int.MAX_VALUE.toLong())
.toInt()
connection.instanceFollowRedirects = true connection.instanceFollowRedirects = true
connection.connectTimeout = minOf(CONNECT_TIMEOUT_MS, remainingMillis) connection.connectTimeout = minOf(CONNECT_TIMEOUT_MS, remainingMillis)
connection.readTimeout = minOf(READ_TIMEOUT_MS, remainingMillis) connection.readTimeout = minOf(READ_TIMEOUT_MS, remainingMillis)
@@ -155,41 +187,73 @@ internal class SystemShelfArtworkStore(private val cacheDir: File) {
} }
val read = input.read(buffer, 0, minOf(buffer.size, remaining)) val read = input.read(buffer, 0, minOf(buffer.size, remaining))
if (read < 0) break if (read < 0) break
session.budget.charge(read) if (!session.budget.charge(read)) return null
total += read total += read
output.write(buffer, 0, read) output.write(buffer, 0, read)
} }
output.toByteArray() output.toByteArray()
} }
if (!session.isActive() || !isSupportedImage(bytes)) return null if (!session.isActive() || !isSupportedImage(bytes)) return null
val ownerKey = sha256(ownerId)
val directory = File(root, ownerKey) val directory = destination.parentFile ?: return null
if (!directory.mkdirs() && !directory.isDirectory) return null if (!directory.mkdirs() && !directory.isDirectory) return null
val key = UUID.randomUUID().toString().replace("-", "") + ".art" val staged = File.createTempFile(".$key.", ".tmp", directory)
val staged = File(directory, ".$key.tmp") stagingFile = staged
staged.outputStream().use { output -> staged.outputStream().use { output ->
output.write(bytes) output.write(bytes)
output.flush() output.flush()
output.fd.sync() output.fd.sync()
} }
if (!session.isActive()) { if (!isSupportedImage(staged) || !session.isActive()) return null
staged.delete() retainStagingFile = true
return null Prepared.Staged(materialized, staged)
}
val destination = File(directory, key)
if (!session.commitIfActive { staged.renameTo(destination) }) {
staged.delete()
return null
}
Materialized(key, contentUri(ownerKey, key), destination)
} catch (_: Exception) { } catch (_: Exception) {
null null
} finally { } finally {
if (!retainStagingFile) stagingFile?.delete()
abort.cancel(false) abort.cancel(false)
connection.disconnect() connection.disconnect()
} }
} }
fun publish(prepared: Prepared): Publication? {
val expected = confinedCandidate(prepared.materialized.uri) ?: return null
val recorded = runCatching { prepared.materialized.file.canonicalFile }.getOrNull() ?: return null
if (recorded != expected) return null
resolve(prepared.materialized.uri)?.let { existing ->
if (prepared is Prepared.Staged && !deleteStagingFile(prepared.stagingFile, expected)) {
return null
}
return Publication(prepared.materialized.copy(file = existing), newlyPublished = false)
}
if (prepared !is Prepared.Staged) return null
val staged = confinedStagingFile(prepared.stagingFile, expected) ?: return null
if (!isSupportedImage(staged)) return null
val renamed = try {
Os.rename(staged.absolutePath, expected.absolutePath)
!staged.exists() || staged.renameTo(expected)
} catch (_: Exception) {
runCatching { staged.renameTo(expected) }.getOrDefault(false)
}
if (!renamed) return null
return if (!isSupportedImage(expected)) {
expected.delete()
null
} else {
Publication(prepared.materialized.copy(file = expected), newlyPublished = true)
}
}
fun discard(prepared: Iterable<Prepared>) {
prepared.forEach { candidate ->
if (candidate is Prepared.Staged) {
deleteStagingFile(candidate.stagingFile, candidate.materialized.file)
}
}
}
fun contentUri(ownerKey: String, key: String): Uri = Uri.Builder() fun contentUri(ownerKey: String, key: String): Uri = Uri.Builder()
.scheme("content") .scheme("content")
.authority(SystemShelfArtworkProvider.AUTHORITY) .authority(SystemShelfArtworkProvider.AUTHORITY)
@@ -199,60 +263,257 @@ internal class SystemShelfArtworkStore(private val cacheDir: File) {
.build() .build()
fun resolve(uri: Uri): File? { fun resolve(uri: Uri): File? {
val candidate = confinedCandidate(uri) ?: return null
return candidate.takeIf(::isSupportedImage)
}
fun resolveOwned(ownerId: String, uri: Uri): File? {
if (ownerId.isBlank() || uri.pathSegments.getOrNull(1) != sha256(ownerId)) return null
return resolve(uri)
}
fun deleteExcept(keep: Set<File>) {
val canonicalKeep = keep.mapNotNullTo(HashSet()) {
runCatching { it.canonicalFile }.getOrNull()
}
val canonicalRoot = runCatching { root.canonicalFile }.getOrNull() ?: return
for (ownerDirectory in root.listFiles().orEmpty()) {
for (file in ownerDirectory.listFiles().orEmpty()) {
val candidate = confinedCacheFile(file, canonicalRoot) ?: continue
if (candidate !in canonicalKeep) candidate.delete()
}
}
removeEmptyDirectories()
}
fun delete(files: Set<File>) {
val canonicalRoot = runCatching { root.canonicalFile }.getOrNull() ?: return
files.forEach { file ->
confinedCacheFile(file, canonicalRoot)?.delete()
}
removeEmptyDirectories()
}
fun deleteAll(): Boolean = !root.exists() || root.deleteRecursively()
private fun confinedCandidate(uri: Uri): File? {
if (uri.scheme != "content" || uri.authority != SystemShelfArtworkProvider.AUTHORITY) return null if (uri.scheme != "content" || uri.authority != SystemShelfArtworkProvider.AUTHORITY) return null
val segments = uri.pathSegments val segments = uri.pathSegments
if (segments.size != 3 || segments[0] != "art") return null if (segments.size != 3 || segments[0] != "art") return null
val owner = segments[1] val owner = segments[1]
val key = segments[2] val key = segments[2]
if (!opaquePart.matches(owner) || !artworkKey.matches(key)) return null if (!opaquePart.matches(owner) || !artworkKey.matches(key)) return null
return runCatching {
val canonicalRoot = root.canonicalFile val canonicalRoot = root.canonicalFile
val candidate = File(File(canonicalRoot, owner), key).canonicalFile val ownerDirectory = File(canonicalRoot, owner).canonicalFile
if (candidate.parentFile?.parentFile != canonicalRoot || !candidate.isFile) return null if (ownerDirectory.parentFile != canonicalRoot || ownerDirectory.name != owner) return null
return candidate val candidate = File(ownerDirectory, key).canonicalFile
if (candidate.parentFile != ownerDirectory || candidate.name != key) return null
candidate
}.getOrNull()
} }
fun deleteExcept(keep: Set<File>) { private fun confinedCacheFile(file: File, canonicalRoot: File): File? {
val canonicalKeep = keep.mapTo(HashSet()) { it.canonicalFile } return runCatching {
root.listFiles()?.forEach { ownerDirectory -> val originalDirectory = file.parentFile ?: return null
ownerDirectory.listFiles()?.forEach { file -> val directory = originalDirectory.canonicalFile
if (file.canonicalFile !in canonicalKeep) file.delete() if (
} directory.parentFile != canonicalRoot ||
if (ownerDirectory.listFiles().isNullOrEmpty()) ownerDirectory.delete() directory.name != originalDirectory.name
) {
return null
} }
val candidate = file.canonicalFile
if (candidate.parentFile != directory || candidate.name != file.name) return null
candidate
}.getOrNull()
} }
fun delete(files: Set<File>) { private fun confinedStagingFile(stagingFile: File, destination: File): File? {
val canonicalRoot = root.canonicalFile val staged = runCatching { stagingFile.canonicalFile }.getOrNull() ?: return null
files.forEach { file -> val canonicalDestination = runCatching { destination.canonicalFile }.getOrNull() ?: return null
val candidate = runCatching { file.canonicalFile }.getOrNull() ?: return@forEach val directory = canonicalDestination.parentFile ?: return null
if (candidate.parentFile?.parentFile == canonicalRoot) candidate.delete() if (
staged == canonicalDestination ||
staged.parentFile != directory ||
!staged.name.startsWith(".${canonicalDestination.name}.") ||
!staged.name.endsWith(".tmp") ||
!staged.isFile
) {
return null
} }
return staged
}
private fun deleteStagingFile(stagingFile: File, destination: File): Boolean {
if (!stagingFile.exists()) return true
return confinedStagingFile(stagingFile, destination)?.delete() == true
}
private fun removeEmptyDirectories() {
root.listFiles()?.forEach { directory -> root.listFiles()?.forEach { directory ->
if (directory.listFiles().isNullOrEmpty()) directory.delete() if (directory.listFiles().isNullOrEmpty()) directory.delete()
} }
} }
fun deleteAll(): Boolean = !root.exists() || root.deleteRecursively() private enum class ImageFormat {
PNG,
JPEG,
GIF,
WEBP
}
private fun isSupportedImage(file: File): Boolean {
val length = file.length()
if (!file.isFile || length !in 1L..MAX_IMAGE_BYTES.toLong()) return false
val header = ByteArray(12)
val headerSize = runCatching {
file.inputStream().use { input ->
var total = 0
while (total < header.size) {
val read = input.read(header, total, header.size - total)
if (read <= 0) break
total += read
}
total
}
}.getOrNull() ?: return false
val format = imageFormat(header, headerSize) ?: return false
if (!hasCompleteContainer(file, format, header, length)) return false
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
return runCatching {
BitmapFactory.decodeFile(file.absolutePath, options)
hasSupportedDimensions(options)
}.getOrDefault(false)
}
private fun isSupportedImage(bytes: ByteArray): Boolean { private fun isSupportedImage(bytes: ByteArray): Boolean {
if (bytes.size < 4) return false val format = imageFormat(bytes, bytes.size) ?: return false
val png = bytes.size >= 8 && if (!hasCompleteContainer(bytes, format)) return false
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
return runCatching {
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
hasSupportedDimensions(options)
}.getOrDefault(false)
}
private fun imageFormat(bytes: ByteArray, size: Int): ImageFormat? {
if (
size >= 8 &&
bytes[0] == 0x89.toByte() && bytes[0] == 0x89.toByte() &&
bytes[1] == 0x50.toByte() && bytes[1] == 0x50.toByte() &&
bytes[2] == 0x4e.toByte() && bytes[2] == 0x4e.toByte() &&
bytes[3] == 0x47.toByte() bytes[3] == 0x47.toByte() &&
val jpeg = bytes[0] == 0xff.toByte() && bytes[1] == 0xd8.toByte() && bytes[2] == 0xff.toByte() bytes[4] == 0x0d.toByte() &&
val gif = bytes[0] == 0x47.toByte() && bytes[1] == 0x49.toByte() && bytes[2] == 0x46.toByte() bytes[5] == 0x0a.toByte() &&
val webp = bytes.size >= 12 && bytes[6] == 0x1a.toByte() &&
bytes.copyOfRange(0, 4).contentEquals("RIFF".toByteArray()) && bytes[7] == 0x0a.toByte()
bytes.copyOfRange(8, 12).contentEquals("WEBP".toByteArray()) ) {
if (!png && !jpeg && !gif && !webp) return false return ImageFormat.PNG
}
if (
size >= 3 &&
bytes[0] == 0xff.toByte() &&
bytes[1] == 0xd8.toByte() &&
bytes[2] == 0xff.toByte()
) {
return ImageFormat.JPEG
}
if (
size >= 6 &&
bytes[0] == 0x47.toByte() &&
bytes[1] == 0x49.toByte() &&
bytes[2] == 0x46.toByte() &&
bytes[3] == 0x38.toByte() &&
(bytes[4] == 0x37.toByte() || bytes[4] == 0x39.toByte()) &&
bytes[5] == 0x61.toByte()
) {
return ImageFormat.GIF
}
if (
size >= 12 &&
bytes[0] == 0x52.toByte() &&
bytes[1] == 0x49.toByte() &&
bytes[2] == 0x46.toByte() &&
bytes[3] == 0x46.toByte() &&
bytes[8] == 0x57.toByte() &&
bytes[9] == 0x45.toByte() &&
bytes[10] == 0x42.toByte() &&
bytes[11] == 0x50.toByte()
) {
return ImageFormat.WEBP
}
return null
}
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } private fun hasCompleteContainer(
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) file: File,
format: ImageFormat,
header: ByteArray,
length: Long
): Boolean = runCatching {
if (format == ImageFormat.WEBP) return@runCatching webpLength(header) == length
RandomAccessFile(file, "r").use { input ->
when (format) {
ImageFormat.PNG -> {
if (length < 12) return@use false
input.seek(length - 12)
input.readInt() == 0 &&
input.readInt() == 0x49454e44 &&
input.readInt() == 0xae426082.toInt()
}
ImageFormat.JPEG -> {
if (length < 2) return@use false
input.seek(length - 2)
input.readUnsignedByte() == 0xff && input.readUnsignedByte() == 0xd9
}
ImageFormat.GIF -> {
input.seek(length - 1)
input.readUnsignedByte() == 0x3b
}
ImageFormat.WEBP -> false
}
}
}.getOrDefault(false)
private fun hasCompleteContainer(bytes: ByteArray, format: ImageFormat): Boolean {
val size = bytes.size
return when (format) {
ImageFormat.PNG ->
size >= 12 &&
bytes[size - 8] == 0x49.toByte() &&
bytes[size - 7] == 0x45.toByte() &&
bytes[size - 6] == 0x4e.toByte() &&
bytes[size - 5] == 0x44.toByte() &&
bytes[size - 4] == 0xae.toByte() &&
bytes[size - 3] == 0x42.toByte() &&
bytes[size - 2] == 0x60.toByte() &&
bytes[size - 1] == 0x82.toByte()
ImageFormat.JPEG ->
size >= 2 &&
bytes[size - 2] == 0xff.toByte() &&
bytes[size - 1] == 0xd9.toByte()
ImageFormat.GIF -> size >= 1 && bytes[size - 1] == 0x3b.toByte()
ImageFormat.WEBP -> webpLength(bytes) == size.toLong()
}
}
private fun webpLength(header: ByteArray): Long {
val riffSize = (header[4].toLong() and 0xff) or
((header[5].toLong() and 0xff) shl 8) or
((header[6].toLong() and 0xff) shl 16) or
((header[7].toLong() and 0xff) shl 24)
return riffSize + 8
}
private fun hasSupportedDimensions(options: BitmapFactory.Options): Boolean {
val width = options.outWidth val width = options.outWidth
val height = options.outHeight val height = options.outHeight
return width in 1..4096 && height in 1..4096 && width.toLong() * height <= 16_777_216L return width in 1..4096 &&
height in 1..4096 &&
width.toLong() * height <= 16_777_216L
} }
private fun sha256(value: String): String = MessageDigest.getInstance("SHA-256") private fun sha256(value: String): String = MessageDigest.getInstance("SHA-256")
@@ -5,6 +5,7 @@ import android.content.ContentUris
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.database.Cursor
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
@@ -118,6 +119,12 @@ class WatchNextProvider internal constructor(
internal data class PreparedWatchNextItem(val metadata: WatchNextItem, val localPosterUri: Uri?) internal data class PreparedWatchNextItem(val metadata: WatchNextItem, val localPosterUri: Uri?)
private data class CommittedRow(
val id: Long,
val contentId: String,
val posterUri: Uri?
)
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private val artwork = SystemShelfArtworkStore(context.cacheDir) private val artwork = SystemShelfArtworkStore(context.cacheDir)
@@ -133,34 +140,71 @@ class WatchNextProvider internal constructor(
if (items.size > SystemShelfArtworkStore.MAX_ITEMS) return false if (items.size > SystemShelfArtworkStore.MAX_ITEMS) return false
val operationOwnership = ownership ?: claimOwnership(ownerId, generation) ?: return false val operationOwnership = ownership ?: claimOwnership(ownerId, generation) ?: return false
if (!isOperationActive()) return false if (!isOperationActive()) return false
val oldUris = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty().mapNotNull(Uri::parse).toSet()
val oldPackages = storedPackages()
val oldSchemaVersion = prefs.getInt(SHELF_SCHEMA_VERSION_KEY, 0)
val session = SystemShelfSyncSession( val session = SystemShelfSyncSession(
operationOwnership, operationOwnership,
syncDurationMillis syncDurationMillis
) )
val materializedFiles = LinkedHashSet<java.io.File>()
var committed = false val previousArtwork = snapshotCommittedArtwork(ownerId) ?: return false
if (!isOperationActive() || !session.isActive()) return false
val oldUris = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty().mapNotNull(Uri::parse).toSet()
val oldPackages = storedPackages()
val oldSchemaVersion = prefs.getInt(SHELF_SCHEMA_VERSION_KEY, 0)
val preparedArtwork = LinkedHashSet<SystemShelfArtworkStore.Prepared>()
val preparedBySource = HashMap<String, SystemShelfArtworkStore.Prepared?>()
try { try {
val prepared = items.map { item -> val preparedItems = items.map { item ->
if (!isOperationActive() || !session.isActive()) { val source = item.posterSourceUri
PreparedWatchNextItem(item, null) val localArtwork = if (source == null) {
null
} else { } else {
val materialized = item.posterSourceUri?.let { artwork.materialize(ownerId, it, session) } val candidate = if (preparedBySource.containsKey(source)) {
materialized?.file?.let(materializedFiles::add) preparedBySource[source]
PreparedWatchNextItem(item, materialized?.uri) } else {
artwork.prepare(ownerId, source, session).also { prepared ->
preparedBySource[source] = prepared
prepared?.let(preparedArtwork::add)
} }
} }
val newUris = prepared.mapNotNullTo(LinkedHashSet()) { it.localPosterUri } candidate?.materialized ?: previousArtwork[item.contentId]
}
PreparedWatchNextItem(item, localArtwork?.uri)
}
val newUris = preparedItems.mapNotNullTo(LinkedHashSet()) { it.localPosterUri }
val newPackages = consumerPackages() val newPackages = consumerPackages()
if (!isOperationActive() || !session.isActive()) return false if (!isOperationActive() || !session.isActive()) return false
committed = SystemShelfLifecycle.whileCurrent(session.ownership) {
return SystemShelfLifecycle.whileCurrent(session.ownership) {
if (!isOperationActive() || session.isExpired()) return@whileCurrent false if (!isOperationActive() || session.isExpired()) return@whileCurrent false
reconcileReadAccess(oldUris, oldPackages, newUris, newPackages) val publishedFiles = LinkedHashSet<java.io.File>()
preparedArtwork.forEach { candidate ->
val publication = artwork.publish(candidate)
if (publication == null) {
artwork.delete(publishedFiles)
return@whileCurrent false
}
if (publication.newlyPublished) publishedFiles += publication.materialized.file
}
val referencedFiles = LinkedHashSet<java.io.File>()
preparedItems.forEach { item ->
val posterUri = item.localPosterUri ?: return@forEach
val file = artwork.resolveOwned(ownerId, posterUri)
if (file == null) {
artwork.delete(publishedFiles)
return@whileCurrent false
}
referencedFiles += file
}
if (session.isExpired()) {
artwork.delete(publishedFiles)
return@whileCurrent false
}
grantReadAccess(newUris, newPackages)
if (session.isExpired()) { if (session.isExpired()) {
reconcileReadAccess(newUris, newPackages, oldUris, oldPackages) reconcileReadAccess(newUris, newPackages, oldUris, oldPackages)
artwork.delete(publishedFiles)
return@whileCurrent false return@whileCurrent false
} }
val preferencesCommitted = prefs.edit() val preferencesCommitted = prefs.edit()
@@ -170,9 +214,10 @@ class WatchNextProvider internal constructor(
.commit() .commit()
if (!preferencesCommitted) { if (!preferencesCommitted) {
reconcileReadAccess(newUris, newPackages, oldUris, oldPackages) reconcileReadAccess(newUris, newPackages, oldUris, oldPackages)
artwork.delete(publishedFiles)
return@whileCurrent false return@whileCurrent false
} }
if (session.isExpired() || !replaceRows(prepared)) { if (session.isExpired() || !replaceRows(preparedItems)) {
val rollback = prefs.edit() val rollback = prefs.edit()
.putStringSet(GRANTED_URIS, oldUris.mapTo(LinkedHashSet(), Uri::toString)) .putStringSet(GRANTED_URIS, oldUris.mapTo(LinkedHashSet(), Uri::toString))
.putStringSet(GRANTED_PACKAGES, oldPackages) .putStringSet(GRANTED_PACKAGES, oldPackages)
@@ -183,17 +228,16 @@ class WatchNextProvider internal constructor(
} }
rollback.commit() rollback.commit()
reconcileReadAccess(newUris, newPackages, oldUris, oldPackages) reconcileReadAccess(newUris, newPackages, oldUris, oldPackages)
artwork.delete(publishedFiles)
return@whileCurrent false return@whileCurrent false
} }
artwork.deleteExcept(materializedFiles) reconcileReadAccess(oldUris, oldPackages, newUris, newPackages)
artwork.deleteExcept(referencedFiles)
true true
} ?: false } ?: false
return committed
} finally { } finally {
if (!committed) { artwork.discard(preparedArtwork)
materializedFiles.forEach { it.delete() }
}
} }
} }
@@ -256,6 +300,95 @@ class WatchNextProvider internal constructor(
.commit() .commit()
} }
private fun snapshotCommittedArtwork(
ownerId: String
): Map<String, SystemShelfArtworkStore.Materialized>? {
val rows = queryCommittedRows() ?: return null
val snapshot = LinkedHashMap<String, SystemShelfArtworkStore.Materialized>()
rows.forEach { row ->
val uri = row.posterUri ?: return@forEach
val file = artwork.resolveOwned(ownerId, uri) ?: return@forEach
if (row.contentId !in snapshot) {
snapshot[row.contentId] = SystemShelfArtworkStore.Materialized(uri, file)
}
}
return snapshot
}
private fun queryCommittedRows(): List<CommittedRow>? {
return try {
val cursor = context.contentResolver.query(
TvContractCompat.WatchNextPrograms.CONTENT_URI,
arrayOf(
TvContractCompat.WatchNextPrograms._ID,
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID,
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_DATA,
TvContractCompat.WatchNextPrograms.COLUMN_INTENT_URI,
TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI
),
null,
null,
null
) ?: return null
cursor.use {
val idIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms._ID)
val providerIdIndex = it.getColumnIndex(
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID
)
val providerDataIndex = it.getColumnIndex(
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_DATA
)
val intentIndex = it.getColumnIndex(
TvContractCompat.WatchNextPrograms.COLUMN_INTENT_URI
)
val posterIndex = it.getColumnIndex(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI)
if (
idIndex < 0 ||
posterIndex < 0 ||
providerIdIndex < 0 &&
providerDataIndex < 0 &&
intentIndex < 0
) {
return null
}
val rows = ArrayList<CommittedRow>(it.count)
while (it.moveToNext()) {
val providerId = cursorString(it, providerIdIndex)
?.takeIf(String::isNotBlank)
val providerData = cursorString(it, providerDataIndex)
?.takeIf(String::isNotBlank)
val contentId = providerId
?: providerData
?: contentIdFromIntent(cursorString(it, intentIndex))
?: continue
val posterUri = it.getString(posterIndex)
?.takeIf(String::isNotBlank)
?.let(Uri::parse)
rows += CommittedRow(it.getLong(idIndex), contentId, posterUri)
}
rows
}
} catch (_: Exception) {
Log.e(TAG, "Failed to query committed Watch Next programs")
null
}
}
private fun cursorString(cursor: Cursor, index: Int): String? {
if (index < 0 || cursor.isNull(index)) return null
return if (cursor.getType(index) == Cursor.FIELD_TYPE_BLOB) {
cursor.getBlob(index)?.toString(Charsets.UTF_8)
} else {
cursor.getString(index)
}
}
private fun contentIdFromIntent(value: String?): String? {
val uri = value?.let { runCatching { Uri.parse(it) }.getOrNull() } ?: return null
if (uri.scheme != "plezy" || uri.authority != "play") return null
return uri.getQueryParameter("content_id")?.takeIf(String::isNotBlank)
}
internal fun removeItem( internal fun removeItem(
ownerId: String, ownerId: String,
generation: Long, generation: Long,
@@ -266,46 +399,27 @@ class WatchNextProvider internal constructor(
val operationOwnership = ownership ?: claimOwnership(ownerId, generation) ?: return false val operationOwnership = ownership ?: claimOwnership(ownerId, generation) ?: return false
return SystemShelfLifecycle.whileCurrent(operationOwnership) { return SystemShelfLifecycle.whileCurrent(operationOwnership) {
if (!isOperationActive()) return@whileCurrent false if (!isOperationActive()) return@whileCurrent false
removeItemOwned(contentId) removeItemOwned(ownerId, contentId)
} ?: false } ?: false
} }
private fun removeItemOwned(contentId: String): Boolean { private fun removeItemOwned(ownerId: String, contentId: String): Boolean {
return try { return try {
val cursor = context.contentResolver.query( val rows = queryCommittedRows() ?: return false
val target = rows.firstOrNull { it.contentId == contentId } ?: return false
val deleteUri = ContentUris.withAppendedId(
TvContractCompat.WatchNextPrograms.CONTENT_URI, TvContractCompat.WatchNextPrograms.CONTENT_URI,
arrayOf( target.id
TvContractCompat.WatchNextPrograms._ID,
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID,
TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI
),
null,
null,
null
) )
cursor?.use { if (context.contentResolver.delete(deleteUri, null, null) <= 0) return false
val idIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms._ID)
val providerIdIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID) val poster = target.posterUri ?: return true
val posterIndex = it.getColumnIndex(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI) if (rows.any { it.id != target.id && it.posterUri == poster }) return true
if (idIndex < 0 || providerIdIndex < 0) return false
while (it.moveToNext()) {
if (it.getString(providerIdIndex) == contentId) {
val deleteUri = ContentUris.withAppendedId(TvContractCompat.WatchNextPrograms.CONTENT_URI, it.getLong(idIndex))
context.contentResolver.delete(deleteUri, null, null)
if (posterIndex >= 0) {
val poster = it.getString(posterIndex)?.let(Uri::parse)
if (poster != null) {
revokeReadAccess(setOf(poster)) revokeReadAccess(setOf(poster))
artwork.resolve(poster)?.delete()
val remaining = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty() - poster.toString() val remaining = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty() - poster.toString()
prefs.edit().putStringSet(GRANTED_URIS, remaining).commit() prefs.edit().putStringSet(GRANTED_URIS, remaining).commit()
} artwork.resolveOwned(ownerId, poster)?.let { artwork.delete(setOf(it)) }
} true
return true
}
}
}
false
} catch (_: Exception) { } catch (_: Exception) {
Log.e(TAG, "Failed to remove Watch Next item") Log.e(TAG, "Failed to remove Watch Next item")
false false
@@ -316,8 +430,14 @@ class WatchNextProvider internal constructor(
val operations = ArrayList<ContentProviderOperation>(items.size + 1) val operations = ArrayList<ContentProviderOperation>(items.size + 1)
operations += ContentProviderOperation.newDelete(TvContractCompat.WatchNextPrograms.CONTENT_URI).build() operations += ContentProviderOperation.newDelete(TvContractCompat.WatchNextPrograms.CONTENT_URI).build()
items.forEach { item -> items.forEach { item ->
val values = buildProgram(item).toContentValues().apply {
put(
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_DATA,
item.metadata.contentId.toByteArray(Charsets.UTF_8)
)
}
operations += ContentProviderOperation.newInsert(TvContractCompat.WatchNextPrograms.CONTENT_URI) operations += ContentProviderOperation.newInsert(TvContractCompat.WatchNextPrograms.CONTENT_URI)
.withValues(buildProgram(item).toContentValues()) .withValues(values)
.build() .build()
} }
context.contentResolver.applyBatch(TvContractCompat.AUTHORITY, operations) context.contentResolver.applyBatch(TvContractCompat.AUTHORITY, operations)
@@ -366,8 +486,13 @@ class WatchNextProvider internal constructor(
} }
} }
} }
currentPackages.forEach { packageName -> grantReadAccess(currentUris, currentPackages)
currentUris.forEach { uri -> }
private fun grantReadAccess(uris: Set<Uri>, packages: Set<String>) {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
packages.forEach { packageName ->
uris.forEach { uri ->
runCatching { context.grantUriPermission(packageName, uri, flags) } runCatching { context.grantUriPermission(packageName, uri, flags) }
} }
} }
@@ -191,8 +191,7 @@ class ExoPlayerFallbackTerminalTest {
} }
} }
private fun getField(target: Any, name: String): Any? = private fun getField(target: Any, name: String): Any? = target.javaClass.getDeclaredField(name).apply { isAccessible = true }.get(target)
target.javaClass.getDeclaredField(name).apply { isAccessible = true }.get(target)
private fun requestFallback(core: ExoPlayerCore, mediaGeneration: Int): Boolean { private fun requestFallback(core: ExoPlayerCore, mediaGeneration: Int): Boolean {
val method = ExoPlayerCore::class.java.getDeclaredMethod( val method = ExoPlayerCore::class.java.getDeclaredMethod(
@@ -1,11 +1,14 @@
package com.edde746.plezy.exoplayer package com.edde746.plezy.exoplayer
import android.app.Activity import android.app.Activity
import android.media.AudioManager
import android.os.Handler
import android.os.Looper import android.os.Looper
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewTreeObserver import android.view.ViewTreeObserver
import android.widget.FrameLayout import android.widget.FrameLayout
import com.edde746.plezy.mpv.MpvPlayerCore import com.edde746.plezy.mpv.MpvPlayerCore
import com.edde746.plezy.shared.AudioFocusManager
import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
@@ -111,6 +114,54 @@ class ExoPlayerPluginTest {
assertEquals(null, result.successValue) assertEquals(null, result.successValue)
} }
@Test
fun fallbackPropertyCancelledByTeardownReturnsNotInitializedOnce() {
val writerEntered = CountDownLatch(1)
val releaseWriter = CountDownLatch(1)
val plugin = fallbackPlugin { _, _ ->
writerEntered.countDown()
releaseWriter.await(2, TimeUnit.SECONDS)
}
val property = RecordingResult()
plugin.onMethodCall(MethodCall("setRate", mapOf("rate" to 1.5)), property)
assertTrue("fallback property writer never started", writerEntered.await(2, TimeUnit.SECONDS))
val dispose = RecordingResult()
plugin.onMethodCall(MethodCall("dispose", null), dispose)
awaitCompletion(dispose)
releaseWriter.countDown()
awaitCompletion(property)
assertEquals(1, property.completionCount)
assertEquals("NOT_INITIALIZED", property.errorCode)
assertEquals(null, property.successValue)
}
@Test
fun staleSuccessfulFallbackPropertyReturnsNotInitializedOnce() {
val writerEntered = CountDownLatch(1)
val releaseWriter = CountDownLatch(1)
val plugin = fallbackPlugin { _, _ ->
writerEntered.countDown()
releaseWriter.await(2, TimeUnit.SECONDS)
}
val core = getField(plugin, "mpvCore") as MpvPlayerCore
val result = RecordingResult()
plugin.onMethodCall(MethodCall("setRate", mapOf("rate" to 1.5)), result)
assertTrue("fallback property writer never started", writerEntered.await(2, TimeUnit.SECONDS))
setField(plugin, "usingMpvFallback", false)
releaseWriter.countDown()
awaitCompletion(result)
assertEquals(1, result.completionCount)
assertEquals("NOT_INITIALIZED", result.errorCode)
assertEquals(null, result.successValue)
core.dispose()
}
@Test @Test
fun fallbackWithoutCoreReturnsNotInitializedOnce() { fun fallbackWithoutCoreReturnsNotInitializedOnce() {
val plugin = ExoPlayerPlugin() val plugin = ExoPlayerPlugin()
@@ -473,6 +524,35 @@ class ExoPlayerPluginTest {
assertEquals(listOf("end-file"), sink.eventNames) assertEquals(listOf("end-file"), sink.eventNames)
} }
@Test
fun fallbackPlayAfterFocusLossUsesExplicitResumeContract() {
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 }
val focusManager = testAudioFocusManager(activity, core)
core.setPrivateField("audioFocusManager", focusManager)
core.setPrivateField("desiredPaused", false)
core.setPrivateField("cachedPaused", false)
val plugin = reusedFallbackPlugin(activity, core)
dispatchAudioFocusChange(focusManager, AudioManager.AUDIOFOCUS_LOSS)
assertTrue(awaitQueueEntry(writes, "pause" to "yes"))
assertEquals(true, core.getPrivateField("pausedForAudioFocusLoss"))
setNextAudioFocusRequestResponse(focusManager, AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
val result = RecordingResult()
plugin.onMethodCall(MethodCall("play", null), result)
awaitCompletion(result)
assertEquals(1, result.completionCount)
assertNull(result.errorCode)
assertEquals(listOf("pause" to "yes", "pause" to "no"), writes.toList())
assertEquals(1, writes.count { it == "pause" to "no" })
assertEquals(false, core.getPrivateField("pausedForAudioFocusLoss"))
assertEquals(false, core.getPrivateField("deferredResumeRequested"))
core.dispose()
}
@Test @Test
fun initialHeldFallbackSynchronouslyBlocksFocusAndSurfaceResumeWithoutPausePropertyWrite() { fun initialHeldFallbackSynchronouslyBlocksFocusAndSurfaceResumeWithoutPausePropertyWrite() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get() val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
@@ -790,6 +870,46 @@ class ExoPlayerPluginTest {
} }
} }
private fun testAudioFocusManager(
activity: Activity,
core: MpvPlayerCore
): AudioFocusManager = AudioFocusManager(
context = activity,
handler = Handler(Looper.getMainLooper()),
onPause = {
MpvPlayerCore::class.java.getDeclaredMethod("pauseForAudioFocusLoss").apply {
isAccessible = true
invoke(core)
}
},
onResume = {
MpvPlayerCore::class.java.getDeclaredMethod(
"resumeAfterAudioFocusGain",
String::class.java
).apply {
isAccessible = true
invoke(core, "audio focus gain")
}
},
isPaused = { core.getPrivateField("desiredPaused") as Boolean }
)
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 setNextAudioFocusRequestResponse(manager: AudioFocusManager, response: Int) {
val audioManager = AudioFocusManager::class.java.getDeclaredField("audioManager").run {
isAccessible = true
get(manager) as AudioManager
}
shadowOf(audioManager).setNextFocusRequestResponse(response)
}
private fun invokeAutoResume(core: MpvPlayerCore, reason: String) { private fun invokeAutoResume(core: MpvPlayerCore, reason: String) {
MpvPlayerCore::class.java.getDeclaredMethod("requestAutoResume", String::class.java).apply { MpvPlayerCore::class.java.getDeclaredMethod("requestAutoResume", String::class.java).apply {
isAccessible = true isAccessible = true
@@ -287,6 +287,221 @@ class MpvPlayerPluginTest {
assertEquals(listOf("pause" to "yes"), writes.toList()) assertEquals(listOf("pause" to "yes"), writes.toList())
} }
@Test
fun activeFocusLossExplicitResumeReacquiresFocusAndWritesOnce() {
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
val focusResumeCallbacks = AtomicInteger()
val completionCount = 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.count { it == "pause" to "yes" } == 1 }
assertEquals(true, getBoolean(core, "pausedForAudioFocusLoss"))
setNextAudioFocusRequestResponse(focusManager, AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
var outcome: Result<Unit>? = null
core.setProperty("pause", "no") {
completionCount.incrementAndGet()
outcome = it
}
awaitCondition { outcome != null }
assertTrue(outcome?.isSuccess == true)
assertEquals(1, completionCount.get())
assertEquals(listOf("pause" to "yes", "pause" to "no"), writes.toList())
assertEquals(1, writes.count { it == "pause" to "no" })
assertEquals(false, getBoolean(core, "pausedForAudioFocusLoss"))
assertEquals(false, getBoolean(core, "deferredResumeRequested"))
assertEquals(false, getBoolean(core, "cachedPaused"))
}
@Test
fun deniedExplicitResumeFocusRequestFailsWithoutWriting() {
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
val focusResumeCallbacks = AtomicInteger()
val core = testCore { name, value -> writes += name to value }
val focusManager = testAudioFocusManager(core, focusResumeCallbacks)
val plugin = MpvAudioPlayerPlugin()
setCoreField(core, "audioFocusManager", focusManager)
installCore(plugin, core)
setBoolean(core, "desiredPaused", false)
setBoolean(core, "cachedPaused", false)
dispatchAudioFocusChange(focusManager, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)
awaitCondition { writes.contains("pause" to "yes") }
assertEquals(true, getBoolean(core, "pausedForAudioFocusLoss"))
val coreCompletionCount = AtomicInteger()
var coreOutcome: Result<Unit>? = null
setNextAudioFocusRequestResponse(focusManager, AudioManager.AUDIOFOCUS_REQUEST_FAILED)
core.setProperty("pause", "no") {
coreCompletionCount.incrementAndGet()
coreOutcome = it
}
awaitCondition { coreOutcome != null }
assertTrue(coreOutcome?.isFailure == true)
assertFalse(coreOutcome?.exceptionOrNull() is CancellationException)
assertEquals(1, coreCompletionCount.get())
assertEquals(0, writes.count { it == "pause" to "no" })
assertEquals(true, getBoolean(core, "pausedForAudioFocusLoss"))
assertEquals(true, getBoolean(core, "cachedPaused"))
setNextAudioFocusRequestResponse(focusManager, AudioManager.AUDIOFOCUS_REQUEST_FAILED)
val denied = RecordingResult()
plugin.onMethodCall(
MethodCall("setProperty", mapOf("name" to "pause", "value" to "no")),
denied
)
awaitCompletion(denied)
assertEquals(1, denied.completionCount)
assertEquals("SET_PROPERTY_FAILED", denied.errorCode)
assertEquals(0, writes.count { it == "pause" to "no" })
assertEquals(true, getBoolean(core, "pausedForAudioFocusLoss"))
setNextAudioFocusRequestResponse(focusManager, AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
val retry = RecordingResult()
plugin.onMethodCall(
MethodCall("setProperty", mapOf("name" to "pause", "value" to "no")),
retry
)
awaitCompletion(retry)
assertEquals(1, retry.completionCount)
assertNull(retry.errorCode)
assertEquals(1, writes.count { it == "pause" to "no" })
assertEquals(false, getBoolean(core, "pausedForAudioFocusLoss"))
assertEquals(false, getBoolean(core, "cachedPaused"))
}
@Test
fun delayedFocusGainAfterExplicitResumeDoesNotWriteAgain() {
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
val explicitResumeStarted = CountDownLatch(1)
val releaseExplicitResume = CountDownLatch(1)
val duplicateResume = CountDownLatch(1)
val resumeWrites = AtomicInteger()
val focusResumeCallbacks = AtomicInteger()
val core = testCore { name, value ->
writes += name to value
if (name == "pause" && value == "no") {
if (resumeWrites.incrementAndGet() == 1) {
explicitResumeStarted.countDown()
releaseExplicitResume.await(1, TimeUnit.SECONDS)
} else {
duplicateResume.countDown()
}
}
}
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") }
setNextAudioFocusRequestResponse(focusManager, AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
var outcome: Result<Unit>? = null
core.setProperty("pause", "no") { outcome = it }
assertTrue(explicitResumeStarted.await(1, TimeUnit.SECONDS))
assertEquals(false, getBoolean(core, "pausedForAudioFocusLoss"))
dispatchAudioFocusChange(focusManager, AudioManager.AUDIOFOCUS_GAIN)
assertEquals(1, focusResumeCallbacks.get())
releaseExplicitResume.countDown()
awaitCondition { outcome != null }
shadowOf(Looper.getMainLooper()).idle()
assertFalse(duplicateResume.await(100, TimeUnit.MILLISECONDS))
assertTrue(outcome?.isSuccess == true)
assertEquals(1, resumeWrites.get())
assertEquals(listOf("pause" to "yes", "pause" to "no"), writes.toList())
}
@Test
fun freshFocusLossAfterExplicitClaimPreventsThePendingResumeWrite() {
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
val blockerStarted = CountDownLatch(1)
val releaseBlocker = CountDownLatch(1)
val focusResumeCallbacks = AtomicInteger()
val core = testCore { name, value ->
writes += name to value
if (name == "block") {
blockerStarted.countDown()
releaseBlocker.await(1, TimeUnit.SECONDS)
}
}
val focusManager = testAudioFocusManager(core, focusResumeCallbacks)
setCoreField(core, "audioFocusManager", focusManager)
setBoolean(core, "desiredPaused", false)
setBoolean(core, "cachedPaused", false)
dispatchAudioFocusChange(focusManager, AudioManager.AUDIOFOCUS_LOSS)
awaitCondition { writes.count { it == "pause" to "yes" } == 1 }
var blockerOutcome: Result<Unit>? = null
core.setProperty("block", "value") { blockerOutcome = it }
assertTrue(blockerStarted.await(1, TimeUnit.SECONDS))
setNextAudioFocusRequestResponse(focusManager, AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
val resumeCompletionCount = AtomicInteger()
var resumeOutcome: Result<Unit>? = null
core.setProperty("pause", "no") {
resumeCompletionCount.incrementAndGet()
resumeOutcome = it
}
assertEquals(false, getBoolean(core, "pausedForAudioFocusLoss"))
dispatchAudioFocusChange(focusManager, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)
assertEquals(true, getBoolean(core, "pausedForAudioFocusLoss"))
releaseBlocker.countDown()
awaitCondition {
blockerOutcome != null &&
resumeOutcome != null &&
writes.count { it == "pause" to "yes" } == 2
}
assertTrue(blockerOutcome?.isSuccess == true)
assertTrue(resumeOutcome?.isFailure == true)
assertEquals(1, resumeCompletionCount.get())
assertEquals(0, writes.count { it == "pause" to "no" })
assertEquals(true, getBoolean(core, "pausedForAudioFocusLoss"))
assertEquals(true, getBoolean(core, "cachedPaused"))
}
@Test
fun explicitVideoResumeAfterFocusLossRemainsDeferredOnlyForSurface() {
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
val focusResumeCallbacks = AtomicInteger()
val completionCount = AtomicInteger()
val core = testVideoCore { 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") }
setNextAudioFocusRequestResponse(focusManager, AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
var outcome: Result<Unit>? = null
core.setProperty("pause", "no") {
completionCount.incrementAndGet()
outcome = it
}
awaitCondition { outcome != null }
assertTrue(outcome?.isSuccess == true)
assertEquals(1, completionCount.get())
assertEquals(listOf("pause" to "yes"), writes.toList())
assertEquals(false, getBoolean(core, "pausedForAudioFocusLoss"))
assertEquals(true, getBoolean(core, "deferredResumeRequested"))
assertEquals(true, getBoolean(core, "cachedPaused"))
}
@Test @Test
fun pausedFocusLossAndGainWithoutResumeCallbackAllowsOneExplicitResume() { fun pausedFocusLossAndGainWithoutResumeCallbackAllowsOneExplicitResume() {
val writes = ConcurrentLinkedQueue<Pair<String, String>>() val writes = ConcurrentLinkedQueue<Pair<String, String>>()
@@ -624,6 +839,14 @@ class MpvPlayerPluginTest {
listener.onAudioFocusChange(focusChange) listener.onAudioFocusChange(focusChange)
} }
private fun setNextAudioFocusRequestResponse(manager: AudioFocusManager, response: Int) {
val audioManager = AudioFocusManager::class.java.getDeclaredField("audioManager").run {
isAccessible = true
get(manager) as AudioManager
}
shadowOf(audioManager).setNextFocusRequestResponse(response)
}
private fun invokeAutoResume(core: MpvPlayerCore, reason: String) { private fun invokeAutoResume(core: MpvPlayerCore, reason: String) {
MpvPlayerCore::class.java.getDeclaredMethod("requestAutoResume", String::class.java).apply { MpvPlayerCore::class.java.getDeclaredMethod("requestAutoResume", String::class.java).apply {
isAccessible = true isAccessible = true
@@ -13,6 +13,7 @@ import android.content.pm.ActivityInfo
import android.content.pm.ApplicationInfo import android.content.pm.ApplicationInfo
import android.content.pm.ResolveInfo import android.content.pm.ResolveInfo
import android.database.Cursor import android.database.Cursor
import android.database.MatrixCursor
import android.net.Uri import android.net.Uri
import android.os.ParcelFileDescriptor.AutoCloseInputStream import android.os.ParcelFileDescriptor.AutoCloseInputStream
import androidx.tvprovider.media.tv.TvContractCompat import androidx.tvprovider.media.tv.TvContractCompat
@@ -20,6 +21,7 @@ import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import java.io.Closeable
import java.lang.reflect.Proxy import java.lang.reflect.Proxy
import java.net.InetAddress import java.net.InetAddress
import java.net.ServerSocket import java.net.ServerSocket
@@ -31,6 +33,7 @@ import java.util.concurrent.Executor
import java.util.concurrent.ExecutorService import java.util.concurrent.ExecutorService
import java.util.concurrent.RejectedExecutionException import java.util.concurrent.RejectedExecutionException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
import kotlin.concurrent.thread import kotlin.concurrent.thread
import org.junit.After import org.junit.After
@@ -222,6 +225,7 @@ class WatchNextProviderTest {
try { try {
assertTrue(secondRequestReceived.await(2, TimeUnit.SECONDS)) assertTrue(secondRequestReceived.await(2, TimeUnit.SECONDS))
assertEquals(1, artworkFiles().size) assertEquals(1, artworkFiles().size)
assertTrue(artworkFiles().single().name.endsWith(".tmp"))
SystemShelfLifecycle.acquire() SystemShelfLifecycle.acquire()
releaseSecondResponse.countDown() releaseSecondResponse.countDown()
worker.join(2_000) worker.join(2_000)
@@ -440,7 +444,7 @@ class WatchNextProviderTest {
val ownership = SystemShelfLifecycle.claim(SystemShelfLifecycle.acquire(), "owner", 1)!! val ownership = SystemShelfLifecycle.claim(SystemShelfLifecycle.acquire(), "owner", 1)!!
val session = SystemShelfSyncSession(ownership, 2_000, budget = budget) val session = SystemShelfSyncSession(ownership, 2_000, budget = budget)
assertNull(SystemShelfArtworkStore(context.cacheDir).materialize("owner", source, session)) assertNull(SystemShelfArtworkStore(context.cacheDir).prepare("owner", source, session))
assertEquals(malformed.size.toLong(), budget.consumed) assertEquals(malformed.size.toLong(), budget.consumed)
assertEquals(100 - malformed.size, budget.remaining) assertEquals(100 - malformed.size, budget.remaining)
} }
@@ -452,7 +456,7 @@ class WatchNextProviderTest {
val ownership = SystemShelfLifecycle.claim(SystemShelfLifecycle.acquire(), "owner", 1)!! val ownership = SystemShelfLifecycle.claim(SystemShelfLifecycle.acquire(), "owner", 1)!!
val session = SystemShelfSyncSession(ownership, 2_000, budget = budget) val session = SystemShelfSyncSession(ownership, 2_000, budget = budget)
assertNull(SystemShelfArtworkStore(context.cacheDir).materialize("owner", source, session)) assertNull(SystemShelfArtworkStore(context.cacheDir).prepare("owner", source, session))
assertEquals(imageBytes.size.toLong(), budget.consumed) assertEquals(imageBytes.size.toLong(), budget.consumed)
assertEquals(100 - imageBytes.size, budget.remaining) assertEquals(100 - imageBytes.size, budget.remaining)
} }
@@ -480,13 +484,30 @@ class WatchNextProviderTest {
val inactive = registerHandler(homeIntent, "inactive.home.launcher") val inactive = registerHandler(homeIntent, "inactive.home.launcher")
selectDefaultHome(selected, selected, inactive) selectDefaultHome(selected, selected, inactive)
val owner = "a".repeat(64) val owner = "a".repeat(64)
val key = "${"b".repeat(32)}.art" val legacyKey = "${"b".repeat(32)}.art"
context.cacheDir.resolve("system_shelf_artwork/$owner").mkdirs() val contentKey = "${"c".repeat(64)}.art"
context.cacheDir.resolve("system_shelf_artwork/$owner/$key").writeBytes(imageBytes) val corruptKey = "${"d".repeat(64)}.art"
val valid = SystemShelfArtworkStore(context.cacheDir).contentUri(owner, key) val directory = context.cacheDir.resolve("system_shelf_artwork/$owner").apply { mkdirs() }
val invalid = Uri.parse("content://${SystemShelfArtworkProvider.AUTHORITY}/art/not/confined.art") directory.resolve(legacyKey).writeBytes(imageBytes)
directory.resolve(contentKey).writeBytes(imageBytes)
directory.resolve(corruptKey).writeText("corrupt")
val store = SystemShelfArtworkStore(context.cacheDir)
val legacy = store.contentUri(owner, legacyKey)
val contentAddressed = store.contentUri(owner, contentKey)
val corrupt = store.contentUri(owner, corruptKey)
val malformed = Uri.parse(
"content://${SystemShelfArtworkProvider.AUTHORITY}/art/$owner/${"e".repeat(48)}.art"
)
context.getSharedPreferences("system_shelf_state", 0).edit() context.getSharedPreferences("system_shelf_state", 0).edit()
.putStringSet("granted_uris", setOf(valid.toString(), invalid.toString())) .putStringSet(
"granted_uris",
setOf(
legacy.toString(),
contentAddressed.toString(),
corrupt.toString(),
malformed.toString()
)
)
.putInt("shelf_schema_version", WatchNextProvider.SHELF_SCHEMA_VERSION) .putInt("shelf_schema_version", WatchNextProvider.SHELF_SCHEMA_VERSION)
.commit() .commit()
val recordingContext = RecordingGrantContext(context) val recordingContext = RecordingGrantContext(context)
@@ -495,12 +516,24 @@ class WatchNextProviderTest {
.onReceive(recordingContext, Intent(Intent.ACTION_BOOT_COMPLETED)) .onReceive(recordingContext, Intent(Intent.ACTION_BOOT_COMPLETED))
assertEquals( assertEquals(
listOf(Grant("selected.home.launcher", valid, Intent.FLAG_GRANT_READ_URI_PERMISSION)), setOf(
recordingContext.grants Grant("selected.home.launcher", legacy, Intent.FLAG_GRANT_READ_URI_PERMISSION),
Grant("selected.home.launcher", contentAddressed, Intent.FLAG_GRANT_READ_URI_PERMISSION)
),
recordingContext.grants.toSet()
) )
assertEquals( assertEquals(
setOf(valid.toString()), setOf(legacy.toString(), contentAddressed.toString()),
context.getSharedPreferences("system_shelf_state", 0).getStringSet("granted_uris", emptySet()) context.getSharedPreferences("system_shelf_state", 0)
.getStringSet("granted_uris", emptySet())
)
assertArrayEquals(imageBytes, openArtwork(legacy))
assertArrayEquals(imageBytes, openArtwork(contentAddressed))
assertNull(store.resolve(corrupt))
assertEquals(
WatchNextProvider.SHELF_SCHEMA_VERSION,
context.getSharedPreferences("system_shelf_state", 0)
.getInt("shelf_schema_version", 0)
) )
} }
@@ -538,9 +571,12 @@ class WatchNextProviderTest {
@Test @Test
fun packageUpdatePreservesRowsAndArtworkAtCurrentShelfSchema() { fun packageUpdatePreservesRowsAndArtworkAtCurrentShelfSchema() {
val file = context.cacheDir.resolve("system_shelf_artwork/${"a".repeat(64)}/${"b".repeat(32)}.art") val directory = context.cacheDir.resolve("system_shelf_artwork/${"a".repeat(64)}")
file.parentFile?.mkdirs() .apply { mkdirs() }
file.writeBytes(imageBytes) val legacyFile = directory.resolve("${"b".repeat(32)}.art")
val contentAddressedFile = directory.resolve("${"c".repeat(64)}.art")
legacyFile.writeBytes(imageBytes)
contentAddressedFile.writeBytes(imageBytes)
context.getSharedPreferences("system_shelf_state", 0).edit() context.getSharedPreferences("system_shelf_state", 0).edit()
.putInt("shelf_schema_version", WatchNextProvider.SHELF_SCHEMA_VERSION) .putInt("shelf_schema_version", WatchNextProvider.SHELF_SCHEMA_VERSION)
.commit() .commit()
@@ -549,7 +585,9 @@ class WatchNextProviderTest {
receiver.onReceive(context, Intent(Intent.ACTION_MY_PACKAGE_REPLACED)) receiver.onReceive(context, Intent(Intent.ACTION_MY_PACKAGE_REPLACED))
assertEquals(0, tvProvider.deleteCount) assertEquals(0, tvProvider.deleteCount)
assertTrue(file.isFile) assertTrue(legacyFile.isFile)
assertTrue(contentAddressedFile.isFile)
assertEquals(1, WatchNextProvider.SHELF_SCHEMA_VERSION)
} }
@Test @Test
@@ -564,18 +602,525 @@ class WatchNextProviderTest {
@Test @Test
fun providerFailureDeletesNewArtworkAndPreservesCommittedArtwork() { fun providerFailureDeletesNewArtworkAndPreservesCommittedArtwork() {
val provider = WatchNextProvider(context) ScriptedHttpServer(
withServer("image/png", imageBytes) { source -> listOf(
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(source)))) ScriptedResponse(body = imageBytes),
} ScriptedResponse(body = imageBytes)
val committedArtwork = artworkFiles().single().canonicalFile )
).use { server ->
val homeIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
val selectedHome = registerHandler(homeIntent, "selected.home.launcher")
selectDefaultHome(selectedHome, selectedHome)
val grantContext = RecordingGrantContext(context)
val provider = WatchNextProvider(grantContext)
val sourceA = "${server.baseUrl}/a"
val sourceB = "${server.baseUrl}/b"
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(sourceA))))
val committedPoster = committedPoster()!!
val committedArtwork = SystemShelfArtworkStore(context.cacheDir)
.resolveOwned("owner-a", committedPoster)!!
.canonicalFile
tvProvider.failBatch = true tvProvider.failBatch = true
withServer("image/png", imageBytes) { source -> assertFalse(provider.syncWatchNextPrograms("owner-a", 2, listOf(item(sourceB))))
assertFalse(provider.syncWatchNextPrograms("owner-a", 2, listOf(item(source))))
assertEquals(2, server.requestCount.get())
assertEquals(committedPoster, committedPoster())
assertEquals(setOf(committedArtwork), artworkFiles().mapTo(HashSet()) { it.canonicalFile })
assertArrayEquals(imageBytes, openArtwork(committedPoster))
assertFalse(artworkFiles().any { it.name.endsWith(".tmp") })
val uncommittedPoster = grantContext.grants
.map(Grant::uri)
.first { it != committedPoster }
val revokedUris = grantContext.packageRevocations.map(PackageRevocation::uri) +
grantContext.uriWideRevocations
assertFalse(committedPoster in revokedUris)
assertTrue(uncommittedPoster in revokedUris)
assertEquals(
setOf(committedPoster.toString()),
context.getSharedPreferences("system_shelf_state", 0)
.getStringSet("granted_uris", emptySet())
)
}
} }
assertEquals(setOf(committedArtwork), artworkFiles().mapTo(HashSet()) { it.canonicalFile }) @Test
fun repeatedSameSourceSyncReusesValidatedArtwork() {
ScriptedHttpServer(
listOf(
ScriptedResponse(body = imageBytes),
ScriptedResponse(body = imageBytes)
)
).use { server ->
val homeIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
val selectedHome = registerHandler(homeIntent, "selected.home.launcher")
selectDefaultHome(selectedHome, selectedHome)
val grantContext = RecordingGrantContext(context)
val provider = WatchNextProvider(grantContext)
val store = SystemShelfArtworkStore(context.cacheDir)
val stableSource = "${server.baseUrl}/stable"
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(stableSource))))
val firstPoster = committedPoster()!!
val firstFile = store.resolveOwned("owner-a", firstPoster)!!.canonicalFile
val stableTimestamp = 1_600_000_000_000L
assertTrue(firstFile.setLastModified(stableTimestamp))
assertTrue(Regex("[a-f0-9]{64}\\.art").matches(firstPoster.lastPathSegment.orEmpty()))
assertTrue(
provider.syncWatchNextPrograms(
"owner-a",
2,
listOf(item(stableSource).copy(title = "Updated title", lastPlaybackPosition = 20))
)
)
assertEquals(1, server.requestCount.get())
assertEquals(firstPoster, committedPoster())
assertEquals(setOf(firstFile), artworkFiles().mapTo(HashSet()) { it.canonicalFile })
assertEquals(stableTimestamp, firstFile.lastModified())
assertFalse(artworkFiles().any { it.name.endsWith(".tmp") })
assertTrue(grantContext.uriWideRevocations.isEmpty())
assertTrue(grantContext.packageRevocations.isEmpty())
val zeroBudget = SystemShelfArtworkStore.Budget(0)
val cachedOwnership = provider.claimOwnership("owner-a", 3)!!
val cached = store.prepare(
"owner-a",
stableSource,
SystemShelfSyncSession(cachedOwnership, 2_000, budget = zeroBudget)
)
assertTrue(cached is SystemShelfArtworkStore.Prepared.Existing)
assertEquals(0L, zeroBudget.consumed)
assertEquals(0, zeroBudget.remaining)
val sharedSource = "${server.baseUrl}/shared"
assertTrue(
provider.syncWatchNextPrograms(
"owner-a",
4,
listOf(
item(sharedSource).copy(contentId = "first"),
item(sharedSource).copy(contentId = "second")
)
)
)
assertEquals(2, server.requestCount.get())
val sharedPosters = tvProvider.inserted.map {
it.getAsString(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI)
}.toSet()
assertEquals(1, sharedPosters.size)
val sharedPoster = Uri.parse(sharedPosters.single())
assertEquals(1, artworkFiles().size)
assertArrayEquals(imageBytes, openArtwork(sharedPoster))
grantContext.uriWideRevocations.clear()
grantContext.packageRevocations.clear()
val firstRemoved = provider.removeItem("owner-a", 5, "first")
assertEquals(listOf("first", "second"), tvProvider.lastQueryContentIds)
assertEquals(
listOf(
TvContractCompat.WatchNextPrograms._ID,
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID,
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_DATA,
TvContractCompat.WatchNextPrograms.COLUMN_INTENT_URI,
TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI
),
tvProvider.lastQueryProjection
)
assertEquals(
listOf(sharedPoster.toString(), sharedPoster.toString()),
tvProvider.lastQueryPosters
)
assertTrue(firstRemoved)
assertTrue(grantContext.uriWideRevocations.isEmpty())
assertTrue(grantContext.packageRevocations.isEmpty())
assertEquals(listOf("second"), tvProvider.contentIds())
assertArrayEquals(imageBytes, openArtwork(sharedPoster))
assertEquals(
setOf(sharedPoster.toString()),
context.getSharedPreferences("system_shelf_state", 0)
.getStringSet("granted_uris", emptySet())
)
assertTrue(provider.removeItem("owner-a", 6, "second"))
val finalReferenceRevocations =
grantContext.packageRevocations.map(PackageRevocation::uri) +
grantContext.uriWideRevocations
assertEquals(listOf(sharedPoster), finalReferenceRevocations)
assertTrue(artworkFiles().isEmpty())
assertTrue(
runCatching {
Robolectric.buildContentProvider(SystemShelfArtworkProvider::class.java)
.create()
.get()
.openFile(sharedPoster, "r")
.close()
}.isFailure
)
}
}
@Test
fun changedSourceFetchFailureRetainsLastKnownGoodArtwork() {
ScriptedHttpServer(
listOf(
ScriptedResponse(body = imageBytes),
ScriptedResponse(status = 503, contentType = "text/plain", body = "retry".toByteArray()),
ScriptedResponse(body = imageBytes)
)
).use { server ->
val homeIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
val selectedHome = registerHandler(homeIntent, "selected.home.launcher")
selectDefaultHome(selectedHome, selectedHome)
val grantContext = RecordingGrantContext(context)
val provider = WatchNextProvider(grantContext)
val sourceA = "${server.baseUrl}/a"
val sourceB = "${server.baseUrl}/b"
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(sourceA))))
val originalPoster = committedPoster()!!
val originalFile = SystemShelfArtworkStore(context.cacheDir)
.resolveOwned("owner-a", originalPoster)!!
.canonicalFile
assertTrue(
provider.syncWatchNextPrograms(
"owner-a",
2,
listOf(item(sourceB).copy(title = "Progress update", lastPlaybackPosition = 40))
)
)
assertEquals(2, server.requestCount.get())
assertEquals(listOf("plezy_server_item"), tvProvider.lastQueryContentIds)
assertEquals(listOf(originalPoster.toString()), tvProvider.lastQueryPosters)
assertEquals(originalPoster, committedPoster())
assertEquals(
"Progress update",
tvProvider.inserted.single()
.getAsString(TvContractCompat.WatchNextPrograms.COLUMN_TITLE)
)
assertTrue(originalFile.isFile)
assertArrayEquals(imageBytes, openArtwork(originalPoster))
tvProvider.blockNextBatch = true
val retryResult = AtomicReference<Boolean>()
val retry = thread(start = true, name = "system-shelf-source-retry") {
retryResult.set(
provider.syncWatchNextPrograms(
"owner-a",
3,
listOf(item(sourceB).copy(title = "Replacement ready"))
)
)
}
try {
assertTrue(tvProvider.batchStarted.await(2, TimeUnit.SECONDS))
assertTrue(originalFile.isFile)
assertArrayEquals(imageBytes, openArtwork(originalPoster))
assertEquals(2, artworkFiles().count { it.name.endsWith(".art") })
assertFalse(artworkFiles().any { it.name.endsWith(".tmp") })
val revokedBeforeCommit =
grantContext.packageRevocations.map(PackageRevocation::uri) +
grantContext.uriWideRevocations
assertFalse(originalPoster in revokedBeforeCommit)
} finally {
tvProvider.releaseBatch.countDown()
retry.join(2_000)
}
assertEquals(true, retryResult.get())
assertEquals(3, server.requestCount.get())
val replacementPoster = committedPoster()!!
assertFalse(replacementPoster == originalPoster)
assertFalse(originalFile.exists())
assertArrayEquals(imageBytes, openArtwork(replacementPoster))
val revokedAfterCommit =
grantContext.packageRevocations.map(PackageRevocation::uri) +
grantContext.uriWideRevocations
assertTrue(originalPoster in revokedAfterCommit)
assertTrue(
provider.syncWatchNextPrograms(
"owner-a",
4,
listOf(item(sourceB).copy(posterSourceUri = null))
)
)
assertNull(committedPoster())
assertTrue(artworkFiles().isEmpty())
assertEquals(3, server.requestCount.get())
}
}
@Test
fun malformedReplacementRetainsLastKnownGoodArtwork() {
ScriptedHttpServer(
listOf(
ScriptedResponse(body = imageBytes),
ScriptedResponse(body = "not an image".toByteArray())
)
).use { server ->
val provider = WatchNextProvider(context)
val sourceA = "${server.baseUrl}/valid"
val sourceB = "${server.baseUrl}/malformed"
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(sourceA))))
val originalPoster = committedPoster()!!
val originalFile = artworkFiles().single().canonicalFile
assertTrue(
provider.syncWatchNextPrograms(
"owner-a",
2,
listOf(item(sourceB).copy(lastPlaybackPosition = 50))
)
)
assertEquals(2, server.requestCount.get())
assertEquals(listOf("plezy_server_item"), tvProvider.lastQueryContentIds)
assertEquals(listOf(originalPoster.toString()), tvProvider.lastQueryPosters)
assertEquals(originalPoster, committedPoster())
assertEquals(setOf(originalFile), artworkFiles().mapTo(HashSet()) { it.canonicalFile })
assertArrayEquals(imageBytes, openArtwork(originalPoster))
assertFalse(artworkFiles().any { it.name.endsWith(".tmp") })
}
}
@Test
fun corruptContentAddressIsRefetchedWithoutPartialPublish() {
val replacementRequested = CountDownLatch(1)
val releaseReplacement = CountDownLatch(1)
ScriptedHttpServer(
listOf(
ScriptedResponse(body = imageBytes),
ScriptedResponse(
body = imageBytes,
beforeResponse = {
replacementRequested.countDown()
releaseReplacement.await(2, TimeUnit.SECONDS)
}
),
ScriptedResponse(status = 503, contentType = "text/plain"),
ScriptedResponse(body = imageBytes),
ScriptedResponse(status = 503, contentType = "text/plain")
)
).use { server ->
val provider = WatchNextProvider(context)
val store = SystemShelfArtworkStore(context.cacheDir)
val source = "${server.baseUrl}/stable"
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(source))))
val stablePoster = committedPoster()!!
val deterministicFile = store.resolveOwned("owner-a", stablePoster)!!
val corruptBytes = "corrupt-cache".toByteArray()
deterministicFile.writeBytes(corruptBytes)
assertNull(store.resolveOwned("owner-a", stablePoster))
assertTrue(runCatching { openArtwork(stablePoster) }.isFailure)
val refetchResult = AtomicReference<Boolean>()
val refetch = thread(start = true, name = "system-shelf-corrupt-refetch") {
refetchResult.set(
provider.syncWatchNextPrograms("owner-a", 2, listOf(item(source)))
)
}
try {
assertTrue(replacementRequested.await(2, TimeUnit.SECONDS))
assertArrayEquals(corruptBytes, deterministicFile.readBytes())
assertEquals(1, artworkFiles().size)
assertFalse(artworkFiles().any { it.name.endsWith(".tmp") })
} finally {
releaseReplacement.countDown()
refetch.join(2_000)
}
assertEquals(true, refetchResult.get())
assertEquals(stablePoster, committedPoster())
assertArrayEquals(imageBytes, deterministicFile.readBytes())
assertArrayEquals(imageBytes, openArtwork(stablePoster))
assertFalse(artworkFiles().any { it.name.endsWith(".tmp") })
deterministicFile.writeBytes(corruptBytes)
assertTrue(provider.syncWatchNextPrograms("owner-a", 3, listOf(item(source))))
assertEquals(3, server.requestCount.get())
assertNull(committedPoster())
assertTrue(artworkFiles().isEmpty())
assertTrue(provider.syncWatchNextPrograms("owner-a", 4, listOf(item(source))))
assertEquals(stablePoster, committedPoster())
assertTrue(deterministicFile.delete())
assertTrue(provider.syncWatchNextPrograms("owner-a", 5, listOf(item(source))))
assertEquals(5, server.requestCount.get())
assertNull(committedPoster())
assertTrue(artworkFiles().isEmpty())
}
}
@Test
fun committedRowQueryFailureAbortsBeforeArtworkOrShelfMutation() {
ScriptedHttpServer(
listOf(
ScriptedResponse(body = imageBytes),
ScriptedResponse(body = imageBytes)
)
).use { server ->
val provider = WatchNextProvider(context)
val sourceA = "${server.baseUrl}/a"
val sourceB = "${server.baseUrl}/b"
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(sourceA))))
val originalPoster = committedPoster()!!
val originalFile = artworkFiles().single().canonicalFile
val preferences = context.getSharedPreferences("system_shelf_state", 0)
val grantedBefore = preferences.getStringSet("granted_uris", emptySet())!!.toSet()
val packagesBefore = preferences.getStringSet("granted_packages", emptySet())!!.toSet()
tvProvider.failQuery = true
assertFalse(provider.syncWatchNextPrograms("owner-a", 2, listOf(item(sourceB))))
tvProvider.failQuery = false
tvProvider.returnNullQuery = true
assertFalse(provider.syncWatchNextPrograms("owner-a", 3, listOf(item(sourceB))))
tvProvider.returnNullQuery = false
assertEquals(1, server.requestCount.get())
assertEquals(3, tvProvider.queryCount)
assertEquals(originalPoster, committedPoster())
assertEquals(setOf(originalFile), artworkFiles().mapTo(HashSet()) { it.canonicalFile })
assertEquals(grantedBefore, preferences.getStringSet("granted_uris", emptySet()))
assertEquals(packagesBefore, preferences.getStringSet("granted_packages", emptySet()))
assertArrayEquals(imageBytes, openArtwork(originalPoster))
}
}
@Test
fun interruptedPublishLeavesCommittedArtworkAndUniqueStagesUntouched() {
ScriptedHttpServer(
listOf(
ScriptedResponse(body = imageBytes),
ScriptedResponse(body = imageBytes),
ScriptedResponse(body = imageBytes)
)
).use { server ->
val provider = WatchNextProvider(context)
val store = SystemShelfArtworkStore(context.cacheDir)
val sourceA = "${server.baseUrl}/a"
val sourceB = "${server.baseUrl}/b"
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(sourceA))))
val originalPoster = committedPoster()!!
val originalFile = store.resolveOwned("owner-a", originalPoster)!!.canonicalFile
val firstOwnership = provider.claimOwnership("owner-a", 2)!!
val firstStage = store.prepare(
"owner-a",
sourceB,
SystemShelfSyncSession(firstOwnership, 2_000)
) as SystemShelfArtworkStore.Prepared.Staged
val secondOwnership = provider.claimOwnership("owner-a", 3)!!
val secondStage = store.prepare(
"owner-a",
sourceB,
SystemShelfSyncSession(secondOwnership, 2_000)
) as SystemShelfArtworkStore.Prepared.Staged
assertFalse(firstStage.stagingFile.canonicalFile == secondStage.stagingFile.canonicalFile)
assertFalse(firstStage.materialized.file.exists())
assertTrue(secondStage.stagingFile.delete())
val publication = SystemShelfLifecycle.whileCurrent(secondOwnership) {
store.publish(secondStage)
}
assertNull(publication)
store.discard(listOf(firstStage, secondStage))
assertEquals(3, server.requestCount.get())
assertEquals(originalPoster, committedPoster())
assertTrue(originalFile.isFile)
assertFalse(firstStage.materialized.file.exists())
assertEquals(setOf(originalFile), artworkFiles().mapTo(HashSet()) { it.canonicalFile })
assertArrayEquals(imageBytes, openArtwork(originalPoster))
}
}
@Test
fun contentAddressesAndFallbackAreOwnerIsolated() {
ScriptedHttpServer(
listOf(
ScriptedResponse(body = imageBytes),
ScriptedResponse(body = imageBytes),
ScriptedResponse(status = 503, contentType = "text/plain")
)
).use { server ->
val provider = WatchNextProvider(context)
val source = "${server.baseUrl}/shared"
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(source))))
val ownerAPoster = committedPoster()!!
assertTrue(provider.syncWatchNextPrograms("owner-b", 2, listOf(item(source))))
val ownerBPoster = committedPoster()!!
assertFalse(ownerAPoster == ownerBPoster)
assertFalse(ownerAPoster.pathSegments[1] == ownerBPoster.pathSegments[1])
assertEquals(2, server.requestCount.get())
assertArrayEquals(imageBytes, openArtwork(ownerBPoster))
assertTrue(
provider.syncWatchNextPrograms(
"owner-c",
3,
listOf(item(source).copy(title = "Owner C metadata"))
)
)
assertEquals(3, server.requestCount.get())
assertNull(committedPoster())
assertTrue(artworkFiles().isEmpty())
}
}
@Test
fun staleOperationCannotPublishOrPruneCommittedArtwork() {
val replacementRequest = CountDownLatch(1)
val releaseReplacement = CountDownLatch(1)
ScriptedHttpServer(
listOf(
ScriptedResponse(body = imageBytes),
ScriptedResponse(
body = imageBytes,
beforeResponse = {
replacementRequest.countDown()
releaseReplacement.await(2, TimeUnit.SECONDS)
}
)
)
).use { server ->
val provider = WatchNextProvider(context)
val sourceA = "${server.baseUrl}/a"
val sourceB = "${server.baseUrl}/b"
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(sourceA))))
val originalPoster = committedPoster()!!
val originalFile = artworkFiles().single().canonicalFile
val staleResult = AtomicReference<Boolean>()
val staleWorker = thread(start = true, name = "system-shelf-stale-artwork") {
staleResult.set(
provider.syncWatchNextPrograms("owner-a", 2, listOf(item(sourceB)))
)
}
try {
assertTrue(replacementRequest.await(2, TimeUnit.SECONDS))
assertTrue(provider.claimOwnership("owner-a", 3) != null)
} finally {
releaseReplacement.countDown()
staleWorker.join(2_000)
}
assertFalse(staleWorker.isAlive)
assertEquals(false, staleResult.get())
assertEquals(originalPoster, committedPoster())
assertEquals(setOf(originalFile), artworkFiles().mapTo(HashSet()) { it.canonicalFile })
assertFalse(artworkFiles().any { it.name.endsWith(".tmp") })
assertArrayEquals(imageBytes, openArtwork(originalPoster))
}
}
private fun committedPoster(): Uri? = tvProvider.inserted.singleOrNull()
?.getAsString(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI)
?.let(Uri::parse)
private fun openArtwork(uri: Uri): ByteArray {
val provider = Robolectric.buildContentProvider(SystemShelfArtworkProvider::class.java)
.create()
.get()
return AutoCloseInputStream(provider.openFile(uri, "r")).use { it.readBytes() }
} }
private fun registerHandler(intent: Intent, packageName: String): ComponentName { private fun registerHandler(intent: Intent, packageName: String): ComponentName {
@@ -687,6 +1232,59 @@ class WatchNextProviderTest {
} }
} }
private data class ScriptedResponse(
val status: Int = 200,
val contentType: String = "image/png",
val body: ByteArray = ByteArray(0),
val beforeResponse: (() -> Unit)? = null
)
private class ScriptedHttpServer(responses: List<ScriptedResponse>) : Closeable {
private val server = ServerSocket(0, 8, InetAddress.getByName("127.0.0.1"))
private val scriptedResponses = ArrayDeque<ScriptedResponse>().apply { addAll(responses) }
val requestCount = AtomicInteger()
val baseUrl = "http://127.0.0.1:${server.localPort}"
private val responder = thread(start = true, name = "system-shelf-scripted-http") {
while (!server.isClosed) {
val socket = runCatching { server.accept() }.getOrNull() ?: break
runCatching {
socket.use {
val reader = it.getInputStream().bufferedReader()
while (reader.readLine()?.isNotEmpty() == true) {
// Consume request headers.
}
requestCount.incrementAndGet()
val response = synchronized(scriptedResponses) {
if (scriptedResponses.isEmpty()) {
ScriptedResponse(status = 500, contentType = "text/plain")
} else {
scriptedResponses.removeFirst()
}
}
response.beforeResponse?.invoke()
val reason = if (response.status in 200..299) "OK" else "Injected"
val headers = (
"HTTP/1.1 ${response.status} $reason\r\n" +
"Content-Type: ${response.contentType}\r\n" +
"Content-Length: ${response.body.size}\r\n" +
"Connection: close\r\n\r\n"
).toByteArray()
it.getOutputStream().use { output ->
output.write(headers)
output.write(response.body)
output.flush()
}
}
}
}
}
override fun close() {
server.close()
responder.join(2_000)
}
}
private data class Grant(val packageName: String, val uri: Uri, val modeFlags: Int) 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 data class PackageRevocation(val packageName: String, val uri: Uri, val modeFlags: Int)
@@ -715,22 +1313,60 @@ private class RecordingGrantContext(base: Context) : ContextWrapper(base) {
private class CapturingTvProvider : ContentProvider() { private class CapturingTvProvider : ContentProvider() {
val inserted = mutableListOf<ContentValues>() val inserted = mutableListOf<ContentValues>()
fun contentIds(): List<String?> = inserted.map { values ->
valueAsString(values, TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID)
?.takeIf(String::isNotBlank)
?: valueAsString(
values,
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_DATA
)?.takeIf(String::isNotBlank)
?: valueAsString(values, TvContractCompat.WatchNextPrograms.COLUMN_INTENT_URI)
?.let(Uri::parse)
?.takeIf { uri -> uri.scheme == "plezy" && uri.authority == "play" }
?.getQueryParameter("content_id")
}
private val rowIds = mutableListOf<Long>()
private var nextRowId = 1L
var deleteCount = 0 var deleteCount = 0
var queryCount = 0
var lastQueryContentIds: List<String?> = emptyList()
var lastQueryPosters: List<String?> = emptyList()
var lastQueryProjection: List<String> = emptyList()
var failQuery = false
var returnNullQuery = false
var failBatch = false var failBatch = false
var blockNextBatch = false var blockNextBatch = false
val batchStarted = CountDownLatch(1) val batchStarted = CountDownLatch(1)
val releaseBatch = CountDownLatch(1) val releaseBatch = CountDownLatch(1)
override fun onCreate(): Boolean = true override fun onCreate(): Boolean = true
override fun insert(uri: Uri, values: ContentValues?): Uri { override fun insert(uri: Uri, values: ContentValues?): Uri {
val id = nextRowId++
inserted += ContentValues(values) inserted += ContentValues(values)
return uri.buildUpon().appendPath(inserted.size.toString()).build() rowIds += id
return uri.buildUpon().appendPath(id.toString()).build()
} }
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int { override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
deleteCount++ deleteCount++
if (uri == TvContractCompat.WatchNextPrograms.CONTENT_URI) {
val deleted = inserted.size
inserted.clear() inserted.clear()
rowIds.clear()
return deleted
}
val id = uri.lastPathSegment?.toLongOrNull() ?: return 0
val index = rowIds.indexOf(id)
if (index < 0) return 0
rowIds.removeAt(index)
inserted.removeAt(index)
return 1 return 1
} }
override fun applyBatch(operations: ArrayList<ContentProviderOperation>): Array<ContentProviderResult> {
override fun applyBatch(
operations: ArrayList<ContentProviderOperation>
): Array<ContentProviderResult> {
if (failBatch) throw IllegalStateException("Injected provider failure") if (failBatch) throw IllegalStateException("Injected provider failure")
if (blockNextBatch) { if (blockNextBatch) {
blockNextBatch = false blockNextBatch = false
@@ -739,9 +1375,61 @@ private class CapturingTvProvider : ContentProvider() {
} }
return super.applyBatch(operations) return super.applyBatch(operations)
} }
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?
): Cursor? {
queryCount++
if (failQuery) throw IllegalStateException("Injected query failure")
if (returnNullQuery) return null
val columns = projection ?: arrayOf(
TvContractCompat.WatchNextPrograms._ID,
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID,
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_DATA,
TvContractCompat.WatchNextPrograms.COLUMN_INTENT_URI,
TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI
)
lastQueryProjection = columns.toList()
lastQueryContentIds = contentIds()
lastQueryPosters = inserted.map { values ->
valueAsString(values, TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI)
}
return MatrixCursor(columns).apply {
inserted.indices.forEach { index ->
addRow(
columns.map { column ->
when (column) {
TvContractCompat.WatchNextPrograms._ID -> rowIds[index]
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_DATA ->
inserted[index].get(column)
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID,
TvContractCompat.WatchNextPrograms.COLUMN_INTENT_URI,
TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI ->
valueAsString(inserted[index], column)
else -> inserted[index].get(column)
}
}.toTypedArray()
)
}
}
}
private fun valueAsString(values: ContentValues, column: String): String? = when (val value = values.get(column)) {
is ByteArray -> value.toString(Charsets.UTF_8)
else -> values.getAsString(column)
}
override fun getType(uri: Uri): String? = null 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(
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int = 0 uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?
): Int = 0
} }
private class RecordingResult : MethodChannel.Result { private class RecordingResult : MethodChannel.Result {
+22 -2
View File
@@ -179,6 +179,17 @@ final class MpvPlayerContractTests: XCTestCase {
XCTAssertEqual((invalidResults[0] as? FlutterError)?.code, "INVALID_ARGS") XCTAssertEqual((invalidResults[0] as? FlutterError)?.code, "INVALID_ARGS")
} }
func testSharedSetPropertyMapsLifecycleCancellationAsNotInitialized() {
let core = ControllablePropertyCore()
let plugin = RecordingMpvPlugin(core: core)
core.nextResult = .failure(MpvLifecycleUnavailableError("controlled cancellation"))
let cancelled = invokeSetProperty(plugin, name: "volume", value: "50")
XCTAssertEqual(cancelled.count, 1)
XCTAssertEqual((cancelled[0] as? FlutterError)?.code, "NOT_INITIALIZED")
}
func testRealSetPropertyValidInvalidNonexistentAndPauseCache() { func testRealSetPropertyValidInvalidNonexistentAndPauseCache() {
let core = MpvAudioPlayerCore() let core = MpvAudioPlayerCore()
XCTAssertTrue(core.initialize()) XCTAssertTrue(core.initialize())
@@ -314,10 +325,14 @@ final class MpvPlayerContractTests: XCTestCase {
let completion = expectation(description: "cancelled property completion") let completion = expectation(description: "cancelled property completion")
completion.assertForOverFulfill = true completion.assertForOverFulfill = true
var completionCount = 0 var completionCount = 0
var completionError: Error?
core.setPropertyAsync("volume", value: "51") { result in core.setPropertyAsync("volume", value: "51") { result in
completionCount += 1 completionCount += 1
if case .success = result { switch result {
case .success:
XCTFail("Disposal must fail an accepted-but-pending property request") XCTFail("Disposal must fail an accepted-but-pending property request")
case .failure(let error):
completionError = error
} }
completion.fulfill() completion.fulfill()
} }
@@ -327,7 +342,12 @@ final class MpvPlayerContractTests: XCTestCase {
wait(for: [completion], timeout: 2) wait(for: [completion], timeout: 2)
core.queue.sync {} core.queue.sync {}
XCTAssertEqual(completionCount, 1) XCTAssertEqual(completionCount, 1)
XCTAssertFailure(awaitProperty(core, name: "volume", value: "52")) XCTAssertTrue(completionError is MpvLifecycleUnavailableError)
let unavailableResult = awaitProperty(core, name: "volume", value: "52")
XCTAssertFailure(unavailableResult)
if case .failure(let error) = unavailableResult {
XCTAssertTrue(error is MpvLifecycleUnavailableError)
}
} }
func testRapidAudioCoreReplacementOwnsLifecycleOnce() { func testRapidAudioCoreReplacementOwnsLifecycleOnce() {
+1 -1
View File
@@ -560,7 +560,7 @@ void MpvPlayer::Dispose() {
auto cancelled = pending_requests_.CancelAll(); auto cancelled = pending_requests_.CancelAll();
for (auto& callback : cancelled.status) { for (auto& callback : cancelled.status) {
callback(-1); callback(MPV_ERROR_UNINITIALIZED);
} }
for (auto& callback : cancelled.properties) { for (auto& callback : cancelled.properties) {
callback(-1, ""); callback(-1, "");
@@ -446,7 +446,7 @@ void TestPendingPropertyWriteFailsOnDispose() {
player.Dispose(); player.Dispose();
Check(callback_count == 1, "dispose must complete a pending property write exactly once"); Check(callback_count == 1, "dispose must complete a pending property write exactly once");
Check(status < 0, "dispose must fail a pending property write"); Check(status == MPV_ERROR_UNINITIALIZED, "dispose must cancel a pending property write as uninitialized");
player.Dispose(); player.Dispose();
Check(callback_count == 1, "repeated dispose must not complete a property write twice"); Check(callback_count == 1, "repeated dispose must not complete a property write twice");
+6 -3
View File
@@ -379,9 +379,12 @@ static void mpv_plugin_handle_method_call(FlMethodChannel* channel, FlMethodCall
if (plezy::mpv_common::SetPropertyStatusSucceeded(error)) { if (plezy::mpv_common::SetPropertyStatusSucceeded(error)) {
async_response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); async_response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
} else { } else {
const std::string description = plezy::mpv_common::SetPropertyErrorDescription(error); const char* error_code = plezy::mpv_common::SetPropertyErrorCode(error);
async_response = FL_METHOD_RESPONSE(fl_method_error_response_new( const std::string description = error == MPV_ERROR_UNINITIALIZED
plezy::mpv_common::kSetPropertyFailedCode, description.c_str(), nullptr)); ? std::string("Player not initialized")
: plezy::mpv_common::SetPropertyErrorDescription(error);
async_response =
FL_METHOD_RESPONSE(fl_method_error_response_new(error_code, description.c_str(), nullptr));
} }
fl_method_call_respond(method_call, async_response, nullptr); fl_method_call_respond(method_call, async_response, nullptr);
g_object_unref(method_call); g_object_unref(method_call);
+22 -2
View File
@@ -94,6 +94,17 @@ final class MpvPlayerContractTests: XCTestCase {
XCTAssertEqual((invalidResults[0] as? FlutterError)?.code, "INVALID_ARGS") XCTAssertEqual((invalidResults[0] as? FlutterError)?.code, "INVALID_ARGS")
} }
func testSharedSetPropertyMapsLifecycleCancellationAsNotInitialized() {
let core = ControllablePropertyCore()
let plugin = RecordingMpvPlugin(core: core)
core.nextResult = .failure(MpvLifecycleUnavailableError("controlled cancellation"))
let cancelled = invokeSetProperty(plugin, name: "volume", value: "50")
XCTAssertEqual(cancelled.count, 1)
XCTAssertEqual((cancelled[0] as? FlutterError)?.code, "NOT_INITIALIZED")
}
func testRealSetPropertyValidInvalidNonexistentAndPauseCache() { func testRealSetPropertyValidInvalidNonexistentAndPauseCache() {
let core = MpvAudioPlayerCore() let core = MpvAudioPlayerCore()
XCTAssertTrue(core.initialize()) XCTAssertTrue(core.initialize())
@@ -161,10 +172,14 @@ final class MpvPlayerContractTests: XCTestCase {
let completion = expectation(description: "cancelled property completion") let completion = expectation(description: "cancelled property completion")
completion.assertForOverFulfill = true completion.assertForOverFulfill = true
var completionCount = 0 var completionCount = 0
var completionError: Error?
core.setPropertyAsync("volume", value: "51") { result in core.setPropertyAsync("volume", value: "51") { result in
completionCount += 1 completionCount += 1
if case .success = result { switch result {
case .success:
XCTFail("Disposal must fail an accepted-but-pending property request") XCTFail("Disposal must fail an accepted-but-pending property request")
case .failure(let error):
completionError = error
} }
completion.fulfill() completion.fulfill()
} }
@@ -174,7 +189,12 @@ final class MpvPlayerContractTests: XCTestCase {
wait(for: [completion], timeout: 2) wait(for: [completion], timeout: 2)
core.queue.sync {} core.queue.sync {}
XCTAssertEqual(completionCount, 1) XCTAssertEqual(completionCount, 1)
XCTAssertFailure(awaitProperty(core, name: "volume", value: "52")) XCTAssertTrue(completionError is MpvLifecycleUnavailableError)
let unavailableResult = awaitProperty(core, name: "volume", value: "52")
XCTAssertFailure(unavailableResult)
if case .failure(let error) = unavailableResult {
XCTAssertTrue(error is MpvLifecycleUnavailableError)
}
} }
func testRapidAudioCoreReplacementOwnsLifecycleOnce() { func testRapidAudioCoreReplacementOwnsLifecycleOnce() {
+11 -11
View File
@@ -10,6 +10,14 @@ import QuartzCore
import Metal import Metal
#endif #endif
struct MpvLifecycleUnavailableError: LocalizedError {
let errorDescription: String?
init(_ description: String) {
errorDescription = description
}
}
protocol MpvPlayerDelegate: AnyObject { protocol MpvPlayerDelegate: AnyObject {
func onPropertyChange(name: String, value: Any?) func onPropertyChange(name: String, value: Any?)
func onEvent(name: String, data: [String: Any]?) func onEvent(name: String, data: [String: Any]?)
@@ -944,11 +952,7 @@ class MpvPlayerCoreBase: NSObject {
pendingRequests.removeAll() pendingRequests.removeAll()
pendingRequestsLock.unlock() pendingRequestsLock.unlock()
let error = NSError( let error = MpvLifecycleUnavailableError("Player disposed")
domain: "mpv",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Player disposed"]
)
for (_, request) in pending { for (_, request) in pending {
DispatchQueue.main.async { DispatchQueue.main.async {
switch request { switch request {
@@ -977,12 +981,8 @@ class MpvPlayerCoreBase: NSObject {
return pendingRequests.removeValue(forKey: requestId) return pendingRequests.removeValue(forKey: requestId)
} }
private func lifecycleUnavailableError() -> NSError { private func lifecycleUnavailableError() -> MpvLifecycleUnavailableError {
NSError( MpvLifecycleUnavailableError("Player is not initialized or has been disposed")
domain: "mpv",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Player is not initialized or has been disposed"]
)
} }
private func mpvError(_ status: CInt) -> NSError { private func mpvError(_ status: CInt) -> NSError {
@@ -49,11 +49,15 @@ extension MpvPluginShared {
self?.didSetPauseProperty(value: value) self?.didSetPauseProperty(value: value)
} }
result(nil) result(nil)
case .failure: case .failure(let error):
let lifecycleUnavailable = error is MpvLifecycleUnavailableError
result( result(
FlutterError( FlutterError(
code: "SET_PROPERTY_FAILED", code: lifecycleUnavailable ? "NOT_INITIALIZED" : "SET_PROPERTY_FAILED",
message: "MPV rejected or cancelled the property write", message:
lifecycleUnavailable
? "MPV player is not initialized"
: "MPV rejected or cancelled the property write",
details: nil)) details: nil))
} }
} }
+4
View File
@@ -26,6 +26,10 @@ static constexpr size_t kSetPropertyErrorDescriptionLimit = 160;
inline bool SetPropertyStatusSucceeded(int status) { return status >= 0; } inline bool SetPropertyStatusSucceeded(int status) { return status >= 0; }
inline const char* SetPropertyErrorCode(int status) {
return status == MPV_ERROR_UNINITIALIZED ? kSetPropertyNotInitializedCode : kSetPropertyFailedCode;
}
inline std::string SetPropertyErrorDescription(int status) { inline std::string SetPropertyErrorDescription(int status) {
const char* description = mpv_error_string(status); const char* description = mpv_error_string(status);
if (!description || description[0] == '\0') { if (!description || description[0] == '\0') {
+16 -3
View File
@@ -81,14 +81,27 @@ void TestSetPropertyResultContract() {
assert(SetPropertyStatusSucceeded(MPV_ERROR_SUCCESS)); assert(SetPropertyStatusSucceeded(MPV_ERROR_SUCCESS));
assert(SetPropertyStatusSucceeded(1)); assert(SetPropertyStatusSucceeded(1));
constexpr int kFailureStatuses[] = { assert(!SetPropertyStatusSucceeded(MPV_ERROR_UNINITIALIZED));
assert(std::string(SetPropertyErrorCode(MPV_ERROR_UNINITIALIZED)) == kSetPropertyNotInitializedCode);
constexpr int kRejectedStatuses[] = {
MPV_ERROR_INVALID_PARAMETER,
MPV_ERROR_PROPERTY_ERROR,
-1,
};
for (const int status : kRejectedStatuses) {
assert(!SetPropertyStatusSucceeded(status));
assert(std::string(SetPropertyErrorCode(status)) == kSetPropertyFailedCode);
}
constexpr int kDescribedStatuses[] = {
MPV_ERROR_INVALID_PARAMETER, MPV_ERROR_INVALID_PARAMETER,
MPV_ERROR_PROPERTY_ERROR, MPV_ERROR_PROPERTY_ERROR,
-1, -1,
MPV_ERROR_UNINITIALIZED, MPV_ERROR_UNINITIALIZED,
}; };
for (const int status : kFailureStatuses) { for (const int status : kDescribedStatuses) {
assert(!SetPropertyStatusSucceeded(status));
const std::string description = SetPropertyErrorDescription(status); const std::string description = SetPropertyErrorDescription(status);
assert(!description.empty()); assert(!description.empty());
assert(description.size() <= kSetPropertyErrorDescriptionLimit); assert(description.size() <= kSetPropertyErrorDescriptionLimit);
+4
View File
@@ -10,6 +10,7 @@
055E465F9095D0B6E9B91D46 /* PathProviderPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = D41AA251EF365516E2AC5287 /* PathProviderPlugin.swift */; }; 055E465F9095D0B6E9B91D46 /* PathProviderPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = D41AA251EF365516E2AC5287 /* PathProviderPlugin.swift */; };
1C6B234E223CDFFE5BF3FC0B /* TVServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 25B7925EFD8A2C1C7EB667D5 /* TVServices.framework */; }; 1C6B234E223CDFFE5BF3FC0B /* TVServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 25B7925EFD8A2C1C7EB667D5 /* TVServices.framework */; };
28DB4404B17342F46BC2B0A1 /* TopShelfProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2F829B3F190657106F66379 /* TopShelfProvider.swift */; }; 28DB4404B17342F46BC2B0A1 /* TopShelfProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2F829B3F190657106F66379 /* TopShelfProvider.swift */; };
2A7C0B1D9E5F4A6381027C12 /* MpvPlayerContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A7C0B1D9E5F4A6381027C11 /* MpvPlayerContractTests.swift */; };
35DB0C8FEF635A3BCA0B722A /* PackageInfoPlusPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9426EFA282CDA8E0E98EEE9 /* PackageInfoPlusPlugin.swift */; }; 35DB0C8FEF635A3BCA0B722A /* PackageInfoPlusPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9426EFA282CDA8E0E98EEE9 /* PackageInfoPlusPlugin.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
5C71F5F7B33075F2B007825B /* MpvPlayerPluginShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73645904F226A24585A092CE /* MpvPlayerPluginShared.swift */; }; 5C71F5F7B33075F2B007825B /* MpvPlayerPluginShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73645904F226A24585A092CE /* MpvPlayerPluginShared.swift */; };
@@ -117,6 +118,7 @@
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
9D7A998830EDC8F77BF521D1 /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPlayerPlugin.swift; path = ../ios/Runner/MpvPlayer/MpvPlayerPlugin.swift; sourceTree = "<source_root>"; }; 9D7A998830EDC8F77BF521D1 /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPlayerPlugin.swift; path = ../ios/Runner/MpvPlayer/MpvPlayerPlugin.swift; sourceTree = "<source_root>"; };
A12B8610AE5D580077264851 /* MpvPlayerCore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPlayerCore.swift; path = ../ios/Runner/MpvPlayer/MpvPlayerCore.swift; sourceTree = "<source_root>"; }; A12B8610AE5D580077264851 /* MpvPlayerCore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPlayerCore.swift; path = ../ios/Runner/MpvPlayer/MpvPlayerCore.swift; sourceTree = "<source_root>"; };
2A7C0B1D9E5F4A6381027C11 /* MpvPlayerContractTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MpvPlayerContractTests.swift; sourceTree = "<group>"; };
A2484B9C94406BF0A99EB64A /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; }; A2484B9C94406BF0A99EB64A /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
A2635E12EB9322B151EE5127 /* MpvPipController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPipController.swift; path = ../ios/Runner/MpvPlayer/MpvPipController.swift; sourceTree = "<source_root>"; }; A2635E12EB9322B151EE5127 /* MpvPipController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPipController.swift; path = ../ios/Runner/MpvPlayer/MpvPipController.swift; sourceTree = "<source_root>"; };
A5E1F001234567890ABCDE01 /* SystemShelfPluginTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SystemShelfPluginTests.swift; sourceTree = "<group>"; }; A5E1F001234567890ABCDE01 /* SystemShelfPluginTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SystemShelfPluginTests.swift; sourceTree = "<group>"; };
@@ -335,6 +337,7 @@
F9BDF6E09E3B9347861D2A50 /* RunnerTests */ = { F9BDF6E09E3B9347861D2A50 /* RunnerTests */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
2A7C0B1D9E5F4A6381027C11 /* MpvPlayerContractTests.swift */,
FE6C8125B201A8BC3261AE2B /* TvosEventDeliveryCoordinatorTests.swift */, FE6C8125B201A8BC3261AE2B /* TvosEventDeliveryCoordinatorTests.swift */,
934BC4E316D2AC788C954766 /* ConnectivityPlusPluginTests.swift */, 934BC4E316D2AC788C954766 /* ConnectivityPlusPluginTests.swift */,
A5E1F001234567890ABCDE01 /* SystemShelfPluginTests.swift */, A5E1F001234567890ABCDE01 /* SystemShelfPluginTests.swift */,
@@ -587,6 +590,7 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
2A7C0B1D9E5F4A6381027C12 /* MpvPlayerContractTests.swift in Sources */,
D2004D7BB4A40340AB7A01E0 /* TvosEventDeliveryCoordinatorTests.swift in Sources */, D2004D7BB4A40340AB7A01E0 /* TvosEventDeliveryCoordinatorTests.swift in Sources */,
65AC2C222043B3E6723E2076 /* ConnectivityPlusPluginTests.swift in Sources */, 65AC2C222043B3E6723E2076 /* ConnectivityPlusPluginTests.swift in Sources */,
A5E1F001234567890ABCDE02 /* SystemShelfPluginTests.swift in Sources */, A5E1F001234567890ABCDE02 /* SystemShelfPluginTests.swift in Sources */,
+4 -7
View File
@@ -561,18 +561,15 @@ import TVServices
else { return nil } else { return nil }
let data = download.data let data = download.data
let key = UUID().uuidString.lowercased().replacingOccurrences(of: "-", with: "") + ".art" let key = UUID().uuidString.lowercased().replacingOccurrences(of: "-", with: "") + ".art"
let staged = directory.appendingPathComponent(".\(key).tmp")
let destination = directory.appendingPathComponent(key) let destination = directory.appendingPathComponent(key)
do { do {
try data.write(to: staged, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) try data.write(
if FileManager.default.fileExists(atPath: destination.path) { to: destination,
try FileManager.default.removeItem(at: destination) options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]
} )
try FileManager.default.moveItem(at: staged, to: destination)
remaining -= data.count remaining -= data.count
return key return key
} catch { } catch {
try? FileManager.default.removeItem(at: staged)
return nil return nil
} }
} }
@@ -0,0 +1,78 @@
import Foundation
import Flutter
import XCTest
@testable import Runner
private final class TvosControllablePropertyCore: MpvPlayerCoreBase {
var nextResult: Result<Void, Error>?
override func setPropertyAsync(
_ name: String,
value: String,
completion: @escaping (Result<Void, Error>) -> Void
) {
guard let nextResult else {
XCTFail("A controlled property result was not configured")
return
}
self.nextResult = nil
completion(nextResult)
}
}
private final class TvosRecordingMpvPlugin: MpvPluginShared {
var coreBase: MpvPlayerCoreBase?
var eventSink: FlutterEventSink?
var nameToId: [String: Int] = [:]
init(core: MpvPlayerCoreBase?) {
coreBase = core
}
func setPlayerVisible(_ visible: Bool, restoreOnWindowVisible: Bool) {}
func updatePlayerFrame() {}
func didSetPauseProperty(value: String) {}
}
final class MpvPlayerContractTests: XCTestCase {
func testSharedSetPropertyMapsLifecycleCancellationAsNotInitialized() {
let core = TvosControllablePropertyCore()
let plugin = TvosRecordingMpvPlugin(core: core)
core.nextResult = .failure(MpvLifecycleUnavailableError("controlled cancellation"))
let results = invokeSetProperty(plugin)
XCTAssertEqual(results.count, 1)
XCTAssertEqual((results[0] as? FlutterError)?.code, "NOT_INITIALIZED")
}
func testSharedSetPropertyKeepsGenuineRejectionNonRecoverable() {
let core = TvosControllablePropertyCore()
let plugin = TvosRecordingMpvPlugin(core: core)
core.nextResult = .failure(
NSError(
domain: "MpvPlayerContractTests",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "controlled rejection"]
))
let results = invokeSetProperty(plugin)
XCTAssertEqual(results.count, 1)
XCTAssertEqual((results[0] as? FlutterError)?.code, "SET_PROPERTY_FAILED")
}
private func invokeSetProperty(_ plugin: TvosRecordingMpvPlugin) -> [Any?] {
var results: [Any?] = []
plugin.handleSetProperty(
call: FlutterMethodCall(
methodName: "setProperty",
arguments: ["name": "volume", "value": "50"]
)
) {
results.append($0)
}
return results
}
}
+415 -61
View File
@@ -23,6 +23,20 @@ constexpr wchar_t kRegOriginalHeight[] = L"OriginalHeight";
constexpr wchar_t kRegOriginalHDR[] = L"OriginalHDREnabled"; constexpr wchar_t kRegOriginalHDR[] = L"OriginalHDREnabled";
constexpr wchar_t kRegModeChanged[] = L"ModeChanged"; constexpr wchar_t kRegModeChanged[] = L"ModeChanged";
constexpr wchar_t kRegHDRChanged[] = L"HDRChanged"; constexpr wchar_t kRegHDRChanged[] = L"HDRChanged";
constexpr wchar_t kRegModeTakeoverEligible[] = L"ModeTakeoverEligible";
constexpr wchar_t kRegHDRTakeoverEligible[] = L"HDRTakeoverEligible";
constexpr wchar_t kRegModeRecoverySlot[] = L"ModeRecoverySlot";
constexpr wchar_t kRegHDRRecoverySlot[] = L"HDRRecoverySlot";
constexpr wchar_t kRegModeDeviceNameAlternate[] = L"ModeDeviceNameAlternate";
constexpr wchar_t kRegHDRDeviceNameAlternate[] = L"HDRDeviceNameAlternate";
constexpr wchar_t kRegOriginalRefreshRateAlternate[] = L"OriginalRefreshRateAlternate";
constexpr wchar_t kRegOriginalWidthAlternate[] = L"OriginalWidthAlternate";
constexpr wchar_t kRegOriginalHeightAlternate[] = L"OriginalHeightAlternate";
constexpr wchar_t kRegOriginalHDRAlternate[] = L"OriginalHDREnabledAlternate";
constexpr wchar_t kRegModeHandoffPending[] = L"ModeHandoffPending";
constexpr wchar_t kRegHDRHandoffPending[] = L"HDRHandoffPending";
constexpr wchar_t kRegModePreviousRecoverySlot[] = L"ModePreviousRecoverySlot";
constexpr wchar_t kRegHDRPreviousRecoverySlot[] = L"HDRPreviousRecoverySlot";
std::recursive_mutex g_display_override_mutex; std::recursive_mutex g_display_override_mutex;
bool g_live_mode_recovery_record = false; bool g_live_mode_recovery_record = false;
@@ -46,7 +60,11 @@ class RecoveryRunGuard {
bool PrepareModeRecoveryAtRegistry(const std::wstring& device_name, DWORD width, DWORD height, DWORD refresh_rate); bool PrepareModeRecoveryAtRegistry(const std::wstring& device_name, DWORD width, DWORD height, DWORD refresh_rate);
bool PrepareHDRRecoveryAtRegistry(const std::wstring& device_name, bool enabled); bool PrepareHDRRecoveryAtRegistry(const std::wstring& device_name, bool enabled);
bool CompleteRecoveryOperationAtRegistry(const wchar_t* marker); bool CompleteRecoveryOperationAtRegistry(
const wchar_t* marker, const wchar_t* takeover_disposition, const wchar_t* handoff_pending);
bool FinalizeModeRecoveryAtRegistry(bool os_apply_succeeded);
bool FinalizeHDRRecoveryAtRegistry(bool os_apply_succeeded);
bool MarkTakeoverEligibleAtRegistry(const wchar_t* takeover_disposition);
} // namespace } // namespace
@@ -240,12 +258,14 @@ bool DisplayModeManager::SetDisplayMode(HWND window, DWORD width, DWORD height,
} }
if (changed) { if (changed) {
FinalizeModeRecoveryAtRegistry(true);
mode_changed_ = true; mode_changed_ = true;
} else if (!mode_was_changed) { } else if (!mode_was_changed) {
// Do not discard an independently persisted HDR operation owned by // A failed takeover rolls back to its prior marked original. A fresh
// another manager or retained from startup recovery. // failed operation still clears only its own marker.
CompleteRecoveryOperationAtRegistry(kRegModeChanged); const bool recovery_completed = FinalizeModeRecoveryAtRegistry(false);
g_live_mode_recovery_record = false; g_live_mode_recovery_record = false;
if (!recovery_completed) MarkTakeoverEligibleAtRegistry(kRegModeTakeoverEligible);
} }
return changed; return changed;
@@ -256,6 +276,7 @@ bool DisplayModeManager::RestoreOriginalMode(HWND) {
if (!mode_changed_) return false; if (!mode_changed_) return false;
if (original_device_name_.empty()) { if (original_device_name_.empty()) {
g_live_mode_recovery_record = false; g_live_mode_recovery_record = false;
MarkTakeoverEligibleAtRegistry(kRegModeTakeoverEligible);
return false; return false;
} }
@@ -269,14 +290,18 @@ bool DisplayModeManager::RestoreOriginalMode(HWND) {
} }
if (rc != DISP_CHANGE_SUCCESSFUL) { if (rc != DISP_CHANGE_SUCCESSFUL) {
// The explicit owner has given up. Keep the durable marker, but release it // The explicit owner has given up. Keep the durable marker, but release it
// so a later topology notification can restore a reconnected target. // so a later topology notification can restore a reconnected target or a
// conflicting mode request can consume the failed recovery disposition.
g_live_mode_recovery_record = false; g_live_mode_recovery_record = false;
MarkTakeoverEligibleAtRegistry(kRegModeTakeoverEligible);
return false; return false;
} }
mode_changed_ = false; mode_changed_ = false;
CompleteRecoveryOperationAtRegistry(kRegModeChanged); const bool recovery_completed =
CompleteRecoveryOperationAtRegistry(kRegModeChanged, kRegModeTakeoverEligible, kRegModeHandoffPending);
g_live_mode_recovery_record = false; g_live_mode_recovery_record = false;
if (!recovery_completed) MarkTakeoverEligibleAtRegistry(kRegModeTakeoverEligible);
return true; return true;
} }
@@ -385,8 +410,9 @@ bool DisplayModeManager::SetHDREnabled(HWND window, bool enabled) {
const LONG result = SetHDRStateForTarget(*target_id, enabled); const LONG result = SetHDRStateForTarget(*target_id, enabled);
if (result != ERROR_SUCCESS) { if (result != ERROR_SUCCESS) {
if (!hdr_was_changed) { if (!hdr_was_changed) {
CompleteRecoveryOperationAtRegistry(kRegHDRChanged); const bool recovery_completed = FinalizeHDRRecoveryAtRegistry(false);
g_live_hdr_recovery_record = false; g_live_hdr_recovery_record = false;
if (!recovery_completed) MarkTakeoverEligibleAtRegistry(kRegHDRTakeoverEligible);
} }
return false; return false;
} }
@@ -398,6 +424,7 @@ bool DisplayModeManager::SetHDREnabled(HWND window, bool enabled) {
ChangeDisplaySettingsExW(device_name.c_str(), &pre_toggle_dm, nullptr, CDS_FULLSCREEN, nullptr); ChangeDisplaySettingsExW(device_name.c_str(), &pre_toggle_dm, nullptr, CDS_FULLSCREEN, nullptr);
} }
FinalizeHDRRecoveryAtRegistry(true);
hdr_changed_ = true; hdr_changed_ = true;
return true; return true;
} }
@@ -407,12 +434,14 @@ bool DisplayModeManager::RestoreOriginalHDRState(HWND window) {
if (!hdr_changed_) return false; if (!hdr_changed_) return false;
if (original_hdr_device_name_.empty()) { if (original_hdr_device_name_.empty()) {
g_live_hdr_recovery_record = false; g_live_hdr_recovery_record = false;
MarkTakeoverEligibleAtRegistry(kRegHDRTakeoverEligible);
return false; return false;
} }
const auto target_id = GetDisplayTargetId(original_hdr_device_name_); const auto target_id = GetDisplayTargetId(original_hdr_device_name_);
if (!target_id) { if (!target_id) {
g_live_hdr_recovery_record = false; g_live_hdr_recovery_record = false;
MarkTakeoverEligibleAtRegistry(kRegHDRTakeoverEligible);
return false; return false;
} }
@@ -428,6 +457,7 @@ bool DisplayModeManager::RestoreOriginalHDRState(HWND window) {
if (SetHDRStateForTarget(*target_id, original_hdr_enabled_) != ERROR_SUCCESS) { if (SetHDRStateForTarget(*target_id, original_hdr_enabled_) != ERROR_SUCCESS) {
g_live_hdr_recovery_record = false; g_live_hdr_recovery_record = false;
MarkTakeoverEligibleAtRegistry(kRegHDRTakeoverEligible);
return false; return false;
} }
@@ -439,8 +469,10 @@ bool DisplayModeManager::RestoreOriginalHDRState(HWND window) {
} }
hdr_changed_ = false; hdr_changed_ = false;
CompleteRecoveryOperationAtRegistry(kRegHDRChanged); const bool recovery_completed =
CompleteRecoveryOperationAtRegistry(kRegHDRChanged, kRegHDRTakeoverEligible, kRegHDRHandoffPending);
g_live_hdr_recovery_record = false; g_live_hdr_recovery_record = false;
if (!recovery_completed) MarkTakeoverEligibleAtRegistry(kRegHDRTakeoverEligible);
return true; return true;
} }
@@ -528,8 +560,30 @@ bool RecoveryRecordExists() {
} }
bool exists = false; bool exists = false;
for (const wchar_t* value_name : for (const wchar_t* value_name :
{kRegVersion, kRegModeDeviceName, kRegLegacyDeviceName, kRegHDRDeviceName, kRegOriginalRefreshRate, {kRegVersion,
kRegOriginalWidth, kRegOriginalHeight, kRegOriginalHDR, kRegModeChanged, kRegHDRChanged}) { kRegModeDeviceName,
kRegLegacyDeviceName,
kRegHDRDeviceName,
kRegOriginalRefreshRate,
kRegOriginalWidth,
kRegOriginalHeight,
kRegOriginalHDR,
kRegModeChanged,
kRegHDRChanged,
kRegModeTakeoverEligible,
kRegHDRTakeoverEligible,
kRegModeRecoverySlot,
kRegHDRRecoverySlot,
kRegModeDeviceNameAlternate,
kRegHDRDeviceNameAlternate,
kRegOriginalRefreshRateAlternate,
kRegOriginalWidthAlternate,
kRegOriginalHeightAlternate,
kRegOriginalHDRAlternate,
kRegModeHandoffPending,
kRegHDRHandoffPending,
kRegModePreviousRecoverySlot,
kRegHDRPreviousRecoverySlot}) {
DWORD size = 0; DWORD size = 0;
const LONG result = RegQueryValueExW(key, value_name, nullptr, nullptr, nullptr, &size); const LONG result = RegQueryValueExW(key, value_name, nullptr, nullptr, nullptr, &size);
if (result == ERROR_SUCCESS || result == ERROR_MORE_DATA) { if (result == ERROR_SUCCESS || result == ERROR_MORE_DATA) {
@@ -606,8 +660,30 @@ class Win32DisplayRecoveryBackend final : public DisplayRecoveryBackend {
bool deleted = true; bool deleted = true;
for (const wchar_t* value_name : for (const wchar_t* value_name :
{kRegVersion, kRegModeDeviceName, kRegLegacyDeviceName, kRegHDRDeviceName, kRegOriginalRefreshRate, {kRegVersion,
kRegOriginalWidth, kRegOriginalHeight, kRegOriginalHDR, kRegModeChanged, kRegHDRChanged}) { kRegModeDeviceName,
kRegLegacyDeviceName,
kRegHDRDeviceName,
kRegOriginalRefreshRate,
kRegOriginalWidth,
kRegOriginalHeight,
kRegOriginalHDR,
kRegModeChanged,
kRegHDRChanged,
kRegModeTakeoverEligible,
kRegHDRTakeoverEligible,
kRegModeRecoverySlot,
kRegHDRRecoverySlot,
kRegModeDeviceNameAlternate,
kRegHDRDeviceNameAlternate,
kRegOriginalRefreshRateAlternate,
kRegOriginalWidthAlternate,
kRegOriginalHeightAlternate,
kRegOriginalHDRAlternate,
kRegModeHandoffPending,
kRegHDRHandoffPending,
kRegModePreviousRecoverySlot,
kRegHDRPreviousRecoverySlot}) {
const LONG result = RegDeleteValueW(key, value_name); const LONG result = RegDeleteValueW(key, value_name);
deleted = deleted && (result == ERROR_SUCCESS || result == ERROR_FILE_NOT_FOUND); deleted = deleted && (result == ERROR_SUCCESS || result == ERROR_FILE_NOT_FOUND);
} }
@@ -620,33 +696,78 @@ namespace {
bool ReadValidModeValues( bool ReadValidModeValues(
DisplayRecoveryBackend& backend, const wchar_t* device_value_name, std::wstring& device_name, DWORD& width, DisplayRecoveryBackend& backend, const wchar_t* device_value_name, std::wstring& device_name, DWORD& width,
DWORD& height, DWORD& refresh_rate) { DWORD& height, DWORD& refresh_rate, bool alternate = false) {
return backend.ReadString(device_value_name, device_name) && backend.ReadDWORD(kRegOriginalWidth, width) && const wchar_t* width_value_name = alternate ? kRegOriginalWidthAlternate : kRegOriginalWidth;
width > 0 && backend.ReadDWORD(kRegOriginalHeight, height) && height > 0 && const wchar_t* height_value_name = alternate ? kRegOriginalHeightAlternate : kRegOriginalHeight;
backend.ReadDWORD(kRegOriginalRefreshRate, refresh_rate) && refresh_rate > 0; const wchar_t* refresh_value_name = alternate ? kRegOriginalRefreshRateAlternate : kRegOriginalRefreshRate;
return backend.ReadString(device_value_name, device_name) && backend.ReadDWORD(width_value_name, width) &&
width > 0 && backend.ReadDWORD(height_value_name, height) && height > 0 &&
backend.ReadDWORD(refresh_value_name, refresh_rate) && refresh_rate > 0;
} }
bool ReadValidHDRValues( bool ReadValidHDRValues(
DisplayRecoveryBackend& backend, const wchar_t* device_value_name, std::wstring& device_name, DWORD& original_hdr) { DisplayRecoveryBackend& backend, const wchar_t* device_value_name, std::wstring& device_name, DWORD& original_hdr,
return backend.ReadString(device_value_name, device_name) && backend.ReadDWORD(kRegOriginalHDR, original_hdr) && bool alternate = false) {
const wchar_t* original_value_name = alternate ? kRegOriginalHDRAlternate : kRegOriginalHDR;
return backend.ReadString(device_value_name, device_name) && backend.ReadDWORD(original_value_name, original_hdr) &&
original_hdr <= 1; original_hdr <= 1;
} }
bool ReadValidMarkedMode( bool ReadRecoverySlot(DisplayRecoveryBackend& backend, const wchar_t* slot_value_name, bool& alternate) {
DisplayRecoveryBackend& backend, std::wstring& device_name, DWORD& width, DWORD& height, DWORD& refresh_rate) { DWORD slot = 0;
DWORD version = 0; if (!backend.ReadDWORD(slot_value_name, slot)) {
DWORD marker = 0; // Version-1 records created before alternate slots implicitly use the
return backend.ReadDWORD(kRegVersion, version) && version == kRecoveryVersion && // original value set.
backend.ReadDWORD(kRegModeChanged, marker) && marker == 1 && alternate = false;
ReadValidModeValues(backend, kRegModeDeviceName, device_name, width, height, refresh_rate); return true;
}
if (slot > 1) return false;
alternate = slot == 1;
return true;
} }
bool ReadValidMarkedHDR(DisplayRecoveryBackend& backend, std::wstring& device_name, DWORD& original_hdr) { bool ReadValidModeSlot(
DisplayRecoveryBackend& backend, bool alternate, std::wstring& device_name, DWORD& width, DWORD& height,
DWORD& refresh_rate) {
return ReadValidModeValues(
backend, alternate ? kRegModeDeviceNameAlternate : kRegModeDeviceName, device_name, width, height, refresh_rate,
alternate);
}
bool ReadValidHDRSlot(DisplayRecoveryBackend& backend, bool alternate, std::wstring& device_name, DWORD& original_hdr) {
return ReadValidHDRValues(
backend, alternate ? kRegHDRDeviceNameAlternate : kRegHDRDeviceName, device_name, original_hdr, alternate);
}
bool ReadValidMarkedMode(
DisplayRecoveryBackend& backend, std::wstring& device_name, DWORD& width, DWORD& height, DWORD& refresh_rate,
bool* alternate_slot = nullptr) {
DWORD version = 0; DWORD version = 0;
DWORD marker = 0; DWORD marker = 0;
return backend.ReadDWORD(kRegVersion, version) && version == kRecoveryVersion && bool alternate = false;
backend.ReadDWORD(kRegHDRChanged, marker) && marker == 1 && if (!backend.ReadDWORD(kRegVersion, version) || version != kRecoveryVersion ||
ReadValidHDRValues(backend, kRegHDRDeviceName, device_name, original_hdr); !backend.ReadDWORD(kRegModeChanged, marker) || marker != 1 ||
!ReadRecoverySlot(backend, kRegModeRecoverySlot, alternate) ||
!ReadValidModeSlot(backend, alternate, device_name, width, height, refresh_rate)) {
return false;
}
if (alternate_slot) *alternate_slot = alternate;
return true;
}
bool ReadValidMarkedHDR(
DisplayRecoveryBackend& backend, std::wstring& device_name, DWORD& original_hdr, bool* alternate_slot = nullptr) {
DWORD version = 0;
DWORD marker = 0;
bool alternate = false;
if (!backend.ReadDWORD(kRegVersion, version) || version != kRecoveryVersion ||
!backend.ReadDWORD(kRegHDRChanged, marker) || marker != 1 ||
!ReadRecoverySlot(backend, kRegHDRRecoverySlot, alternate) ||
!ReadValidHDRSlot(backend, alternate, device_name, original_hdr)) {
return false;
}
if (alternate_slot) *alternate_slot = alternate;
return true;
} }
bool DeleteRecordIfNoMarkedOperations(DisplayRecoveryBackend& backend) { bool DeleteRecordIfNoMarkedOperations(DisplayRecoveryBackend& backend) {
@@ -662,12 +783,82 @@ bool DeleteRecordIfNoMarkedOperations(DisplayRecoveryBackend& backend) {
return true; return true;
} }
bool CompleteRecoveryOperation(DisplayRecoveryBackend& backend, const wchar_t* marker) { bool IsTakeoverEligible(DisplayRecoveryBackend& backend, const wchar_t* takeover_disposition) {
if (!backend.ClearMarker(marker)) return false; DWORD value = 0;
return backend.ReadDWORD(takeover_disposition, value) && value == 1;
}
bool MarkTakeoverEligible(DisplayRecoveryBackend& backend, const wchar_t* takeover_disposition) {
return backend.WriteDWORD(takeover_disposition, 1);
}
bool ResetTakeoverEligibility(DisplayRecoveryBackend& backend, const wchar_t* takeover_disposition) {
return backend.WriteDWORD(takeover_disposition, 0);
}
bool ReadHandoffPending(DisplayRecoveryBackend& backend, const wchar_t* handoff_value_name, bool& pending) {
DWORD value = 0;
if (!backend.ReadDWORD(handoff_value_name, value)) {
pending = false;
return true;
}
if (value > 1) return false;
pending = value == 1;
return true;
}
bool ReadStoredRecoverySlot(DisplayRecoveryBackend& backend, const wchar_t* slot_value_name, bool& alternate) {
DWORD value = 0;
if (!backend.ReadDWORD(slot_value_name, value) || value > 1) return false;
alternate = value == 1;
return true;
}
bool CompleteRecoveryOperation(
DisplayRecoveryBackend& backend, const wchar_t* marker, const wchar_t* takeover_disposition,
const wchar_t* handoff_pending) {
// Handoff metadata must be inactive before its shared marker disappears.
if (!backend.WriteDWORD(handoff_pending, 0) || !ResetTakeoverEligibility(backend, takeover_disposition) ||
!backend.ClearMarker(marker)) {
return false;
}
DeleteRecordIfNoMarkedOperations(backend); DeleteRecordIfNoMarkedOperations(backend);
return true; return true;
} }
bool ConfirmPreparedRecovery(DisplayRecoveryBackend& backend, const wchar_t* handoff_pending) {
bool pending = false;
if (!ReadHandoffPending(backend, handoff_pending, pending)) return false;
return !pending || backend.WriteDWORD(handoff_pending, 0);
}
bool RollbackRecoveryHandoff(
DisplayRecoveryBackend& backend, const wchar_t* takeover_disposition, const wchar_t* recovery_slot,
const wchar_t* previous_recovery_slot, const wchar_t* handoff_pending) {
bool previous_alternate = false;
if (!ReadStoredRecoverySlot(backend, previous_recovery_slot, previous_alternate) ||
!backend.WriteDWORD(recovery_slot, previous_alternate ? 1 : 0) ||
!MarkTakeoverEligible(backend, takeover_disposition) || !backend.WriteDWORD(handoff_pending, 0)) {
return false;
}
return true;
}
bool FinalizePreparedRecovery(
DisplayRecoveryBackend& backend, bool os_apply_succeeded, const wchar_t* marker,
const wchar_t* takeover_disposition, const wchar_t* recovery_slot, const wchar_t* previous_recovery_slot,
const wchar_t* handoff_pending) {
if (os_apply_succeeded) return ConfirmPreparedRecovery(backend, handoff_pending);
bool pending = false;
if (!ReadHandoffPending(backend, handoff_pending, pending)) return false;
if (pending) {
return RollbackRecoveryHandoff(
backend, takeover_disposition, recovery_slot, previous_recovery_slot, handoff_pending);
}
return CompleteRecoveryOperation(backend, marker, takeover_disposition, handoff_pending);
}
bool PreserveValidModeSiblingOrClear(DisplayRecoveryBackend& backend) { bool PreserveValidModeSiblingOrClear(DisplayRecoveryBackend& backend) {
DWORD marker = 0; DWORD marker = 0;
if (!backend.ReadDWORD(kRegModeChanged, marker)) { if (!backend.ReadDWORD(kRegModeChanged, marker)) {
@@ -711,22 +902,59 @@ bool DisplayModeManager::PrepareModeRecovery(
DWORD existing_height = 0; DWORD existing_height = 0;
DWORD existing_refresh_rate = 0; DWORD existing_refresh_rate = 0;
std::wstring existing_device_name; std::wstring existing_device_name;
if (ReadValidMarkedMode(backend, existing_device_name, existing_width, existing_height, existing_refresh_rate)) { bool existing_alternate = false;
// A valid marked original is already protecting a live or failed if (ReadValidMarkedMode(
// operation. Reuse it only when this manager has the same original; backend, existing_device_name, existing_width, existing_height, existing_refresh_rate, &existing_alternate)) {
// replacing it would lose the only restoration point. bool handoff_pending = false;
return existing_device_name == device_name && existing_width == width && existing_height == height && if (!ReadHandoffPending(backend, kRegModeHandoffPending, handoff_pending) || handoff_pending) return false;
existing_refresh_rate == refresh_rate;
const bool same_original = existing_device_name == device_name && existing_width == width &&
existing_height == height && existing_refresh_rate == refresh_rate;
if (same_original) {
// Reusing the restoration point makes it live again, so stale takeover
// permission must be durably revoked before the Windows mutation.
return ResetTakeoverEligibility(backend, kRegModeTakeoverEligible);
}
if (!IsTakeoverEligible(backend, kRegModeTakeoverEligible)) return false;
// Stage the replacement in the inactive slot. Handoff metadata keeps both
// originals recoverable from the selector switch until the OS apply is
// confirmed.
const bool replacement_alternate = !existing_alternate;
const wchar_t* replacement_device_name = replacement_alternate ? kRegModeDeviceNameAlternate : kRegModeDeviceName;
const wchar_t* replacement_width = replacement_alternate ? kRegOriginalWidthAlternate : kRegOriginalWidth;
const wchar_t* replacement_height = replacement_alternate ? kRegOriginalHeightAlternate : kRegOriginalHeight;
const wchar_t* replacement_refresh =
replacement_alternate ? kRegOriginalRefreshRateAlternate : kRegOriginalRefreshRate;
if (!backend.WriteDWORD(kRegVersion, kRecoveryVersion) ||
!backend.WriteString(replacement_device_name, device_name) || !backend.WriteDWORD(replacement_width, width) ||
!backend.WriteDWORD(replacement_height, height) || !backend.WriteDWORD(replacement_refresh, refresh_rate) ||
!backend.WriteDWORD(kRegModePreviousRecoverySlot, existing_alternate ? 1 : 0) ||
!ResetTakeoverEligibility(backend, kRegModeTakeoverEligible)) {
return false;
}
if (!backend.WriteDWORD(kRegModeHandoffPending, 1)) {
MarkTakeoverEligible(backend, kRegModeTakeoverEligible);
return false;
}
if (!backend.WriteDWORD(kRegModeRecoverySlot, replacement_alternate ? 1 : 0)) {
RollbackRecoveryHandoff(
backend, kRegModeTakeoverEligible, kRegModeRecoverySlot, kRegModePreviousRecoverySlot,
kRegModeHandoffPending);
return false;
}
return true;
} }
// Deactivate an incomplete old mode operation before replacing any // An incomplete operation has no authoritative original to preserve. Keep
// originals. A crash anywhere before the final write is therefore a // the existing marker-last preparation sequence and select the primary slot
// harmless pre-mutation prefix. // before publishing the new operation.
if (!backend.ClearMarker(kRegModeChanged)) return false; if (!backend.ClearMarker(kRegModeChanged)) return false;
return backend.WriteDWORD(kRegVersion, kRecoveryVersion) && backend.WriteString(kRegModeDeviceName, device_name) && return backend.WriteDWORD(kRegVersion, kRecoveryVersion) && backend.WriteString(kRegModeDeviceName, device_name) &&
backend.WriteDWORD(kRegOriginalWidth, width) && backend.WriteDWORD(kRegOriginalHeight, height) && backend.WriteDWORD(kRegOriginalWidth, width) && backend.WriteDWORD(kRegOriginalHeight, height) &&
backend.WriteDWORD(kRegOriginalRefreshRate, refresh_rate) && backend.WriteDWORD(kRegModeChanged, 1); backend.WriteDWORD(kRegOriginalRefreshRate, refresh_rate) && backend.WriteDWORD(kRegModeHandoffPending, 0) &&
ResetTakeoverEligibility(backend, kRegModeTakeoverEligible) && backend.WriteDWORD(kRegModeRecoverySlot, 0) &&
backend.WriteDWORD(kRegModeChanged, 1);
} }
bool DisplayModeManager::PrepareHDRRecovery( bool DisplayModeManager::PrepareHDRRecovery(
@@ -736,14 +964,42 @@ bool DisplayModeManager::PrepareHDRRecovery(
DWORD existing_original = 0; DWORD existing_original = 0;
std::wstring existing_device_name; std::wstring existing_device_name;
if (ReadValidMarkedHDR(backend, existing_device_name, existing_original)) { bool existing_alternate = false;
return existing_device_name == device_name && existing_original == (enabled ? 1u : 0u); if (ReadValidMarkedHDR(backend, existing_device_name, existing_original, &existing_alternate)) {
bool handoff_pending = false;
if (!ReadHandoffPending(backend, kRegHDRHandoffPending, handoff_pending) || handoff_pending) return false;
const bool same_original = existing_device_name == device_name && existing_original == (enabled ? 1u : 0u);
if (same_original) return ResetTakeoverEligibility(backend, kRegHDRTakeoverEligible);
if (!IsTakeoverEligible(backend, kRegHDRTakeoverEligible)) return false;
const bool replacement_alternate = !existing_alternate;
const wchar_t* replacement_device_name = replacement_alternate ? kRegHDRDeviceNameAlternate : kRegHDRDeviceName;
const wchar_t* replacement_original = replacement_alternate ? kRegOriginalHDRAlternate : kRegOriginalHDR;
if (!backend.WriteDWORD(kRegVersion, kRecoveryVersion) ||
!backend.WriteString(replacement_device_name, device_name) ||
!backend.WriteDWORD(replacement_original, enabled ? 1 : 0) ||
!backend.WriteDWORD(kRegHDRPreviousRecoverySlot, existing_alternate ? 1 : 0) ||
!ResetTakeoverEligibility(backend, kRegHDRTakeoverEligible)) {
return false;
}
if (!backend.WriteDWORD(kRegHDRHandoffPending, 1)) {
MarkTakeoverEligible(backend, kRegHDRTakeoverEligible);
return false;
}
if (!backend.WriteDWORD(kRegHDRRecoverySlot, replacement_alternate ? 1 : 0)) {
RollbackRecoveryHandoff(
backend, kRegHDRTakeoverEligible, kRegHDRRecoverySlot, kRegHDRPreviousRecoverySlot, kRegHDRHandoffPending);
return false;
}
return true;
} }
if (!backend.ClearMarker(kRegHDRChanged)) return false; if (!backend.ClearMarker(kRegHDRChanged)) return false;
return backend.WriteDWORD(kRegVersion, kRecoveryVersion) && backend.WriteString(kRegHDRDeviceName, device_name) && return backend.WriteDWORD(kRegVersion, kRecoveryVersion) && backend.WriteString(kRegHDRDeviceName, device_name) &&
backend.WriteDWORD(kRegOriginalHDR, enabled ? 1 : 0) && backend.WriteDWORD(kRegHDRChanged, 1); backend.WriteDWORD(kRegOriginalHDR, enabled ? 1 : 0) && backend.WriteDWORD(kRegHDRHandoffPending, 0) &&
ResetTakeoverEligibility(backend, kRegHDRTakeoverEligible) && backend.WriteDWORD(kRegHDRRecoverySlot, 0) &&
backend.WriteDWORD(kRegHDRChanged, 1);
} }
namespace { namespace {
@@ -758,9 +1014,29 @@ bool PrepareHDRRecoveryAtRegistry(const std::wstring& device_name, bool enabled)
return DisplayModeManager::PrepareHDRRecovery(backend, device_name, enabled); return DisplayModeManager::PrepareHDRRecovery(backend, device_name, enabled);
} }
bool CompleteRecoveryOperationAtRegistry(const wchar_t* marker) { bool CompleteRecoveryOperationAtRegistry(
const wchar_t* marker, const wchar_t* takeover_disposition, const wchar_t* handoff_pending) {
Win32DisplayRecoveryBackend backend; Win32DisplayRecoveryBackend backend;
return CompleteRecoveryOperation(backend, marker); return CompleteRecoveryOperation(backend, marker, takeover_disposition, handoff_pending);
}
bool FinalizeModeRecoveryAtRegistry(bool os_apply_succeeded) {
Win32DisplayRecoveryBackend backend;
return FinalizePreparedRecovery(
backend, os_apply_succeeded, kRegModeChanged, kRegModeTakeoverEligible, kRegModeRecoverySlot,
kRegModePreviousRecoverySlot, kRegModeHandoffPending);
}
bool FinalizeHDRRecoveryAtRegistry(bool os_apply_succeeded) {
Win32DisplayRecoveryBackend backend;
return FinalizePreparedRecovery(
backend, os_apply_succeeded, kRegHDRChanged, kRegHDRTakeoverEligible, kRegHDRRecoverySlot,
kRegHDRPreviousRecoverySlot, kRegHDRHandoffPending);
}
bool MarkTakeoverEligibleAtRegistry(const wchar_t* takeover_disposition) {
Win32DisplayRecoveryBackend backend;
return MarkTakeoverEligible(backend, takeover_disposition);
} }
bool RecoverRecord(DisplayRecoveryBackend& backend, bool mode_is_live, bool hdr_is_live) { bool RecoverRecord(DisplayRecoveryBackend& backend, bool mode_is_live, bool hdr_is_live) {
@@ -794,9 +1070,32 @@ bool RecoverRecord(DisplayRecoveryBackend& backend, bool mode_is_live, bool hdr_
DWORD width = 0; DWORD width = 0;
DWORD height = 0; DWORD height = 0;
DWORD refresh_rate = 0; DWORD refresh_rate = 0;
const bool mode_requested = bool mode_handoff_pending = false;
mode_marker_read && mode_marker == 1 && std::wstring previous_mode_device_name;
DWORD previous_width = 0;
DWORD previous_height = 0;
DWORD previous_refresh_rate = 0;
bool mode_requested = false;
if (mode_marker_read && mode_marker == 1) {
if (has_version) {
bool handoff_state_valid = ReadHandoffPending(backend, kRegModeHandoffPending, mode_handoff_pending);
if (handoff_state_valid && mode_handoff_pending) {
bool previous_alternate = false;
handoff_state_valid =
ReadStoredRecoverySlot(backend, kRegModePreviousRecoverySlot, previous_alternate) &&
ReadValidModeSlot(backend, !previous_alternate, mode_device_name, width, height, refresh_rate) &&
ReadValidModeSlot(
backend, previous_alternate, previous_mode_device_name, previous_width, previous_height,
previous_refresh_rate);
} else if (handoff_state_valid) {
handoff_state_valid = ReadValidMarkedMode(backend, mode_device_name, width, height, refresh_rate);
}
mode_requested = handoff_state_valid;
} else {
mode_requested =
ReadValidModeValues(backend, mode_device_value_name, mode_device_name, width, height, refresh_rate); ReadValidModeValues(backend, mode_device_value_name, mode_device_name, width, height, refresh_rate);
}
}
if (!mode_is_live && (!mode_marker_read || mode_marker > 1 || (mode_marker == 1 && !mode_requested))) { if (!mode_is_live && (!mode_marker_read || mode_marker > 1 || (mode_marker == 1 && !mode_requested))) {
// Malformation in one operation does not erase a valid or live sibling. // Malformation in one operation does not erase a valid or live sibling.
backend.ClearMarker(kRegModeChanged); backend.ClearMarker(kRegModeChanged);
@@ -806,8 +1105,27 @@ bool RecoverRecord(DisplayRecoveryBackend& backend, bool mode_is_live, bool hdr_
const bool hdr_marker_read = backend.ReadDWORD(kRegHDRChanged, hdr_marker); const bool hdr_marker_read = backend.ReadDWORD(kRegHDRChanged, hdr_marker);
std::wstring hdr_device_name; std::wstring hdr_device_name;
DWORD original_hdr = 0; DWORD original_hdr = 0;
const bool hdr_requested = hdr_marker_read && hdr_marker == 1 && bool hdr_handoff_pending = false;
ReadValidHDRValues(backend, hdr_device_value_name, hdr_device_name, original_hdr); std::wstring previous_hdr_device_name;
DWORD previous_original_hdr = 0;
bool hdr_requested = false;
if (hdr_marker_read && hdr_marker == 1) {
if (has_version) {
bool handoff_state_valid = ReadHandoffPending(backend, kRegHDRHandoffPending, hdr_handoff_pending);
if (handoff_state_valid && hdr_handoff_pending) {
bool previous_alternate = false;
handoff_state_valid =
ReadStoredRecoverySlot(backend, kRegHDRPreviousRecoverySlot, previous_alternate) &&
ReadValidHDRSlot(backend, !previous_alternate, hdr_device_name, original_hdr) &&
ReadValidHDRSlot(backend, previous_alternate, previous_hdr_device_name, previous_original_hdr);
} else if (handoff_state_valid) {
handoff_state_valid = ReadValidMarkedHDR(backend, hdr_device_name, original_hdr);
}
hdr_requested = handoff_state_valid;
} else {
hdr_requested = ReadValidHDRValues(backend, hdr_device_value_name, hdr_device_name, original_hdr);
}
}
if (!hdr_is_live && (!hdr_marker_read || hdr_marker > 1 || (hdr_marker == 1 && !hdr_requested))) { if (!hdr_is_live && (!hdr_marker_read || hdr_marker > 1 || (hdr_marker == 1 && !hdr_requested))) {
backend.ClearMarker(kRegHDRChanged); backend.ClearMarker(kRegHDRChanged);
} }
@@ -821,19 +1139,43 @@ bool RecoverRecord(DisplayRecoveryBackend& backend, bool mode_is_live, bool hdr_
bool completed = true; bool completed = true;
if (recover_mode) { if (recover_mode) {
if (backend.IsDevicePresent(mode_device_name) && const bool restored =
backend.RestoreMode(mode_device_name, width, height, refresh_rate)) { backend.IsDevicePresent(mode_device_name) && backend.RestoreMode(mode_device_name, width, height, refresh_rate);
// A failed marker clear leaves an idempotent restoration for a later pass. if (mode_handoff_pending) {
completed = CompleteRecoveryOperation(backend, kRegModeChanged) && completed; // Restore the replacement first and the pre-handoff original last. Both
} else { // remain marked until both idempotent restores and cleanup succeed.
const bool previous_restored =
backend.IsDevicePresent(previous_mode_device_name) &&
backend.RestoreMode(previous_mode_device_name, previous_width, previous_height, previous_refresh_rate);
if (!restored || !previous_restored ||
!CompleteRecoveryOperation(backend, kRegModeChanged, kRegModeTakeoverEligible, kRegModeHandoffPending)) {
completed = false;
}
} else if (
!restored ||
!CompleteRecoveryOperation(backend, kRegModeChanged, kRegModeTakeoverEligible, kRegModeHandoffPending)) {
// Retain the restoration point for topology retries, but remember that
// one real non-live recovery attempt failed so a later conflicting mode
// request may deliberately replace it.
MarkTakeoverEligible(backend, kRegModeTakeoverEligible);
completed = false; completed = false;
} }
} }
if (recover_hdr) { if (recover_hdr) {
if (backend.IsDevicePresent(hdr_device_name) && backend.RestoreHDR(hdr_device_name, original_hdr != 0)) { const bool restored =
completed = CompleteRecoveryOperation(backend, kRegHDRChanged) && completed; backend.IsDevicePresent(hdr_device_name) && backend.RestoreHDR(hdr_device_name, original_hdr != 0);
} else { if (hdr_handoff_pending) {
const bool previous_restored = backend.IsDevicePresent(previous_hdr_device_name) &&
backend.RestoreHDR(previous_hdr_device_name, previous_original_hdr != 0);
if (!restored || !previous_restored ||
!CompleteRecoveryOperation(backend, kRegHDRChanged, kRegHDRTakeoverEligible, kRegHDRHandoffPending)) {
completed = false;
}
} else if (
!restored ||
!CompleteRecoveryOperation(backend, kRegHDRChanged, kRegHDRTakeoverEligible, kRegHDRHandoffPending)) {
MarkTakeoverEligible(backend, kRegHDRTakeoverEligible);
completed = false; completed = false;
} }
} }
@@ -860,7 +1202,19 @@ bool DisplayModeManager::RecoverIfNeeded(DisplayRecoveryBackend& backend) {
#if defined(PLEZY_DISPLAY_MODE_MANAGER_TESTING) #if defined(PLEZY_DISPLAY_MODE_MANAGER_TESTING)
bool DisplayModeManager::CompleteRecoveryOperationForTesting(DisplayRecoveryBackend& backend, bool mode) { bool DisplayModeManager::CompleteRecoveryOperationForTesting(DisplayRecoveryBackend& backend, bool mode) {
std::lock_guard<std::recursive_mutex> transaction_lock(g_display_override_mutex); std::lock_guard<std::recursive_mutex> transaction_lock(g_display_override_mutex);
return CompleteRecoveryOperation(backend, mode ? kRegModeChanged : kRegHDRChanged); return CompleteRecoveryOperation(
backend, mode ? kRegModeChanged : kRegHDRChanged, mode ? kRegModeTakeoverEligible : kRegHDRTakeoverEligible,
mode ? kRegModeHandoffPending : kRegHDRHandoffPending);
}
bool DisplayModeManager::FinalizePreparedRecoveryForTesting(
DisplayRecoveryBackend& backend, bool mode, bool os_apply_succeeded) {
std::lock_guard<std::recursive_mutex> transaction_lock(g_display_override_mutex);
return FinalizePreparedRecovery(
backend, os_apply_succeeded, mode ? kRegModeChanged : kRegHDRChanged,
mode ? kRegModeTakeoverEligible : kRegHDRTakeoverEligible, mode ? kRegModeRecoverySlot : kRegHDRRecoverySlot,
mode ? kRegModePreviousRecoverySlot : kRegHDRPreviousRecoverySlot,
mode ? kRegModeHandoffPending : kRegHDRHandoffPending);
} }
bool DisplayModeManager::RecoverIfNeededForTesting( bool DisplayModeManager::RecoverIfNeededForTesting(
+12 -4
View File
@@ -105,21 +105,29 @@ class DisplayModeManager {
// --- Crash recovery --- // --- Crash recovery ---
// Persist a complete original followed by its operation marker. These // Persist a complete original followed by its operation marker. A failed
// runner-internal seams make the crash ordering deterministic in tests. // non-live recovery remains retryable, but durably permits a later
// conflicting request of the same kind to replace it. Conflicting originals
// are staged in an inactive slot. A persisted handoff keeps both originals
// recoverable across the selector switch and OS call; success confirms the
// new slot, while OS failure rolls back the prior selector and eligibility.
// Reusing the same original revokes permission before it becomes live.
// These runner-internal seams make the crash ordering deterministic in tests.
static bool PrepareModeRecovery( static bool PrepareModeRecovery(
DisplayRecoveryBackend& backend, const std::wstring& device_name, DWORD width, DWORD height, DWORD refresh_rate); DisplayRecoveryBackend& backend, const std::wstring& device_name, DWORD width, DWORD height, DWORD refresh_rate);
static bool PrepareHDRRecovery(DisplayRecoveryBackend& backend, const std::wstring& device_name, bool enabled); static bool PrepareHDRRecovery(DisplayRecoveryBackend& backend, const std::wstring& device_name, bool enabled);
// Check for and recover from a prior crash that left display settings // Check for and recover from a prior crash that left display settings
// changed. Successful operation markers are cleared independently. Failed // changed. Successful operation markers and their takeover dispositions are
// operations remain for the next startup or display-topology notification. // cleared independently. Failed operations remain recovery-first until a
// conflicting same-kind preparation consumes their persisted disposition.
static bool RecoverIfNeeded(); static bool RecoverIfNeeded();
static bool RecoverIfNeeded(DisplayRecoveryBackend& backend); static bool RecoverIfNeeded(DisplayRecoveryBackend& backend);
#if defined(PLEZY_DISPLAY_MODE_MANAGER_TESTING) #if defined(PLEZY_DISPLAY_MODE_MANAGER_TESTING)
// Exercise persisted lifecycle exits and per-operation live ownership without // Exercise persisted lifecycle exits and per-operation live ownership without
// touching a real display or registry. // touching a real display or registry.
static bool CompleteRecoveryOperationForTesting(DisplayRecoveryBackend& backend, bool mode); static bool CompleteRecoveryOperationForTesting(DisplayRecoveryBackend& backend, bool mode);
static bool FinalizePreparedRecoveryForTesting(DisplayRecoveryBackend& backend, bool mode, bool os_apply_succeeded);
static bool RecoverIfNeededForTesting(DisplayRecoveryBackend& backend, bool mode_is_live, bool hdr_is_live); static bool RecoverIfNeededForTesting(DisplayRecoveryBackend& backend, bool mode_is_live, bool hdr_is_live);
#endif #endif
+629 -29
View File
@@ -19,8 +19,24 @@ constexpr wchar_t kOriginalHeight[] = L"OriginalHeight";
constexpr wchar_t kOriginalHDR[] = L"OriginalHDREnabled"; constexpr wchar_t kOriginalHDR[] = L"OriginalHDREnabled";
constexpr wchar_t kModeChanged[] = L"ModeChanged"; constexpr wchar_t kModeChanged[] = L"ModeChanged";
constexpr wchar_t kHDRChanged[] = L"HDRChanged"; constexpr wchar_t kHDRChanged[] = L"HDRChanged";
constexpr wchar_t kModeTakeoverEligible[] = L"ModeTakeoverEligible";
constexpr wchar_t kHDRTakeoverEligible[] = L"HDRTakeoverEligible";
constexpr wchar_t kModeRecoverySlot[] = L"ModeRecoverySlot";
constexpr wchar_t kHDRRecoverySlot[] = L"HDRRecoverySlot";
constexpr wchar_t kModeDeviceNameAlternate[] = L"ModeDeviceNameAlternate";
constexpr wchar_t kHDRDeviceNameAlternate[] = L"HDRDeviceNameAlternate";
constexpr wchar_t kOriginalRefreshRateAlternate[] = L"OriginalRefreshRateAlternate";
constexpr wchar_t kOriginalWidthAlternate[] = L"OriginalWidthAlternate";
constexpr wchar_t kOriginalHeightAlternate[] = L"OriginalHeightAlternate";
constexpr wchar_t kOriginalHDRAlternate[] = L"OriginalHDREnabledAlternate";
constexpr wchar_t kModeHandoffPending[] = L"ModeHandoffPending";
constexpr wchar_t kHDRHandoffPending[] = L"HDRHandoffPending";
constexpr wchar_t kModePreviousRecoverySlot[] = L"ModePreviousRecoverySlot";
constexpr wchar_t kHDRPreviousRecoverySlot[] = L"HDRPreviousRecoverySlot";
constexpr wchar_t kModeDevice[] = L"\\\\.\\DISPLAY1"; constexpr wchar_t kModeDevice[] = L"\\\\.\\DISPLAY1";
constexpr wchar_t kHDRDevice[] = L"\\\\.\\DISPLAY2"; constexpr wchar_t kHDRDevice[] = L"\\\\.\\DISPLAY2";
constexpr wchar_t kReplacementModeDevice[] = L"\\\\.\\DISPLAY3";
constexpr wchar_t kReplacementHDRDevice[] = L"\\\\.\\DISPLAY4";
void Check(bool condition, const char* message) { void Check(bool condition, const char* message) {
if (!condition) { if (!condition) {
@@ -29,14 +45,35 @@ void Check(bool condition, const char* message) {
} }
} }
struct ExpectedModeRestore {
std::wstring device_name;
DWORD width;
DWORD height;
DWORD refresh_rate;
};
struct ExpectedHDRRestore {
std::wstring device_name;
bool enabled;
};
class FakeRecoveryBackend final : public DisplayRecoveryBackend { class FakeRecoveryBackend final : public DisplayRecoveryBackend {
public: public:
std::map<std::wstring, DWORD> dwords; std::map<std::wstring, DWORD> dwords;
std::map<std::wstring, std::wstring> strings; std::map<std::wstring, std::wstring> strings;
std::map<std::wstring, bool> device_present = {{kModeDevice, true}, {kHDRDevice, true}}; std::map<std::wstring, bool> device_present = {
{kModeDevice, true}, {kHDRDevice, true}, {kReplacementModeDevice, true}, {kReplacementHDRDevice, true}};
std::vector<std::wstring> events; std::vector<std::wstring> events;
std::wstring expected_mode_device = kModeDevice; std::wstring expected_mode_device = kModeDevice;
DWORD expected_mode_width = 3840;
DWORD expected_mode_height = 2160;
DWORD expected_mode_refresh_rate = 60;
std::wstring expected_hdr_device = kHDRDevice; std::wstring expected_hdr_device = kHDRDevice;
bool expected_original_hdr = false;
std::vector<ExpectedModeRestore> expected_mode_restores;
std::vector<ExpectedHDRRestore> expected_hdr_restores;
size_t mode_restore_attempts = 0;
size_t hdr_restore_attempts = 0;
bool mode_restore_succeeds = true; bool mode_restore_succeeds = true;
bool hdr_restore_succeeds = true; bool hdr_restore_succeeds = true;
bool mode_marker_clear_succeeds = true; bool mode_marker_clear_succeeds = true;
@@ -44,6 +81,7 @@ class FakeRecoveryBackend final : public DisplayRecoveryBackend {
bool delete_succeeds = true; bool delete_succeeds = true;
bool final_mode_marker_write_succeeds = true; bool final_mode_marker_write_succeeds = true;
bool final_hdr_marker_write_succeeds = true; bool final_hdr_marker_write_succeeds = true;
std::wstring rejected_dword_write_event;
int delete_attempts = 0; int delete_attempts = 0;
void SeedBoth() { void SeedBoth() {
@@ -76,8 +114,10 @@ class FakeRecoveryBackend final : public DisplayRecoveryBackend {
bool WriteDWORD(const wchar_t* value_name, DWORD value) override { bool WriteDWORD(const wchar_t* value_name, DWORD value) override {
const std::wstring name(value_name); const std::wstring name(value_name);
events.push_back(L"write:" + name + L"=" + std::to_wstring(value)); const std::wstring event = L"write:" + name + L"=" + std::to_wstring(value);
if ((name == kModeChanged && value == 1 && !final_mode_marker_write_succeeds) || events.push_back(event);
if (event == rejected_dword_write_event ||
(name == kModeChanged && value == 1 && !final_mode_marker_write_succeeds) ||
(name == kHDRChanged && value == 1 && !final_hdr_marker_write_succeeds)) { (name == kHDRChanged && value == 1 && !final_hdr_marker_write_succeeds)) {
return false; return false;
} }
@@ -98,15 +138,35 @@ class FakeRecoveryBackend final : public DisplayRecoveryBackend {
} }
bool RestoreMode(const std::wstring& device_name, DWORD width, DWORD height, DWORD refresh_rate) override { bool RestoreMode(const std::wstring& device_name, DWORD width, DWORD height, DWORD refresh_rate) override {
if (expected_mode_restores.empty()) {
Check(device_name == expected_mode_device, "mode restore must use its persisted display"); Check(device_name == expected_mode_device, "mode restore must use its persisted display");
Check(width == 3840 && height == 2160 && refresh_rate == 60, "mode restore must use persisted originals"); Check(
width == expected_mode_width && height == expected_mode_height && refresh_rate == expected_mode_refresh_rate,
"mode restore must use persisted originals");
} else {
Check(mode_restore_attempts < expected_mode_restores.size(), "mode recovery made an unexpected extra restore");
const auto& expected = expected_mode_restores[mode_restore_attempts];
Check(device_name == expected.device_name, "mode handoff restore order must be deterministic");
Check(
width == expected.width && height == expected.height && refresh_rate == expected.refresh_rate,
"mode handoff restore must use each persisted original");
}
++mode_restore_attempts;
events.push_back(L"restore:mode"); events.push_back(L"restore:mode");
return mode_restore_succeeds; return mode_restore_succeeds;
} }
bool RestoreHDR(const std::wstring& device_name, bool enabled) override { bool RestoreHDR(const std::wstring& device_name, bool enabled) override {
if (expected_hdr_restores.empty()) {
Check(device_name == expected_hdr_device, "HDR restore must use its independently persisted display"); Check(device_name == expected_hdr_device, "HDR restore must use its independently persisted display");
Check(!enabled, "HDR restore must use the persisted original state"); Check(enabled == expected_original_hdr, "HDR restore must use the persisted original state");
} else {
Check(hdr_restore_attempts < expected_hdr_restores.size(), "HDR recovery made an unexpected extra restore");
const auto& expected = expected_hdr_restores[hdr_restore_attempts];
Check(device_name == expected.device_name, "HDR handoff restore order must be deterministic");
Check(enabled == expected.enabled, "HDR handoff restore must use each persisted original");
}
++hdr_restore_attempts;
events.push_back(L"restore:hdr"); events.push_back(L"restore:hdr");
return hdr_restore_succeeds; return hdr_restore_succeeds;
} }
@@ -136,18 +196,24 @@ size_t EventIndex(const std::vector<std::wstring>& events, const std::wstring& e
return events.size(); return events.size();
} }
bool ApplyModeAfterPreparing(FakeRecoveryBackend& backend) { bool ApplyModeAfterPreparing(
if (!DisplayModeManager::PrepareModeRecovery(backend, kModeDevice, 3840, 2160, 60)) { FakeRecoveryBackend& backend, const std::wstring& device_name = kModeDevice, DWORD width = 3840,
DWORD height = 2160, DWORD refresh_rate = 60, bool os_apply_succeeds = true) {
if (!DisplayModeManager::PrepareModeRecovery(backend, device_name, width, height, refresh_rate)) {
return false; return false;
} }
backend.events.push_back(L"os:mode"); backend.events.push_back(os_apply_succeeds ? L"os:mode" : L"os:mode:failed");
return true; DisplayModeManager::FinalizePreparedRecoveryForTesting(backend, true, os_apply_succeeds);
return os_apply_succeeds;
} }
bool ApplyHDRAfterPreparing(FakeRecoveryBackend& backend) { bool ApplyHDRAfterPreparing(
if (!DisplayModeManager::PrepareHDRRecovery(backend, kHDRDevice, false)) return false; FakeRecoveryBackend& backend, const std::wstring& device_name = kHDRDevice, bool enabled = false,
backend.events.push_back(L"os:hdr"); bool os_apply_succeeds = true) {
return true; if (!DisplayModeManager::PrepareHDRRecovery(backend, device_name, enabled)) return false;
backend.events.push_back(os_apply_succeeds ? L"os:hdr" : L"os:hdr:failed");
DisplayModeManager::FinalizePreparedRecoveryForTesting(backend, false, os_apply_succeeds);
return os_apply_succeeds;
} }
void TestMarkersArePersistedBeforeMutation() { void TestMarkersArePersistedBeforeMutation() {
@@ -160,8 +226,10 @@ void TestMarkersArePersistedBeforeMutation() {
EventIndex(mode.events, L"write:ModeDeviceName") < mode_marker && EventIndex(mode.events, L"write:ModeDeviceName") < mode_marker &&
EventIndex(mode.events, L"write:OriginalWidth=3840") < mode_marker && EventIndex(mode.events, L"write:OriginalWidth=3840") < mode_marker &&
EventIndex(mode.events, L"write:OriginalHeight=2160") < mode_marker && EventIndex(mode.events, L"write:OriginalHeight=2160") < mode_marker &&
EventIndex(mode.events, L"write:OriginalRefreshRate=60") < mode_marker, EventIndex(mode.events, L"write:OriginalRefreshRate=60") < mode_marker &&
"all mode originals must precede the operation marker"); EventIndex(mode.events, L"write:ModeTakeoverEligible=0") < mode_marker &&
EventIndex(mode.events, L"write:ModeRecoverySlot=0") < mode_marker,
"all mode originals, slot, and reset disposition must precede the operation marker");
FakeRecoveryBackend hdr; FakeRecoveryBackend hdr;
Check(ApplyHDRAfterPreparing(hdr), "a complete HDR recovery record must admit the OS mutation"); Check(ApplyHDRAfterPreparing(hdr), "a complete HDR recovery record must admit the OS mutation");
@@ -169,8 +237,10 @@ void TestMarkersArePersistedBeforeMutation() {
Check( Check(
EventIndex(hdr.events, L"write:HDRDeviceName") < hdr_marker && EventIndex(hdr.events, L"write:HDRDeviceName") < hdr_marker &&
EventIndex(hdr.events, L"write:OriginalHDREnabled=0") < hdr_marker && EventIndex(hdr.events, L"write:OriginalHDREnabled=0") < hdr_marker &&
EventIndex(hdr.events, L"write:HDRTakeoverEligible=0") < hdr_marker &&
EventIndex(hdr.events, L"write:HDRRecoverySlot=0") < hdr_marker &&
hdr_marker < EventIndex(hdr.events, L"os:hdr"), hdr_marker < EventIndex(hdr.events, L"os:hdr"),
"the HDR original and marker must be durable before the OS mutation"); "the HDR original, slot, reset disposition, and marker must be durable before the OS mutation");
FakeRecoveryBackend failed_marker; FakeRecoveryBackend failed_marker;
failed_marker.final_mode_marker_write_succeeds = false; failed_marker.final_mode_marker_write_succeeds = false;
@@ -256,8 +326,12 @@ void TestModeAndHDRRestoreIndependently() {
EventIndex(backend.events, L"restore:mode") < backend.events.size() && EventIndex(backend.events, L"restore:mode") < backend.events.size() &&
EventIndex(backend.events, L"restore:hdr") < backend.events.size(), EventIndex(backend.events, L"restore:hdr") < backend.events.size(),
"mode failure must not prevent the independent HDR restore"); "mode failure must not prevent the independent HDR restore");
Check(backend.dwords[kModeChanged] == 1, "the failed mode marker must remain set"); Check(
Check(backend.dwords[kHDRChanged] == 0, "the successful HDR marker must be cleared"); backend.dwords[kModeChanged] == 1 && backend.dwords[kModeTakeoverEligible] == 1,
"the failed mode marker and its takeover disposition must remain set");
Check(
backend.dwords[kHDRChanged] == 0 && backend.dwords[kHDRTakeoverEligible] == 0,
"the successful HDR marker and matching disposition must be cleared");
backend.events.clear(); backend.events.clear();
backend.mode_restore_succeeds = true; backend.mode_restore_succeeds = true;
@@ -275,7 +349,9 @@ void TestFailedRestoreRemainsForTopologyRetry() {
backend.device_present[kModeDevice] = false; backend.device_present[kModeDevice] = false;
Check(!DisplayModeManager::RecoverIfNeeded(backend), "an absent display must retain its marked restore"); Check(!DisplayModeManager::RecoverIfNeeded(backend), "an absent display must retain its marked restore");
Check(backend.dwords[kModeChanged] == 1, "an absent display must keep its operation marker"); Check(
backend.dwords[kModeChanged] == 1 && backend.dwords[kModeTakeoverEligible] == 1,
"an absent display must keep its operation marker and persist takeover eligibility");
Check( Check(
EventIndex(backend.events, L"restore:mode") == backend.events.size(), EventIndex(backend.events, L"restore:mode") == backend.events.size(),
"an absent display must not call its restore API"); "an absent display must not call its restore API");
@@ -287,6 +363,9 @@ void TestFailedRestoreRemainsForTopologyRetry() {
Check( Check(
EventIndex(backend.events, L"restore:mode") < backend.events.size(), EventIndex(backend.events, L"restore:mode") < backend.events.size(),
"the topology retry must attempt the retained restore"); "the topology retry must attempt the retained restore");
Check(
!backend.RecordExists() && backend.dwords.find(kModeTakeoverEligible) == backend.dwords.end(),
"reconnect recovery must clear the marker and remove its disposition through normal cleanup");
} }
void TestMarkerClearFailureRemainsRetryable() { void TestMarkerClearFailureRemainsRetryable() {
@@ -296,7 +375,9 @@ void TestMarkerClearFailureRemainsRetryable() {
backend.mode_marker_clear_succeeds = false; backend.mode_marker_clear_succeeds = false;
Check(!DisplayModeManager::RecoverIfNeeded(backend), "marker persistence is part of recovery completion"); Check(!DisplayModeManager::RecoverIfNeeded(backend), "marker persistence is part of recovery completion");
Check(backend.dwords[kModeChanged] == 1, "a failed marker clear must retain idempotent recovery evidence"); Check(
backend.dwords[kModeChanged] == 1 && backend.dwords[kModeTakeoverEligible] == 1,
"a failed marker clear must retain recovery evidence and become takeover eligible");
backend.events.clear(); backend.events.clear();
backend.mode_marker_clear_succeeds = true; backend.mode_marker_clear_succeeds = true;
@@ -304,17 +385,21 @@ void TestMarkerClearFailureRemainsRetryable() {
Check( Check(
EventIndex(backend.events, L"restore:mode") < backend.events.size(), EventIndex(backend.events, L"restore:mode") < backend.events.size(),
"the retained marker must cause the restore to be retried"); "the retained marker must cause the restore to be retried");
Check(!backend.RecordExists(), "the successful retry must remove the marker and stale disposition");
} }
void TestLifecycleCleanupPreservesPersistedSibling() { void TestLifecycleCleanupPreservesPersistedSibling() {
FakeRecoveryBackend mode_completed; FakeRecoveryBackend mode_completed;
mode_completed.SeedBoth(); mode_completed.SeedBoth();
mode_completed.dwords[kModeTakeoverEligible] = 1;
mode_completed.dwords[kHDRTakeoverEligible] = 1;
Check( Check(
DisplayModeManager::CompleteRecoveryOperationForTesting(mode_completed, true), DisplayModeManager::CompleteRecoveryOperationForTesting(mode_completed, true),
"successful mode cleanup must durably clear its own marker"); "successful mode cleanup must durably clear its own marker");
Check( Check(
mode_completed.dwords[kModeChanged] == 0 && mode_completed.dwords[kHDRChanged] == 1, mode_completed.dwords[kModeChanged] == 0 && mode_completed.dwords[kModeTakeoverEligible] == 0 &&
"mode cleanup must preserve a persisted HDR sibling even without local HDR ownership"); mode_completed.dwords[kHDRChanged] == 1 && mode_completed.dwords[kHDRTakeoverEligible] == 1,
"mode cleanup must reset only its disposition while preserving a persisted HDR sibling");
Check( Check(
mode_completed.dwords[kOriginalHDR] == 0 && mode_completed.strings[kHDRDeviceName] == kHDRDevice && mode_completed.dwords[kOriginalHDR] == 0 && mode_completed.strings[kHDRDeviceName] == kHDRDevice &&
mode_completed.delete_attempts == 0, mode_completed.delete_attempts == 0,
@@ -322,12 +407,15 @@ void TestLifecycleCleanupPreservesPersistedSibling() {
FakeRecoveryBackend hdr_failed_apply; FakeRecoveryBackend hdr_failed_apply;
hdr_failed_apply.SeedBoth(); hdr_failed_apply.SeedBoth();
hdr_failed_apply.dwords[kModeTakeoverEligible] = 1;
hdr_failed_apply.dwords[kHDRTakeoverEligible] = 1;
Check( Check(
DisplayModeManager::CompleteRecoveryOperationForTesting(hdr_failed_apply, false), DisplayModeManager::CompleteRecoveryOperationForTesting(hdr_failed_apply, false),
"failed HDR apply cleanup must durably clear its own marker"); "failed HDR apply cleanup must durably clear its own marker");
Check( Check(
hdr_failed_apply.dwords[kHDRChanged] == 0 && hdr_failed_apply.dwords[kModeChanged] == 1, hdr_failed_apply.dwords[kHDRChanged] == 0 && hdr_failed_apply.dwords[kHDRTakeoverEligible] == 0 &&
"HDR cleanup must preserve a persisted mode sibling even without local mode ownership"); hdr_failed_apply.dwords[kModeChanged] == 1 && hdr_failed_apply.dwords[kModeTakeoverEligible] == 1,
"HDR cleanup must reset only its disposition while preserving a persisted mode sibling");
Check( Check(
hdr_failed_apply.dwords[kOriginalWidth] == 3840 && hdr_failed_apply.strings[kModeDeviceName] == kModeDevice && hdr_failed_apply.dwords[kOriginalWidth] == 3840 && hdr_failed_apply.strings[kModeDeviceName] == kModeDevice &&
hdr_failed_apply.delete_attempts == 0, hdr_failed_apply.delete_attempts == 0,
@@ -343,13 +431,18 @@ void TestReleasedLiveOperationRecoversAfterReconnect() {
!DisplayModeManager::RecoverIfNeededForTesting(backend, true, true), !DisplayModeManager::RecoverIfNeededForTesting(backend, true, true),
"topology recovery must not take either genuinely live operation"); "topology recovery must not take either genuinely live operation");
Check(backend.events.empty(), "live operations must not reach restore or persistence APIs"); Check(backend.events.empty(), "live operations must not reach restore or persistence APIs");
Check(
backend.dwords.find(kModeTakeoverEligible) == backend.dwords.end() &&
backend.dwords.find(kHDRTakeoverEligible) == backend.dwords.end(),
"live operations must not acquire takeover dispositions");
Check( Check(
!DisplayModeManager::RecoverIfNeededForTesting(backend, false, true), !DisplayModeManager::RecoverIfNeededForTesting(backend, false, true),
"a released operation must remain marked while its target is absent"); "a released operation must remain marked while its target is absent");
Check( Check(
backend.dwords[kModeChanged] == 1 && backend.dwords[kHDRChanged] == 1, backend.dwords[kModeChanged] == 1 && backend.dwords[kModeTakeoverEligible] == 1 &&
"an absent released mode and its live HDR sibling must both retain their markers"); backend.dwords[kHDRChanged] == 1,
"an absent released mode must become eligible without changing its live HDR sibling");
backend.events.clear(); backend.events.clear();
backend.device_present[kModeDevice] = true; backend.device_present[kModeDevice] = true;
@@ -361,8 +454,503 @@ void TestReleasedLiveOperationRecoversAfterReconnect() {
EventIndex(backend.events, L"restore:hdr") == backend.events.size(), EventIndex(backend.events, L"restore:hdr") == backend.events.size(),
"reconnect recovery must restore the released mode without stealing live HDR"); "reconnect recovery must restore the released mode without stealing live HDR");
Check( Check(
backend.dwords[kModeChanged] == 0 && backend.dwords[kHDRChanged] == 1 && backend.delete_attempts == 0, backend.dwords[kModeChanged] == 0 && backend.dwords[kModeTakeoverEligible] == 0 &&
"reconnect recovery must preserve the genuinely live sibling record"); backend.dwords[kHDRChanged] == 1 && backend.delete_attempts == 0,
"reconnect recovery must clear its disposition while preserving the live sibling record");
}
void TestUnfailedMarkersRejectSameKindTakeover() {
FakeRecoveryBackend mode;
mode.SeedBoth();
const auto mode_dwords = mode.dwords;
const auto mode_strings = mode.strings;
Check(
!ApplyModeAfterPreparing(mode, kReplacementModeDevice, 2560, 1440, 120),
"a version-1 mode marker without a failed-recovery disposition must remain authoritative");
Check(
mode.dwords == mode_dwords && mode.strings == mode_strings && mode.events.empty(),
"rejected mode takeover must not rewrite either operation or reach the OS mutation");
FakeRecoveryBackend hdr;
hdr.SeedBoth();
hdr.dwords[kHDRTakeoverEligible] = 2;
const auto hdr_dwords = hdr.dwords;
const auto hdr_strings = hdr.strings;
Check(
!ApplyHDRAfterPreparing(hdr, kReplacementHDRDevice, true),
"only an HDR takeover disposition of exactly one may replace a valid marker");
Check(
hdr.dwords == hdr_dwords && hdr.strings == hdr_strings && hdr.events.empty(),
"rejected HDR takeover must preserve its mode sibling and avoid the OS mutation");
}
void TestAbsentModeTakeoverSurvivesRelaunchAndPreservesHDRSibling() {
FakeRecoveryBackend failed_recovery;
failed_recovery.SeedBoth();
failed_recovery.dwords[kModeTakeoverEligible] = 0;
failed_recovery.dwords[kHDRTakeoverEligible] = 1;
failed_recovery.device_present[kModeDevice] = false;
Check(
!DisplayModeManager::RecoverIfNeededForTesting(failed_recovery, false, true),
"an absent mode target must produce a retained failed recovery");
Check(
failed_recovery.dwords[kModeChanged] == 1 && failed_recovery.dwords[kModeTakeoverEligible] == 1,
"the failed non-live mode recovery must durably become takeover eligible");
Check(
failed_recovery.dwords[kHDRChanged] == 1 && failed_recovery.dwords[kHDRTakeoverEligible] == 1 &&
failed_recovery.dwords[kOriginalHDR] == 0 && failed_recovery.strings[kHDRDeviceName] == kHDRDevice,
"recording mode failure must not alter the live HDR sibling");
// A new backend represents the next process reading only persisted state.
FakeRecoveryBackend relaunched;
relaunched.dwords = failed_recovery.dwords;
relaunched.strings = failed_recovery.strings;
Check(
ApplyModeAfterPreparing(relaunched, kReplacementModeDevice, 2560, 1440, 120),
"a persisted failed mode recovery must admit a conflicting request after relaunch");
Check(
relaunched.dwords[kModeChanged] == 1 && relaunched.dwords[kModeTakeoverEligible] == 0 &&
relaunched.dwords[kModeRecoverySlot] == 1 && relaunched.dwords[kOriginalWidthAlternate] == 2560 &&
relaunched.dwords[kOriginalHeightAlternate] == 1440 &&
relaunched.dwords[kOriginalRefreshRateAlternate] == 120 &&
relaunched.strings[kModeDeviceNameAlternate] == kReplacementModeDevice,
"mode takeover must atomically select its staged original and revoke the consumed disposition");
Check(
relaunched.dwords[kOriginalWidth] == 3840 && relaunched.dwords[kOriginalHeight] == 2160 &&
relaunched.dwords[kOriginalRefreshRate] == 60 && relaunched.strings[kModeDeviceName] == kModeDevice,
"mode takeover must retain the prior originals in the inactive slot");
Check(
relaunched.dwords[kHDRChanged] == 1 && relaunched.dwords[kHDRTakeoverEligible] == 1 &&
relaunched.dwords[kOriginalHDR] == 0 && relaunched.strings[kHDRDeviceName] == kHDRDevice,
"mode takeover must preserve every persisted HDR sibling value");
Check(
EventIndex(relaunched.events, L"write:ModeTakeoverEligible=0") <
EventIndex(relaunched.events, L"write:ModeRecoverySlot=1") &&
EventIndex(relaunched.events, L"write:ModeRecoverySlot=1") < EventIndex(relaunched.events, L"os:mode") &&
EventIndex(relaunched.events, L"clear:ModeChanged") == relaunched.events.size() &&
EventIndex(relaunched.events, L"write:ModeChanged=1") == relaunched.events.size(),
"mode takeover must keep its marker authoritative and commit the staged slot immediately before mutation");
FakeRecoveryBackend recovery_relaunch;
recovery_relaunch.dwords = relaunched.dwords;
recovery_relaunch.strings = relaunched.strings;
recovery_relaunch.expected_mode_device = kReplacementModeDevice;
recovery_relaunch.expected_mode_width = 2560;
recovery_relaunch.expected_mode_height = 1440;
recovery_relaunch.expected_mode_refresh_rate = 120;
Check(
DisplayModeManager::RecoverIfNeededForTesting(recovery_relaunch, false, true),
"relaunch recovery must follow the committed mode slot");
Check(
EventIndex(recovery_relaunch.events, L"restore:mode") < recovery_relaunch.events.size(),
"the committed alternate mode original must reach recovery");
}
void TestBadModeHDRRestoreAllowsSameKindTakeover() {
FakeRecoveryBackend backend;
backend.SeedBoth();
backend.dwords[kModeTakeoverEligible] = 1;
backend.dwords[kHDRTakeoverEligible] = 0;
backend.hdr_restore_succeeds = false;
Check(
!DisplayModeManager::RecoverIfNeededForTesting(backend, true, false),
"a present HDR target whose restore fails must retain its marker");
Check(
EventIndex(backend.events, L"restore:hdr") < backend.events.size() && backend.dwords[kHDRChanged] == 1 &&
backend.dwords[kHDRTakeoverEligible] == 1,
"a failed HDR restore must durably grant only HDR takeover");
backend.events.clear();
Check(
ApplyHDRAfterPreparing(backend, kReplacementHDRDevice, true),
"a later conflicting HDR request must consume the failed-restore disposition");
Check(
backend.dwords[kHDRChanged] == 1 && backend.dwords[kHDRTakeoverEligible] == 0 &&
backend.dwords[kHDRRecoverySlot] == 1 && backend.dwords[kOriginalHDRAlternate] == 1 &&
backend.strings[kHDRDeviceNameAlternate] == kReplacementHDRDevice,
"HDR takeover must atomically select its staged replacement original");
Check(
backend.dwords[kOriginalHDR] == 0 && backend.strings[kHDRDeviceName] == kHDRDevice,
"HDR takeover must retain the prior original in the inactive slot");
Check(
backend.dwords[kModeChanged] == 1 && backend.dwords[kModeTakeoverEligible] == 1 &&
backend.dwords[kOriginalWidth] == 3840 && backend.dwords[kOriginalHeight] == 2160 &&
backend.dwords[kOriginalRefreshRate] == 60 && backend.strings[kModeDeviceName] == kModeDevice,
"HDR takeover must preserve every persisted mode sibling value");
Check(
EventIndex(backend.events, L"write:HDRTakeoverEligible=0") <
EventIndex(backend.events, L"write:HDRRecoverySlot=1") &&
EventIndex(backend.events, L"write:HDRRecoverySlot=1") < EventIndex(backend.events, L"os:hdr") &&
EventIndex(backend.events, L"clear:HDRChanged") == backend.events.size() &&
EventIndex(backend.events, L"write:HDRChanged=1") == backend.events.size(),
"HDR takeover must keep its marker authoritative and commit the staged slot immediately before mutation");
FakeRecoveryBackend recovery_relaunch;
recovery_relaunch.dwords = backend.dwords;
recovery_relaunch.strings = backend.strings;
recovery_relaunch.expected_hdr_device = kReplacementHDRDevice;
recovery_relaunch.expected_original_hdr = true;
Check(
DisplayModeManager::RecoverIfNeededForTesting(recovery_relaunch, true, false),
"relaunch recovery must follow the committed HDR slot");
Check(
EventIndex(recovery_relaunch.events, L"restore:hdr") < recovery_relaunch.events.size(),
"the committed alternate HDR original must reach recovery");
}
void TestFailedOSApplyRollsBackTakeover() {
FakeRecoveryBackend mode;
mode.SeedBoth();
mode.dwords[kModeTakeoverEligible] = 1;
mode.dwords[kHDRTakeoverEligible] = 1;
Check(
!ApplyModeAfterPreparing(mode, kReplacementModeDevice, 2560, 1440, 120, false),
"a failed mode OS apply must report failure");
Check(
mode.dwords[kModeChanged] == 1 && mode.dwords[kModeRecoverySlot] == 0 &&
mode.dwords[kModeTakeoverEligible] == 1 && mode.dwords[kModeHandoffPending] == 0 &&
mode.dwords[kOriginalWidth] == 3840 && mode.dwords[kOriginalHeight] == 2160 &&
mode.dwords[kOriginalRefreshRate] == 60 && mode.strings[kModeDeviceName] == kModeDevice &&
mode.dwords[kHDRChanged] == 1 && mode.dwords[kHDRTakeoverEligible] == 1,
"failed mode apply must restore the prior selector, eligibility, original, and sibling");
Check(
EventIndex(mode.events, L"write:ModeHandoffPending=1") < EventIndex(mode.events, L"write:ModeRecoverySlot=1") &&
EventIndex(mode.events, L"write:ModeRecoverySlot=1") < EventIndex(mode.events, L"os:mode:failed") &&
EventIndex(mode.events, L"os:mode:failed") < EventIndex(mode.events, L"write:ModeRecoverySlot=0") &&
EventIndex(mode.events, L"clear:ModeChanged") == mode.events.size(),
"mode rollback must retain both records until the failed OS apply is observed");
FakeRecoveryBackend mode_relaunch;
mode_relaunch.dwords = mode.dwords;
mode_relaunch.strings = mode.strings;
Check(
DisplayModeManager::RecoverIfNeededForTesting(mode_relaunch, false, true),
"relaunch after failed mode apply must recover the prior original");
FakeRecoveryBackend hdr;
hdr.SeedBoth();
hdr.dwords[kModeTakeoverEligible] = 1;
hdr.dwords[kHDRTakeoverEligible] = 1;
Check(!ApplyHDRAfterPreparing(hdr, kReplacementHDRDevice, true, false), "a failed HDR OS apply must report failure");
Check(
hdr.dwords[kHDRChanged] == 1 && hdr.dwords[kHDRRecoverySlot] == 0 && hdr.dwords[kHDRTakeoverEligible] == 1 &&
hdr.dwords[kHDRHandoffPending] == 0 && hdr.dwords[kOriginalHDR] == 0 &&
hdr.strings[kHDRDeviceName] == kHDRDevice && hdr.dwords[kModeChanged] == 1 &&
hdr.dwords[kModeTakeoverEligible] == 1,
"failed HDR apply must restore the prior selector, eligibility, original, and sibling");
Check(
EventIndex(hdr.events, L"write:HDRHandoffPending=1") < EventIndex(hdr.events, L"write:HDRRecoverySlot=1") &&
EventIndex(hdr.events, L"write:HDRRecoverySlot=1") < EventIndex(hdr.events, L"os:hdr:failed") &&
EventIndex(hdr.events, L"os:hdr:failed") < EventIndex(hdr.events, L"write:HDRRecoverySlot=0") &&
EventIndex(hdr.events, L"clear:HDRChanged") == hdr.events.size(),
"HDR rollback must retain both records until the failed OS apply is observed");
FakeRecoveryBackend hdr_relaunch;
hdr_relaunch.dwords = hdr.dwords;
hdr_relaunch.strings = hdr.strings;
Check(
DisplayModeManager::RecoverIfNeededForTesting(hdr_relaunch, true, false),
"relaunch after failed HDR apply must recover the prior original");
}
void TestCrashDuringTakeoverHandoffRecoversBothOriginals() {
FakeRecoveryBackend mode;
mode.SeedBoth();
mode.dwords[kModeTakeoverEligible] = 1;
mode.dwords[kHDRTakeoverEligible] = 1;
Check(
DisplayModeManager::PrepareModeRecovery(mode, kReplacementModeDevice, 2560, 1440, 120),
"mode takeover preparation must reach its pre-apply handoff");
Check(
mode.dwords[kModeChanged] == 1 && mode.dwords[kModeHandoffPending] == 1 &&
mode.dwords[kModePreviousRecoverySlot] == 0 && mode.dwords[kModeRecoverySlot] == 1,
"mode pre-apply handoff must retain both slot identities under one marker");
mode.expected_mode_restores = {{kReplacementModeDevice, 2560, 1440, 120}, {kModeDevice, 3840, 2160, 60}};
Check(
DisplayModeManager::RecoverIfNeededForTesting(mode, false, true),
"crash recovery must restore both mode originals from a pending handoff");
Check(
mode.mode_restore_attempts == 2 && mode.dwords[kModeChanged] == 0 && mode.dwords[kModeHandoffPending] == 0 &&
mode.dwords[kHDRChanged] == 1,
"mode handoff recovery must restore replacement then prior and preserve its sibling");
FakeRecoveryBackend hdr;
hdr.SeedBoth();
hdr.dwords[kModeTakeoverEligible] = 1;
hdr.dwords[kHDRTakeoverEligible] = 1;
Check(
DisplayModeManager::PrepareHDRRecovery(hdr, kReplacementHDRDevice, true),
"HDR takeover preparation must reach its pre-apply handoff");
Check(
hdr.dwords[kHDRChanged] == 1 && hdr.dwords[kHDRHandoffPending] == 1 &&
hdr.dwords[kHDRPreviousRecoverySlot] == 0 && hdr.dwords[kHDRRecoverySlot] == 1,
"HDR pre-apply handoff must retain both slot identities under one marker");
hdr.expected_hdr_restores = {{kReplacementHDRDevice, true}, {kHDRDevice, false}};
Check(
DisplayModeManager::RecoverIfNeededForTesting(hdr, true, false),
"crash recovery must restore both HDR originals from a pending handoff");
Check(
hdr.hdr_restore_attempts == 2 && hdr.dwords[kHDRChanged] == 0 && hdr.dwords[kHDRHandoffPending] == 0 &&
hdr.dwords[kModeChanged] == 1,
"HDR handoff recovery must restore replacement then prior and preserve its sibling");
}
void TestHDRReconnectRecoversBeforeTakeover() {
FakeRecoveryBackend backend;
backend.SeedBoth();
backend.dwords[kModeChanged] = 0;
backend.device_present[kHDRDevice] = false;
Check(!DisplayModeManager::RecoverIfNeeded(backend), "an absent HDR target must retain its recovery marker");
Check(
backend.dwords[kHDRChanged] == 1 && backend.dwords[kHDRTakeoverEligible] == 1 &&
EventIndex(backend.events, L"restore:hdr") == backend.events.size(),
"absent HDR recovery must become eligible without calling the restore API");
backend.events.clear();
backend.device_present[kHDRDevice] = true;
Check(
DisplayModeManager::RecoverIfNeeded(backend),
"reconnecting HDR before a conflicting request must recover the old target");
Check(
EventIndex(backend.events, L"restore:hdr") < backend.events.size() && !backend.RecordExists(),
"successful reconnect recovery must win and remove its disposition");
}
void TestEligibleSameOriginalReuseResetsDisposition() {
FakeRecoveryBackend mode;
mode.SeedBoth();
mode.dwords[kModeTakeoverEligible] = 1;
mode.dwords[kHDRTakeoverEligible] = 1;
Check(ApplyModeAfterPreparing(mode), "an eligible mode marker must remain reusable by the same original");
Check(
mode.dwords[kModeTakeoverEligible] == 0 && mode.dwords[kHDRTakeoverEligible] == 1,
"same-original mode reuse must revoke only mode takeover");
mode.events.clear();
Check(
!ApplyModeAfterPreparing(mode, kReplacementModeDevice, 2560, 1440, 120) && mode.events.empty(),
"a mismatched mode request must not steal a marker after same-original reuse");
FakeRecoveryBackend hdr;
hdr.SeedBoth();
hdr.dwords[kModeTakeoverEligible] = 1;
hdr.dwords[kHDRTakeoverEligible] = 1;
Check(ApplyHDRAfterPreparing(hdr), "an eligible HDR marker must remain reusable by the same original");
Check(
hdr.dwords[kHDRTakeoverEligible] == 0 && hdr.dwords[kModeTakeoverEligible] == 1,
"same-original HDR reuse must revoke only HDR takeover");
hdr.events.clear();
Check(
!ApplyHDRAfterPreparing(hdr, kReplacementHDRDevice, true) && hdr.events.empty(),
"a mismatched HDR request must not steal a marker after same-original reuse");
}
void TestRepeatedModeTakeoverStagesOnlyTheInactiveSlot() {
FakeRecoveryBackend backend;
backend.SeedBoth();
backend.dwords[kModeTakeoverEligible] = 1;
backend.dwords[kHDRTakeoverEligible] = 1;
Check(
ApplyModeAfterPreparing(backend, kReplacementModeDevice, 2560, 1440, 120),
"the first mode takeover must commit the alternate slot");
backend.events.clear();
backend.device_present[kReplacementModeDevice] = false;
Check(
!DisplayModeManager::RecoverIfNeededForTesting(backend, false, true),
"a later failed recovery must make the alternate mode original eligible");
Check(
backend.dwords[kModeRecoverySlot] == 1 && backend.dwords[kModeTakeoverEligible] == 1,
"the active alternate slot must remain selected after recovery failure");
backend.events.clear();
backend.device_present[kReplacementModeDevice] = true;
backend.rejected_dword_write_event = L"write:OriginalRefreshRate=75";
Check(
!ApplyModeAfterPreparing(backend, kModeDevice, 1920, 1080, 75),
"a repeated takeover must fail when staging into the primary slot fails");
Check(
backend.dwords[kModeChanged] == 1 && backend.dwords[kModeRecoverySlot] == 1 &&
backend.dwords[kOriginalWidthAlternate] == 2560 && backend.dwords[kOriginalHeightAlternate] == 1440 &&
backend.dwords[kOriginalRefreshRateAlternate] == 120 &&
backend.strings[kModeDeviceNameAlternate] == kReplacementModeDevice &&
EventIndex(backend.events, L"os:mode") == backend.events.size(),
"failed repeated staging must not overwrite or deselect the active alternate original");
FakeRecoveryBackend relaunched;
relaunched.dwords = backend.dwords;
relaunched.strings = backend.strings;
relaunched.expected_mode_device = kReplacementModeDevice;
relaunched.expected_mode_width = 2560;
relaunched.expected_mode_height = 1440;
relaunched.expected_mode_refresh_rate = 120;
Check(
DisplayModeManager::RecoverIfNeededForTesting(relaunched, false, true),
"relaunch must still recover the alternate original after repeated takeover staging fails");
}
void TestLiveMarkersCannotBecomeEligibleOrBeTakenOver() {
FakeRecoveryBackend mode;
mode.SeedBoth();
mode.dwords[kHDRChanged] = 0;
Check(
!DisplayModeManager::RecoverIfNeededForTesting(mode, true, false),
"a live mode marker must be skipped by synchronous recovery");
Check(
mode.events.empty() && mode.dwords.find(kModeTakeoverEligible) == mode.dwords.end(),
"skipping live mode recovery must not grant takeover");
Check(
!ApplyModeAfterPreparing(mode, kReplacementModeDevice, 2560, 1440, 120),
"a mismatched request must not steal a live mode marker");
FakeRecoveryBackend hdr;
hdr.SeedBoth();
hdr.dwords[kModeChanged] = 0;
Check(
!DisplayModeManager::RecoverIfNeededForTesting(hdr, false, true),
"a live HDR marker must be skipped by synchronous recovery");
Check(
hdr.events.empty() && hdr.dwords.find(kHDRTakeoverEligible) == hdr.dwords.end(),
"skipping live HDR recovery must not grant takeover");
Check(
!ApplyHDRAfterPreparing(hdr, kReplacementHDRDevice, true),
"a mismatched request must not steal a live HDR marker");
}
void TestTakeoverPersistenceFailuresRemainConservative() {
FakeRecoveryBackend failed_grant;
failed_grant.SeedBoth();
failed_grant.dwords[kHDRChanged] = 0;
failed_grant.device_present[kModeDevice] = false;
failed_grant.rejected_dword_write_event = L"write:ModeTakeoverEligible=1";
Check(
!DisplayModeManager::RecoverIfNeeded(failed_grant),
"failure to persist mode takeover eligibility must leave recovery incomplete");
Check(
failed_grant.dwords[kModeChanged] == 1 &&
failed_grant.dwords.find(kModeTakeoverEligible) == failed_grant.dwords.end(),
"a failed eligibility write must leave the old marker authoritative");
failed_grant.rejected_dword_write_event.clear();
failed_grant.events.clear();
Check(
!ApplyModeAfterPreparing(failed_grant, kReplacementModeDevice, 2560, 1440, 120) && failed_grant.events.empty(),
"an unpersisted failure must not admit takeover");
FakeRecoveryBackend failed_reuse_reset;
failed_reuse_reset.SeedBoth();
failed_reuse_reset.dwords[kHDRTakeoverEligible] = 1;
failed_reuse_reset.rejected_dword_write_event = L"write:HDRTakeoverEligible=0";
Check(!ApplyHDRAfterPreparing(failed_reuse_reset), "same-original reuse must fail when takeover cannot be reset");
Check(
failed_reuse_reset.dwords[kHDRChanged] == 1 && failed_reuse_reset.dwords[kHDRTakeoverEligible] == 1 &&
EventIndex(failed_reuse_reset.events, L"os:hdr") == failed_reuse_reset.events.size(),
"failed HDR disposition reset must retain the marker and prevent mutation");
FakeRecoveryBackend failed_mode_stage;
failed_mode_stage.SeedBoth();
failed_mode_stage.dwords[kModeTakeoverEligible] = 1;
failed_mode_stage.dwords[kHDRTakeoverEligible] = 1;
failed_mode_stage.rejected_dword_write_event = L"write:OriginalRefreshRateAlternate=120";
Check(
!ApplyModeAfterPreparing(failed_mode_stage, kReplacementModeDevice, 2560, 1440, 120),
"mode takeover must fail if a staged replacement original cannot be persisted");
Check(
failed_mode_stage.dwords[kModeChanged] == 1 && failed_mode_stage.dwords[kModeTakeoverEligible] == 1 &&
failed_mode_stage.dwords.find(kModeRecoverySlot) == failed_mode_stage.dwords.end() &&
failed_mode_stage.dwords[kOriginalWidth] == 3840 && failed_mode_stage.dwords[kOriginalHeight] == 2160 &&
failed_mode_stage.dwords[kOriginalRefreshRate] == 60 &&
failed_mode_stage.strings[kModeDeviceName] == kModeDevice && failed_mode_stage.dwords[kHDRChanged] == 1 &&
failed_mode_stage.dwords[kHDRTakeoverEligible] == 1 && failed_mode_stage.dwords[kOriginalHDR] == 0 &&
failed_mode_stage.strings[kHDRDeviceName] == kHDRDevice &&
EventIndex(failed_mode_stage.events, L"os:mode") == failed_mode_stage.events.size(),
"failed mode staging must leave the old marker and originals authoritative");
FakeRecoveryBackend failed_mode_commit;
failed_mode_commit.SeedBoth();
failed_mode_commit.dwords[kModeTakeoverEligible] = 1;
failed_mode_commit.dwords[kHDRTakeoverEligible] = 1;
failed_mode_commit.rejected_dword_write_event = L"write:ModeRecoverySlot=1";
Check(
!ApplyModeAfterPreparing(failed_mode_commit, kReplacementModeDevice, 2560, 1440, 120),
"mode takeover must fail when its atomic authority switch cannot be persisted");
Check(
failed_mode_commit.dwords[kModeChanged] == 1 && failed_mode_commit.dwords[kModeTakeoverEligible] == 1 &&
failed_mode_commit.dwords[kModeRecoverySlot] == 0 && failed_mode_commit.dwords[kModeHandoffPending] == 0 &&
failed_mode_commit.dwords[kOriginalWidth] == 3840 && failed_mode_commit.dwords[kOriginalHeight] == 2160 &&
failed_mode_commit.dwords[kOriginalRefreshRate] == 60 &&
failed_mode_commit.strings[kModeDeviceName] == kModeDevice &&
failed_mode_commit.dwords[kOriginalWidthAlternate] == 2560 &&
failed_mode_commit.strings[kModeDeviceNameAlternate] == kReplacementModeDevice &&
failed_mode_commit.dwords[kHDRChanged] == 1 && failed_mode_commit.dwords[kHDRTakeoverEligible] == 1 &&
failed_mode_commit.dwords[kOriginalHDR] == 0 && failed_mode_commit.strings[kHDRDeviceName] == kHDRDevice &&
EventIndex(failed_mode_commit.events, L"os:mode") == failed_mode_commit.events.size(),
"failed mode commit must keep the primary recovery point authoritative despite complete staging");
FakeRecoveryBackend mode_relaunch;
mode_relaunch.dwords = failed_mode_commit.dwords;
mode_relaunch.strings = failed_mode_commit.strings;
Check(
DisplayModeManager::RecoverIfNeededForTesting(mode_relaunch, false, true),
"relaunch after a failed mode commit must recover the old primary original");
Check(
EventIndex(mode_relaunch.events, L"restore:mode") < mode_relaunch.events.size(),
"failed mode authority switch must not redirect relaunch recovery to staging");
FakeRecoveryBackend failed_hdr_stage;
failed_hdr_stage.SeedBoth();
failed_hdr_stage.dwords[kModeTakeoverEligible] = 1;
failed_hdr_stage.dwords[kHDRTakeoverEligible] = 1;
failed_hdr_stage.rejected_dword_write_event = L"write:OriginalHDREnabledAlternate=1";
Check(
!ApplyHDRAfterPreparing(failed_hdr_stage, kReplacementHDRDevice, true),
"HDR takeover must fail if its staged replacement original cannot be persisted");
Check(
failed_hdr_stage.dwords[kHDRChanged] == 1 && failed_hdr_stage.dwords[kHDRTakeoverEligible] == 1 &&
failed_hdr_stage.dwords.find(kHDRRecoverySlot) == failed_hdr_stage.dwords.end() &&
failed_hdr_stage.dwords[kOriginalHDR] == 0 && failed_hdr_stage.strings[kHDRDeviceName] == kHDRDevice &&
failed_hdr_stage.dwords[kModeChanged] == 1 && failed_hdr_stage.dwords[kModeTakeoverEligible] == 1 &&
failed_hdr_stage.dwords[kOriginalWidth] == 3840 && failed_hdr_stage.dwords[kOriginalHeight] == 2160 &&
failed_hdr_stage.dwords[kOriginalRefreshRate] == 60 &&
failed_hdr_stage.strings[kModeDeviceName] == kModeDevice &&
EventIndex(failed_hdr_stage.events, L"os:hdr") == failed_hdr_stage.events.size(),
"failed HDR staging must leave the old marker and original authoritative");
FakeRecoveryBackend failed_hdr_commit;
failed_hdr_commit.SeedBoth();
failed_hdr_commit.dwords[kModeTakeoverEligible] = 1;
failed_hdr_commit.dwords[kHDRTakeoverEligible] = 1;
failed_hdr_commit.rejected_dword_write_event = L"write:HDRRecoverySlot=1";
Check(
!ApplyHDRAfterPreparing(failed_hdr_commit, kReplacementHDRDevice, true),
"HDR takeover must fail when its atomic authority switch cannot be persisted");
Check(
failed_hdr_commit.dwords[kHDRChanged] == 1 && failed_hdr_commit.dwords[kHDRTakeoverEligible] == 1 &&
failed_hdr_commit.dwords[kHDRRecoverySlot] == 0 && failed_hdr_commit.dwords[kHDRHandoffPending] == 0 &&
failed_hdr_commit.dwords[kOriginalHDR] == 0 && failed_hdr_commit.strings[kHDRDeviceName] == kHDRDevice &&
failed_hdr_commit.dwords[kOriginalHDRAlternate] == 1 &&
failed_hdr_commit.strings[kHDRDeviceNameAlternate] == kReplacementHDRDevice &&
failed_hdr_commit.dwords[kModeChanged] == 1 && failed_hdr_commit.dwords[kModeTakeoverEligible] == 1 &&
failed_hdr_commit.dwords[kOriginalWidth] == 3840 && failed_hdr_commit.dwords[kOriginalHeight] == 2160 &&
failed_hdr_commit.dwords[kOriginalRefreshRate] == 60 &&
failed_hdr_commit.strings[kModeDeviceName] == kModeDevice &&
EventIndex(failed_hdr_commit.events, L"os:hdr") == failed_hdr_commit.events.size(),
"failed HDR commit must keep the primary recovery point authoritative despite complete staging");
FakeRecoveryBackend hdr_relaunch;
hdr_relaunch.dwords = failed_hdr_commit.dwords;
hdr_relaunch.strings = failed_hdr_commit.strings;
Check(
DisplayModeManager::RecoverIfNeededForTesting(hdr_relaunch, true, false),
"relaunch after a failed HDR commit must recover the old primary original");
Check(
EventIndex(hdr_relaunch.events, L"restore:hdr") < hdr_relaunch.events.size(),
"failed HDR authority switch must not redirect relaunch recovery to staging");
} }
void TestVersionlessModeRecoveryAndCleanup() { void TestVersionlessModeRecoveryAndCleanup() {
@@ -431,7 +1019,9 @@ void TestCleanupFailureDoesNotBlockNewOverride() {
Check( Check(
DisplayModeManager::RecoverIfNeeded(backend), DisplayModeManager::RecoverIfNeeded(backend),
"successful restoration must complete even when stale-value deletion fails"); "successful restoration must complete even when stale-value deletion fails");
Check(backend.dwords[kModeChanged] == 0, "the successful restore marker must be clear"); Check(
backend.dwords[kModeChanged] == 0 && backend.dwords[kModeTakeoverEligible] == 0,
"the successful restore marker and matching disposition must be clear");
Check(backend.delete_attempts == 1, "completed recovery must make one best-effort cleanup attempt"); Check(backend.delete_attempts == 1, "completed recovery must make one best-effort cleanup attempt");
backend.events.clear(); backend.events.clear();
@@ -456,6 +1046,16 @@ int main() {
mpv::TestMarkerClearFailureRemainsRetryable(); mpv::TestMarkerClearFailureRemainsRetryable();
mpv::TestLifecycleCleanupPreservesPersistedSibling(); mpv::TestLifecycleCleanupPreservesPersistedSibling();
mpv::TestReleasedLiveOperationRecoversAfterReconnect(); mpv::TestReleasedLiveOperationRecoversAfterReconnect();
mpv::TestUnfailedMarkersRejectSameKindTakeover();
mpv::TestAbsentModeTakeoverSurvivesRelaunchAndPreservesHDRSibling();
mpv::TestBadModeHDRRestoreAllowsSameKindTakeover();
mpv::TestFailedOSApplyRollsBackTakeover();
mpv::TestCrashDuringTakeoverHandoffRecoversBothOriginals();
mpv::TestHDRReconnectRecoversBeforeTakeover();
mpv::TestEligibleSameOriginalReuseResetsDisposition();
mpv::TestRepeatedModeTakeoverStagesOnlyTheInactiveSlot();
mpv::TestLiveMarkersCannotBecomeEligibleOrBeTakenOver();
mpv::TestTakeoverPersistenceFailuresRemainConservative();
mpv::TestVersionlessModeRecoveryAndCleanup(); mpv::TestVersionlessModeRecoveryAndCleanup();
mpv::TestVersionlessHDRRecoveryAndCleanup(); mpv::TestVersionlessHDRRecoveryAndCleanup();
mpv::TestVersionlessModeAndHDRUseSharedDevice(); mpv::TestVersionlessModeAndHDRUseSharedDevice();
+1 -1
View File
@@ -547,7 +547,7 @@ void MpvPlayer::Dispose() {
auto cancelled = pending_requests_.CancelAll(); auto cancelled = pending_requests_.CancelAll();
for (auto& callback : cancelled.status) { for (auto& callback : cancelled.status) {
callback(-1); callback(MPV_ERROR_UNINITIALIZED);
} }
for (auto& callback : cancelled.properties) { for (auto& callback : cancelled.properties) {
callback(-1, ""); callback(-1, "");
@@ -94,7 +94,7 @@ void TestPendingPropertyWriteFailsOnDispose() {
player.Dispose(); player.Dispose();
Check(callback_count == 1, "dispose must complete a pending property write exactly once"); Check(callback_count == 1, "dispose must complete a pending property write exactly once");
Check(status < 0, "dispose must fail a pending property write"); Check(status == MPV_ERROR_UNINITIALIZED, "dispose must cancel a pending property write as uninitialized");
player.Dispose(); player.Dispose();
Check(callback_count == 1, "repeated dispose must not complete a property write twice"); Check(callback_count == 1, "repeated dispose must not complete a property write twice");
+5 -3
View File
@@ -282,9 +282,11 @@ void MpvPlayerPlugin::HandleMethodCall(
if (plezy::mpv_common::SetPropertyStatusSucceeded(error)) { if (plezy::mpv_common::SetPropertyStatusSucceeded(error)) {
(*result_ptr)->Success(); (*result_ptr)->Success();
} else { } else {
(*result_ptr) const auto* error_code = plezy::mpv_common::SetPropertyErrorCode(error);
->Error( const auto description = error == MPV_ERROR_UNINITIALIZED
plezy::mpv_common::kSetPropertyFailedCode, plezy::mpv_common::SetPropertyErrorDescription(error)); ? std::string("Player not initialized")
: plezy::mpv_common::SetPropertyErrorDescription(error);
(*result_ptr)->Error(error_code, description);
} }
}); });
}); });