fix(native): harden player teardown and shelf recovery

This commit is contained in:
edde746
2026-07-25 16:16:24 +02:00
parent 4af77f4696
commit f8f366a513
26 changed files with 2895 additions and 302 deletions
@@ -18,6 +18,7 @@ import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.CancellationException
import java.util.concurrent.atomic.AtomicBoolean
class ExoPlayerPlugin :
@@ -774,16 +775,17 @@ class ExoPlayerPlugin :
}
pendingMpvProperties[name] = value
core.setProperty(name, value) { outcome ->
val currentOutcome = if (
val fallbackIsCurrent =
usingMpvFallback &&
generation == sessionGeneration &&
activity === currentActivity &&
mpvCore === core
) {
outcome
} else {
Result.failure(IllegalStateException("MPV fallback unavailable"))
}
generation == sessionGeneration &&
activity === currentActivity &&
mpvCore === core
val currentOutcome =
if (fallbackIsCurrent || outcome.isFailure) {
outcome
} else {
Result.failure(CancellationException("MPV fallback unavailable"))
}
completeMpvPropertyResult(result, currentOutcome, successValue)
}
}
@@ -700,8 +700,15 @@ class MpvPlayerCore private constructor(
}
private fun resumeAfterAudioFocusGain(reason: String) {
pausedForAudioFocusLoss = false
requestAutoResume(reason)
val shouldResume = synchronized(publicPauseIntentLock) {
if (!pausedForAudioFocusLoss) {
false
} else {
pausedForAudioFocusLoss = false
true
}
}
if (shouldResume) requestAutoResume(reason)
}
private fun rollbackFailedPublicPauseIntent(intent: PublicPauseIntent) {
@@ -713,6 +720,22 @@ class MpvPlayerCore private constructor(
}
}
private fun completeFailedPublicResume(
intent: PublicPauseIntent,
failure: Throwable,
onComplete: ((Result<Unit>) -> Unit)?
) {
rollbackFailedPublicPauseIntent(intent)
runOnMain {
val completion: Result<Unit> = if (disposing || !isInitialized || !scope.isActive) {
Result.failure(CancellationException("MPV core unavailable"))
} else {
Result.failure(failure)
}
onComplete?.invoke(completion)
}
}
private fun requestAutoResume(reason: String) {
val p = player
if (p == null && propertyWriterOverride == null) return
@@ -847,44 +870,89 @@ class MpvPlayerCore private constructor(
}
}
if (paused == false && (pausedForAudioFocusLoss || !hasReadyVideoOutput())) {
if (paused == false && pauseIntent != null) {
val shouldReclaimAudioFocus = synchronized(publicPauseIntentLock) {
publicPauseIntentGeneration == pauseIntent.generation && pausedForAudioFocusLoss
}
if (shouldReclaimAudioFocus) {
val focusGranted = audioFocusManager?.requestAudioFocus() == true
if (!focusGranted) {
Log.w(TAG, "Audio focus request denied for public resume")
completeFailedPublicResume(
pauseIntent,
IllegalStateException("Audio focus unavailable for resume"),
onComplete
)
return
}
synchronized(publicPauseIntentLock) {
if (publicPauseIntentGeneration == pauseIntent.generation && pausedForAudioFocusLoss) {
pausedForAudioFocusLoss = false
}
}
}
}
if (paused == false && pauseIntent != null && !hasReadyVideoOutput()) {
runOnMain {
if (!isInitialized || disposing || !scope.isActive) {
onComplete?.invoke(Result.failure(CancellationException("MPV core unavailable")))
return@runOnMain
}
val isCurrent = synchronized(publicPauseIntentLock) {
pauseIntent != null && publicPauseIntentGeneration == pauseIntent.generation
var deferredForSurface = false
val interruptedAgain = synchronized(publicPauseIntentLock) {
if (publicPauseIntentGeneration != pauseIntent.generation) {
false
} else if (pausedForAudioFocusLoss) {
true
} else {
deferredResumeRequested = true
deferredForSurface = true
false
}
}
if (isCurrent) {
deferredResumeRequested = true
Log.d(
TAG,
if (pausedForAudioFocusLoss) {
"Deferring public resume until audio focus returns"
} else {
"Deferring public resume until video output is ready"
}
if (interruptedAgain) {
Log.d(TAG, "Public resume interrupted by a newer audio-focus loss")
completeFailedPublicResume(
pauseIntent,
IllegalStateException("Audio focus unavailable for resume"),
onComplete
)
} else {
if (deferredForSurface) {
Log.d(TAG, "Deferring public resume until video output is ready")
}
onComplete?.invoke(Result.success(Unit))
}
onComplete?.invoke(Result.success(Unit))
}
return
}
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
var interruptedBeforeWrite = false
val writeResult = try {
if (pauseIntent == null) {
writeProperty(name, value)
} else {
publicPauseWriteMutex.withLock {
val shouldWrite = synchronized(publicPauseIntentLock) {
publicPauseIntentGeneration == pauseIntent.generation
val isCurrent = publicPauseIntentGeneration == pauseIntent.generation
if (isCurrent && paused == false && pausedForAudioFocusLoss) {
interruptedBeforeWrite = true
false
} else {
isCurrent
}
}
if (shouldWrite) writeProperty(name, value)
}
}
Result.success(Unit)
if (interruptedBeforeWrite) {
Result.failure(IllegalStateException("Audio focus unavailable for resume"))
} else {
Result.success(Unit)
}
} catch (error: CancellationException) {
Result.failure(error)
} catch (error: Exception) {
@@ -8,13 +8,14 @@ import android.net.Uri
import android.os.Binder
import android.os.ParcelFileDescriptor
import android.os.Process
import android.system.Os
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileNotFoundException
import java.io.RandomAccessFile
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
import java.util.UUID
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
@@ -88,14 +89,28 @@ internal class SystemShelfArtworkStore(private val cacheDir: File) {
const val CONNECT_TIMEOUT_MS = 2_500
const val READ_TIMEOUT_MS = 2_500
private val opaquePart = Regex("^[a-f0-9]{64}$")
private val artworkKey = Regex("^[a-f0-9]{32}\\.art$")
private val artworkKey = Regex("^(?:[a-f0-9]{32}|[a-f0-9]{64})\\.art$")
private val deadlineAborter: ScheduledExecutorService =
Executors.newSingleThreadScheduledExecutor { runnable ->
Thread(runnable, "system-shelf-deadline").apply { isDaemon = true }
}
}
data class Materialized(val key: String, val uri: Uri, val file: File)
data class Materialized(val uri: Uri, val file: File)
sealed class Prepared {
abstract val materialized: Materialized
data class Existing(override val materialized: Materialized) : Prepared()
data class Staged(
override val materialized: Materialized,
val stagingFile: File
) : Prepared()
}
data class Publication(val materialized: Materialized, val newlyPublished: Boolean)
class Budget(var remaining: Int = MAX_SYNC_BYTES) {
var consumed: Long = 0
private set
@@ -114,20 +129,37 @@ internal class SystemShelfArtworkStore(private val cacheDir: File) {
private val root: File get() = File(cacheDir, "system_shelf_artwork")
fun materialize(ownerId: String, source: String, session: SystemShelfSyncSession): Materialized? {
if (ownerId.isBlank() || session.budget.remaining <= 0 || !session.isActive()) return null
fun prepare(ownerId: String, source: String, session: SystemShelfSyncSession): Prepared? {
if (ownerId.isBlank()) return null
val url = runCatching { URL(source) }.getOrNull() ?: return null
if (url.protocol != "https" && url.protocol != "http") return null
val connection = (url.openConnection() as? HttpURLConnection) ?: return null
if (!session.isActive()) return null
val ownerKey = sha256(ownerId)
val key = sha256(ownerId + "\u0000" + source) + ".art"
val uri = contentUri(ownerKey, key)
val destination = File(File(root, ownerKey), key)
val materialized = Materialized(uri, destination)
resolveOwned(ownerId, uri)?.let { existing ->
return Prepared.Existing(materialized.copy(file = existing))
}
if (session.budget.remaining <= 0) return null
val remainingNanos = session.remainingNanos()
if (remainingNanos <= 0) return null
val connection = runCatching { url.openConnection() as? HttpURLConnection }.getOrNull()
?: return null
val abort = deadlineAborter.schedule(
{ connection.disconnect() },
remainingNanos,
TimeUnit.NANOSECONDS
)
var stagingFile: File? = null
var retainStagingFile = false
return try {
val remainingMillis = TimeUnit.NANOSECONDS.toMillis(remainingNanos).coerceIn(1, Int.MAX_VALUE.toLong()).toInt()
val remainingMillis = TimeUnit.NANOSECONDS.toMillis(remainingNanos)
.coerceIn(1, Int.MAX_VALUE.toLong())
.toInt()
connection.instanceFollowRedirects = true
connection.connectTimeout = minOf(CONNECT_TIMEOUT_MS, remainingMillis)
connection.readTimeout = minOf(READ_TIMEOUT_MS, remainingMillis)
@@ -155,41 +187,73 @@ internal class SystemShelfArtworkStore(private val cacheDir: File) {
}
val read = input.read(buffer, 0, minOf(buffer.size, remaining))
if (read < 0) break
session.budget.charge(read)
if (!session.budget.charge(read)) return null
total += read
output.write(buffer, 0, read)
}
output.toByteArray()
}
if (!session.isActive() || !isSupportedImage(bytes)) return null
val ownerKey = sha256(ownerId)
val directory = File(root, ownerKey)
val directory = destination.parentFile ?: return null
if (!directory.mkdirs() && !directory.isDirectory) return null
val key = UUID.randomUUID().toString().replace("-", "") + ".art"
val staged = File(directory, ".$key.tmp")
val staged = File.createTempFile(".$key.", ".tmp", directory)
stagingFile = staged
staged.outputStream().use { output ->
output.write(bytes)
output.flush()
output.fd.sync()
}
if (!session.isActive()) {
staged.delete()
return null
}
val destination = File(directory, key)
if (!session.commitIfActive { staged.renameTo(destination) }) {
staged.delete()
return null
}
Materialized(key, contentUri(ownerKey, key), destination)
if (!isSupportedImage(staged) || !session.isActive()) return null
retainStagingFile = true
Prepared.Staged(materialized, staged)
} catch (_: Exception) {
null
} finally {
if (!retainStagingFile) stagingFile?.delete()
abort.cancel(false)
connection.disconnect()
}
}
fun publish(prepared: Prepared): Publication? {
val expected = confinedCandidate(prepared.materialized.uri) ?: return null
val recorded = runCatching { prepared.materialized.file.canonicalFile }.getOrNull() ?: return null
if (recorded != expected) return null
resolve(prepared.materialized.uri)?.let { existing ->
if (prepared is Prepared.Staged && !deleteStagingFile(prepared.stagingFile, expected)) {
return null
}
return Publication(prepared.materialized.copy(file = existing), newlyPublished = false)
}
if (prepared !is Prepared.Staged) return null
val staged = confinedStagingFile(prepared.stagingFile, expected) ?: return null
if (!isSupportedImage(staged)) return null
val renamed = try {
Os.rename(staged.absolutePath, expected.absolutePath)
!staged.exists() || staged.renameTo(expected)
} catch (_: Exception) {
runCatching { staged.renameTo(expected) }.getOrDefault(false)
}
if (!renamed) return null
return if (!isSupportedImage(expected)) {
expected.delete()
null
} else {
Publication(prepared.materialized.copy(file = expected), newlyPublished = true)
}
}
fun discard(prepared: Iterable<Prepared>) {
prepared.forEach { candidate ->
if (candidate is Prepared.Staged) {
deleteStagingFile(candidate.stagingFile, candidate.materialized.file)
}
}
}
fun contentUri(ownerKey: String, key: String): Uri = Uri.Builder()
.scheme("content")
.authority(SystemShelfArtworkProvider.AUTHORITY)
@@ -199,60 +263,257 @@ internal class SystemShelfArtworkStore(private val cacheDir: File) {
.build()
fun resolve(uri: Uri): File? {
val candidate = confinedCandidate(uri) ?: return null
return candidate.takeIf(::isSupportedImage)
}
fun resolveOwned(ownerId: String, uri: Uri): File? {
if (ownerId.isBlank() || uri.pathSegments.getOrNull(1) != sha256(ownerId)) return null
return resolve(uri)
}
fun deleteExcept(keep: Set<File>) {
val canonicalKeep = keep.mapNotNullTo(HashSet()) {
runCatching { it.canonicalFile }.getOrNull()
}
val canonicalRoot = runCatching { root.canonicalFile }.getOrNull() ?: return
for (ownerDirectory in root.listFiles().orEmpty()) {
for (file in ownerDirectory.listFiles().orEmpty()) {
val candidate = confinedCacheFile(file, canonicalRoot) ?: continue
if (candidate !in canonicalKeep) candidate.delete()
}
}
removeEmptyDirectories()
}
fun delete(files: Set<File>) {
val canonicalRoot = runCatching { root.canonicalFile }.getOrNull() ?: return
files.forEach { file ->
confinedCacheFile(file, canonicalRoot)?.delete()
}
removeEmptyDirectories()
}
fun deleteAll(): Boolean = !root.exists() || root.deleteRecursively()
private fun confinedCandidate(uri: Uri): File? {
if (uri.scheme != "content" || uri.authority != SystemShelfArtworkProvider.AUTHORITY) return null
val segments = uri.pathSegments
if (segments.size != 3 || segments[0] != "art") return null
val owner = segments[1]
val key = segments[2]
if (!opaquePart.matches(owner) || !artworkKey.matches(key)) return null
val canonicalRoot = root.canonicalFile
val candidate = File(File(canonicalRoot, owner), key).canonicalFile
if (candidate.parentFile?.parentFile != canonicalRoot || !candidate.isFile) return null
return candidate
return runCatching {
val canonicalRoot = root.canonicalFile
val ownerDirectory = File(canonicalRoot, owner).canonicalFile
if (ownerDirectory.parentFile != canonicalRoot || ownerDirectory.name != owner) return null
val candidate = File(ownerDirectory, key).canonicalFile
if (candidate.parentFile != ownerDirectory || candidate.name != key) return null
candidate
}.getOrNull()
}
fun deleteExcept(keep: Set<File>) {
val canonicalKeep = keep.mapTo(HashSet()) { it.canonicalFile }
root.listFiles()?.forEach { ownerDirectory ->
ownerDirectory.listFiles()?.forEach { file ->
if (file.canonicalFile !in canonicalKeep) file.delete()
private fun confinedCacheFile(file: File, canonicalRoot: File): File? {
return runCatching {
val originalDirectory = file.parentFile ?: return null
val directory = originalDirectory.canonicalFile
if (
directory.parentFile != canonicalRoot ||
directory.name != originalDirectory.name
) {
return null
}
if (ownerDirectory.listFiles().isNullOrEmpty()) ownerDirectory.delete()
}
val candidate = file.canonicalFile
if (candidate.parentFile != directory || candidate.name != file.name) return null
candidate
}.getOrNull()
}
fun delete(files: Set<File>) {
val canonicalRoot = root.canonicalFile
files.forEach { file ->
val candidate = runCatching { file.canonicalFile }.getOrNull() ?: return@forEach
if (candidate.parentFile?.parentFile == canonicalRoot) candidate.delete()
private fun confinedStagingFile(stagingFile: File, destination: File): File? {
val staged = runCatching { stagingFile.canonicalFile }.getOrNull() ?: return null
val canonicalDestination = runCatching { destination.canonicalFile }.getOrNull() ?: return null
val directory = canonicalDestination.parentFile ?: return null
if (
staged == canonicalDestination ||
staged.parentFile != directory ||
!staged.name.startsWith(".${canonicalDestination.name}.") ||
!staged.name.endsWith(".tmp") ||
!staged.isFile
) {
return null
}
return staged
}
private fun deleteStagingFile(stagingFile: File, destination: File): Boolean {
if (!stagingFile.exists()) return true
return confinedStagingFile(stagingFile, destination)?.delete() == true
}
private fun removeEmptyDirectories() {
root.listFiles()?.forEach { directory ->
if (directory.listFiles().isNullOrEmpty()) directory.delete()
}
}
fun deleteAll(): Boolean = !root.exists() || root.deleteRecursively()
private enum class ImageFormat {
PNG,
JPEG,
GIF,
WEBP
}
private fun isSupportedImage(file: File): Boolean {
val length = file.length()
if (!file.isFile || length !in 1L..MAX_IMAGE_BYTES.toLong()) return false
val header = ByteArray(12)
val headerSize = runCatching {
file.inputStream().use { input ->
var total = 0
while (total < header.size) {
val read = input.read(header, total, header.size - total)
if (read <= 0) break
total += read
}
total
}
}.getOrNull() ?: return false
val format = imageFormat(header, headerSize) ?: return false
if (!hasCompleteContainer(file, format, header, length)) return false
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
return runCatching {
BitmapFactory.decodeFile(file.absolutePath, options)
hasSupportedDimensions(options)
}.getOrDefault(false)
}
private fun isSupportedImage(bytes: ByteArray): Boolean {
if (bytes.size < 4) return false
val png = bytes.size >= 8 &&
val format = imageFormat(bytes, bytes.size) ?: return false
if (!hasCompleteContainer(bytes, format)) return false
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
return runCatching {
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
hasSupportedDimensions(options)
}.getOrDefault(false)
}
private fun imageFormat(bytes: ByteArray, size: Int): ImageFormat? {
if (
size >= 8 &&
bytes[0] == 0x89.toByte() &&
bytes[1] == 0x50.toByte() &&
bytes[2] == 0x4e.toByte() &&
bytes[3] == 0x47.toByte()
val jpeg = bytes[0] == 0xff.toByte() && bytes[1] == 0xd8.toByte() && bytes[2] == 0xff.toByte()
val gif = bytes[0] == 0x47.toByte() && bytes[1] == 0x49.toByte() && bytes[2] == 0x46.toByte()
val webp = bytes.size >= 12 &&
bytes.copyOfRange(0, 4).contentEquals("RIFF".toByteArray()) &&
bytes.copyOfRange(8, 12).contentEquals("WEBP".toByteArray())
if (!png && !jpeg && !gif && !webp) return false
bytes[3] == 0x47.toByte() &&
bytes[4] == 0x0d.toByte() &&
bytes[5] == 0x0a.toByte() &&
bytes[6] == 0x1a.toByte() &&
bytes[7] == 0x0a.toByte()
) {
return ImageFormat.PNG
}
if (
size >= 3 &&
bytes[0] == 0xff.toByte() &&
bytes[1] == 0xd8.toByte() &&
bytes[2] == 0xff.toByte()
) {
return ImageFormat.JPEG
}
if (
size >= 6 &&
bytes[0] == 0x47.toByte() &&
bytes[1] == 0x49.toByte() &&
bytes[2] == 0x46.toByte() &&
bytes[3] == 0x38.toByte() &&
(bytes[4] == 0x37.toByte() || bytes[4] == 0x39.toByte()) &&
bytes[5] == 0x61.toByte()
) {
return ImageFormat.GIF
}
if (
size >= 12 &&
bytes[0] == 0x52.toByte() &&
bytes[1] == 0x49.toByte() &&
bytes[2] == 0x46.toByte() &&
bytes[3] == 0x46.toByte() &&
bytes[8] == 0x57.toByte() &&
bytes[9] == 0x45.toByte() &&
bytes[10] == 0x42.toByte() &&
bytes[11] == 0x50.toByte()
) {
return ImageFormat.WEBP
}
return null
}
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
private fun hasCompleteContainer(
file: File,
format: ImageFormat,
header: ByteArray,
length: Long
): Boolean = runCatching {
if (format == ImageFormat.WEBP) return@runCatching webpLength(header) == length
RandomAccessFile(file, "r").use { input ->
when (format) {
ImageFormat.PNG -> {
if (length < 12) return@use false
input.seek(length - 12)
input.readInt() == 0 &&
input.readInt() == 0x49454e44 &&
input.readInt() == 0xae426082.toInt()
}
ImageFormat.JPEG -> {
if (length < 2) return@use false
input.seek(length - 2)
input.readUnsignedByte() == 0xff && input.readUnsignedByte() == 0xd9
}
ImageFormat.GIF -> {
input.seek(length - 1)
input.readUnsignedByte() == 0x3b
}
ImageFormat.WEBP -> false
}
}
}.getOrDefault(false)
private fun hasCompleteContainer(bytes: ByteArray, format: ImageFormat): Boolean {
val size = bytes.size
return when (format) {
ImageFormat.PNG ->
size >= 12 &&
bytes[size - 8] == 0x49.toByte() &&
bytes[size - 7] == 0x45.toByte() &&
bytes[size - 6] == 0x4e.toByte() &&
bytes[size - 5] == 0x44.toByte() &&
bytes[size - 4] == 0xae.toByte() &&
bytes[size - 3] == 0x42.toByte() &&
bytes[size - 2] == 0x60.toByte() &&
bytes[size - 1] == 0x82.toByte()
ImageFormat.JPEG ->
size >= 2 &&
bytes[size - 2] == 0xff.toByte() &&
bytes[size - 1] == 0xd9.toByte()
ImageFormat.GIF -> size >= 1 && bytes[size - 1] == 0x3b.toByte()
ImageFormat.WEBP -> webpLength(bytes) == size.toLong()
}
}
private fun webpLength(header: ByteArray): Long {
val riffSize = (header[4].toLong() and 0xff) or
((header[5].toLong() and 0xff) shl 8) or
((header[6].toLong() and 0xff) shl 16) or
((header[7].toLong() and 0xff) shl 24)
return riffSize + 8
}
private fun hasSupportedDimensions(options: BitmapFactory.Options): Boolean {
val width = options.outWidth
val height = options.outHeight
return width in 1..4096 && height in 1..4096 && width.toLong() * height <= 16_777_216L
return width in 1..4096 &&
height in 1..4096 &&
width.toLong() * height <= 16_777_216L
}
private fun sha256(value: String): String = MessageDigest.getInstance("SHA-256")
@@ -5,6 +5,7 @@ import android.content.ContentUris
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.util.Log
@@ -118,6 +119,12 @@ class WatchNextProvider internal constructor(
internal data class PreparedWatchNextItem(val metadata: WatchNextItem, val localPosterUri: Uri?)
private data class CommittedRow(
val id: Long,
val contentId: String,
val posterUri: Uri?
)
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private val artwork = SystemShelfArtworkStore(context.cacheDir)
@@ -133,34 +140,71 @@ class WatchNextProvider internal constructor(
if (items.size > SystemShelfArtworkStore.MAX_ITEMS) return false
val operationOwnership = ownership ?: claimOwnership(ownerId, generation) ?: return false
if (!isOperationActive()) return false
val oldUris = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty().mapNotNull(Uri::parse).toSet()
val oldPackages = storedPackages()
val oldSchemaVersion = prefs.getInt(SHELF_SCHEMA_VERSION_KEY, 0)
val session = SystemShelfSyncSession(
operationOwnership,
syncDurationMillis
)
val materializedFiles = LinkedHashSet<java.io.File>()
var committed = false
val previousArtwork = snapshotCommittedArtwork(ownerId) ?: return false
if (!isOperationActive() || !session.isActive()) return false
val oldUris = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty().mapNotNull(Uri::parse).toSet()
val oldPackages = storedPackages()
val oldSchemaVersion = prefs.getInt(SHELF_SCHEMA_VERSION_KEY, 0)
val preparedArtwork = LinkedHashSet<SystemShelfArtworkStore.Prepared>()
val preparedBySource = HashMap<String, SystemShelfArtworkStore.Prepared?>()
try {
val prepared = items.map { item ->
if (!isOperationActive() || !session.isActive()) {
PreparedWatchNextItem(item, null)
val preparedItems = items.map { item ->
val source = item.posterSourceUri
val localArtwork = if (source == null) {
null
} else {
val materialized = item.posterSourceUri?.let { artwork.materialize(ownerId, it, session) }
materialized?.file?.let(materializedFiles::add)
PreparedWatchNextItem(item, materialized?.uri)
val candidate = if (preparedBySource.containsKey(source)) {
preparedBySource[source]
} else {
artwork.prepare(ownerId, source, session).also { prepared ->
preparedBySource[source] = prepared
prepared?.let(preparedArtwork::add)
}
}
candidate?.materialized ?: previousArtwork[item.contentId]
}
PreparedWatchNextItem(item, localArtwork?.uri)
}
val newUris = prepared.mapNotNullTo(LinkedHashSet()) { it.localPosterUri }
val newUris = preparedItems.mapNotNullTo(LinkedHashSet()) { it.localPosterUri }
val newPackages = consumerPackages()
if (!isOperationActive() || !session.isActive()) return false
committed = SystemShelfLifecycle.whileCurrent(session.ownership) {
return SystemShelfLifecycle.whileCurrent(session.ownership) {
if (!isOperationActive() || session.isExpired()) return@whileCurrent false
reconcileReadAccess(oldUris, oldPackages, newUris, newPackages)
val publishedFiles = LinkedHashSet<java.io.File>()
preparedArtwork.forEach { candidate ->
val publication = artwork.publish(candidate)
if (publication == null) {
artwork.delete(publishedFiles)
return@whileCurrent false
}
if (publication.newlyPublished) publishedFiles += publication.materialized.file
}
val referencedFiles = LinkedHashSet<java.io.File>()
preparedItems.forEach { item ->
val posterUri = item.localPosterUri ?: return@forEach
val file = artwork.resolveOwned(ownerId, posterUri)
if (file == null) {
artwork.delete(publishedFiles)
return@whileCurrent false
}
referencedFiles += file
}
if (session.isExpired()) {
artwork.delete(publishedFiles)
return@whileCurrent false
}
grantReadAccess(newUris, newPackages)
if (session.isExpired()) {
reconcileReadAccess(newUris, newPackages, oldUris, oldPackages)
artwork.delete(publishedFiles)
return@whileCurrent false
}
val preferencesCommitted = prefs.edit()
@@ -170,9 +214,10 @@ class WatchNextProvider internal constructor(
.commit()
if (!preferencesCommitted) {
reconcileReadAccess(newUris, newPackages, oldUris, oldPackages)
artwork.delete(publishedFiles)
return@whileCurrent false
}
if (session.isExpired() || !replaceRows(prepared)) {
if (session.isExpired() || !replaceRows(preparedItems)) {
val rollback = prefs.edit()
.putStringSet(GRANTED_URIS, oldUris.mapTo(LinkedHashSet(), Uri::toString))
.putStringSet(GRANTED_PACKAGES, oldPackages)
@@ -183,17 +228,16 @@ class WatchNextProvider internal constructor(
}
rollback.commit()
reconcileReadAccess(newUris, newPackages, oldUris, oldPackages)
artwork.delete(publishedFiles)
return@whileCurrent false
}
artwork.deleteExcept(materializedFiles)
reconcileReadAccess(oldUris, oldPackages, newUris, newPackages)
artwork.deleteExcept(referencedFiles)
true
} ?: false
return committed
} finally {
if (!committed) {
materializedFiles.forEach { it.delete() }
}
artwork.discard(preparedArtwork)
}
}
@@ -256,6 +300,95 @@ class WatchNextProvider internal constructor(
.commit()
}
private fun snapshotCommittedArtwork(
ownerId: String
): Map<String, SystemShelfArtworkStore.Materialized>? {
val rows = queryCommittedRows() ?: return null
val snapshot = LinkedHashMap<String, SystemShelfArtworkStore.Materialized>()
rows.forEach { row ->
val uri = row.posterUri ?: return@forEach
val file = artwork.resolveOwned(ownerId, uri) ?: return@forEach
if (row.contentId !in snapshot) {
snapshot[row.contentId] = SystemShelfArtworkStore.Materialized(uri, file)
}
}
return snapshot
}
private fun queryCommittedRows(): List<CommittedRow>? {
return try {
val cursor = context.contentResolver.query(
TvContractCompat.WatchNextPrograms.CONTENT_URI,
arrayOf(
TvContractCompat.WatchNextPrograms._ID,
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID,
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_DATA,
TvContractCompat.WatchNextPrograms.COLUMN_INTENT_URI,
TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI
),
null,
null,
null
) ?: return null
cursor.use {
val idIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms._ID)
val providerIdIndex = it.getColumnIndex(
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID
)
val providerDataIndex = it.getColumnIndex(
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_DATA
)
val intentIndex = it.getColumnIndex(
TvContractCompat.WatchNextPrograms.COLUMN_INTENT_URI
)
val posterIndex = it.getColumnIndex(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI)
if (
idIndex < 0 ||
posterIndex < 0 ||
providerIdIndex < 0 &&
providerDataIndex < 0 &&
intentIndex < 0
) {
return null
}
val rows = ArrayList<CommittedRow>(it.count)
while (it.moveToNext()) {
val providerId = cursorString(it, providerIdIndex)
?.takeIf(String::isNotBlank)
val providerData = cursorString(it, providerDataIndex)
?.takeIf(String::isNotBlank)
val contentId = providerId
?: providerData
?: contentIdFromIntent(cursorString(it, intentIndex))
?: continue
val posterUri = it.getString(posterIndex)
?.takeIf(String::isNotBlank)
?.let(Uri::parse)
rows += CommittedRow(it.getLong(idIndex), contentId, posterUri)
}
rows
}
} catch (_: Exception) {
Log.e(TAG, "Failed to query committed Watch Next programs")
null
}
}
private fun cursorString(cursor: Cursor, index: Int): String? {
if (index < 0 || cursor.isNull(index)) return null
return if (cursor.getType(index) == Cursor.FIELD_TYPE_BLOB) {
cursor.getBlob(index)?.toString(Charsets.UTF_8)
} else {
cursor.getString(index)
}
}
private fun contentIdFromIntent(value: String?): String? {
val uri = value?.let { runCatching { Uri.parse(it) }.getOrNull() } ?: return null
if (uri.scheme != "plezy" || uri.authority != "play") return null
return uri.getQueryParameter("content_id")?.takeIf(String::isNotBlank)
}
internal fun removeItem(
ownerId: String,
generation: Long,
@@ -266,46 +399,27 @@ class WatchNextProvider internal constructor(
val operationOwnership = ownership ?: claimOwnership(ownerId, generation) ?: return false
return SystemShelfLifecycle.whileCurrent(operationOwnership) {
if (!isOperationActive()) return@whileCurrent false
removeItemOwned(contentId)
removeItemOwned(ownerId, contentId)
} ?: false
}
private fun removeItemOwned(contentId: String): Boolean {
private fun removeItemOwned(ownerId: String, contentId: String): Boolean {
return try {
val cursor = context.contentResolver.query(
val rows = queryCommittedRows() ?: return false
val target = rows.firstOrNull { it.contentId == contentId } ?: return false
val deleteUri = ContentUris.withAppendedId(
TvContractCompat.WatchNextPrograms.CONTENT_URI,
arrayOf(
TvContractCompat.WatchNextPrograms._ID,
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID,
TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI
),
null,
null,
null
target.id
)
cursor?.use {
val idIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms._ID)
val providerIdIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID)
val posterIndex = it.getColumnIndex(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI)
if (idIndex < 0 || providerIdIndex < 0) return false
while (it.moveToNext()) {
if (it.getString(providerIdIndex) == contentId) {
val deleteUri = ContentUris.withAppendedId(TvContractCompat.WatchNextPrograms.CONTENT_URI, it.getLong(idIndex))
context.contentResolver.delete(deleteUri, null, null)
if (posterIndex >= 0) {
val poster = it.getString(posterIndex)?.let(Uri::parse)
if (poster != null) {
revokeReadAccess(setOf(poster))
artwork.resolve(poster)?.delete()
val remaining = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty() - poster.toString()
prefs.edit().putStringSet(GRANTED_URIS, remaining).commit()
}
}
return true
}
}
}
false
if (context.contentResolver.delete(deleteUri, null, null) <= 0) return false
val poster = target.posterUri ?: return true
if (rows.any { it.id != target.id && it.posterUri == poster }) return true
revokeReadAccess(setOf(poster))
val remaining = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty() - poster.toString()
prefs.edit().putStringSet(GRANTED_URIS, remaining).commit()
artwork.resolveOwned(ownerId, poster)?.let { artwork.delete(setOf(it)) }
true
} catch (_: Exception) {
Log.e(TAG, "Failed to remove Watch Next item")
false
@@ -316,8 +430,14 @@ class WatchNextProvider internal constructor(
val operations = ArrayList<ContentProviderOperation>(items.size + 1)
operations += ContentProviderOperation.newDelete(TvContractCompat.WatchNextPrograms.CONTENT_URI).build()
items.forEach { item ->
val values = buildProgram(item).toContentValues().apply {
put(
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_DATA,
item.metadata.contentId.toByteArray(Charsets.UTF_8)
)
}
operations += ContentProviderOperation.newInsert(TvContractCompat.WatchNextPrograms.CONTENT_URI)
.withValues(buildProgram(item).toContentValues())
.withValues(values)
.build()
}
context.contentResolver.applyBatch(TvContractCompat.AUTHORITY, operations)
@@ -366,8 +486,13 @@ class WatchNextProvider internal constructor(
}
}
}
currentPackages.forEach { packageName ->
currentUris.forEach { uri ->
grantReadAccess(currentUris, currentPackages)
}
private fun grantReadAccess(uris: Set<Uri>, packages: Set<String>) {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
packages.forEach { packageName ->
uris.forEach { uri ->
runCatching { context.grantUriPermission(packageName, uri, flags) }
}
}
@@ -191,8 +191,7 @@ class ExoPlayerFallbackTerminalTest {
}
}
private fun getField(target: Any, name: String): Any? =
target.javaClass.getDeclaredField(name).apply { isAccessible = true }.get(target)
private fun getField(target: Any, name: String): Any? = target.javaClass.getDeclaredField(name).apply { isAccessible = true }.get(target)
private fun requestFallback(core: ExoPlayerCore, mediaGeneration: Int): Boolean {
val method = ExoPlayerCore::class.java.getDeclaredMethod(
@@ -1,11 +1,14 @@
package com.edde746.plezy.exoplayer
import android.app.Activity
import android.media.AudioManager
import android.os.Handler
import android.os.Looper
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.widget.FrameLayout
import com.edde746.plezy.mpv.MpvPlayerCore
import com.edde746.plezy.shared.AudioFocusManager
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
@@ -111,6 +114,54 @@ class ExoPlayerPluginTest {
assertEquals(null, result.successValue)
}
@Test
fun fallbackPropertyCancelledByTeardownReturnsNotInitializedOnce() {
val writerEntered = CountDownLatch(1)
val releaseWriter = CountDownLatch(1)
val plugin = fallbackPlugin { _, _ ->
writerEntered.countDown()
releaseWriter.await(2, TimeUnit.SECONDS)
}
val property = RecordingResult()
plugin.onMethodCall(MethodCall("setRate", mapOf("rate" to 1.5)), property)
assertTrue("fallback property writer never started", writerEntered.await(2, TimeUnit.SECONDS))
val dispose = RecordingResult()
plugin.onMethodCall(MethodCall("dispose", null), dispose)
awaitCompletion(dispose)
releaseWriter.countDown()
awaitCompletion(property)
assertEquals(1, property.completionCount)
assertEquals("NOT_INITIALIZED", property.errorCode)
assertEquals(null, property.successValue)
}
@Test
fun staleSuccessfulFallbackPropertyReturnsNotInitializedOnce() {
val writerEntered = CountDownLatch(1)
val releaseWriter = CountDownLatch(1)
val plugin = fallbackPlugin { _, _ ->
writerEntered.countDown()
releaseWriter.await(2, TimeUnit.SECONDS)
}
val core = getField(plugin, "mpvCore") as MpvPlayerCore
val result = RecordingResult()
plugin.onMethodCall(MethodCall("setRate", mapOf("rate" to 1.5)), result)
assertTrue("fallback property writer never started", writerEntered.await(2, TimeUnit.SECONDS))
setField(plugin, "usingMpvFallback", false)
releaseWriter.countDown()
awaitCompletion(result)
assertEquals(1, result.completionCount)
assertEquals("NOT_INITIALIZED", result.errorCode)
assertEquals(null, result.successValue)
core.dispose()
}
@Test
fun fallbackWithoutCoreReturnsNotInitializedOnce() {
val plugin = ExoPlayerPlugin()
@@ -473,6 +524,35 @@ class ExoPlayerPluginTest {
assertEquals(listOf("end-file"), sink.eventNames)
}
@Test
fun fallbackPlayAfterFocusLossUsesExplicitResumeContract() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
val core = MpvPlayerCore(activity, true) { name, value -> writes += name to value }
val focusManager = testAudioFocusManager(activity, core)
core.setPrivateField("audioFocusManager", focusManager)
core.setPrivateField("desiredPaused", false)
core.setPrivateField("cachedPaused", false)
val plugin = reusedFallbackPlugin(activity, core)
dispatchAudioFocusChange(focusManager, AudioManager.AUDIOFOCUS_LOSS)
assertTrue(awaitQueueEntry(writes, "pause" to "yes"))
assertEquals(true, core.getPrivateField("pausedForAudioFocusLoss"))
setNextAudioFocusRequestResponse(focusManager, AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
val result = RecordingResult()
plugin.onMethodCall(MethodCall("play", null), result)
awaitCompletion(result)
assertEquals(1, result.completionCount)
assertNull(result.errorCode)
assertEquals(listOf("pause" to "yes", "pause" to "no"), writes.toList())
assertEquals(1, writes.count { it == "pause" to "no" })
assertEquals(false, core.getPrivateField("pausedForAudioFocusLoss"))
assertEquals(false, core.getPrivateField("deferredResumeRequested"))
core.dispose()
}
@Test
fun initialHeldFallbackSynchronouslyBlocksFocusAndSurfaceResumeWithoutPausePropertyWrite() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
@@ -790,6 +870,46 @@ class ExoPlayerPluginTest {
}
}
private fun testAudioFocusManager(
activity: Activity,
core: MpvPlayerCore
): AudioFocusManager = AudioFocusManager(
context = activity,
handler = Handler(Looper.getMainLooper()),
onPause = {
MpvPlayerCore::class.java.getDeclaredMethod("pauseForAudioFocusLoss").apply {
isAccessible = true
invoke(core)
}
},
onResume = {
MpvPlayerCore::class.java.getDeclaredMethod(
"resumeAfterAudioFocusGain",
String::class.java
).apply {
isAccessible = true
invoke(core, "audio focus gain")
}
},
isPaused = { core.getPrivateField("desiredPaused") as Boolean }
)
private fun dispatchAudioFocusChange(manager: AudioFocusManager, focusChange: Int) {
val listener = AudioFocusManager::class.java.getDeclaredField("audioFocusChangeListener").run {
isAccessible = true
get(manager) as AudioManager.OnAudioFocusChangeListener
}
listener.onAudioFocusChange(focusChange)
}
private fun setNextAudioFocusRequestResponse(manager: AudioFocusManager, response: Int) {
val audioManager = AudioFocusManager::class.java.getDeclaredField("audioManager").run {
isAccessible = true
get(manager) as AudioManager
}
shadowOf(audioManager).setNextFocusRequestResponse(response)
}
private fun invokeAutoResume(core: MpvPlayerCore, reason: String) {
MpvPlayerCore::class.java.getDeclaredMethod("requestAutoResume", String::class.java).apply {
isAccessible = true
@@ -287,6 +287,221 @@ class MpvPlayerPluginTest {
assertEquals(listOf("pause" to "yes"), writes.toList())
}
@Test
fun activeFocusLossExplicitResumeReacquiresFocusAndWritesOnce() {
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
val focusResumeCallbacks = AtomicInteger()
val completionCount = AtomicInteger()
val core = testCore { name, value -> writes += name to value }
val focusManager = testAudioFocusManager(core, focusResumeCallbacks)
setCoreField(core, "audioFocusManager", focusManager)
setBoolean(core, "desiredPaused", false)
setBoolean(core, "cachedPaused", false)
dispatchAudioFocusChange(focusManager, AudioManager.AUDIOFOCUS_LOSS)
awaitCondition { writes.count { it == "pause" to "yes" } == 1 }
assertEquals(true, getBoolean(core, "pausedForAudioFocusLoss"))
setNextAudioFocusRequestResponse(focusManager, AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
var outcome: Result<Unit>? = null
core.setProperty("pause", "no") {
completionCount.incrementAndGet()
outcome = it
}
awaitCondition { outcome != null }
assertTrue(outcome?.isSuccess == true)
assertEquals(1, completionCount.get())
assertEquals(listOf("pause" to "yes", "pause" to "no"), writes.toList())
assertEquals(1, writes.count { it == "pause" to "no" })
assertEquals(false, getBoolean(core, "pausedForAudioFocusLoss"))
assertEquals(false, getBoolean(core, "deferredResumeRequested"))
assertEquals(false, getBoolean(core, "cachedPaused"))
}
@Test
fun deniedExplicitResumeFocusRequestFailsWithoutWriting() {
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
val focusResumeCallbacks = AtomicInteger()
val core = testCore { name, value -> writes += name to value }
val focusManager = testAudioFocusManager(core, focusResumeCallbacks)
val plugin = MpvAudioPlayerPlugin()
setCoreField(core, "audioFocusManager", focusManager)
installCore(plugin, core)
setBoolean(core, "desiredPaused", false)
setBoolean(core, "cachedPaused", false)
dispatchAudioFocusChange(focusManager, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)
awaitCondition { writes.contains("pause" to "yes") }
assertEquals(true, getBoolean(core, "pausedForAudioFocusLoss"))
val coreCompletionCount = AtomicInteger()
var coreOutcome: Result<Unit>? = null
setNextAudioFocusRequestResponse(focusManager, AudioManager.AUDIOFOCUS_REQUEST_FAILED)
core.setProperty("pause", "no") {
coreCompletionCount.incrementAndGet()
coreOutcome = it
}
awaitCondition { coreOutcome != null }
assertTrue(coreOutcome?.isFailure == true)
assertFalse(coreOutcome?.exceptionOrNull() is CancellationException)
assertEquals(1, coreCompletionCount.get())
assertEquals(0, writes.count { it == "pause" to "no" })
assertEquals(true, getBoolean(core, "pausedForAudioFocusLoss"))
assertEquals(true, getBoolean(core, "cachedPaused"))
setNextAudioFocusRequestResponse(focusManager, AudioManager.AUDIOFOCUS_REQUEST_FAILED)
val denied = RecordingResult()
plugin.onMethodCall(
MethodCall("setProperty", mapOf("name" to "pause", "value" to "no")),
denied
)
awaitCompletion(denied)
assertEquals(1, denied.completionCount)
assertEquals("SET_PROPERTY_FAILED", denied.errorCode)
assertEquals(0, writes.count { it == "pause" to "no" })
assertEquals(true, getBoolean(core, "pausedForAudioFocusLoss"))
setNextAudioFocusRequestResponse(focusManager, AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
val retry = RecordingResult()
plugin.onMethodCall(
MethodCall("setProperty", mapOf("name" to "pause", "value" to "no")),
retry
)
awaitCompletion(retry)
assertEquals(1, retry.completionCount)
assertNull(retry.errorCode)
assertEquals(1, writes.count { it == "pause" to "no" })
assertEquals(false, getBoolean(core, "pausedForAudioFocusLoss"))
assertEquals(false, getBoolean(core, "cachedPaused"))
}
@Test
fun delayedFocusGainAfterExplicitResumeDoesNotWriteAgain() {
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
val explicitResumeStarted = CountDownLatch(1)
val releaseExplicitResume = CountDownLatch(1)
val duplicateResume = CountDownLatch(1)
val resumeWrites = AtomicInteger()
val focusResumeCallbacks = AtomicInteger()
val core = testCore { name, value ->
writes += name to value
if (name == "pause" && value == "no") {
if (resumeWrites.incrementAndGet() == 1) {
explicitResumeStarted.countDown()
releaseExplicitResume.await(1, TimeUnit.SECONDS)
} else {
duplicateResume.countDown()
}
}
}
val focusManager = testAudioFocusManager(core, focusResumeCallbacks)
setCoreField(core, "audioFocusManager", focusManager)
setBoolean(core, "desiredPaused", false)
setBoolean(core, "cachedPaused", false)
dispatchAudioFocusChange(focusManager, AudioManager.AUDIOFOCUS_LOSS)
awaitCondition { writes.contains("pause" to "yes") }
setNextAudioFocusRequestResponse(focusManager, AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
var outcome: Result<Unit>? = null
core.setProperty("pause", "no") { outcome = it }
assertTrue(explicitResumeStarted.await(1, TimeUnit.SECONDS))
assertEquals(false, getBoolean(core, "pausedForAudioFocusLoss"))
dispatchAudioFocusChange(focusManager, AudioManager.AUDIOFOCUS_GAIN)
assertEquals(1, focusResumeCallbacks.get())
releaseExplicitResume.countDown()
awaitCondition { outcome != null }
shadowOf(Looper.getMainLooper()).idle()
assertFalse(duplicateResume.await(100, TimeUnit.MILLISECONDS))
assertTrue(outcome?.isSuccess == true)
assertEquals(1, resumeWrites.get())
assertEquals(listOf("pause" to "yes", "pause" to "no"), writes.toList())
}
@Test
fun freshFocusLossAfterExplicitClaimPreventsThePendingResumeWrite() {
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
val blockerStarted = CountDownLatch(1)
val releaseBlocker = CountDownLatch(1)
val focusResumeCallbacks = AtomicInteger()
val core = testCore { name, value ->
writes += name to value
if (name == "block") {
blockerStarted.countDown()
releaseBlocker.await(1, TimeUnit.SECONDS)
}
}
val focusManager = testAudioFocusManager(core, focusResumeCallbacks)
setCoreField(core, "audioFocusManager", focusManager)
setBoolean(core, "desiredPaused", false)
setBoolean(core, "cachedPaused", false)
dispatchAudioFocusChange(focusManager, AudioManager.AUDIOFOCUS_LOSS)
awaitCondition { writes.count { it == "pause" to "yes" } == 1 }
var blockerOutcome: Result<Unit>? = null
core.setProperty("block", "value") { blockerOutcome = it }
assertTrue(blockerStarted.await(1, TimeUnit.SECONDS))
setNextAudioFocusRequestResponse(focusManager, AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
val resumeCompletionCount = AtomicInteger()
var resumeOutcome: Result<Unit>? = null
core.setProperty("pause", "no") {
resumeCompletionCount.incrementAndGet()
resumeOutcome = it
}
assertEquals(false, getBoolean(core, "pausedForAudioFocusLoss"))
dispatchAudioFocusChange(focusManager, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)
assertEquals(true, getBoolean(core, "pausedForAudioFocusLoss"))
releaseBlocker.countDown()
awaitCondition {
blockerOutcome != null &&
resumeOutcome != null &&
writes.count { it == "pause" to "yes" } == 2
}
assertTrue(blockerOutcome?.isSuccess == true)
assertTrue(resumeOutcome?.isFailure == true)
assertEquals(1, resumeCompletionCount.get())
assertEquals(0, writes.count { it == "pause" to "no" })
assertEquals(true, getBoolean(core, "pausedForAudioFocusLoss"))
assertEquals(true, getBoolean(core, "cachedPaused"))
}
@Test
fun explicitVideoResumeAfterFocusLossRemainsDeferredOnlyForSurface() {
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
val focusResumeCallbacks = AtomicInteger()
val completionCount = AtomicInteger()
val core = testVideoCore { name, value -> writes += name to value }
val focusManager = testAudioFocusManager(core, focusResumeCallbacks)
setCoreField(core, "audioFocusManager", focusManager)
setBoolean(core, "desiredPaused", false)
setBoolean(core, "cachedPaused", false)
dispatchAudioFocusChange(focusManager, AudioManager.AUDIOFOCUS_LOSS)
awaitCondition { writes.contains("pause" to "yes") }
setNextAudioFocusRequestResponse(focusManager, AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
var outcome: Result<Unit>? = null
core.setProperty("pause", "no") {
completionCount.incrementAndGet()
outcome = it
}
awaitCondition { outcome != null }
assertTrue(outcome?.isSuccess == true)
assertEquals(1, completionCount.get())
assertEquals(listOf("pause" to "yes"), writes.toList())
assertEquals(false, getBoolean(core, "pausedForAudioFocusLoss"))
assertEquals(true, getBoolean(core, "deferredResumeRequested"))
assertEquals(true, getBoolean(core, "cachedPaused"))
}
@Test
fun pausedFocusLossAndGainWithoutResumeCallbackAllowsOneExplicitResume() {
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
@@ -624,6 +839,14 @@ class MpvPlayerPluginTest {
listener.onAudioFocusChange(focusChange)
}
private fun setNextAudioFocusRequestResponse(manager: AudioFocusManager, response: Int) {
val audioManager = AudioFocusManager::class.java.getDeclaredField("audioManager").run {
isAccessible = true
get(manager) as AudioManager
}
shadowOf(audioManager).setNextFocusRequestResponse(response)
}
private fun invokeAutoResume(core: MpvPlayerCore, reason: String) {
MpvPlayerCore::class.java.getDeclaredMethod("requestAutoResume", String::class.java).apply {
isAccessible = true
@@ -13,6 +13,7 @@ import android.content.pm.ActivityInfo
import android.content.pm.ApplicationInfo
import android.content.pm.ResolveInfo
import android.database.Cursor
import android.database.MatrixCursor
import android.net.Uri
import android.os.ParcelFileDescriptor.AutoCloseInputStream
import androidx.tvprovider.media.tv.TvContractCompat
@@ -20,6 +21,7 @@ import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.io.Closeable
import java.lang.reflect.Proxy
import java.net.InetAddress
import java.net.ServerSocket
@@ -31,6 +33,7 @@ import java.util.concurrent.Executor
import java.util.concurrent.ExecutorService
import java.util.concurrent.RejectedExecutionException
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import kotlin.concurrent.thread
import org.junit.After
@@ -222,6 +225,7 @@ class WatchNextProviderTest {
try {
assertTrue(secondRequestReceived.await(2, TimeUnit.SECONDS))
assertEquals(1, artworkFiles().size)
assertTrue(artworkFiles().single().name.endsWith(".tmp"))
SystemShelfLifecycle.acquire()
releaseSecondResponse.countDown()
worker.join(2_000)
@@ -440,7 +444,7 @@ class WatchNextProviderTest {
val ownership = SystemShelfLifecycle.claim(SystemShelfLifecycle.acquire(), "owner", 1)!!
val session = SystemShelfSyncSession(ownership, 2_000, budget = budget)
assertNull(SystemShelfArtworkStore(context.cacheDir).materialize("owner", source, session))
assertNull(SystemShelfArtworkStore(context.cacheDir).prepare("owner", source, session))
assertEquals(malformed.size.toLong(), budget.consumed)
assertEquals(100 - malformed.size, budget.remaining)
}
@@ -452,7 +456,7 @@ class WatchNextProviderTest {
val ownership = SystemShelfLifecycle.claim(SystemShelfLifecycle.acquire(), "owner", 1)!!
val session = SystemShelfSyncSession(ownership, 2_000, budget = budget)
assertNull(SystemShelfArtworkStore(context.cacheDir).materialize("owner", source, session))
assertNull(SystemShelfArtworkStore(context.cacheDir).prepare("owner", source, session))
assertEquals(imageBytes.size.toLong(), budget.consumed)
assertEquals(100 - imageBytes.size, budget.remaining)
}
@@ -480,13 +484,30 @@ class WatchNextProviderTest {
val inactive = registerHandler(homeIntent, "inactive.home.launcher")
selectDefaultHome(selected, selected, inactive)
val owner = "a".repeat(64)
val key = "${"b".repeat(32)}.art"
context.cacheDir.resolve("system_shelf_artwork/$owner").mkdirs()
context.cacheDir.resolve("system_shelf_artwork/$owner/$key").writeBytes(imageBytes)
val valid = SystemShelfArtworkStore(context.cacheDir).contentUri(owner, key)
val invalid = Uri.parse("content://${SystemShelfArtworkProvider.AUTHORITY}/art/not/confined.art")
val legacyKey = "${"b".repeat(32)}.art"
val contentKey = "${"c".repeat(64)}.art"
val corruptKey = "${"d".repeat(64)}.art"
val directory = context.cacheDir.resolve("system_shelf_artwork/$owner").apply { mkdirs() }
directory.resolve(legacyKey).writeBytes(imageBytes)
directory.resolve(contentKey).writeBytes(imageBytes)
directory.resolve(corruptKey).writeText("corrupt")
val store = SystemShelfArtworkStore(context.cacheDir)
val legacy = store.contentUri(owner, legacyKey)
val contentAddressed = store.contentUri(owner, contentKey)
val corrupt = store.contentUri(owner, corruptKey)
val malformed = Uri.parse(
"content://${SystemShelfArtworkProvider.AUTHORITY}/art/$owner/${"e".repeat(48)}.art"
)
context.getSharedPreferences("system_shelf_state", 0).edit()
.putStringSet("granted_uris", setOf(valid.toString(), invalid.toString()))
.putStringSet(
"granted_uris",
setOf(
legacy.toString(),
contentAddressed.toString(),
corrupt.toString(),
malformed.toString()
)
)
.putInt("shelf_schema_version", WatchNextProvider.SHELF_SCHEMA_VERSION)
.commit()
val recordingContext = RecordingGrantContext(context)
@@ -495,12 +516,24 @@ class WatchNextProviderTest {
.onReceive(recordingContext, Intent(Intent.ACTION_BOOT_COMPLETED))
assertEquals(
listOf(Grant("selected.home.launcher", valid, Intent.FLAG_GRANT_READ_URI_PERMISSION)),
recordingContext.grants
setOf(
Grant("selected.home.launcher", legacy, Intent.FLAG_GRANT_READ_URI_PERMISSION),
Grant("selected.home.launcher", contentAddressed, Intent.FLAG_GRANT_READ_URI_PERMISSION)
),
recordingContext.grants.toSet()
)
assertEquals(
setOf(valid.toString()),
context.getSharedPreferences("system_shelf_state", 0).getStringSet("granted_uris", emptySet())
setOf(legacy.toString(), contentAddressed.toString()),
context.getSharedPreferences("system_shelf_state", 0)
.getStringSet("granted_uris", emptySet())
)
assertArrayEquals(imageBytes, openArtwork(legacy))
assertArrayEquals(imageBytes, openArtwork(contentAddressed))
assertNull(store.resolve(corrupt))
assertEquals(
WatchNextProvider.SHELF_SCHEMA_VERSION,
context.getSharedPreferences("system_shelf_state", 0)
.getInt("shelf_schema_version", 0)
)
}
@@ -538,9 +571,12 @@ class WatchNextProviderTest {
@Test
fun packageUpdatePreservesRowsAndArtworkAtCurrentShelfSchema() {
val file = context.cacheDir.resolve("system_shelf_artwork/${"a".repeat(64)}/${"b".repeat(32)}.art")
file.parentFile?.mkdirs()
file.writeBytes(imageBytes)
val directory = context.cacheDir.resolve("system_shelf_artwork/${"a".repeat(64)}")
.apply { mkdirs() }
val legacyFile = directory.resolve("${"b".repeat(32)}.art")
val contentAddressedFile = directory.resolve("${"c".repeat(64)}.art")
legacyFile.writeBytes(imageBytes)
contentAddressedFile.writeBytes(imageBytes)
context.getSharedPreferences("system_shelf_state", 0).edit()
.putInt("shelf_schema_version", WatchNextProvider.SHELF_SCHEMA_VERSION)
.commit()
@@ -549,7 +585,9 @@ class WatchNextProviderTest {
receiver.onReceive(context, Intent(Intent.ACTION_MY_PACKAGE_REPLACED))
assertEquals(0, tvProvider.deleteCount)
assertTrue(file.isFile)
assertTrue(legacyFile.isFile)
assertTrue(contentAddressedFile.isFile)
assertEquals(1, WatchNextProvider.SHELF_SCHEMA_VERSION)
}
@Test
@@ -564,18 +602,525 @@ class WatchNextProviderTest {
@Test
fun providerFailureDeletesNewArtworkAndPreservesCommittedArtwork() {
val provider = WatchNextProvider(context)
withServer("image/png", imageBytes) { source ->
ScriptedHttpServer(
listOf(
ScriptedResponse(body = imageBytes),
ScriptedResponse(body = imageBytes)
)
).use { server ->
val homeIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
val selectedHome = registerHandler(homeIntent, "selected.home.launcher")
selectDefaultHome(selectedHome, selectedHome)
val grantContext = RecordingGrantContext(context)
val provider = WatchNextProvider(grantContext)
val sourceA = "${server.baseUrl}/a"
val sourceB = "${server.baseUrl}/b"
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(sourceA))))
val committedPoster = committedPoster()!!
val committedArtwork = SystemShelfArtworkStore(context.cacheDir)
.resolveOwned("owner-a", committedPoster)!!
.canonicalFile
tvProvider.failBatch = true
assertFalse(provider.syncWatchNextPrograms("owner-a", 2, listOf(item(sourceB))))
assertEquals(2, server.requestCount.get())
assertEquals(committedPoster, committedPoster())
assertEquals(setOf(committedArtwork), artworkFiles().mapTo(HashSet()) { it.canonicalFile })
assertArrayEquals(imageBytes, openArtwork(committedPoster))
assertFalse(artworkFiles().any { it.name.endsWith(".tmp") })
val uncommittedPoster = grantContext.grants
.map(Grant::uri)
.first { it != committedPoster }
val revokedUris = grantContext.packageRevocations.map(PackageRevocation::uri) +
grantContext.uriWideRevocations
assertFalse(committedPoster in revokedUris)
assertTrue(uncommittedPoster in revokedUris)
assertEquals(
setOf(committedPoster.toString()),
context.getSharedPreferences("system_shelf_state", 0)
.getStringSet("granted_uris", emptySet())
)
}
}
@Test
fun repeatedSameSourceSyncReusesValidatedArtwork() {
ScriptedHttpServer(
listOf(
ScriptedResponse(body = imageBytes),
ScriptedResponse(body = imageBytes)
)
).use { server ->
val homeIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
val selectedHome = registerHandler(homeIntent, "selected.home.launcher")
selectDefaultHome(selectedHome, selectedHome)
val grantContext = RecordingGrantContext(context)
val provider = WatchNextProvider(grantContext)
val store = SystemShelfArtworkStore(context.cacheDir)
val stableSource = "${server.baseUrl}/stable"
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(stableSource))))
val firstPoster = committedPoster()!!
val firstFile = store.resolveOwned("owner-a", firstPoster)!!.canonicalFile
val stableTimestamp = 1_600_000_000_000L
assertTrue(firstFile.setLastModified(stableTimestamp))
assertTrue(Regex("[a-f0-9]{64}\\.art").matches(firstPoster.lastPathSegment.orEmpty()))
assertTrue(
provider.syncWatchNextPrograms(
"owner-a",
2,
listOf(item(stableSource).copy(title = "Updated title", lastPlaybackPosition = 20))
)
)
assertEquals(1, server.requestCount.get())
assertEquals(firstPoster, committedPoster())
assertEquals(setOf(firstFile), artworkFiles().mapTo(HashSet()) { it.canonicalFile })
assertEquals(stableTimestamp, firstFile.lastModified())
assertFalse(artworkFiles().any { it.name.endsWith(".tmp") })
assertTrue(grantContext.uriWideRevocations.isEmpty())
assertTrue(grantContext.packageRevocations.isEmpty())
val zeroBudget = SystemShelfArtworkStore.Budget(0)
val cachedOwnership = provider.claimOwnership("owner-a", 3)!!
val cached = store.prepare(
"owner-a",
stableSource,
SystemShelfSyncSession(cachedOwnership, 2_000, budget = zeroBudget)
)
assertTrue(cached is SystemShelfArtworkStore.Prepared.Existing)
assertEquals(0L, zeroBudget.consumed)
assertEquals(0, zeroBudget.remaining)
val sharedSource = "${server.baseUrl}/shared"
assertTrue(
provider.syncWatchNextPrograms(
"owner-a",
4,
listOf(
item(sharedSource).copy(contentId = "first"),
item(sharedSource).copy(contentId = "second")
)
)
)
assertEquals(2, server.requestCount.get())
val sharedPosters = tvProvider.inserted.map {
it.getAsString(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI)
}.toSet()
assertEquals(1, sharedPosters.size)
val sharedPoster = Uri.parse(sharedPosters.single())
assertEquals(1, artworkFiles().size)
assertArrayEquals(imageBytes, openArtwork(sharedPoster))
grantContext.uriWideRevocations.clear()
grantContext.packageRevocations.clear()
val firstRemoved = provider.removeItem("owner-a", 5, "first")
assertEquals(listOf("first", "second"), tvProvider.lastQueryContentIds)
assertEquals(
listOf(
TvContractCompat.WatchNextPrograms._ID,
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID,
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_DATA,
TvContractCompat.WatchNextPrograms.COLUMN_INTENT_URI,
TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI
),
tvProvider.lastQueryProjection
)
assertEquals(
listOf(sharedPoster.toString(), sharedPoster.toString()),
tvProvider.lastQueryPosters
)
assertTrue(firstRemoved)
assertTrue(grantContext.uriWideRevocations.isEmpty())
assertTrue(grantContext.packageRevocations.isEmpty())
assertEquals(listOf("second"), tvProvider.contentIds())
assertArrayEquals(imageBytes, openArtwork(sharedPoster))
assertEquals(
setOf(sharedPoster.toString()),
context.getSharedPreferences("system_shelf_state", 0)
.getStringSet("granted_uris", emptySet())
)
assertTrue(provider.removeItem("owner-a", 6, "second"))
val finalReferenceRevocations =
grantContext.packageRevocations.map(PackageRevocation::uri) +
grantContext.uriWideRevocations
assertEquals(listOf(sharedPoster), finalReferenceRevocations)
assertTrue(artworkFiles().isEmpty())
assertTrue(
runCatching {
Robolectric.buildContentProvider(SystemShelfArtworkProvider::class.java)
.create()
.get()
.openFile(sharedPoster, "r")
.close()
}.isFailure
)
}
}
@Test
fun changedSourceFetchFailureRetainsLastKnownGoodArtwork() {
ScriptedHttpServer(
listOf(
ScriptedResponse(body = imageBytes),
ScriptedResponse(status = 503, contentType = "text/plain", body = "retry".toByteArray()),
ScriptedResponse(body = imageBytes)
)
).use { server ->
val homeIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
val selectedHome = registerHandler(homeIntent, "selected.home.launcher")
selectDefaultHome(selectedHome, selectedHome)
val grantContext = RecordingGrantContext(context)
val provider = WatchNextProvider(grantContext)
val sourceA = "${server.baseUrl}/a"
val sourceB = "${server.baseUrl}/b"
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(sourceA))))
val originalPoster = committedPoster()!!
val originalFile = SystemShelfArtworkStore(context.cacheDir)
.resolveOwned("owner-a", originalPoster)!!
.canonicalFile
assertTrue(
provider.syncWatchNextPrograms(
"owner-a",
2,
listOf(item(sourceB).copy(title = "Progress update", lastPlaybackPosition = 40))
)
)
assertEquals(2, server.requestCount.get())
assertEquals(listOf("plezy_server_item"), tvProvider.lastQueryContentIds)
assertEquals(listOf(originalPoster.toString()), tvProvider.lastQueryPosters)
assertEquals(originalPoster, committedPoster())
assertEquals(
"Progress update",
tvProvider.inserted.single()
.getAsString(TvContractCompat.WatchNextPrograms.COLUMN_TITLE)
)
assertTrue(originalFile.isFile)
assertArrayEquals(imageBytes, openArtwork(originalPoster))
tvProvider.blockNextBatch = true
val retryResult = AtomicReference<Boolean>()
val retry = thread(start = true, name = "system-shelf-source-retry") {
retryResult.set(
provider.syncWatchNextPrograms(
"owner-a",
3,
listOf(item(sourceB).copy(title = "Replacement ready"))
)
)
}
try {
assertTrue(tvProvider.batchStarted.await(2, TimeUnit.SECONDS))
assertTrue(originalFile.isFile)
assertArrayEquals(imageBytes, openArtwork(originalPoster))
assertEquals(2, artworkFiles().count { it.name.endsWith(".art") })
assertFalse(artworkFiles().any { it.name.endsWith(".tmp") })
val revokedBeforeCommit =
grantContext.packageRevocations.map(PackageRevocation::uri) +
grantContext.uriWideRevocations
assertFalse(originalPoster in revokedBeforeCommit)
} finally {
tvProvider.releaseBatch.countDown()
retry.join(2_000)
}
assertEquals(true, retryResult.get())
assertEquals(3, server.requestCount.get())
val replacementPoster = committedPoster()!!
assertFalse(replacementPoster == originalPoster)
assertFalse(originalFile.exists())
assertArrayEquals(imageBytes, openArtwork(replacementPoster))
val revokedAfterCommit =
grantContext.packageRevocations.map(PackageRevocation::uri) +
grantContext.uriWideRevocations
assertTrue(originalPoster in revokedAfterCommit)
assertTrue(
provider.syncWatchNextPrograms(
"owner-a",
4,
listOf(item(sourceB).copy(posterSourceUri = null))
)
)
assertNull(committedPoster())
assertTrue(artworkFiles().isEmpty())
assertEquals(3, server.requestCount.get())
}
}
@Test
fun malformedReplacementRetainsLastKnownGoodArtwork() {
ScriptedHttpServer(
listOf(
ScriptedResponse(body = imageBytes),
ScriptedResponse(body = "not an image".toByteArray())
)
).use { server ->
val provider = WatchNextProvider(context)
val sourceA = "${server.baseUrl}/valid"
val sourceB = "${server.baseUrl}/malformed"
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(sourceA))))
val originalPoster = committedPoster()!!
val originalFile = artworkFiles().single().canonicalFile
assertTrue(
provider.syncWatchNextPrograms(
"owner-a",
2,
listOf(item(sourceB).copy(lastPlaybackPosition = 50))
)
)
assertEquals(2, server.requestCount.get())
assertEquals(listOf("plezy_server_item"), tvProvider.lastQueryContentIds)
assertEquals(listOf(originalPoster.toString()), tvProvider.lastQueryPosters)
assertEquals(originalPoster, committedPoster())
assertEquals(setOf(originalFile), artworkFiles().mapTo(HashSet()) { it.canonicalFile })
assertArrayEquals(imageBytes, openArtwork(originalPoster))
assertFalse(artworkFiles().any { it.name.endsWith(".tmp") })
}
}
@Test
fun corruptContentAddressIsRefetchedWithoutPartialPublish() {
val replacementRequested = CountDownLatch(1)
val releaseReplacement = CountDownLatch(1)
ScriptedHttpServer(
listOf(
ScriptedResponse(body = imageBytes),
ScriptedResponse(
body = imageBytes,
beforeResponse = {
replacementRequested.countDown()
releaseReplacement.await(2, TimeUnit.SECONDS)
}
),
ScriptedResponse(status = 503, contentType = "text/plain"),
ScriptedResponse(body = imageBytes),
ScriptedResponse(status = 503, contentType = "text/plain")
)
).use { server ->
val provider = WatchNextProvider(context)
val store = SystemShelfArtworkStore(context.cacheDir)
val source = "${server.baseUrl}/stable"
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(source))))
}
val committedArtwork = artworkFiles().single().canonicalFile
tvProvider.failBatch = true
val stablePoster = committedPoster()!!
val deterministicFile = store.resolveOwned("owner-a", stablePoster)!!
val corruptBytes = "corrupt-cache".toByteArray()
deterministicFile.writeBytes(corruptBytes)
assertNull(store.resolveOwned("owner-a", stablePoster))
assertTrue(runCatching { openArtwork(stablePoster) }.isFailure)
withServer("image/png", imageBytes) { source ->
assertFalse(provider.syncWatchNextPrograms("owner-a", 2, listOf(item(source))))
}
val refetchResult = AtomicReference<Boolean>()
val refetch = thread(start = true, name = "system-shelf-corrupt-refetch") {
refetchResult.set(
provider.syncWatchNextPrograms("owner-a", 2, listOf(item(source)))
)
}
try {
assertTrue(replacementRequested.await(2, TimeUnit.SECONDS))
assertArrayEquals(corruptBytes, deterministicFile.readBytes())
assertEquals(1, artworkFiles().size)
assertFalse(artworkFiles().any { it.name.endsWith(".tmp") })
} finally {
releaseReplacement.countDown()
refetch.join(2_000)
}
assertEquals(setOf(committedArtwork), artworkFiles().mapTo(HashSet()) { it.canonicalFile })
assertEquals(true, refetchResult.get())
assertEquals(stablePoster, committedPoster())
assertArrayEquals(imageBytes, deterministicFile.readBytes())
assertArrayEquals(imageBytes, openArtwork(stablePoster))
assertFalse(artworkFiles().any { it.name.endsWith(".tmp") })
deterministicFile.writeBytes(corruptBytes)
assertTrue(provider.syncWatchNextPrograms("owner-a", 3, listOf(item(source))))
assertEquals(3, server.requestCount.get())
assertNull(committedPoster())
assertTrue(artworkFiles().isEmpty())
assertTrue(provider.syncWatchNextPrograms("owner-a", 4, listOf(item(source))))
assertEquals(stablePoster, committedPoster())
assertTrue(deterministicFile.delete())
assertTrue(provider.syncWatchNextPrograms("owner-a", 5, listOf(item(source))))
assertEquals(5, server.requestCount.get())
assertNull(committedPoster())
assertTrue(artworkFiles().isEmpty())
}
}
@Test
fun committedRowQueryFailureAbortsBeforeArtworkOrShelfMutation() {
ScriptedHttpServer(
listOf(
ScriptedResponse(body = imageBytes),
ScriptedResponse(body = imageBytes)
)
).use { server ->
val provider = WatchNextProvider(context)
val sourceA = "${server.baseUrl}/a"
val sourceB = "${server.baseUrl}/b"
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(sourceA))))
val originalPoster = committedPoster()!!
val originalFile = artworkFiles().single().canonicalFile
val preferences = context.getSharedPreferences("system_shelf_state", 0)
val grantedBefore = preferences.getStringSet("granted_uris", emptySet())!!.toSet()
val packagesBefore = preferences.getStringSet("granted_packages", emptySet())!!.toSet()
tvProvider.failQuery = true
assertFalse(provider.syncWatchNextPrograms("owner-a", 2, listOf(item(sourceB))))
tvProvider.failQuery = false
tvProvider.returnNullQuery = true
assertFalse(provider.syncWatchNextPrograms("owner-a", 3, listOf(item(sourceB))))
tvProvider.returnNullQuery = false
assertEquals(1, server.requestCount.get())
assertEquals(3, tvProvider.queryCount)
assertEquals(originalPoster, committedPoster())
assertEquals(setOf(originalFile), artworkFiles().mapTo(HashSet()) { it.canonicalFile })
assertEquals(grantedBefore, preferences.getStringSet("granted_uris", emptySet()))
assertEquals(packagesBefore, preferences.getStringSet("granted_packages", emptySet()))
assertArrayEquals(imageBytes, openArtwork(originalPoster))
}
}
@Test
fun interruptedPublishLeavesCommittedArtworkAndUniqueStagesUntouched() {
ScriptedHttpServer(
listOf(
ScriptedResponse(body = imageBytes),
ScriptedResponse(body = imageBytes),
ScriptedResponse(body = imageBytes)
)
).use { server ->
val provider = WatchNextProvider(context)
val store = SystemShelfArtworkStore(context.cacheDir)
val sourceA = "${server.baseUrl}/a"
val sourceB = "${server.baseUrl}/b"
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(sourceA))))
val originalPoster = committedPoster()!!
val originalFile = store.resolveOwned("owner-a", originalPoster)!!.canonicalFile
val firstOwnership = provider.claimOwnership("owner-a", 2)!!
val firstStage = store.prepare(
"owner-a",
sourceB,
SystemShelfSyncSession(firstOwnership, 2_000)
) as SystemShelfArtworkStore.Prepared.Staged
val secondOwnership = provider.claimOwnership("owner-a", 3)!!
val secondStage = store.prepare(
"owner-a",
sourceB,
SystemShelfSyncSession(secondOwnership, 2_000)
) as SystemShelfArtworkStore.Prepared.Staged
assertFalse(firstStage.stagingFile.canonicalFile == secondStage.stagingFile.canonicalFile)
assertFalse(firstStage.materialized.file.exists())
assertTrue(secondStage.stagingFile.delete())
val publication = SystemShelfLifecycle.whileCurrent(secondOwnership) {
store.publish(secondStage)
}
assertNull(publication)
store.discard(listOf(firstStage, secondStage))
assertEquals(3, server.requestCount.get())
assertEquals(originalPoster, committedPoster())
assertTrue(originalFile.isFile)
assertFalse(firstStage.materialized.file.exists())
assertEquals(setOf(originalFile), artworkFiles().mapTo(HashSet()) { it.canonicalFile })
assertArrayEquals(imageBytes, openArtwork(originalPoster))
}
}
@Test
fun contentAddressesAndFallbackAreOwnerIsolated() {
ScriptedHttpServer(
listOf(
ScriptedResponse(body = imageBytes),
ScriptedResponse(body = imageBytes),
ScriptedResponse(status = 503, contentType = "text/plain")
)
).use { server ->
val provider = WatchNextProvider(context)
val source = "${server.baseUrl}/shared"
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(source))))
val ownerAPoster = committedPoster()!!
assertTrue(provider.syncWatchNextPrograms("owner-b", 2, listOf(item(source))))
val ownerBPoster = committedPoster()!!
assertFalse(ownerAPoster == ownerBPoster)
assertFalse(ownerAPoster.pathSegments[1] == ownerBPoster.pathSegments[1])
assertEquals(2, server.requestCount.get())
assertArrayEquals(imageBytes, openArtwork(ownerBPoster))
assertTrue(
provider.syncWatchNextPrograms(
"owner-c",
3,
listOf(item(source).copy(title = "Owner C metadata"))
)
)
assertEquals(3, server.requestCount.get())
assertNull(committedPoster())
assertTrue(artworkFiles().isEmpty())
}
}
@Test
fun staleOperationCannotPublishOrPruneCommittedArtwork() {
val replacementRequest = CountDownLatch(1)
val releaseReplacement = CountDownLatch(1)
ScriptedHttpServer(
listOf(
ScriptedResponse(body = imageBytes),
ScriptedResponse(
body = imageBytes,
beforeResponse = {
replacementRequest.countDown()
releaseReplacement.await(2, TimeUnit.SECONDS)
}
)
)
).use { server ->
val provider = WatchNextProvider(context)
val sourceA = "${server.baseUrl}/a"
val sourceB = "${server.baseUrl}/b"
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(sourceA))))
val originalPoster = committedPoster()!!
val originalFile = artworkFiles().single().canonicalFile
val staleResult = AtomicReference<Boolean>()
val staleWorker = thread(start = true, name = "system-shelf-stale-artwork") {
staleResult.set(
provider.syncWatchNextPrograms("owner-a", 2, listOf(item(sourceB)))
)
}
try {
assertTrue(replacementRequest.await(2, TimeUnit.SECONDS))
assertTrue(provider.claimOwnership("owner-a", 3) != null)
} finally {
releaseReplacement.countDown()
staleWorker.join(2_000)
}
assertFalse(staleWorker.isAlive)
assertEquals(false, staleResult.get())
assertEquals(originalPoster, committedPoster())
assertEquals(setOf(originalFile), artworkFiles().mapTo(HashSet()) { it.canonicalFile })
assertFalse(artworkFiles().any { it.name.endsWith(".tmp") })
assertArrayEquals(imageBytes, openArtwork(originalPoster))
}
}
private fun committedPoster(): Uri? = tvProvider.inserted.singleOrNull()
?.getAsString(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI)
?.let(Uri::parse)
private fun openArtwork(uri: Uri): ByteArray {
val provider = Robolectric.buildContentProvider(SystemShelfArtworkProvider::class.java)
.create()
.get()
return AutoCloseInputStream(provider.openFile(uri, "r")).use { it.readBytes() }
}
private fun registerHandler(intent: Intent, packageName: String): ComponentName {
@@ -687,6 +1232,59 @@ class WatchNextProviderTest {
}
}
private data class ScriptedResponse(
val status: Int = 200,
val contentType: String = "image/png",
val body: ByteArray = ByteArray(0),
val beforeResponse: (() -> Unit)? = null
)
private class ScriptedHttpServer(responses: List<ScriptedResponse>) : Closeable {
private val server = ServerSocket(0, 8, InetAddress.getByName("127.0.0.1"))
private val scriptedResponses = ArrayDeque<ScriptedResponse>().apply { addAll(responses) }
val requestCount = AtomicInteger()
val baseUrl = "http://127.0.0.1:${server.localPort}"
private val responder = thread(start = true, name = "system-shelf-scripted-http") {
while (!server.isClosed) {
val socket = runCatching { server.accept() }.getOrNull() ?: break
runCatching {
socket.use {
val reader = it.getInputStream().bufferedReader()
while (reader.readLine()?.isNotEmpty() == true) {
// Consume request headers.
}
requestCount.incrementAndGet()
val response = synchronized(scriptedResponses) {
if (scriptedResponses.isEmpty()) {
ScriptedResponse(status = 500, contentType = "text/plain")
} else {
scriptedResponses.removeFirst()
}
}
response.beforeResponse?.invoke()
val reason = if (response.status in 200..299) "OK" else "Injected"
val headers = (
"HTTP/1.1 ${response.status} $reason\r\n" +
"Content-Type: ${response.contentType}\r\n" +
"Content-Length: ${response.body.size}\r\n" +
"Connection: close\r\n\r\n"
).toByteArray()
it.getOutputStream().use { output ->
output.write(headers)
output.write(response.body)
output.flush()
}
}
}
}
}
override fun close() {
server.close()
responder.join(2_000)
}
}
private data class Grant(val packageName: String, val uri: Uri, val modeFlags: Int)
private data class PackageRevocation(val packageName: String, val uri: Uri, val modeFlags: Int)
@@ -715,22 +1313,60 @@ private class RecordingGrantContext(base: Context) : ContextWrapper(base) {
private class CapturingTvProvider : ContentProvider() {
val inserted = mutableListOf<ContentValues>()
fun contentIds(): List<String?> = inserted.map { values ->
valueAsString(values, TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID)
?.takeIf(String::isNotBlank)
?: valueAsString(
values,
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_DATA
)?.takeIf(String::isNotBlank)
?: valueAsString(values, TvContractCompat.WatchNextPrograms.COLUMN_INTENT_URI)
?.let(Uri::parse)
?.takeIf { uri -> uri.scheme == "plezy" && uri.authority == "play" }
?.getQueryParameter("content_id")
}
private val rowIds = mutableListOf<Long>()
private var nextRowId = 1L
var deleteCount = 0
var queryCount = 0
var lastQueryContentIds: List<String?> = emptyList()
var lastQueryPosters: List<String?> = emptyList()
var lastQueryProjection: List<String> = emptyList()
var failQuery = false
var returnNullQuery = false
var failBatch = false
var blockNextBatch = false
val batchStarted = CountDownLatch(1)
val releaseBatch = CountDownLatch(1)
override fun onCreate(): Boolean = true
override fun insert(uri: Uri, values: ContentValues?): Uri {
val id = nextRowId++
inserted += ContentValues(values)
return uri.buildUpon().appendPath(inserted.size.toString()).build()
rowIds += id
return uri.buildUpon().appendPath(id.toString()).build()
}
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
deleteCount++
inserted.clear()
if (uri == TvContractCompat.WatchNextPrograms.CONTENT_URI) {
val deleted = inserted.size
inserted.clear()
rowIds.clear()
return deleted
}
val id = uri.lastPathSegment?.toLongOrNull() ?: return 0
val index = rowIds.indexOf(id)
if (index < 0) return 0
rowIds.removeAt(index)
inserted.removeAt(index)
return 1
}
override fun applyBatch(operations: ArrayList<ContentProviderOperation>): Array<ContentProviderResult> {
override fun applyBatch(
operations: ArrayList<ContentProviderOperation>
): Array<ContentProviderResult> {
if (failBatch) throw IllegalStateException("Injected provider failure")
if (blockNextBatch) {
blockNextBatch = false
@@ -739,9 +1375,61 @@ private class CapturingTvProvider : ContentProvider() {
}
return super.applyBatch(operations)
}
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?
): Cursor? {
queryCount++
if (failQuery) throw IllegalStateException("Injected query failure")
if (returnNullQuery) return null
val columns = projection ?: arrayOf(
TvContractCompat.WatchNextPrograms._ID,
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID,
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_DATA,
TvContractCompat.WatchNextPrograms.COLUMN_INTENT_URI,
TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI
)
lastQueryProjection = columns.toList()
lastQueryContentIds = contentIds()
lastQueryPosters = inserted.map { values ->
valueAsString(values, TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI)
}
return MatrixCursor(columns).apply {
inserted.indices.forEach { index ->
addRow(
columns.map { column ->
when (column) {
TvContractCompat.WatchNextPrograms._ID -> rowIds[index]
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_DATA ->
inserted[index].get(column)
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID,
TvContractCompat.WatchNextPrograms.COLUMN_INTENT_URI,
TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI ->
valueAsString(inserted[index], column)
else -> inserted[index].get(column)
}
}.toTypedArray()
)
}
}
}
private fun valueAsString(values: ContentValues, column: String): String? = when (val value = values.get(column)) {
is ByteArray -> value.toString(Charsets.UTF_8)
else -> values.getAsString(column)
}
override fun getType(uri: Uri): String? = null
override fun query(uri: Uri, projection: Array<out String>?, selection: String?, selectionArgs: Array<out String>?, sortOrder: String?): Cursor? = null
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int = 0
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?
): Int = 0
}
private class RecordingResult : MethodChannel.Result {