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.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) {
|
||||
|
||||
+312
-51
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user