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