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.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.CancellationException
import java.util.concurrent.atomic.AtomicBoolean
class ExoPlayerPlugin :
@@ -774,16 +775,17 @@ class ExoPlayerPlugin :
}
pendingMpvProperties[name] = value
core.setProperty(name, value) { outcome ->
val currentOutcome = if (
val fallbackIsCurrent =
usingMpvFallback &&
generation == sessionGeneration &&
activity === currentActivity &&
mpvCore === core
) {
outcome
} else {
Result.failure(IllegalStateException("MPV fallback unavailable"))
}
generation == sessionGeneration &&
activity === currentActivity &&
mpvCore === core
val currentOutcome =
if (fallbackIsCurrent || outcome.isFailure) {
outcome
} else {
Result.failure(CancellationException("MPV fallback unavailable"))
}
completeMpvPropertyResult(result, currentOutcome, successValue)
}
}
@@ -700,8 +700,15 @@ class MpvPlayerCore private constructor(
}
private fun resumeAfterAudioFocusGain(reason: String) {
pausedForAudioFocusLoss = false
requestAutoResume(reason)
val shouldResume = synchronized(publicPauseIntentLock) {
if (!pausedForAudioFocusLoss) {
false
} else {
pausedForAudioFocusLoss = false
true
}
}
if (shouldResume) requestAutoResume(reason)
}
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) {
val p = player
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 {
if (!isInitialized || disposing || !scope.isActive) {
onComplete?.invoke(Result.failure(CancellationException("MPV core unavailable")))
return@runOnMain
}
val isCurrent = synchronized(publicPauseIntentLock) {
pauseIntent != null && publicPauseIntentGeneration == pauseIntent.generation
var deferredForSurface = false
val interruptedAgain = synchronized(publicPauseIntentLock) {
if (publicPauseIntentGeneration != pauseIntent.generation) {
false
} else if (pausedForAudioFocusLoss) {
true
} else {
deferredResumeRequested = true
deferredForSurface = true
false
}
}
if (isCurrent) {
deferredResumeRequested = true
Log.d(
TAG,
if (pausedForAudioFocusLoss) {
"Deferring public resume until audio focus returns"
} else {
"Deferring public resume until video output is ready"
}
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
}
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
var interruptedBeforeWrite = false
val writeResult = try {
if (pauseIntent == null) {
writeProperty(name, value)
} else {
publicPauseWriteMutex.withLock {
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)
}
}
Result.success(Unit)
if (interruptedBeforeWrite) {
Result.failure(IllegalStateException("Audio focus unavailable for resume"))
} else {
Result.success(Unit)
}
} catch (error: CancellationException) {
Result.failure(error)
} catch (error: Exception) {
@@ -8,13 +8,14 @@ import android.net.Uri
import android.os.Binder
import android.os.ParcelFileDescriptor
import android.os.Process
import android.system.Os
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileNotFoundException
import java.io.RandomAccessFile
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
import java.util.UUID
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
@@ -88,14 +89,28 @@ internal class SystemShelfArtworkStore(private val cacheDir: File) {
const val CONNECT_TIMEOUT_MS = 2_500
const val READ_TIMEOUT_MS = 2_500
private val opaquePart = Regex("^[a-f0-9]{64}$")
private val artworkKey = Regex("^[a-f0-9]{32}\\.art$")
private val artworkKey = Regex("^(?:[a-f0-9]{32}|[a-f0-9]{64})\\.art$")
private val deadlineAborter: ScheduledExecutorService =
Executors.newSingleThreadScheduledExecutor { runnable ->
Thread(runnable, "system-shelf-deadline").apply { isDaemon = true }
}
}
data class Materialized(val key: String, val uri: Uri, val file: File)
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) {
var consumed: Long = 0
private set
@@ -114,20 +129,37 @@ internal class SystemShelfArtworkStore(private val cacheDir: File) {
private val root: File get() = File(cacheDir, "system_shelf_artwork")
fun materialize(ownerId: String, source: String, session: SystemShelfSyncSession): Materialized? {
if (ownerId.isBlank() || session.budget.remaining <= 0 || !session.isActive()) return null
fun prepare(ownerId: String, source: String, session: SystemShelfSyncSession): Prepared? {
if (ownerId.isBlank()) return null
val url = runCatching { URL(source) }.getOrNull() ?: return null
if (url.protocol != "https" && url.protocol != "http") return null
val connection = (url.openConnection() as? HttpURLConnection) ?: return null
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()
if (remainingNanos <= 0) return null
val connection = runCatching { url.openConnection() as? HttpURLConnection }.getOrNull()
?: return null
val abort = deadlineAborter.schedule(
{ connection.disconnect() },
remainingNanos,
TimeUnit.NANOSECONDS
)
var stagingFile: File? = null
var retainStagingFile = false
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.connectTimeout = minOf(CONNECT_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))
if (read < 0) break
session.budget.charge(read)
if (!session.budget.charge(read)) return null
total += read
output.write(buffer, 0, read)
}
output.toByteArray()
}
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
val key = UUID.randomUUID().toString().replace("-", "") + ".art"
val staged = File(directory, ".$key.tmp")
val staged = File.createTempFile(".$key.", ".tmp", directory)
stagingFile = staged
staged.outputStream().use { output ->
output.write(bytes)
output.flush()
output.fd.sync()
}
if (!session.isActive()) {
staged.delete()
return null
}
val destination = File(directory, key)
if (!session.commitIfActive { staged.renameTo(destination) }) {
staged.delete()
return null
}
Materialized(key, contentUri(ownerKey, key), destination)
if (!isSupportedImage(staged) || !session.isActive()) return null
retainStagingFile = true
Prepared.Staged(materialized, staged)
} catch (_: Exception) {
null
} finally {
if (!retainStagingFile) stagingFile?.delete()
abort.cancel(false)
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()
.scheme("content")
.authority(SystemShelfArtworkProvider.AUTHORITY)
@@ -199,60 +263,257 @@ internal class SystemShelfArtworkStore(private val cacheDir: File) {
.build()
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
val segments = uri.pathSegments
if (segments.size != 3 || segments[0] != "art") return null
val owner = segments[1]
val key = segments[2]
if (!opaquePart.matches(owner) || !artworkKey.matches(key)) return null
val canonicalRoot = root.canonicalFile
val candidate = File(File(canonicalRoot, owner), key).canonicalFile
if (candidate.parentFile?.parentFile != canonicalRoot || !candidate.isFile) return null
return candidate
return runCatching {
val canonicalRoot = root.canonicalFile
val ownerDirectory = File(canonicalRoot, owner).canonicalFile
if (ownerDirectory.parentFile != canonicalRoot || ownerDirectory.name != owner) return null
val candidate = File(ownerDirectory, key).canonicalFile
if (candidate.parentFile != ownerDirectory || candidate.name != key) return null
candidate
}.getOrNull()
}
fun deleteExcept(keep: Set<File>) {
val canonicalKeep = keep.mapTo(HashSet()) { it.canonicalFile }
root.listFiles()?.forEach { ownerDirectory ->
ownerDirectory.listFiles()?.forEach { file ->
if (file.canonicalFile !in canonicalKeep) file.delete()
private fun confinedCacheFile(file: File, canonicalRoot: File): File? {
return runCatching {
val originalDirectory = file.parentFile ?: return null
val directory = originalDirectory.canonicalFile
if (
directory.parentFile != canonicalRoot ||
directory.name != originalDirectory.name
) {
return null
}
if (ownerDirectory.listFiles().isNullOrEmpty()) ownerDirectory.delete()
}
val candidate = file.canonicalFile
if (candidate.parentFile != directory || candidate.name != file.name) return null
candidate
}.getOrNull()
}
fun delete(files: Set<File>) {
val canonicalRoot = root.canonicalFile
files.forEach { file ->
val candidate = runCatching { file.canonicalFile }.getOrNull() ?: return@forEach
if (candidate.parentFile?.parentFile == canonicalRoot) candidate.delete()
private fun confinedStagingFile(stagingFile: File, destination: File): File? {
val staged = runCatching { stagingFile.canonicalFile }.getOrNull() ?: return null
val canonicalDestination = runCatching { destination.canonicalFile }.getOrNull() ?: return null
val directory = canonicalDestination.parentFile ?: return null
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 ->
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 {
if (bytes.size < 4) return false
val png = bytes.size >= 8 &&
val format = imageFormat(bytes, bytes.size) ?: return false
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[1] == 0x50.toByte() &&
bytes[2] == 0x4e.toByte() &&
bytes[3] == 0x47.toByte()
val jpeg = bytes[0] == 0xff.toByte() && bytes[1] == 0xd8.toByte() && bytes[2] == 0xff.toByte()
val gif = bytes[0] == 0x47.toByte() && bytes[1] == 0x49.toByte() && bytes[2] == 0x46.toByte()
val webp = bytes.size >= 12 &&
bytes.copyOfRange(0, 4).contentEquals("RIFF".toByteArray()) &&
bytes.copyOfRange(8, 12).contentEquals("WEBP".toByteArray())
if (!png && !jpeg && !gif && !webp) return false
bytes[3] == 0x47.toByte() &&
bytes[4] == 0x0d.toByte() &&
bytes[5] == 0x0a.toByte() &&
bytes[6] == 0x1a.toByte() &&
bytes[7] == 0x0a.toByte()
) {
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 }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
private fun hasCompleteContainer(
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 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")
@@ -5,6 +5,7 @@ import android.content.ContentUris
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.util.Log
@@ -118,6 +119,12 @@ class WatchNextProvider internal constructor(
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 artwork = SystemShelfArtworkStore(context.cacheDir)
@@ -133,34 +140,71 @@ class WatchNextProvider internal constructor(
if (items.size > SystemShelfArtworkStore.MAX_ITEMS) return false
val operationOwnership = ownership ?: claimOwnership(ownerId, generation) ?: return false
if (!isOperationActive()) return false
val oldUris = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty().mapNotNull(Uri::parse).toSet()
val oldPackages = storedPackages()
val oldSchemaVersion = prefs.getInt(SHELF_SCHEMA_VERSION_KEY, 0)
val session = SystemShelfSyncSession(
operationOwnership,
syncDurationMillis
)
val materializedFiles = LinkedHashSet<java.io.File>()
var committed = false
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 {
val prepared = items.map { item ->
if (!isOperationActive() || !session.isActive()) {
PreparedWatchNextItem(item, null)
val preparedItems = items.map { item ->
val source = item.posterSourceUri
val localArtwork = if (source == null) {
null
} else {
val materialized = item.posterSourceUri?.let { artwork.materialize(ownerId, it, session) }
materialized?.file?.let(materializedFiles::add)
PreparedWatchNextItem(item, materialized?.uri)
val candidate = if (preparedBySource.containsKey(source)) {
preparedBySource[source]
} else {
artwork.prepare(ownerId, source, session).also { prepared ->
preparedBySource[source] = prepared
prepared?.let(preparedArtwork::add)
}
}
candidate?.materialized ?: previousArtwork[item.contentId]
}
PreparedWatchNextItem(item, localArtwork?.uri)
}
val newUris = prepared.mapNotNullTo(LinkedHashSet()) { it.localPosterUri }
val newUris = preparedItems.mapNotNullTo(LinkedHashSet()) { it.localPosterUri }
val newPackages = consumerPackages()
if (!isOperationActive() || !session.isActive()) return false
committed = SystemShelfLifecycle.whileCurrent(session.ownership) {
return SystemShelfLifecycle.whileCurrent(session.ownership) {
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()) {
reconcileReadAccess(newUris, newPackages, oldUris, oldPackages)
artwork.delete(publishedFiles)
return@whileCurrent false
}
val preferencesCommitted = prefs.edit()
@@ -170,9 +214,10 @@ class WatchNextProvider internal constructor(
.commit()
if (!preferencesCommitted) {
reconcileReadAccess(newUris, newPackages, oldUris, oldPackages)
artwork.delete(publishedFiles)
return@whileCurrent false
}
if (session.isExpired() || !replaceRows(prepared)) {
if (session.isExpired() || !replaceRows(preparedItems)) {
val rollback = prefs.edit()
.putStringSet(GRANTED_URIS, oldUris.mapTo(LinkedHashSet(), Uri::toString))
.putStringSet(GRANTED_PACKAGES, oldPackages)
@@ -183,17 +228,16 @@ class WatchNextProvider internal constructor(
}
rollback.commit()
reconcileReadAccess(newUris, newPackages, oldUris, oldPackages)
artwork.delete(publishedFiles)
return@whileCurrent false
}
artwork.deleteExcept(materializedFiles)
reconcileReadAccess(oldUris, oldPackages, newUris, newPackages)
artwork.deleteExcept(referencedFiles)
true
} ?: false
return committed
} finally {
if (!committed) {
materializedFiles.forEach { it.delete() }
}
artwork.discard(preparedArtwork)
}
}
@@ -256,6 +300,95 @@ class WatchNextProvider internal constructor(
.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(
ownerId: String,
generation: Long,
@@ -266,46 +399,27 @@ class WatchNextProvider internal constructor(
val operationOwnership = ownership ?: claimOwnership(ownerId, generation) ?: return false
return SystemShelfLifecycle.whileCurrent(operationOwnership) {
if (!isOperationActive()) return@whileCurrent false
removeItemOwned(contentId)
removeItemOwned(ownerId, contentId)
} ?: false
}
private fun removeItemOwned(contentId: String): Boolean {
private fun removeItemOwned(ownerId: String, contentId: String): Boolean {
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,
arrayOf(
TvContractCompat.WatchNextPrograms._ID,
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID,
TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI
),
null,
null,
null
target.id
)
cursor?.use {
val idIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms._ID)
val providerIdIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID)
val posterIndex = it.getColumnIndex(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI)
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))
artwork.resolve(poster)?.delete()
val remaining = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty() - poster.toString()
prefs.edit().putStringSet(GRANTED_URIS, remaining).commit()
}
}
return true
}
}
}
false
if (context.contentResolver.delete(deleteUri, null, null) <= 0) return false
val poster = target.posterUri ?: return true
if (rows.any { it.id != target.id && it.posterUri == poster }) return true
revokeReadAccess(setOf(poster))
val remaining = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty() - poster.toString()
prefs.edit().putStringSet(GRANTED_URIS, remaining).commit()
artwork.resolveOwned(ownerId, poster)?.let { artwork.delete(setOf(it)) }
true
} catch (_: Exception) {
Log.e(TAG, "Failed to remove Watch Next item")
false
@@ -316,8 +430,14 @@ class WatchNextProvider internal constructor(
val operations = ArrayList<ContentProviderOperation>(items.size + 1)
operations += ContentProviderOperation.newDelete(TvContractCompat.WatchNextPrograms.CONTENT_URI).build()
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)
.withValues(buildProgram(item).toContentValues())
.withValues(values)
.build()
}
context.contentResolver.applyBatch(TvContractCompat.AUTHORITY, operations)
@@ -366,8 +486,13 @@ class WatchNextProvider internal constructor(
}
}
}
currentPackages.forEach { packageName ->
currentUris.forEach { uri ->
grantReadAccess(currentUris, currentPackages)
}
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) }
}
}
@@ -191,8 +191,7 @@ class ExoPlayerFallbackTerminalTest {
}
}
private fun getField(target: Any, name: String): Any? =
target.javaClass.getDeclaredField(name).apply { isAccessible = true }.get(target)
private fun getField(target: Any, name: String): Any? = target.javaClass.getDeclaredField(name).apply { isAccessible = true }.get(target)
private fun requestFallback(core: ExoPlayerCore, mediaGeneration: Int): Boolean {
val method = ExoPlayerCore::class.java.getDeclaredMethod(
@@ -1,11 +1,14 @@
package com.edde746.plezy.exoplayer
import android.app.Activity
import android.media.AudioManager
import android.os.Handler
import android.os.Looper
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.widget.FrameLayout
import com.edde746.plezy.mpv.MpvPlayerCore
import com.edde746.plezy.shared.AudioFocusManager
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
@@ -111,6 +114,54 @@ class ExoPlayerPluginTest {
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
fun fallbackWithoutCoreReturnsNotInitializedOnce() {
val plugin = ExoPlayerPlugin()
@@ -473,6 +524,35 @@ class ExoPlayerPluginTest {
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
fun initialHeldFallbackSynchronouslyBlocksFocusAndSurfaceResumeWithoutPausePropertyWrite() {
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) {
MpvPlayerCore::class.java.getDeclaredMethod("requestAutoResume", String::class.java).apply {
isAccessible = true
@@ -287,6 +287,221 @@ class MpvPlayerPluginTest {
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
fun pausedFocusLossAndGainWithoutResumeCallbackAllowsOneExplicitResume() {
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
@@ -624,6 +839,14 @@ class MpvPlayerPluginTest {
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) {
MpvPlayerCore::class.java.getDeclaredMethod("requestAutoResume", String::class.java).apply {
isAccessible = true
@@ -13,6 +13,7 @@ import android.content.pm.ActivityInfo
import android.content.pm.ApplicationInfo
import android.content.pm.ResolveInfo
import android.database.Cursor
import android.database.MatrixCursor
import android.net.Uri
import android.os.ParcelFileDescriptor.AutoCloseInputStream
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.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.io.Closeable
import java.lang.reflect.Proxy
import java.net.InetAddress
import java.net.ServerSocket
@@ -31,6 +33,7 @@ import java.util.concurrent.Executor
import java.util.concurrent.ExecutorService
import java.util.concurrent.RejectedExecutionException
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import kotlin.concurrent.thread
import org.junit.After
@@ -222,6 +225,7 @@ class WatchNextProviderTest {
try {
assertTrue(secondRequestReceived.await(2, TimeUnit.SECONDS))
assertEquals(1, artworkFiles().size)
assertTrue(artworkFiles().single().name.endsWith(".tmp"))
SystemShelfLifecycle.acquire()
releaseSecondResponse.countDown()
worker.join(2_000)
@@ -440,7 +444,7 @@ class WatchNextProviderTest {
val ownership = SystemShelfLifecycle.claim(SystemShelfLifecycle.acquire(), "owner", 1)!!
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(100 - malformed.size, budget.remaining)
}
@@ -452,7 +456,7 @@ class WatchNextProviderTest {
val ownership = SystemShelfLifecycle.claim(SystemShelfLifecycle.acquire(), "owner", 1)!!
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(100 - imageBytes.size, budget.remaining)
}
@@ -480,13 +484,30 @@ class WatchNextProviderTest {
val inactive = registerHandler(homeIntent, "inactive.home.launcher")
selectDefaultHome(selected, selected, inactive)
val owner = "a".repeat(64)
val key = "${"b".repeat(32)}.art"
context.cacheDir.resolve("system_shelf_artwork/$owner").mkdirs()
context.cacheDir.resolve("system_shelf_artwork/$owner/$key").writeBytes(imageBytes)
val valid = SystemShelfArtworkStore(context.cacheDir).contentUri(owner, key)
val invalid = Uri.parse("content://${SystemShelfArtworkProvider.AUTHORITY}/art/not/confined.art")
val legacyKey = "${"b".repeat(32)}.art"
val contentKey = "${"c".repeat(64)}.art"
val corruptKey = "${"d".repeat(64)}.art"
val directory = context.cacheDir.resolve("system_shelf_artwork/$owner").apply { mkdirs() }
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()
.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)
.commit()
val recordingContext = RecordingGrantContext(context)
@@ -495,12 +516,24 @@ class WatchNextProviderTest {
.onReceive(recordingContext, Intent(Intent.ACTION_BOOT_COMPLETED))
assertEquals(
listOf(Grant("selected.home.launcher", valid, Intent.FLAG_GRANT_READ_URI_PERMISSION)),
recordingContext.grants
setOf(
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(
setOf(valid.toString()),
context.getSharedPreferences("system_shelf_state", 0).getStringSet("granted_uris", emptySet())
setOf(legacy.toString(), contentAddressed.toString()),
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
fun packageUpdatePreservesRowsAndArtworkAtCurrentShelfSchema() {
val file = context.cacheDir.resolve("system_shelf_artwork/${"a".repeat(64)}/${"b".repeat(32)}.art")
file.parentFile?.mkdirs()
file.writeBytes(imageBytes)
val directory = context.cacheDir.resolve("system_shelf_artwork/${"a".repeat(64)}")
.apply { mkdirs() }
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()
.putInt("shelf_schema_version", WatchNextProvider.SHELF_SCHEMA_VERSION)
.commit()
@@ -549,7 +585,9 @@ class WatchNextProviderTest {
receiver.onReceive(context, Intent(Intent.ACTION_MY_PACKAGE_REPLACED))
assertEquals(0, tvProvider.deleteCount)
assertTrue(file.isFile)
assertTrue(legacyFile.isFile)
assertTrue(contentAddressedFile.isFile)
assertEquals(1, WatchNextProvider.SHELF_SCHEMA_VERSION)
}
@Test
@@ -564,18 +602,525 @@ class WatchNextProviderTest {
@Test
fun providerFailureDeletesNewArtworkAndPreservesCommittedArtwork() {
val provider = WatchNextProvider(context)
withServer("image/png", imageBytes) { source ->
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 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
assertFalse(provider.syncWatchNextPrograms("owner-a", 2, listOf(item(sourceB))))
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())
)
}
}
@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 committedArtwork = artworkFiles().single().canonicalFile
tvProvider.failBatch = true
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)
withServer("image/png", imageBytes) { source ->
assertFalse(provider.syncWatchNextPrograms("owner-a", 2, listOf(item(source))))
}
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(setOf(committedArtwork), artworkFiles().mapTo(HashSet()) { it.canonicalFile })
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 {
@@ -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 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() {
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 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 blockNextBatch = false
val batchStarted = CountDownLatch(1)
val releaseBatch = CountDownLatch(1)
override fun onCreate(): Boolean = true
override fun insert(uri: Uri, values: ContentValues?): Uri {
val id = nextRowId++
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 {
deleteCount++
inserted.clear()
if (uri == TvContractCompat.WatchNextPrograms.CONTENT_URI) {
val deleted = inserted.size
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
}
override fun applyBatch(operations: ArrayList<ContentProviderOperation>): Array<ContentProviderResult> {
override fun applyBatch(
operations: ArrayList<ContentProviderOperation>
): Array<ContentProviderResult> {
if (failBatch) throw IllegalStateException("Injected provider failure")
if (blockNextBatch) {
blockNextBatch = false
@@ -739,9 +1375,61 @@ private class CapturingTvProvider : ContentProvider() {
}
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 query(uri: Uri, projection: Array<out String>?, selection: String?, selectionArgs: Array<out String>?, sortOrder: String?): Cursor? = null
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int = 0
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?
): Int = 0
}
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")
}
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() {
let core = MpvAudioPlayerCore()
XCTAssertTrue(core.initialize())
@@ -314,10 +325,14 @@ final class MpvPlayerContractTests: XCTestCase {
let completion = expectation(description: "cancelled property completion")
completion.assertForOverFulfill = true
var completionCount = 0
var completionError: Error?
core.setPropertyAsync("volume", value: "51") { result in
completionCount += 1
if case .success = result {
switch result {
case .success:
XCTFail("Disposal must fail an accepted-but-pending property request")
case .failure(let error):
completionError = error
}
completion.fulfill()
}
@@ -327,7 +342,12 @@ final class MpvPlayerContractTests: XCTestCase {
wait(for: [completion], timeout: 2)
core.queue.sync {}
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() {
+1 -1
View File
@@ -560,7 +560,7 @@ void MpvPlayer::Dispose() {
auto cancelled = pending_requests_.CancelAll();
for (auto& callback : cancelled.status) {
callback(-1);
callback(MPV_ERROR_UNINITIALIZED);
}
for (auto& callback : cancelled.properties) {
callback(-1, "");
@@ -446,7 +446,7 @@ void TestPendingPropertyWriteFailsOnDispose() {
player.Dispose();
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();
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)) {
async_response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
} else {
const std::string description = plezy::mpv_common::SetPropertyErrorDescription(error);
async_response = FL_METHOD_RESPONSE(fl_method_error_response_new(
plezy::mpv_common::kSetPropertyFailedCode, description.c_str(), nullptr));
const char* error_code = plezy::mpv_common::SetPropertyErrorCode(error);
const std::string description = error == MPV_ERROR_UNINITIALIZED
? 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);
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")
}
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() {
let core = MpvAudioPlayerCore()
XCTAssertTrue(core.initialize())
@@ -161,10 +172,14 @@ final class MpvPlayerContractTests: XCTestCase {
let completion = expectation(description: "cancelled property completion")
completion.assertForOverFulfill = true
var completionCount = 0
var completionError: Error?
core.setPropertyAsync("volume", value: "51") { result in
completionCount += 1
if case .success = result {
switch result {
case .success:
XCTFail("Disposal must fail an accepted-but-pending property request")
case .failure(let error):
completionError = error
}
completion.fulfill()
}
@@ -174,7 +189,12 @@ final class MpvPlayerContractTests: XCTestCase {
wait(for: [completion], timeout: 2)
core.queue.sync {}
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() {
+11 -11
View File
@@ -10,6 +10,14 @@ import QuartzCore
import Metal
#endif
struct MpvLifecycleUnavailableError: LocalizedError {
let errorDescription: String?
init(_ description: String) {
errorDescription = description
}
}
protocol MpvPlayerDelegate: AnyObject {
func onPropertyChange(name: String, value: Any?)
func onEvent(name: String, data: [String: Any]?)
@@ -944,11 +952,7 @@ class MpvPlayerCoreBase: NSObject {
pendingRequests.removeAll()
pendingRequestsLock.unlock()
let error = NSError(
domain: "mpv",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Player disposed"]
)
let error = MpvLifecycleUnavailableError("Player disposed")
for (_, request) in pending {
DispatchQueue.main.async {
switch request {
@@ -977,12 +981,8 @@ class MpvPlayerCoreBase: NSObject {
return pendingRequests.removeValue(forKey: requestId)
}
private func lifecycleUnavailableError() -> NSError {
NSError(
domain: "mpv",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Player is not initialized or has been disposed"]
)
private func lifecycleUnavailableError() -> MpvLifecycleUnavailableError {
MpvLifecycleUnavailableError("Player is not initialized or has been disposed")
}
private func mpvError(_ status: CInt) -> NSError {
@@ -49,11 +49,15 @@ extension MpvPluginShared {
self?.didSetPauseProperty(value: value)
}
result(nil)
case .failure:
case .failure(let error):
let lifecycleUnavailable = error is MpvLifecycleUnavailableError
result(
FlutterError(
code: "SET_PROPERTY_FAILED",
message: "MPV rejected or cancelled the property write",
code: lifecycleUnavailable ? "NOT_INITIALIZED" : "SET_PROPERTY_FAILED",
message:
lifecycleUnavailable
? "MPV player is not initialized"
: "MPV rejected or cancelled the property write",
details: nil))
}
}
+4
View File
@@ -26,6 +26,10 @@ static constexpr size_t kSetPropertyErrorDescriptionLimit = 160;
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) {
const char* description = mpv_error_string(status);
if (!description || description[0] == '\0') {
+16 -3
View File
@@ -81,14 +81,27 @@ void TestSetPropertyResultContract() {
assert(SetPropertyStatusSucceeded(MPV_ERROR_SUCCESS));
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_PROPERTY_ERROR,
-1,
MPV_ERROR_UNINITIALIZED,
};
for (const int status : kFailureStatuses) {
assert(!SetPropertyStatusSucceeded(status));
for (const int status : kDescribedStatuses) {
const std::string description = SetPropertyErrorDescription(status);
assert(!description.empty());
assert(description.size() <= kSetPropertyErrorDescriptionLimit);
+4
View File
@@ -10,6 +10,7 @@
055E465F9095D0B6E9B91D46 /* PathProviderPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = D41AA251EF365516E2AC5287 /* PathProviderPlugin.swift */; };
1C6B234E223CDFFE5BF3FC0B /* TVServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 25B7925EFD8A2C1C7EB667D5 /* TVServices.framework */; };
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 */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
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>"; };
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>"; };
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>"; };
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>"; };
@@ -335,6 +337,7 @@
F9BDF6E09E3B9347861D2A50 /* RunnerTests */ = {
isa = PBXGroup;
children = (
2A7C0B1D9E5F4A6381027C11 /* MpvPlayerContractTests.swift */,
FE6C8125B201A8BC3261AE2B /* TvosEventDeliveryCoordinatorTests.swift */,
934BC4E316D2AC788C954766 /* ConnectivityPlusPluginTests.swift */,
A5E1F001234567890ABCDE01 /* SystemShelfPluginTests.swift */,
@@ -587,6 +590,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
2A7C0B1D9E5F4A6381027C12 /* MpvPlayerContractTests.swift in Sources */,
D2004D7BB4A40340AB7A01E0 /* TvosEventDeliveryCoordinatorTests.swift in Sources */,
65AC2C222043B3E6723E2076 /* ConnectivityPlusPluginTests.swift in Sources */,
A5E1F001234567890ABCDE02 /* SystemShelfPluginTests.swift in Sources */,
+4 -7
View File
@@ -561,18 +561,15 @@ import TVServices
else { return nil }
let data = download.data
let key = UUID().uuidString.lowercased().replacingOccurrences(of: "-", with: "") + ".art"
let staged = directory.appendingPathComponent(".\(key).tmp")
let destination = directory.appendingPathComponent(key)
do {
try data.write(to: staged, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
if FileManager.default.fileExists(atPath: destination.path) {
try FileManager.default.removeItem(at: destination)
}
try FileManager.default.moveItem(at: staged, to: destination)
try data.write(
to: destination,
options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]
)
remaining -= data.count
return key
} catch {
try? FileManager.default.removeItem(at: staged)
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
}
}
+416 -62
View File
@@ -23,6 +23,20 @@ constexpr wchar_t kRegOriginalHeight[] = L"OriginalHeight";
constexpr wchar_t kRegOriginalHDR[] = L"OriginalHDREnabled";
constexpr wchar_t kRegModeChanged[] = L"ModeChanged";
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;
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 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
@@ -240,12 +258,14 @@ bool DisplayModeManager::SetDisplayMode(HWND window, DWORD width, DWORD height,
}
if (changed) {
FinalizeModeRecoveryAtRegistry(true);
mode_changed_ = true;
} else if (!mode_was_changed) {
// Do not discard an independently persisted HDR operation owned by
// another manager or retained from startup recovery.
CompleteRecoveryOperationAtRegistry(kRegModeChanged);
// A failed takeover rolls back to its prior marked original. A fresh
// failed operation still clears only its own marker.
const bool recovery_completed = FinalizeModeRecoveryAtRegistry(false);
g_live_mode_recovery_record = false;
if (!recovery_completed) MarkTakeoverEligibleAtRegistry(kRegModeTakeoverEligible);
}
return changed;
@@ -256,6 +276,7 @@ bool DisplayModeManager::RestoreOriginalMode(HWND) {
if (!mode_changed_) return false;
if (original_device_name_.empty()) {
g_live_mode_recovery_record = false;
MarkTakeoverEligibleAtRegistry(kRegModeTakeoverEligible);
return false;
}
@@ -269,14 +290,18 @@ bool DisplayModeManager::RestoreOriginalMode(HWND) {
}
if (rc != DISP_CHANGE_SUCCESSFUL) {
// 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;
MarkTakeoverEligibleAtRegistry(kRegModeTakeoverEligible);
return false;
}
mode_changed_ = false;
CompleteRecoveryOperationAtRegistry(kRegModeChanged);
const bool recovery_completed =
CompleteRecoveryOperationAtRegistry(kRegModeChanged, kRegModeTakeoverEligible, kRegModeHandoffPending);
g_live_mode_recovery_record = false;
if (!recovery_completed) MarkTakeoverEligibleAtRegistry(kRegModeTakeoverEligible);
return true;
}
@@ -385,8 +410,9 @@ bool DisplayModeManager::SetHDREnabled(HWND window, bool enabled) {
const LONG result = SetHDRStateForTarget(*target_id, enabled);
if (result != ERROR_SUCCESS) {
if (!hdr_was_changed) {
CompleteRecoveryOperationAtRegistry(kRegHDRChanged);
const bool recovery_completed = FinalizeHDRRecoveryAtRegistry(false);
g_live_hdr_recovery_record = false;
if (!recovery_completed) MarkTakeoverEligibleAtRegistry(kRegHDRTakeoverEligible);
}
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);
}
FinalizeHDRRecoveryAtRegistry(true);
hdr_changed_ = true;
return true;
}
@@ -407,12 +434,14 @@ bool DisplayModeManager::RestoreOriginalHDRState(HWND window) {
if (!hdr_changed_) return false;
if (original_hdr_device_name_.empty()) {
g_live_hdr_recovery_record = false;
MarkTakeoverEligibleAtRegistry(kRegHDRTakeoverEligible);
return false;
}
const auto target_id = GetDisplayTargetId(original_hdr_device_name_);
if (!target_id) {
g_live_hdr_recovery_record = false;
MarkTakeoverEligibleAtRegistry(kRegHDRTakeoverEligible);
return false;
}
@@ -428,6 +457,7 @@ bool DisplayModeManager::RestoreOriginalHDRState(HWND window) {
if (SetHDRStateForTarget(*target_id, original_hdr_enabled_) != ERROR_SUCCESS) {
g_live_hdr_recovery_record = false;
MarkTakeoverEligibleAtRegistry(kRegHDRTakeoverEligible);
return false;
}
@@ -439,8 +469,10 @@ bool DisplayModeManager::RestoreOriginalHDRState(HWND window) {
}
hdr_changed_ = false;
CompleteRecoveryOperationAtRegistry(kRegHDRChanged);
const bool recovery_completed =
CompleteRecoveryOperationAtRegistry(kRegHDRChanged, kRegHDRTakeoverEligible, kRegHDRHandoffPending);
g_live_hdr_recovery_record = false;
if (!recovery_completed) MarkTakeoverEligibleAtRegistry(kRegHDRTakeoverEligible);
return true;
}
@@ -528,8 +560,30 @@ bool RecoveryRecordExists() {
}
bool exists = false;
for (const wchar_t* value_name :
{kRegVersion, kRegModeDeviceName, kRegLegacyDeviceName, kRegHDRDeviceName, kRegOriginalRefreshRate,
kRegOriginalWidth, kRegOriginalHeight, kRegOriginalHDR, kRegModeChanged, kRegHDRChanged}) {
{kRegVersion,
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;
const LONG result = RegQueryValueExW(key, value_name, nullptr, nullptr, nullptr, &size);
if (result == ERROR_SUCCESS || result == ERROR_MORE_DATA) {
@@ -606,8 +660,30 @@ class Win32DisplayRecoveryBackend final : public DisplayRecoveryBackend {
bool deleted = true;
for (const wchar_t* value_name :
{kRegVersion, kRegModeDeviceName, kRegLegacyDeviceName, kRegHDRDeviceName, kRegOriginalRefreshRate,
kRegOriginalWidth, kRegOriginalHeight, kRegOriginalHDR, kRegModeChanged, kRegHDRChanged}) {
{kRegVersion,
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);
deleted = deleted && (result == ERROR_SUCCESS || result == ERROR_FILE_NOT_FOUND);
}
@@ -620,33 +696,78 @@ namespace {
bool ReadValidModeValues(
DisplayRecoveryBackend& backend, const wchar_t* device_value_name, std::wstring& device_name, DWORD& width,
DWORD& height, DWORD& refresh_rate) {
return backend.ReadString(device_value_name, device_name) && backend.ReadDWORD(kRegOriginalWidth, width) &&
width > 0 && backend.ReadDWORD(kRegOriginalHeight, height) && height > 0 &&
backend.ReadDWORD(kRegOriginalRefreshRate, refresh_rate) && refresh_rate > 0;
DWORD& height, DWORD& refresh_rate, bool alternate = false) {
const wchar_t* width_value_name = alternate ? kRegOriginalWidthAlternate : kRegOriginalWidth;
const wchar_t* height_value_name = alternate ? kRegOriginalHeightAlternate : kRegOriginalHeight;
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(
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) &&
DisplayRecoveryBackend& backend, const wchar_t* device_value_name, std::wstring& device_name, DWORD& 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;
}
bool ReadValidMarkedMode(
DisplayRecoveryBackend& backend, std::wstring& device_name, DWORD& width, DWORD& height, DWORD& refresh_rate) {
DWORD version = 0;
DWORD marker = 0;
return backend.ReadDWORD(kRegVersion, version) && version == kRecoveryVersion &&
backend.ReadDWORD(kRegModeChanged, marker) && marker == 1 &&
ReadValidModeValues(backend, kRegModeDeviceName, device_name, width, height, refresh_rate);
bool ReadRecoverySlot(DisplayRecoveryBackend& backend, const wchar_t* slot_value_name, bool& alternate) {
DWORD slot = 0;
if (!backend.ReadDWORD(slot_value_name, slot)) {
// Version-1 records created before alternate slots implicitly use the
// original value set.
alternate = false;
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 marker = 0;
return backend.ReadDWORD(kRegVersion, version) && version == kRecoveryVersion &&
backend.ReadDWORD(kRegHDRChanged, marker) && marker == 1 &&
ReadValidHDRValues(backend, kRegHDRDeviceName, device_name, original_hdr);
bool alternate = false;
if (!backend.ReadDWORD(kRegVersion, version) || version != kRecoveryVersion ||
!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) {
@@ -662,12 +783,82 @@ bool DeleteRecordIfNoMarkedOperations(DisplayRecoveryBackend& backend) {
return true;
}
bool CompleteRecoveryOperation(DisplayRecoveryBackend& backend, const wchar_t* marker) {
if (!backend.ClearMarker(marker)) return false;
bool IsTakeoverEligible(DisplayRecoveryBackend& backend, const wchar_t* takeover_disposition) {
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);
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) {
DWORD marker = 0;
if (!backend.ReadDWORD(kRegModeChanged, marker)) {
@@ -711,22 +902,59 @@ bool DisplayModeManager::PrepareModeRecovery(
DWORD existing_height = 0;
DWORD existing_refresh_rate = 0;
std::wstring existing_device_name;
if (ReadValidMarkedMode(backend, existing_device_name, existing_width, existing_height, existing_refresh_rate)) {
// A valid marked original is already protecting a live or failed
// operation. Reuse it only when this manager has the same original;
// replacing it would lose the only restoration point.
return existing_device_name == device_name && existing_width == width && existing_height == height &&
existing_refresh_rate == refresh_rate;
bool existing_alternate = false;
if (ReadValidMarkedMode(
backend, existing_device_name, existing_width, existing_height, existing_refresh_rate, &existing_alternate)) {
bool handoff_pending = false;
if (!ReadHandoffPending(backend, kRegModeHandoffPending, handoff_pending) || handoff_pending) return false;
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
// originals. A crash anywhere before the final write is therefore a
// harmless pre-mutation prefix.
// An incomplete operation has no authoritative original to preserve. Keep
// the existing marker-last preparation sequence and select the primary slot
// before publishing the new operation.
if (!backend.ClearMarker(kRegModeChanged)) return false;
return backend.WriteDWORD(kRegVersion, kRecoveryVersion) && backend.WriteString(kRegModeDeviceName, device_name) &&
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(
@@ -736,14 +964,42 @@ bool DisplayModeManager::PrepareHDRRecovery(
DWORD existing_original = 0;
std::wstring existing_device_name;
if (ReadValidMarkedHDR(backend, existing_device_name, existing_original)) {
return existing_device_name == device_name && existing_original == (enabled ? 1u : 0u);
bool existing_alternate = false;
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;
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 {
@@ -758,9 +1014,29 @@ bool PrepareHDRRecoveryAtRegistry(const std::wstring& device_name, bool 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;
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) {
@@ -794,9 +1070,32 @@ bool RecoverRecord(DisplayRecoveryBackend& backend, bool mode_is_live, bool hdr_
DWORD width = 0;
DWORD height = 0;
DWORD refresh_rate = 0;
const bool mode_requested =
mode_marker_read && mode_marker == 1 &&
ReadValidModeValues(backend, mode_device_value_name, mode_device_name, width, height, refresh_rate);
bool mode_handoff_pending = false;
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);
}
}
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.
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);
std::wstring hdr_device_name;
DWORD original_hdr = 0;
const bool hdr_requested = hdr_marker_read && hdr_marker == 1 &&
ReadValidHDRValues(backend, hdr_device_value_name, hdr_device_name, original_hdr);
bool hdr_handoff_pending = false;
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))) {
backend.ClearMarker(kRegHDRChanged);
}
@@ -821,19 +1139,43 @@ bool RecoverRecord(DisplayRecoveryBackend& backend, bool mode_is_live, bool hdr_
bool completed = true;
if (recover_mode) {
if (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.
completed = CompleteRecoveryOperation(backend, kRegModeChanged) && completed;
} else {
const bool restored =
backend.IsDevicePresent(mode_device_name) && backend.RestoreMode(mode_device_name, width, height, refresh_rate);
if (mode_handoff_pending) {
// Restore the replacement first and the pre-handoff original last. Both
// 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;
}
}
if (recover_hdr) {
if (backend.IsDevicePresent(hdr_device_name) && backend.RestoreHDR(hdr_device_name, original_hdr != 0)) {
completed = CompleteRecoveryOperation(backend, kRegHDRChanged) && completed;
} else {
const bool restored =
backend.IsDevicePresent(hdr_device_name) && backend.RestoreHDR(hdr_device_name, original_hdr != 0);
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;
}
}
@@ -860,7 +1202,19 @@ bool DisplayModeManager::RecoverIfNeeded(DisplayRecoveryBackend& backend) {
#if defined(PLEZY_DISPLAY_MODE_MANAGER_TESTING)
bool DisplayModeManager::CompleteRecoveryOperationForTesting(DisplayRecoveryBackend& backend, bool mode) {
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(
+12 -4
View File
@@ -105,21 +105,29 @@ class DisplayModeManager {
// --- Crash recovery ---
// Persist a complete original followed by its operation marker. These
// runner-internal seams make the crash ordering deterministic in tests.
// Persist a complete original followed by its operation marker. A failed
// 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(
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);
// Check for and recover from a prior crash that left display settings
// changed. Successful operation markers are cleared independently. Failed
// operations remain for the next startup or display-topology notification.
// changed. Successful operation markers and their takeover dispositions are
// cleared independently. Failed operations remain recovery-first until a
// conflicting same-kind preparation consumes their persisted disposition.
static bool RecoverIfNeeded();
static bool RecoverIfNeeded(DisplayRecoveryBackend& backend);
#if defined(PLEZY_DISPLAY_MODE_MANAGER_TESTING)
// Exercise persisted lifecycle exits and per-operation live ownership without
// touching a real display or registry.
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);
#endif
+631 -31
View File
@@ -19,8 +19,24 @@ constexpr wchar_t kOriginalHeight[] = L"OriginalHeight";
constexpr wchar_t kOriginalHDR[] = L"OriginalHDREnabled";
constexpr wchar_t kModeChanged[] = L"ModeChanged";
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 kHDRDevice[] = L"\\\\.\\DISPLAY2";
constexpr wchar_t kReplacementModeDevice[] = L"\\\\.\\DISPLAY3";
constexpr wchar_t kReplacementHDRDevice[] = L"\\\\.\\DISPLAY4";
void Check(bool condition, const char* message) {
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 {
public:
std::map<std::wstring, DWORD> dwords;
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::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;
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 hdr_restore_succeeds = true;
bool mode_marker_clear_succeeds = true;
@@ -44,6 +81,7 @@ class FakeRecoveryBackend final : public DisplayRecoveryBackend {
bool delete_succeeds = true;
bool final_mode_marker_write_succeeds = true;
bool final_hdr_marker_write_succeeds = true;
std::wstring rejected_dword_write_event;
int delete_attempts = 0;
void SeedBoth() {
@@ -76,8 +114,10 @@ class FakeRecoveryBackend final : public DisplayRecoveryBackend {
bool WriteDWORD(const wchar_t* value_name, DWORD value) override {
const std::wstring name(value_name);
events.push_back(L"write:" + name + L"=" + std::to_wstring(value));
if ((name == kModeChanged && value == 1 && !final_mode_marker_write_succeeds) ||
const std::wstring event = L"write:" + name + L"=" + std::to_wstring(value);
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)) {
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 {
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");
if (expected_mode_restores.empty()) {
Check(device_name == expected_mode_device, "mode restore must use its persisted display");
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");
return mode_restore_succeeds;
}
bool RestoreHDR(const std::wstring& device_name, bool enabled) override {
Check(device_name == expected_hdr_device, "HDR restore must use its independently persisted display");
Check(!enabled, "HDR restore must use the persisted original state");
if (expected_hdr_restores.empty()) {
Check(device_name == expected_hdr_device, "HDR restore must use its independently persisted display");
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");
return hdr_restore_succeeds;
}
@@ -136,18 +196,24 @@ size_t EventIndex(const std::vector<std::wstring>& events, const std::wstring& e
return events.size();
}
bool ApplyModeAfterPreparing(FakeRecoveryBackend& backend) {
if (!DisplayModeManager::PrepareModeRecovery(backend, kModeDevice, 3840, 2160, 60)) {
bool ApplyModeAfterPreparing(
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;
}
backend.events.push_back(L"os:mode");
return true;
backend.events.push_back(os_apply_succeeds ? L"os:mode" : L"os:mode:failed");
DisplayModeManager::FinalizePreparedRecoveryForTesting(backend, true, os_apply_succeeds);
return os_apply_succeeds;
}
bool ApplyHDRAfterPreparing(FakeRecoveryBackend& backend) {
if (!DisplayModeManager::PrepareHDRRecovery(backend, kHDRDevice, false)) return false;
backend.events.push_back(L"os:hdr");
return true;
bool ApplyHDRAfterPreparing(
FakeRecoveryBackend& backend, const std::wstring& device_name = kHDRDevice, bool enabled = false,
bool os_apply_succeeds = 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() {
@@ -160,8 +226,10 @@ void TestMarkersArePersistedBeforeMutation() {
EventIndex(mode.events, L"write:ModeDeviceName") < 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:OriginalRefreshRate=60") < mode_marker,
"all mode originals must precede the operation marker");
EventIndex(mode.events, L"write:OriginalRefreshRate=60") < mode_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;
Check(ApplyHDRAfterPreparing(hdr), "a complete HDR recovery record must admit the OS mutation");
@@ -169,8 +237,10 @@ void TestMarkersArePersistedBeforeMutation() {
Check(
EventIndex(hdr.events, L"write:HDRDeviceName") < 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"),
"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;
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:hdr") < backend.events.size(),
"mode failure must not prevent the independent HDR restore");
Check(backend.dwords[kModeChanged] == 1, "the failed mode marker must remain set");
Check(backend.dwords[kHDRChanged] == 0, "the successful HDR marker must be cleared");
Check(
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.mode_restore_succeeds = true;
@@ -275,7 +349,9 @@ void TestFailedRestoreRemainsForTopologyRetry() {
backend.device_present[kModeDevice] = false;
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(
EventIndex(backend.events, L"restore:mode") == backend.events.size(),
"an absent display must not call its restore API");
@@ -287,6 +363,9 @@ void TestFailedRestoreRemainsForTopologyRetry() {
Check(
EventIndex(backend.events, L"restore:mode") < backend.events.size(),
"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() {
@@ -296,7 +375,9 @@ void TestMarkerClearFailureRemainsRetryable() {
backend.mode_marker_clear_succeeds = false;
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.mode_marker_clear_succeeds = true;
@@ -304,17 +385,21 @@ void TestMarkerClearFailureRemainsRetryable() {
Check(
EventIndex(backend.events, L"restore:mode") < backend.events.size(),
"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() {
FakeRecoveryBackend mode_completed;
mode_completed.SeedBoth();
mode_completed.dwords[kModeTakeoverEligible] = 1;
mode_completed.dwords[kHDRTakeoverEligible] = 1;
Check(
DisplayModeManager::CompleteRecoveryOperationForTesting(mode_completed, true),
"successful mode cleanup must durably clear its own marker");
Check(
mode_completed.dwords[kModeChanged] == 0 && mode_completed.dwords[kHDRChanged] == 1,
"mode cleanup must preserve a persisted HDR sibling even without local HDR ownership");
mode_completed.dwords[kModeChanged] == 0 && mode_completed.dwords[kModeTakeoverEligible] == 0 &&
mode_completed.dwords[kHDRChanged] == 1 && mode_completed.dwords[kHDRTakeoverEligible] == 1,
"mode cleanup must reset only its disposition while preserving a persisted HDR sibling");
Check(
mode_completed.dwords[kOriginalHDR] == 0 && mode_completed.strings[kHDRDeviceName] == kHDRDevice &&
mode_completed.delete_attempts == 0,
@@ -322,12 +407,15 @@ void TestLifecycleCleanupPreservesPersistedSibling() {
FakeRecoveryBackend hdr_failed_apply;
hdr_failed_apply.SeedBoth();
hdr_failed_apply.dwords[kModeTakeoverEligible] = 1;
hdr_failed_apply.dwords[kHDRTakeoverEligible] = 1;
Check(
DisplayModeManager::CompleteRecoveryOperationForTesting(hdr_failed_apply, false),
"failed HDR apply cleanup must durably clear its own marker");
Check(
hdr_failed_apply.dwords[kHDRChanged] == 0 && hdr_failed_apply.dwords[kModeChanged] == 1,
"HDR cleanup must preserve a persisted mode sibling even without local mode ownership");
hdr_failed_apply.dwords[kHDRChanged] == 0 && hdr_failed_apply.dwords[kHDRTakeoverEligible] == 0 &&
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(
hdr_failed_apply.dwords[kOriginalWidth] == 3840 && hdr_failed_apply.strings[kModeDeviceName] == kModeDevice &&
hdr_failed_apply.delete_attempts == 0,
@@ -343,13 +431,18 @@ void TestReleasedLiveOperationRecoversAfterReconnect() {
!DisplayModeManager::RecoverIfNeededForTesting(backend, true, true),
"topology recovery must not take either genuinely live operation");
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(
!DisplayModeManager::RecoverIfNeededForTesting(backend, false, true),
"a released operation must remain marked while its target is absent");
Check(
backend.dwords[kModeChanged] == 1 && backend.dwords[kHDRChanged] == 1,
"an absent released mode and its live HDR sibling must both retain their markers");
backend.dwords[kModeChanged] == 1 && backend.dwords[kModeTakeoverEligible] == 1 &&
backend.dwords[kHDRChanged] == 1,
"an absent released mode must become eligible without changing its live HDR sibling");
backend.events.clear();
backend.device_present[kModeDevice] = true;
@@ -361,8 +454,503 @@ void TestReleasedLiveOperationRecoversAfterReconnect() {
EventIndex(backend.events, L"restore:hdr") == backend.events.size(),
"reconnect recovery must restore the released mode without stealing live HDR");
Check(
backend.dwords[kModeChanged] == 0 && backend.dwords[kHDRChanged] == 1 && backend.delete_attempts == 0,
"reconnect recovery must preserve the genuinely live sibling record");
backend.dwords[kModeChanged] == 0 && backend.dwords[kModeTakeoverEligible] == 0 &&
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() {
@@ -431,7 +1019,9 @@ void TestCleanupFailureDoesNotBlockNewOverride() {
Check(
DisplayModeManager::RecoverIfNeeded(backend),
"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");
backend.events.clear();
@@ -456,6 +1046,16 @@ int main() {
mpv::TestMarkerClearFailureRemainsRetryable();
mpv::TestLifecycleCleanupPreservesPersistedSibling();
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::TestVersionlessHDRRecoveryAndCleanup();
mpv::TestVersionlessModeAndHDRUseSharedDevice();
+1 -1
View File
@@ -547,7 +547,7 @@ void MpvPlayer::Dispose() {
auto cancelled = pending_requests_.CancelAll();
for (auto& callback : cancelled.status) {
callback(-1);
callback(MPV_ERROR_UNINITIALIZED);
}
for (auto& callback : cancelled.properties) {
callback(-1, "");
@@ -94,7 +94,7 @@ void TestPendingPropertyWriteFailsOnDispose() {
player.Dispose();
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();
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)) {
(*result_ptr)->Success();
} else {
(*result_ptr)
->Error(
plezy::mpv_common::kSetPropertyFailedCode, plezy::mpv_common::SetPropertyErrorDescription(error));
const auto* error_code = plezy::mpv_common::SetPropertyErrorCode(error);
const auto description = error == MPV_ERROR_UNINITIALIZED
? std::string("Player not initialized")
: plezy::mpv_common::SetPropertyErrorDescription(error);
(*result_ptr)->Error(error_code, description);
}
});
});