fix(runtime): harden application service boundaries
This commit is contained in:
@@ -88,6 +88,18 @@
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_provider_paths" />
|
||||
</provider>
|
||||
<provider
|
||||
android:name=".watchnext.SystemShelfArtworkProvider"
|
||||
android:authorities="com.edde746.plezy.systemshelf.artwork"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true" />
|
||||
<receiver
|
||||
android:name=".watchnext.SystemShelfUpdateReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
|
||||
@@ -6,6 +6,8 @@ import android.content.Context
|
||||
import android.util.Log
|
||||
import com.edde746.plezy.libass.media.AssHandler
|
||||
import com.edde746.plezy.mpv.MpvPlayerCore
|
||||
import com.edde746.plezy.mpv.completeMpvPropertyNotInitialized
|
||||
import com.edde746.plezy.mpv.completeMpvPropertyResult
|
||||
import com.edde746.plezy.shared.MpvContentUriResolver
|
||||
import com.edde746.plezy.shared.PlayerChannelBinding
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
@@ -336,24 +338,64 @@ class ExoPlayerPlugin :
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePlay(result: MethodChannel.Result) {
|
||||
activity?.runOnUiThread {
|
||||
if (usingMpvFallback) {
|
||||
mpvCore?.setProperty("pause", "no")
|
||||
} else {
|
||||
playerCore?.play()
|
||||
private fun handleFallbackMpvProperty(
|
||||
name: String,
|
||||
value: String,
|
||||
result: MethodChannel.Result,
|
||||
successValue: Any? = null
|
||||
) {
|
||||
val currentActivity = activity
|
||||
val core = mpvCore
|
||||
if (currentActivity == null || core?.isInitialized != true) {
|
||||
completeMpvPropertyNotInitialized(result)
|
||||
return
|
||||
}
|
||||
val generation = sessionGeneration
|
||||
currentActivity.runOnUiThread {
|
||||
if (
|
||||
!usingMpvFallback ||
|
||||
generation != sessionGeneration ||
|
||||
activity !== currentActivity ||
|
||||
mpvCore !== core ||
|
||||
!core.isInitialized
|
||||
) {
|
||||
completeMpvPropertyNotInitialized(result)
|
||||
return@runOnUiThread
|
||||
}
|
||||
core.setProperty(name, value) { outcome ->
|
||||
val currentOutcome = if (
|
||||
usingMpvFallback &&
|
||||
generation == sessionGeneration &&
|
||||
activity === currentActivity &&
|
||||
mpvCore === core
|
||||
) {
|
||||
outcome
|
||||
} else {
|
||||
Result.failure(IllegalStateException("MPV fallback unavailable"))
|
||||
}
|
||||
completeMpvPropertyResult(result, currentOutcome, successValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePlay(result: MethodChannel.Result) {
|
||||
if (usingMpvFallback) {
|
||||
handleFallbackMpvProperty("pause", "no", result)
|
||||
return
|
||||
}
|
||||
activity?.runOnUiThread {
|
||||
playerCore?.play()
|
||||
result.success(null)
|
||||
} ?: result.success(null)
|
||||
}
|
||||
|
||||
private fun handlePause(result: MethodChannel.Result) {
|
||||
if (usingMpvFallback) {
|
||||
handleFallbackMpvProperty("pause", "yes", result)
|
||||
return
|
||||
}
|
||||
activity?.runOnUiThread {
|
||||
if (usingMpvFallback) {
|
||||
mpvCore?.setProperty("pause", "yes")
|
||||
} else {
|
||||
playerCore?.pause()
|
||||
}
|
||||
playerCore?.pause()
|
||||
result.success(null)
|
||||
} ?: result.success(null)
|
||||
}
|
||||
@@ -397,12 +439,12 @@ class ExoPlayerPlugin :
|
||||
return
|
||||
}
|
||||
|
||||
if (usingMpvFallback) {
|
||||
handleFallbackMpvProperty("volume", volume.toString(), result)
|
||||
return
|
||||
}
|
||||
activity?.runOnUiThread {
|
||||
if (usingMpvFallback) {
|
||||
mpvCore?.setProperty("volume", volume.toString())
|
||||
} else {
|
||||
playerCore?.setVolume(volume / 100f) // Convert 0-100 to 0-1
|
||||
}
|
||||
playerCore?.setVolume(volume / 100f) // Convert 0-100 to 0-1
|
||||
result.success(null)
|
||||
} ?: result.success(null)
|
||||
}
|
||||
@@ -415,12 +457,12 @@ class ExoPlayerPlugin :
|
||||
return
|
||||
}
|
||||
|
||||
if (usingMpvFallback) {
|
||||
handleFallbackMpvProperty("speed", rate.toString(), result)
|
||||
return
|
||||
}
|
||||
activity?.runOnUiThread {
|
||||
if (usingMpvFallback) {
|
||||
mpvCore?.setProperty("speed", rate.toString())
|
||||
} else {
|
||||
playerCore?.setPlaybackSpeed(rate)
|
||||
}
|
||||
playerCore?.setPlaybackSpeed(rate)
|
||||
result.success(null)
|
||||
} ?: result.success(null)
|
||||
}
|
||||
@@ -433,13 +475,13 @@ class ExoPlayerPlugin :
|
||||
return
|
||||
}
|
||||
|
||||
if (usingMpvFallback) {
|
||||
// After fallback, track IDs come from mpv's track-list (already 1-indexed)
|
||||
handleFallbackMpvProperty("aid", trackId, result)
|
||||
return
|
||||
}
|
||||
activity?.runOnUiThread {
|
||||
if (usingMpvFallback) {
|
||||
// After fallback, track IDs come from mpv's track-list (already 1-indexed)
|
||||
mpvCore?.setProperty("aid", trackId)
|
||||
} else {
|
||||
playerCore?.selectAudioTrack(trackId)
|
||||
}
|
||||
playerCore?.selectAudioTrack(trackId)
|
||||
result.success(null)
|
||||
} ?: result.success(null)
|
||||
}
|
||||
@@ -448,12 +490,12 @@ class ExoPlayerPlugin :
|
||||
val trackId = call.argument<String>("trackId")
|
||||
|
||||
// trackId can be null or "no" to disable subtitles
|
||||
if (usingMpvFallback) {
|
||||
handleFallbackMpvProperty("sid", trackId ?: "no", result)
|
||||
return
|
||||
}
|
||||
activity?.runOnUiThread {
|
||||
if (usingMpvFallback) {
|
||||
mpvCore?.setProperty("sid", trackId ?: "no")
|
||||
} else {
|
||||
playerCore?.selectSubtitleTrack(trackId)
|
||||
}
|
||||
playerCore?.selectSubtitleTrack(trackId)
|
||||
result.success(null)
|
||||
} ?: result.success(null)
|
||||
}
|
||||
@@ -705,8 +747,7 @@ class ExoPlayerPlugin :
|
||||
val audioSpdif = if (enabled) "ac3,eac3,dts,dts-hd,truehd" else ""
|
||||
pendingMpvProperties["audio-spdif"] = audioSpdif
|
||||
if (usingMpvFallback) {
|
||||
mpvCore?.setProperty("audio-spdif", audioSpdif)
|
||||
result.success(true)
|
||||
handleFallbackMpvProperty("audio-spdif", audioSpdif, result, true)
|
||||
return
|
||||
}
|
||||
activity?.runOnUiThread {
|
||||
@@ -724,24 +765,23 @@ class ExoPlayerPlugin :
|
||||
return
|
||||
}
|
||||
|
||||
// Apply sync offsets to ExoPlayer when active
|
||||
if (!usingMpvFallback) {
|
||||
when (name) {
|
||||
"audio-delay" -> playerCore?.setAudioDelay(value.toDoubleOrNull() ?: 0.0)
|
||||
"sub-delay" -> playerCore?.setSubtitleDelay(value.toDoubleOrNull() ?: 0.0)
|
||||
// mpv semantics mirrored on the libass overlay: anchor non-positioned ASS
|
||||
// events to the visible screen (Dart sets 'yes' for cover mode / zoom > 1)
|
||||
"sub-ass-force-margins" -> playerCore?.setAssForceMargins(value == "yes")
|
||||
"force-seekable" -> playerCore?.setForceSeekable(value == "yes")
|
||||
}
|
||||
if (usingMpvFallback) {
|
||||
handleFallbackMpvProperty(name, value, result)
|
||||
return
|
||||
}
|
||||
|
||||
if (usingMpvFallback) {
|
||||
mpvCore?.setProperty(name, value)
|
||||
} else {
|
||||
// Store for later application if ExoPlayer falls back to MPV
|
||||
pendingMpvProperties[name] = value
|
||||
// Apply sync offsets to ExoPlayer when active
|
||||
when (name) {
|
||||
"audio-delay" -> playerCore?.setAudioDelay(value.toDoubleOrNull() ?: 0.0)
|
||||
"sub-delay" -> playerCore?.setSubtitleDelay(value.toDoubleOrNull() ?: 0.0)
|
||||
// mpv semantics mirrored on the libass overlay: anchor non-positioned ASS
|
||||
// events to the visible screen (Dart sets 'yes' for cover mode / zoom > 1)
|
||||
"sub-ass-force-margins" -> playerCore?.setAssForceMargins(value == "yes")
|
||||
"force-seekable" -> playerCore?.setForceSeekable(value == "yes")
|
||||
}
|
||||
|
||||
// Before fallback this is queue acceptance, not a completed MPV write.
|
||||
pendingMpvProperties[name] = value
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
@@ -867,7 +907,11 @@ class ExoPlayerPlugin :
|
||||
}
|
||||
|
||||
for ((propName, propValue) in pendingProps) {
|
||||
core.setProperty(propName, propValue)
|
||||
core.setProperty(propName, propValue) { outcome ->
|
||||
if (outcome.isFailure) {
|
||||
Log.w(TAG, "Failed to replay queued MPV property")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-observe exactly what Dart registered via observeProperty, so the
|
||||
|
||||
@@ -35,10 +35,19 @@ import kotlinx.coroutines.sync.withLock
|
||||
* configured before init to never open a video output (`vid=no`,
|
||||
* `force-window=no`, `audio-display=no`, plus `gapless-audio=weak`).
|
||||
*/
|
||||
class MpvPlayerCore(
|
||||
class MpvPlayerCore private constructor(
|
||||
private val context: Context,
|
||||
private val audioOnly: Boolean = false
|
||||
private val audioOnly: Boolean,
|
||||
private val propertyWriterOverride: (suspend (String, String) -> Unit)?,
|
||||
initializedForTesting: Boolean
|
||||
) : SurfaceHolder.Callback {
|
||||
constructor(context: Context, audioOnly: Boolean = false) : this(context, audioOnly, null, false)
|
||||
|
||||
internal constructor(
|
||||
context: Context,
|
||||
audioOnly: Boolean,
|
||||
propertyWriter: (suspend (String, String) -> Unit)?
|
||||
) : this(context, audioOnly, propertyWriter, true)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MpvPlayerCore"
|
||||
@@ -71,6 +80,10 @@ class MpvPlayerCore(
|
||||
var isInitialized: Boolean = false
|
||||
private set
|
||||
|
||||
init {
|
||||
if (initializedForTesting) isInitialized = true
|
||||
}
|
||||
|
||||
@Volatile private var player: MpvPlayer? = null
|
||||
private var scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private val endFileDiagnostics = MpvEndFileDiagnostics()
|
||||
@@ -691,43 +704,66 @@ class MpvPlayerCore(
|
||||
|
||||
// Public API
|
||||
|
||||
fun setProperty(name: String, value: String, onComplete: ((Boolean) -> Unit)? = null) {
|
||||
fun setProperty(name: String, value: String, onComplete: ((Result<Unit>) -> Unit)? = null) {
|
||||
if (!isInitialized || disposing || !scope.isActive) {
|
||||
onComplete?.invoke(false)
|
||||
onComplete?.invoke(Result.failure(IllegalStateException("MPV core unavailable")))
|
||||
return
|
||||
}
|
||||
if (name == "pause") {
|
||||
val paused = normalizePauseValue(value)
|
||||
if (paused == true) {
|
||||
cachedPaused = true
|
||||
pausedForSurfaceLoss = false
|
||||
resumeBlockedByPublicPause = true
|
||||
deferredResumeRequested = false
|
||||
Log.d(TAG, "Public pause state updated: paused=true")
|
||||
} else if (paused == false) {
|
||||
|
||||
val paused = if (name == "pause") normalizePauseValue(value) else null
|
||||
if (paused == false && !hasReadyVideoOutput()) {
|
||||
runOnMain {
|
||||
if (!isInitialized || disposing || !scope.isActive) {
|
||||
onComplete?.invoke(Result.failure(CancellationException("MPV core unavailable")))
|
||||
return@runOnMain
|
||||
}
|
||||
resumeBlockedByPublicPause = false
|
||||
if (!hasReadyVideoOutput()) {
|
||||
deferredResumeRequested = true
|
||||
Log.d(TAG, "Deferring public resume until video output is ready")
|
||||
onComplete?.invoke(true)
|
||||
return
|
||||
}
|
||||
cachedPaused = false
|
||||
pausedForSurfaceLoss = false
|
||||
Log.d(TAG, "Public pause state updated: paused=false")
|
||||
deferredResumeRequested = true
|
||||
Log.d(TAG, "Deferring public resume until video output is ready")
|
||||
onComplete?.invoke(Result.success(Unit))
|
||||
}
|
||||
return
|
||||
}
|
||||
scope.launch(mpvWriteDispatcher) {
|
||||
var success = false
|
||||
try {
|
||||
player?.setProperty(name, value)
|
||||
success = true
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "setProperty($name) failed", e)
|
||||
} finally {
|
||||
withContext(NonCancellable + Dispatchers.Main) {
|
||||
onComplete?.invoke(success)
|
||||
|
||||
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
|
||||
val writeResult = try {
|
||||
val writer = propertyWriterOverride
|
||||
if (writer != null) {
|
||||
writer(name, value)
|
||||
} else {
|
||||
val currentPlayer = player ?: throw IllegalStateException("MPV player unavailable")
|
||||
currentPlayer.setProperty(name, value)
|
||||
}
|
||||
Result.success(Unit)
|
||||
} catch (error: CancellationException) {
|
||||
Result.failure(error)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "MPV property write failed")
|
||||
Result.failure(error)
|
||||
}
|
||||
|
||||
withContext(NonCancellable + Dispatchers.Main) {
|
||||
val completion = if (disposing || !isInitialized) {
|
||||
Result.failure(CancellationException("MPV core unavailable"))
|
||||
} else {
|
||||
writeResult
|
||||
}
|
||||
if (completion.isSuccess) {
|
||||
if (paused == true) {
|
||||
cachedPaused = true
|
||||
pausedForSurfaceLoss = false
|
||||
resumeBlockedByPublicPause = true
|
||||
deferredResumeRequested = false
|
||||
Log.d(TAG, "Public pause state updated: paused=true")
|
||||
} else if (paused == false) {
|
||||
cachedPaused = false
|
||||
pausedForSurfaceLoss = false
|
||||
resumeBlockedByPublicPause = false
|
||||
deferredResumeRequested = false
|
||||
Log.d(TAG, "Public pause state updated: paused=false")
|
||||
}
|
||||
}
|
||||
onComplete?.invoke(completion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,26 @@ import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
internal fun completeMpvPropertyResult(
|
||||
result: MethodChannel.Result,
|
||||
outcome: Result<Unit>,
|
||||
successValue: Any? = null
|
||||
) {
|
||||
if (outcome.isSuccess) {
|
||||
result.success(successValue)
|
||||
} else {
|
||||
result.error(
|
||||
"SET_PROPERTY_FAILED",
|
||||
"MPV property write was rejected or cancelled",
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun completeMpvPropertyNotInitialized(result: MethodChannel.Result) {
|
||||
result.error("NOT_INITIALIZED", "Player not initialized", null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel plumbing for [MpvPlayerCore]. The default instance is the video
|
||||
* player; the [audioOnly] instance (see [MpvAudioPlayerPlugin]) drives the
|
||||
@@ -261,13 +281,13 @@ open class MpvPlayerPlugin(
|
||||
}
|
||||
|
||||
val core = playerCore
|
||||
if (core == null) {
|
||||
result.success(null)
|
||||
if (core?.isInitialized != true) {
|
||||
completeMpvPropertyNotInitialized(result)
|
||||
return
|
||||
}
|
||||
|
||||
core.setProperty(name, value) {
|
||||
result.success(null)
|
||||
core.setProperty(name, value) { outcome ->
|
||||
completeMpvPropertyResult(result, outcome)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
package com.edde746.plezy.watchnext
|
||||
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
import android.database.Cursor
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.ParcelFileDescriptor
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.security.MessageDigest
|
||||
import java.util.UUID
|
||||
|
||||
class SystemShelfArtworkProvider : ContentProvider() {
|
||||
companion object {
|
||||
const val AUTHORITY = "com.edde746.plezy.systemshelf.artwork"
|
||||
}
|
||||
|
||||
override fun onCreate(): Boolean = context != null
|
||||
|
||||
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor {
|
||||
if (mode != "r") throw FileNotFoundException("Read-only artwork")
|
||||
val appContext = context ?: throw FileNotFoundException("Provider unavailable")
|
||||
val file = SystemShelfArtworkStore(appContext.cacheDir).resolve(uri)
|
||||
?: throw FileNotFoundException("Unknown artwork")
|
||||
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
|
||||
}
|
||||
|
||||
override fun getType(uri: Uri): String? = if (uri.authority == AUTHORITY) "image/*" else null
|
||||
override fun query(
|
||||
uri: Uri,
|
||||
projection: Array<out String>?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<out String>?,
|
||||
sortOrder: String?
|
||||
): Cursor? = null
|
||||
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
|
||||
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int = 0
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
|
||||
}
|
||||
|
||||
internal class SystemShelfArtworkStore(private val cacheDir: File) {
|
||||
companion object {
|
||||
const val MAX_IMAGE_BYTES = 2 * 1024 * 1024
|
||||
const val MAX_SYNC_BYTES = 8 * 1024 * 1024
|
||||
const val MAX_ITEMS = 20
|
||||
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$")
|
||||
}
|
||||
|
||||
data class Materialized(val key: String, val uri: Uri, val file: File)
|
||||
class Budget(var remaining: Int = MAX_SYNC_BYTES)
|
||||
|
||||
private val root: File get() = File(cacheDir, "system_shelf_artwork")
|
||||
|
||||
fun materialize(ownerId: String, source: String, budget: Budget): Materialized? {
|
||||
if (ownerId.isBlank() || budget.remaining <= 0) 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
|
||||
return try {
|
||||
connection.instanceFollowRedirects = true
|
||||
connection.connectTimeout = CONNECT_TIMEOUT_MS
|
||||
connection.readTimeout = READ_TIMEOUT_MS
|
||||
connection.useCaches = false
|
||||
connection.setRequestProperty("Accept", "image/*")
|
||||
val status = connection.responseCode
|
||||
if (status !in 200..299) return null
|
||||
if (connection.url.protocol != "https" && connection.url.protocol != "http") return null
|
||||
if (!connection.contentType.orEmpty().substringBefore(';').trim().startsWith("image/")) return null
|
||||
val contentLength = connection.contentLengthLong
|
||||
val cap = minOf(MAX_IMAGE_BYTES, budget.remaining)
|
||||
if (contentLength > cap) return null
|
||||
val bytes = connection.inputStream.use { input ->
|
||||
val output = ByteArrayOutputStream(minOf(if (contentLength > 0) contentLength.toInt() else 32 * 1024, cap))
|
||||
val buffer = ByteArray(16 * 1024)
|
||||
var total = 0
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
total += read
|
||||
if (total > cap) return null
|
||||
output.write(buffer, 0, read)
|
||||
}
|
||||
output.toByteArray()
|
||||
}
|
||||
if (!isSupportedImage(bytes)) return null
|
||||
val ownerKey = sha256(ownerId)
|
||||
val directory = File(root, ownerKey)
|
||||
if (!directory.mkdirs() && !directory.isDirectory) return null
|
||||
val key = UUID.randomUUID().toString().replace("-", "") + ".art"
|
||||
val staged = File(directory, ".$key.tmp")
|
||||
staged.outputStream().use { output ->
|
||||
output.write(bytes)
|
||||
output.flush()
|
||||
output.fd.sync()
|
||||
}
|
||||
val destination = File(directory, key)
|
||||
if (!staged.renameTo(destination)) {
|
||||
staged.delete()
|
||||
return null
|
||||
}
|
||||
budget.remaining -= bytes.size
|
||||
Materialized(key, contentUri(ownerKey, key), destination)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
fun contentUri(ownerKey: String, key: String): Uri = Uri.Builder()
|
||||
.scheme("content")
|
||||
.authority(SystemShelfArtworkProvider.AUTHORITY)
|
||||
.appendPath("art")
|
||||
.appendPath(ownerKey)
|
||||
.appendPath(key)
|
||||
.build()
|
||||
|
||||
fun resolve(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
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
if (ownerDirectory.listFiles().isNullOrEmpty()) ownerDirectory.delete()
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteAll(): Boolean = !root.exists() || root.deleteRecursively()
|
||||
|
||||
private fun isSupportedImage(bytes: ByteArray): Boolean {
|
||||
if (bytes.size < 4) return false
|
||||
val png = bytes.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
|
||||
|
||||
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
|
||||
val width = options.outWidth
|
||||
val height = options.outHeight
|
||||
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")
|
||||
.digest(value.toByteArray(Charsets.UTF_8))
|
||||
.joinToString("") { byte -> "%02x".format(byte) }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.edde746.plezy.watchnext
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import java.util.concurrent.Executor
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/** Scrubs unversioned rows that may contain legacy authenticated poster URLs. */
|
||||
class SystemShelfUpdateReceiver private constructor(
|
||||
private val executor: Executor,
|
||||
private val ownsExecutor: Boolean
|
||||
) : BroadcastReceiver() {
|
||||
constructor() : this(Executors.newSingleThreadExecutor(), true)
|
||||
internal constructor(executor: Executor) : this(executor, false)
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) return
|
||||
val pending = goAsync()
|
||||
executor.execute {
|
||||
try {
|
||||
WatchNextProvider(context.applicationContext).clearLegacyOnPackageUpdate()
|
||||
} finally {
|
||||
pending?.finish()
|
||||
if (ownsExecutor) (executor as ExecutorService).shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,30 +12,23 @@ import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Flutter plugin for Android TV Watch Next integration.
|
||||
* Syncs Plex "On Deck" content to the Android TV launcher's Watch Next row.
|
||||
*/
|
||||
/** Flutter bridge for profile-owned Android TV Watch Next mutations. */
|
||||
class WatchNextPlugin :
|
||||
FlutterPlugin,
|
||||
MethodChannel.MethodCallHandler {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "WatchNextPlugin"
|
||||
private const val METHOD_CHANNEL = "com.plezy/watch_next"
|
||||
|
||||
private const val SCHEMA_VERSION = 2
|
||||
private var pendingDeepLink: String? = null
|
||||
|
||||
/**
|
||||
* Parse a Watch Next deep link intent.
|
||||
* Returns the content ID if this was a Watch Next intent, null otherwise.
|
||||
*/
|
||||
fun handleIntent(intent: Intent?): String? {
|
||||
val data = intent?.data ?: return null
|
||||
if (data.scheme == "plezy" && data.authority == "play") {
|
||||
return data.getQueryParameter("content_id")
|
||||
return if (data.scheme == "plezy" && data.authority == "play") {
|
||||
data.getQueryParameter("content_id")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +56,7 @@ class WatchNextPlugin :
|
||||
when (call.method) {
|
||||
"isSupported" -> handleIsSupported(result)
|
||||
"sync" -> handleSync(call, result)
|
||||
"clear" -> handleClear(result)
|
||||
"clear" -> handleClear(call, result)
|
||||
"remove" -> handleRemove(call, result)
|
||||
"getInitialDeepLink" -> handleGetInitialDeepLink(result)
|
||||
else -> result.notImplemented()
|
||||
@@ -72,52 +65,43 @@ class WatchNextPlugin :
|
||||
|
||||
private fun handleIsSupported(result: MethodChannel.Result) {
|
||||
val context = applicationContext
|
||||
if (context == null) {
|
||||
result.success(false)
|
||||
return
|
||||
}
|
||||
result.success(context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
|
||||
result.success(context?.packageManager?.hasSystemFeature(PackageManager.FEATURE_LEANBACK) == true)
|
||||
}
|
||||
|
||||
private fun ownerArguments(call: MethodCall): Pair<String, Long>? {
|
||||
if (call.argument<Number>("schemaVersion")?.toInt() != SCHEMA_VERSION) return null
|
||||
val owner = call.argument<String>("ownerId")?.takeIf(String::isNotBlank) ?: return null
|
||||
val generation = call.argument<Number>("generation")?.toLong()?.takeIf { it > 0 } ?: return null
|
||||
return owner to generation
|
||||
}
|
||||
|
||||
private fun handleSync(call: MethodCall, result: MethodChannel.Result) {
|
||||
val provider = watchNextProvider
|
||||
if (provider == null) {
|
||||
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
|
||||
return
|
||||
}
|
||||
|
||||
val provider = watchNextProvider ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
|
||||
val (owner, generation) = ownerArguments(call)
|
||||
?: return result.error("INVALID_ARGS", "Invalid shelf envelope", null)
|
||||
val itemsData = call.argument<List<Map<String, Any?>>>("items")
|
||||
if (itemsData == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'items' argument", null)
|
||||
return
|
||||
?: return result.error("INVALID_ARGS", "Missing items", null)
|
||||
if (itemsData.size > SystemShelfArtworkStore.MAX_ITEMS) {
|
||||
return result.error("INVALID_ARGS", "Too many items", null)
|
||||
}
|
||||
|
||||
val items = itemsData.mapNotNull { parseWatchNextItem(it) }
|
||||
executeOnIo(result) { provider.syncWatchNextPrograms(items) }
|
||||
val items = itemsData.mapNotNull(::parseWatchNextItem)
|
||||
executeOnIo(result) { provider.syncWatchNextPrograms(owner, generation, items) }
|
||||
}
|
||||
|
||||
private fun handleClear(result: MethodChannel.Result) {
|
||||
val provider = watchNextProvider
|
||||
if (provider == null) {
|
||||
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
|
||||
return
|
||||
}
|
||||
executeOnIo(result) { provider.clearAll() }
|
||||
private fun handleClear(call: MethodCall, result: MethodChannel.Result) {
|
||||
val provider = watchNextProvider ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
|
||||
val (owner, generation) = ownerArguments(call)
|
||||
?: return result.error("INVALID_ARGS", "Invalid shelf envelope", null)
|
||||
executeOnIo(result) { provider.clearAll(owner, generation) }
|
||||
}
|
||||
|
||||
private fun handleRemove(call: MethodCall, result: MethodChannel.Result) {
|
||||
val provider = watchNextProvider
|
||||
if (provider == null) {
|
||||
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
|
||||
return
|
||||
}
|
||||
|
||||
val provider = watchNextProvider ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
|
||||
val (owner, generation) = ownerArguments(call)
|
||||
?: return result.error("INVALID_ARGS", "Invalid shelf envelope", null)
|
||||
val contentId = call.argument<String>("contentId")
|
||||
if (contentId == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'contentId' argument", null)
|
||||
return
|
||||
}
|
||||
executeOnIo(result) { provider.removeItem(contentId) }
|
||||
?: return result.error("INVALID_ARGS", "Missing contentId", null)
|
||||
executeOnIo(result) { provider.removeItem(owner, generation, contentId) }
|
||||
}
|
||||
|
||||
private fun executeOnIo(result: MethodChannel.Result, block: () -> Any?) {
|
||||
@@ -126,12 +110,12 @@ class WatchNextPlugin :
|
||||
try {
|
||||
val value = block()
|
||||
mainHandler.post { result.success(value) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "IO operation failed: ${e.message}", e)
|
||||
mainHandler.post { result.error("IO_ERROR", e.message, null) }
|
||||
} catch (_: Exception) {
|
||||
Log.e(TAG, "System shelf IO operation failed")
|
||||
mainHandler.post { result.error("IO_ERROR", "System shelf operation failed", null) }
|
||||
}
|
||||
}
|
||||
} catch (e: java.util.concurrent.RejectedExecutionException) {
|
||||
} catch (_: java.util.concurrent.RejectedExecutionException) {
|
||||
result.error("SHUTDOWN", "Plugin is shutting down", null)
|
||||
}
|
||||
}
|
||||
@@ -143,22 +127,18 @@ class WatchNextPlugin :
|
||||
}
|
||||
|
||||
private fun parseWatchNextItem(data: Map<String, Any?>): WatchNextProvider.WatchNextItem? {
|
||||
val contentId = data["contentId"] as? String ?: return null
|
||||
val contentId = (data["contentId"] as? String)?.takeIf(String::isNotBlank) ?: return null
|
||||
val title = data["title"] as? String ?: return null
|
||||
|
||||
val typeString = data["type"] as? String ?: "movie"
|
||||
val type = when (typeString.lowercase()) {
|
||||
val type = when ((data["type"] as? String)?.lowercase()) {
|
||||
"episode" -> TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE
|
||||
"movie" -> TvContractCompat.WatchNextPrograms.TYPE_MOVIE
|
||||
else -> TvContractCompat.WatchNextPrograms.TYPE_MOVIE
|
||||
}
|
||||
|
||||
return WatchNextProvider.WatchNextItem(
|
||||
contentId = contentId,
|
||||
title = title,
|
||||
episodeTitle = data["episodeTitle"] as? String,
|
||||
description = data["description"] as? String,
|
||||
posterUri = data["posterUri"] as? String,
|
||||
posterSourceUri = data["posterSourceUri"] as? String,
|
||||
type = type,
|
||||
duration = (data["duration"] as? Number)?.toLong() ?: 0L,
|
||||
lastPlaybackPosition = (data["lastPlaybackPosition"] as? Number)?.toLong() ?: 0L,
|
||||
@@ -169,16 +149,12 @@ class WatchNextPlugin :
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a deep link content ID for delivery to Flutter.
|
||||
* Called from MainActivity on intent receipt.
|
||||
*/
|
||||
fun notifyDeepLink(contentId: String) {
|
||||
pendingDeepLink = contentId
|
||||
try {
|
||||
methodChannel.invokeMethod("onWatchNextTap", mapOf("contentId" to contentId))
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Method channel not ready, stored as pending deep link")
|
||||
} catch (_: Exception) {
|
||||
Log.d(TAG, "Method channel not ready; deep link retained")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,19 +3,19 @@ package com.edde746.plezy.watchnext
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.tvprovider.media.tv.TvContractCompat
|
||||
import androidx.tvprovider.media.tv.WatchNextProgram
|
||||
|
||||
/**
|
||||
* Wraps Android TvProvider API for Watch Next row integration.
|
||||
* Manages WatchNextProgram entries for Plex "On Deck" content.
|
||||
*/
|
||||
/** Owns Plezy's durable Android TV Watch Next rows and their local artwork. */
|
||||
class WatchNextProvider(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "WatchNextProvider"
|
||||
private const val PREFS = "system_shelf_state"
|
||||
private const val GRANTED_URIS = "granted_uris"
|
||||
}
|
||||
|
||||
data class WatchNextItem(
|
||||
@@ -23,7 +23,7 @@ class WatchNextProvider(private val context: Context) {
|
||||
val title: String,
|
||||
val episodeTitle: String?,
|
||||
val description: String?,
|
||||
val posterUri: String?,
|
||||
val posterSourceUri: String?,
|
||||
val type: Int,
|
||||
val duration: Long,
|
||||
val lastPlaybackPosition: Long,
|
||||
@@ -33,132 +33,208 @@ class WatchNextProvider(private val context: Context) {
|
||||
val episodeNumber: Int?
|
||||
)
|
||||
|
||||
/**
|
||||
* Sync items to Watch Next row.
|
||||
* Uses applyBatch to delete + insert in a single transaction so the
|
||||
* launcher receives one content-change notification with the full set.
|
||||
*/
|
||||
fun syncWatchNextPrograms(items: List<WatchNextItem>): Boolean = try {
|
||||
val ops = ArrayList<ContentProviderOperation>()
|
||||
internal data class PreparedWatchNextItem(val metadata: WatchNextItem, val localPosterUri: Uri?)
|
||||
|
||||
ops.add(
|
||||
ContentProviderOperation.newDelete(
|
||||
TvContractCompat.WatchNextPrograms.CONTENT_URI
|
||||
).build()
|
||||
)
|
||||
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
private val artwork = SystemShelfArtworkStore(context.cacheDir)
|
||||
private var currentOwner = ""
|
||||
private var currentGeneration = 0L
|
||||
|
||||
for (item in items) {
|
||||
val program = buildProgram(item)
|
||||
ops.add(
|
||||
ContentProviderOperation.newInsert(
|
||||
TvContractCompat.WatchNextPrograms.CONTENT_URI
|
||||
).withValues(program.toContentValues()).build()
|
||||
)
|
||||
/** Materializes transient art, then atomically replaces the durable rows. */
|
||||
fun syncWatchNextPrograms(ownerId: String, generation: Long, items: List<WatchNextItem>): Boolean {
|
||||
if (!accepts(ownerId, generation) || items.size > SystemShelfArtworkStore.MAX_ITEMS) return false
|
||||
|
||||
val oldUris = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty().mapNotNull(Uri::parse).toSet()
|
||||
val oldFiles = oldUris.mapNotNullTo(HashSet()) { artwork.resolve(it) }
|
||||
val budget = SystemShelfArtworkStore.Budget()
|
||||
val prepared = items.map { item ->
|
||||
val materialized = item.posterSourceUri?.let { artwork.materialize(ownerId, it, budget) }
|
||||
PreparedWatchNextItem(item, materialized?.uri)
|
||||
}
|
||||
if (!accepts(ownerId, generation)) {
|
||||
artwork.deleteExcept(oldFiles)
|
||||
return false
|
||||
}
|
||||
|
||||
context.contentResolver.applyBatch(TvContractCompat.AUTHORITY, ops)
|
||||
Log.d(TAG, "Synced ${items.size} Watch Next entries")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to sync Watch Next programs", e)
|
||||
false
|
||||
val newUris = prepared.mapNotNullTo(LinkedHashSet()) { it.localPosterUri }
|
||||
grantReadAccess(newUris)
|
||||
val committed = replaceRows(prepared)
|
||||
if (!committed) {
|
||||
revokeReadAccess(newUris - oldUris)
|
||||
artwork.deleteExcept(oldFiles)
|
||||
return false
|
||||
}
|
||||
|
||||
prefs.edit()
|
||||
.putStringSet(GRANTED_URIS, newUris.mapTo(LinkedHashSet(), Uri::toString))
|
||||
.commit()
|
||||
currentOwner = ownerId
|
||||
currentGeneration = generation
|
||||
revokeReadAccess(oldUris - newUris)
|
||||
artwork.deleteExcept(prepared.mapNotNullTo(HashSet()) { it.localPosterUri?.let(artwork::resolve) })
|
||||
return true
|
||||
}
|
||||
|
||||
fun clearAll(): Boolean = try {
|
||||
context.contentResolver.delete(
|
||||
TvContractCompat.WatchNextPrograms.CONTENT_URI,
|
||||
null,
|
||||
null
|
||||
)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to clear Watch Next entries", e)
|
||||
false
|
||||
/** Deletes rows first, then grants, then owned files. */
|
||||
fun clearAll(ownerId: String, generation: Long): Boolean {
|
||||
if (!acceptsClear(ownerId, generation)) return false
|
||||
val rowsCleared = deleteRows()
|
||||
if (!rowsCleared) return false
|
||||
val uris = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty().mapNotNull(Uri::parse).toSet()
|
||||
revokeReadAccess(uris)
|
||||
artwork.deleteAll()
|
||||
prefs.edit().remove(GRANTED_URIS).commit()
|
||||
currentOwner = ""
|
||||
currentGeneration = generation
|
||||
return true
|
||||
}
|
||||
|
||||
fun removeItem(contentId: String): Boolean {
|
||||
/** Package replacement is a clean cutover: remote legacy rows cannot survive. */
|
||||
fun clearLegacyOnPackageUpdate(): Boolean {
|
||||
val rowsCleared = deleteRows()
|
||||
val uris = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty().mapNotNull(Uri::parse).toSet()
|
||||
revokeReadAccess(uris)
|
||||
artwork.deleteAll()
|
||||
prefs.edit().clear().commit()
|
||||
currentOwner = ""
|
||||
currentGeneration = 0
|
||||
return rowsCleared
|
||||
}
|
||||
|
||||
fun removeItem(ownerId: String, generation: Long, contentId: String): Boolean {
|
||||
if (!accepts(ownerId, generation)) return false
|
||||
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_ID,
|
||||
TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI
|
||||
),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
|
||||
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 id = it.getLong(idIndex)
|
||||
val deleteUri = ContentUris.withAppendedId(
|
||||
TvContractCompat.WatchNextPrograms.CONTENT_URI,
|
||||
id
|
||||
)
|
||||
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
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to remove Watch Next item: $contentId", e)
|
||||
} catch (_: Exception) {
|
||||
Log.e(TAG, "Failed to remove Watch Next item")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildProgram(item: WatchNextItem): WatchNextProgram {
|
||||
val watchNextType = if (item.lastPlaybackPosition > 0) {
|
||||
private fun accepts(ownerId: String, generation: Long): Boolean {
|
||||
if (ownerId.isBlank() || generation <= 0) return false
|
||||
return generation > currentGeneration || generation == currentGeneration && currentOwner == ownerId
|
||||
}
|
||||
|
||||
private fun acceptsClear(ownerId: String, generation: Long): Boolean {
|
||||
if (ownerId.isBlank() || generation <= 0 || generation < currentGeneration) return false
|
||||
return generation > currentGeneration || currentOwner.isEmpty() || currentOwner == ownerId
|
||||
}
|
||||
|
||||
private fun replaceRows(items: List<PreparedWatchNextItem>): Boolean = try {
|
||||
val operations = ArrayList<ContentProviderOperation>(items.size + 1)
|
||||
operations += ContentProviderOperation.newDelete(TvContractCompat.WatchNextPrograms.CONTENT_URI).build()
|
||||
items.forEach { item ->
|
||||
operations += ContentProviderOperation.newInsert(TvContractCompat.WatchNextPrograms.CONTENT_URI)
|
||||
.withValues(buildProgram(item).toContentValues())
|
||||
.build()
|
||||
}
|
||||
context.contentResolver.applyBatch(TvContractCompat.AUTHORITY, operations)
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
Log.e(TAG, "Failed to sync Watch Next programs")
|
||||
false
|
||||
}
|
||||
|
||||
private fun deleteRows(): Boolean = try {
|
||||
context.contentResolver.delete(TvContractCompat.WatchNextPrograms.CONTENT_URI, null, null)
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
Log.e(TAG, "Failed to clear Watch Next entries")
|
||||
false
|
||||
}
|
||||
|
||||
private fun consumerPackages(): Set<String> {
|
||||
val packages = LinkedHashSet<String>()
|
||||
context.packageManager.resolveContentProvider(TvContractCompat.AUTHORITY, PackageManager.MATCH_ALL)?.packageName
|
||||
?.let(packages::add)
|
||||
val launcherIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LEANBACK_LAUNCHER)
|
||||
context.packageManager.queryIntentActivities(launcherIntent, PackageManager.MATCH_ALL)
|
||||
.mapTo(packages) { it.activityInfo.packageName }
|
||||
return packages
|
||||
}
|
||||
|
||||
private fun grantReadAccess(uris: Set<Uri>) {
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
consumerPackages().forEach { packageName ->
|
||||
uris.forEach { uri ->
|
||||
runCatching { context.grantUriPermission(packageName, uri, flags) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun revokeReadAccess(uris: Set<Uri>) {
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
uris.forEach { uri -> runCatching { context.revokeUriPermission(uri, flags) } }
|
||||
}
|
||||
|
||||
internal fun buildProgram(item: PreparedWatchNextItem): WatchNextProgram {
|
||||
val metadata = item.metadata
|
||||
val watchNextType = if (metadata.lastPlaybackPosition > 0) {
|
||||
TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE
|
||||
} else {
|
||||
TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_NEXT
|
||||
}
|
||||
|
||||
val builder = WatchNextProgram.Builder()
|
||||
.setType(item.type)
|
||||
.setType(metadata.type)
|
||||
.setWatchNextType(watchNextType)
|
||||
.setTitle(item.title)
|
||||
.setInternalProviderId(item.contentId)
|
||||
.setLastEngagementTimeUtcMillis(item.lastEngagementTime)
|
||||
.setTitle(metadata.title)
|
||||
.setInternalProviderId(metadata.contentId)
|
||||
.setLastEngagementTimeUtcMillis(metadata.lastEngagementTime)
|
||||
|
||||
item.description?.let { builder.setDescription(it) }
|
||||
|
||||
item.posterUri?.let { uri ->
|
||||
try {
|
||||
builder.setPosterArtUri(Uri.parse(uri))
|
||||
builder.setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to parse poster URI: $uri", e)
|
||||
metadata.description?.let(builder::setDescription)
|
||||
item.localPosterUri?.let { uri ->
|
||||
builder.setPosterArtUri(uri)
|
||||
builder.setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9)
|
||||
}
|
||||
if (metadata.duration > 0) {
|
||||
builder.setDurationMillis(metadata.duration.coerceAtMost(Int.MAX_VALUE.toLong()).toInt())
|
||||
if (metadata.lastPlaybackPosition > 0) {
|
||||
builder.setLastPlaybackPositionMillis(metadata.lastPlaybackPosition.coerceAtMost(Int.MAX_VALUE.toLong()).toInt())
|
||||
}
|
||||
}
|
||||
|
||||
if (item.duration > 0) {
|
||||
builder.setDurationMillis(item.duration.toInt())
|
||||
if (item.lastPlaybackPosition > 0) {
|
||||
builder.setLastPlaybackPositionMillis(item.lastPlaybackPosition.toInt())
|
||||
}
|
||||
if (metadata.type == TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE) {
|
||||
metadata.episodeTitle?.let(builder::setEpisodeTitle)
|
||||
metadata.seasonNumber?.let(builder::setSeasonNumber)
|
||||
metadata.episodeNumber?.let(builder::setEpisodeNumber)
|
||||
}
|
||||
|
||||
if (item.type == TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE) {
|
||||
item.episodeTitle?.let { builder.setEpisodeTitle(it) }
|
||||
item.seasonNumber?.let { builder.setSeasonNumber(it) }
|
||||
item.episodeNumber?.let { builder.setEpisodeNumber(it) }
|
||||
}
|
||||
|
||||
val intentUri = Uri.Builder()
|
||||
.scheme("plezy")
|
||||
.authority("play")
|
||||
.appendQueryParameter("content_id", item.contentId)
|
||||
.build()
|
||||
builder.setIntentUri(intentUri)
|
||||
|
||||
builder.setIntentUri(
|
||||
Uri.Builder().scheme("plezy").authority("play")
|
||||
.appendQueryParameter("content_id", metadata.contentId).build()
|
||||
)
|
||||
return builder.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Looper
|
||||
import com.edde746.plezy.mpv.MpvPlayerCore
|
||||
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.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.Robolectric
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
|
||||
@@ -40,6 +45,119 @@ class ExoPlayerPluginTest {
|
||||
assertEquals(mapOf("playerType" to "mpv"), result.successValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fallbackPropertyHandlersWaitForAcceptedWritesAndReplyOnce() {
|
||||
for (case in fallbackPropertyCases()) {
|
||||
val writes = mutableListOf<Pair<String, String>>()
|
||||
val plugin = fallbackPlugin { name, value -> writes += name to value }
|
||||
val result = RecordingResult()
|
||||
|
||||
plugin.onMethodCall(MethodCall(case.method, case.arguments), result)
|
||||
awaitCompletion(result)
|
||||
|
||||
assertEquals(listOf(case.expectedWrite), writes)
|
||||
assertEquals(case.successValue, result.successValue)
|
||||
assertEquals(1, result.completionCount)
|
||||
assertEquals(null, result.errorCode)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fallbackPropertyHandlersMapRejectedWritesToBoundedErrorsOnce() {
|
||||
for (case in fallbackPropertyCases()) {
|
||||
val writes = AtomicInteger()
|
||||
val plugin = fallbackPlugin { _, _ ->
|
||||
writes.incrementAndGet()
|
||||
error("secret-fallback-value")
|
||||
}
|
||||
val result = RecordingResult()
|
||||
|
||||
plugin.onMethodCall(MethodCall(case.method, case.arguments), result)
|
||||
awaitCompletion(result)
|
||||
|
||||
assertEquals(1, writes.get())
|
||||
assertEquals(1, result.completionCount)
|
||||
assertEquals("SET_PROPERTY_FAILED", result.errorCode)
|
||||
assertEquals("MPV property write was rejected or cancelled", result.errorMessage)
|
||||
assertTrue(result.errorMessage?.contains("secret-fallback-value") == false)
|
||||
assertEquals(null, result.successValue)
|
||||
assertEquals(null, result.errorDetails)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fallbackCancellationReturnsSetPropertyFailedOnce() {
|
||||
val plugin = fallbackPlugin { _, _ ->
|
||||
throw CancellationException("secret-cancellation")
|
||||
}
|
||||
val result = RecordingResult()
|
||||
|
||||
plugin.onMethodCall(
|
||||
MethodCall("setMpvProperty", mapOf("name" to "custom", "value" to "secret")),
|
||||
result
|
||||
)
|
||||
awaitCompletion(result)
|
||||
|
||||
assertEquals(1, result.completionCount)
|
||||
assertEquals("SET_PROPERTY_FAILED", result.errorCode)
|
||||
assertTrue(result.errorMessage?.contains("secret") == false)
|
||||
assertEquals(null, result.successValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fallbackWithoutCoreReturnsNotInitializedOnce() {
|
||||
val plugin = ExoPlayerPlugin()
|
||||
setField(plugin, "usingMpvFallback", true)
|
||||
setField(plugin, "activity", Robolectric.buildActivity(Activity::class.java).setup().get())
|
||||
val result = RecordingResult()
|
||||
|
||||
plugin.onMethodCall(MethodCall("pause", null), result)
|
||||
|
||||
assertEquals(1, result.completionCount)
|
||||
assertEquals("NOT_INITIALIZED", result.errorCode)
|
||||
assertEquals(null, result.successValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fallbackWithoutActivityReturnsNotInitializedOnce() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val core = MpvPlayerCore(activity, true) { _, _ -> Unit }
|
||||
val plugin = ExoPlayerPlugin()
|
||||
setField(plugin, "usingMpvFallback", true)
|
||||
setField(plugin, "mpvCore", core)
|
||||
val result = RecordingResult()
|
||||
|
||||
plugin.onMethodCall(MethodCall("play", null), result)
|
||||
|
||||
assertEquals(1, result.completionCount)
|
||||
assertEquals("NOT_INITIALIZED", result.errorCode)
|
||||
assertEquals(null, result.successValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun genericPropertyBeforeFallbackIsAcceptedIntoLastWriteWinsPendingMap() {
|
||||
val plugin = ExoPlayerPlugin()
|
||||
val first = RecordingResult()
|
||||
val second = RecordingResult()
|
||||
|
||||
plugin.onMethodCall(
|
||||
MethodCall("setMpvProperty", mapOf("name" to "custom", "value" to "first")),
|
||||
first
|
||||
)
|
||||
plugin.onMethodCall(
|
||||
MethodCall("setMpvProperty", mapOf("name" to "custom", "value" to "second")),
|
||||
second
|
||||
)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val pending = getField(plugin, "pendingMpvProperties") as Map<String, String>
|
||||
assertEquals(mapOf("custom" to "second"), pending)
|
||||
assertEquals(1, first.completionCount)
|
||||
assertEquals(1, second.completionCount)
|
||||
assertEquals(null, first.errorCode)
|
||||
assertEquals(null, second.errorCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun eventCallbacksKeepTheSharedPlayerEnvelope() {
|
||||
val plugin = ExoPlayerPlugin()
|
||||
@@ -58,20 +176,95 @@ class ExoPlayerPluginTest {
|
||||
)
|
||||
}
|
||||
|
||||
private data class FallbackPropertyCase(
|
||||
val method: String,
|
||||
val arguments: Any?,
|
||||
val expectedWrite: Pair<String, String>,
|
||||
val successValue: Any? = null
|
||||
)
|
||||
|
||||
private fun fallbackPropertyCases() = listOf(
|
||||
FallbackPropertyCase("play", null, "pause" to "no"),
|
||||
FallbackPropertyCase("pause", null, "pause" to "yes"),
|
||||
FallbackPropertyCase("setVolume", mapOf("volume" to 25), "volume" to "25.0"),
|
||||
FallbackPropertyCase("setRate", mapOf("rate" to 1.5), "speed" to "1.5"),
|
||||
FallbackPropertyCase("selectAudioTrack", mapOf("trackId" to "2"), "aid" to "2"),
|
||||
FallbackPropertyCase("selectSubtitleTrack", emptyMap<String, Any?>(), "sid" to "no"),
|
||||
FallbackPropertyCase(
|
||||
"setAudioPassthrough",
|
||||
mapOf("enabled" to true),
|
||||
"audio-spdif" to "ac3,eac3,dts,dts-hd,truehd",
|
||||
true
|
||||
),
|
||||
FallbackPropertyCase(
|
||||
"setMpvProperty",
|
||||
mapOf("name" to "custom", "value" to "value"),
|
||||
"custom" to "value"
|
||||
)
|
||||
)
|
||||
|
||||
private fun fallbackPlugin(
|
||||
writer: suspend (String, String) -> Unit
|
||||
): ExoPlayerPlugin {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val core = MpvPlayerCore(activity, true, writer)
|
||||
return ExoPlayerPlugin().also { plugin ->
|
||||
setField(plugin, "activity", activity)
|
||||
setField(plugin, "mpvCore", core)
|
||||
setField(plugin, "usingMpvFallback", true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setField(plugin: ExoPlayerPlugin, name: String, value: Any?) {
|
||||
plugin.javaClass.getDeclaredField(name).apply {
|
||||
isAccessible = true
|
||||
set(plugin, value)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getField(plugin: ExoPlayerPlugin, name: String): Any? = plugin.javaClass.getDeclaredField(name).run {
|
||||
isAccessible = true
|
||||
get(plugin)
|
||||
}
|
||||
|
||||
private fun awaitCompletion(result: RecordingResult) {
|
||||
var completed = false
|
||||
repeat(100) {
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
if (result.completed.await(10, TimeUnit.MILLISECONDS)) {
|
||||
completed = true
|
||||
return@repeat
|
||||
}
|
||||
}
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
assertTrue("fallback property result never completed", completed)
|
||||
assertEquals(1, result.completionCount)
|
||||
}
|
||||
|
||||
private class RecordingResult : MethodChannel.Result {
|
||||
val completed = CountDownLatch(1)
|
||||
var successValue: Any? = null
|
||||
var errorCode: String? = null
|
||||
var errorMessage: String? = null
|
||||
var errorDetails: Any? = null
|
||||
var completionCount: Int = 0
|
||||
|
||||
override fun success(result: Any?) {
|
||||
completionCount++
|
||||
successValue = result
|
||||
completed.countDown()
|
||||
}
|
||||
|
||||
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
|
||||
completionCount++
|
||||
this.errorCode = errorCode
|
||||
this.errorMessage = errorMessage
|
||||
this.errorDetails = errorDetails
|
||||
completed.countDown()
|
||||
}
|
||||
|
||||
override fun notImplemented() {
|
||||
completionCount++
|
||||
completed.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.edde746.plezy.mpv
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Looper
|
||||
import dev.jdtech.mpv.EndFileReason
|
||||
import dev.jdtech.mpv.LogLevel
|
||||
import dev.jdtech.mpv.LogMessage
|
||||
@@ -7,12 +9,19 @@ import dev.jdtech.mpv.MpvEvent
|
||||
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.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.Robolectric
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class MpvPlayerPluginTest {
|
||||
@@ -30,6 +39,139 @@ class MpvPlayerPluginTest {
|
||||
assertNull(result.successValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setPropertyWithoutCoreReportsNotInitializedForVideoAndAudio() {
|
||||
for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) {
|
||||
val result = RecordingResult()
|
||||
|
||||
plugin.onMethodCall(propertyCall(), result)
|
||||
|
||||
assertEquals("NOT_INITIALIZED", result.errorCode)
|
||||
assertEquals(1, result.completionCount)
|
||||
assertNull(result.successValue)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptedSetPropertyCompletesOnceForVideoAndAudio() {
|
||||
for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) {
|
||||
val writes = AtomicInteger()
|
||||
installCore(plugin, testCore { _, _ -> writes.incrementAndGet() })
|
||||
val result = RecordingResult()
|
||||
|
||||
plugin.onMethodCall(propertyCall(), result)
|
||||
awaitCompletion(result)
|
||||
|
||||
assertEquals(1, writes.get())
|
||||
assertEquals(1, result.completionCount)
|
||||
assertNull(result.errorCode)
|
||||
assertNull(result.successValue)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectedSetPropertyFailsOnceForVideoAndAudioWithoutLeakingPayload() {
|
||||
for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) {
|
||||
installCore(plugin, testCore { _, _ -> error("secret-property-value") })
|
||||
val result = RecordingResult()
|
||||
|
||||
plugin.onMethodCall(propertyCall(), result)
|
||||
awaitCompletion(result)
|
||||
|
||||
assertEquals(1, result.completionCount)
|
||||
assertEquals("SET_PROPERTY_FAILED", result.errorCode)
|
||||
assertEquals("MPV property write was rejected or cancelled", result.errorMessage)
|
||||
assertTrue(result.errorMessage?.contains("secret-property-value") == false)
|
||||
assertNull(result.successValue)
|
||||
assertNull(result.errorDetails)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cancelledSetPropertyFailsOnceForVideoAndAudio() {
|
||||
for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) {
|
||||
installCore(plugin, testCore { _, _ -> throw CancellationException("secret-cancellation") })
|
||||
val result = RecordingResult()
|
||||
|
||||
plugin.onMethodCall(propertyCall(), result)
|
||||
awaitCompletion(result)
|
||||
|
||||
assertEquals(1, result.completionCount)
|
||||
assertEquals("SET_PROPERTY_FAILED", result.errorCode)
|
||||
assertTrue(result.errorMessage?.contains("secret-cancellation") == false)
|
||||
assertNull(result.successValue)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun coreReportsMissingPlayerDuringWriteAsFailure() {
|
||||
val core = testCore(null)
|
||||
var outcome: Result<Unit>? = null
|
||||
|
||||
core.setProperty("volume", "50") { outcome = it }
|
||||
awaitCondition { outcome != null }
|
||||
|
||||
assertTrue(outcome?.isFailure == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disposeCancelsQueuedPropertyWritesAndCompletesEachCallbackOnce() {
|
||||
val firstStarted = CountDownLatch(1)
|
||||
val core = testCore { name, _ ->
|
||||
if (name == "first") {
|
||||
suspendCancellableCoroutine<Unit> {
|
||||
firstStarted.countDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
val outcomes = mutableListOf<Result<Unit>>()
|
||||
|
||||
core.setProperty("first", "value") { outcomes += it }
|
||||
assertTrue(firstStarted.await(1, TimeUnit.SECONDS))
|
||||
core.setProperty("second", "value") { outcomes += it }
|
||||
core.dispose()
|
||||
awaitCondition { outcomes.size == 2 }
|
||||
|
||||
assertEquals(2, outcomes.size)
|
||||
assertTrue(outcomes.all { it.isFailure })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun failedPauseLeavesAllPauseBookkeepingUnchanged() {
|
||||
val core = testVideoCore { _, _ -> error("rejected") }
|
||||
setBoolean(core, "cachedPaused", false)
|
||||
setBoolean(core, "pausedForSurfaceLoss", true)
|
||||
setBoolean(core, "resumeBlockedByPublicPause", false)
|
||||
setBoolean(core, "deferredResumeRequested", true)
|
||||
var outcome: Result<Unit>? = null
|
||||
|
||||
core.setProperty("pause", "yes") { outcome = it }
|
||||
awaitCondition { outcome != null }
|
||||
|
||||
assertTrue(outcome?.isFailure == true)
|
||||
assertEquals(false, getBoolean(core, "cachedPaused"))
|
||||
assertEquals(true, getBoolean(core, "pausedForSurfaceLoss"))
|
||||
assertEquals(false, getBoolean(core, "resumeBlockedByPublicPause"))
|
||||
assertEquals(true, getBoolean(core, "deferredResumeRequested"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resumeWithoutReadyVideoOutputIsAcceptedAndDeferredWithoutWriting() {
|
||||
val writes = AtomicInteger()
|
||||
val core = testVideoCore { _, _ -> writes.incrementAndGet() }
|
||||
setBoolean(core, "resumeBlockedByPublicPause", true)
|
||||
var outcome: Result<Unit>? = null
|
||||
|
||||
core.setProperty("pause", "no") { outcome = it }
|
||||
awaitCondition { outcome != null }
|
||||
|
||||
assertTrue(outcome?.isSuccess == true)
|
||||
assertEquals(0, writes.get())
|
||||
assertEquals(false, getBoolean(core, "resumeBlockedByPublicPause"))
|
||||
assertEquals(true, getBoolean(core, "deferredResumeRequested"))
|
||||
assertEquals(true, getBoolean(core, "cachedPaused"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disposeCompletesEveryPendingInitialization() {
|
||||
val plugin = MpvPlayerPlugin()
|
||||
@@ -53,9 +195,9 @@ class MpvPlayerPluginTest {
|
||||
assertEquals(false, first.successValue)
|
||||
assertEquals(false, second.successValue)
|
||||
assertNull(dispose.successValue)
|
||||
assertTrue(first.completed)
|
||||
assertTrue(second.completed)
|
||||
assertTrue(dispose.completed)
|
||||
assertEquals(1, first.completionCount)
|
||||
assertEquals(1, second.completionCount)
|
||||
assertEquals(1, dispose.completionCount)
|
||||
assertEquals(0, pending.size)
|
||||
}
|
||||
|
||||
@@ -125,22 +267,91 @@ class MpvPlayerPluginTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun propertyCall() = MethodCall(
|
||||
"setProperty",
|
||||
mapOf("name" to "volume", "value" to "50")
|
||||
)
|
||||
|
||||
private fun testCore(
|
||||
writer: (suspend (String, String) -> Unit)?
|
||||
): MpvPlayerCore = MpvPlayerCore(
|
||||
Robolectric.buildActivity(Activity::class.java).setup().get(),
|
||||
true,
|
||||
writer
|
||||
)
|
||||
|
||||
private fun testVideoCore(
|
||||
writer: suspend (String, String) -> Unit
|
||||
): MpvPlayerCore = MpvPlayerCore(
|
||||
Robolectric.buildActivity(Activity::class.java).setup().get(),
|
||||
false,
|
||||
writer
|
||||
)
|
||||
|
||||
private fun installCore(plugin: MpvPlayerPlugin, core: MpvPlayerCore) {
|
||||
MpvPlayerPlugin::class.java.getDeclaredField("playerCore").apply {
|
||||
isAccessible = true
|
||||
set(plugin, core)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setBoolean(core: MpvPlayerCore, name: String, value: Boolean) {
|
||||
MpvPlayerCore::class.java.getDeclaredField(name).apply {
|
||||
isAccessible = true
|
||||
setBoolean(core, value)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getBoolean(core: MpvPlayerCore, name: String): Boolean = MpvPlayerCore::class.java.getDeclaredField(name).run {
|
||||
isAccessible = true
|
||||
getBoolean(core)
|
||||
}
|
||||
|
||||
private fun awaitCompletion(result: RecordingResult) {
|
||||
awaitCondition { result.completed.await(10, TimeUnit.MILLISECONDS) }
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
assertEquals(1, result.completionCount)
|
||||
}
|
||||
|
||||
private fun awaitCondition(condition: () -> Boolean) {
|
||||
var completed = false
|
||||
repeat(100) {
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
if (condition()) {
|
||||
completed = true
|
||||
return@repeat
|
||||
}
|
||||
Thread.sleep(10)
|
||||
}
|
||||
assertTrue("asynchronous operation never completed", completed)
|
||||
}
|
||||
|
||||
private class RecordingResult : MethodChannel.Result {
|
||||
val completed = CountDownLatch(1)
|
||||
var successValue: Any? = null
|
||||
var errorCode: String? = null
|
||||
var completed: Boolean = false
|
||||
var errorMessage: String? = null
|
||||
var errorDetails: Any? = null
|
||||
var completionCount: Int = 0
|
||||
|
||||
override fun success(result: Any?) {
|
||||
completed = true
|
||||
completionCount++
|
||||
successValue = result
|
||||
completed.countDown()
|
||||
}
|
||||
|
||||
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
|
||||
completed = true
|
||||
completionCount++
|
||||
this.errorCode = errorCode
|
||||
this.errorMessage = errorMessage
|
||||
this.errorDetails = errorDetails
|
||||
completed.countDown()
|
||||
}
|
||||
|
||||
override fun notImplemented() = Unit
|
||||
override fun notImplemented() {
|
||||
completionCount++
|
||||
completed.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
private class RecordingEventSink : EventChannel.EventSink {
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package com.edde746.plezy.watchnext
|
||||
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentProviderResult
|
||||
import android.content.ContentValues
|
||||
import android.content.Intent
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.ParcelFileDescriptor.AutoCloseInputStream
|
||||
import androidx.tvprovider.media.tv.TvContractCompat
|
||||
import java.net.InetAddress
|
||||
import java.net.ServerSocket
|
||||
import java.util.Base64
|
||||
import java.util.concurrent.Executor
|
||||
import kotlin.concurrent.thread
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.Robolectric
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.shadows.ShadowContentResolver
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class WatchNextProviderTest {
|
||||
private val context get() = RuntimeEnvironment.getApplication()
|
||||
private lateinit var tvProvider: CapturingTvProvider
|
||||
private val imageBytes = Base64.getDecoder().decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
|
||||
)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context.cacheDir.resolve("system_shelf_artwork").deleteRecursively()
|
||||
context.getSharedPreferences("system_shelf_state", 0).edit().clear().commit()
|
||||
tvProvider = CapturingTvProvider()
|
||||
ShadowContentResolver.registerProviderInternal(TvContractCompat.AUTHORITY, tvProvider)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
context.cacheDir.resolve("system_shelf_artwork").deleteRecursively()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncPersistsOnlyGrantedLocalUriAndProviderReturnsValidatedBytes() {
|
||||
withServer("image/png", imageBytes) { source ->
|
||||
val provider = WatchNextProvider(context)
|
||||
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(source))))
|
||||
assertEquals(1, tvProvider.inserted.size)
|
||||
val stored = tvProvider.inserted.single()
|
||||
val poster = stored.getAsString(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI)
|
||||
assertTrue(poster.startsWith("content://${SystemShelfArtworkProvider.AUTHORITY}/art/"))
|
||||
assertFalse(poster.contains("http"))
|
||||
|
||||
val artworkProvider = Robolectric.buildContentProvider(SystemShelfArtworkProvider::class.java).create().get()
|
||||
val localBytes = AutoCloseInputStream(artworkProvider.openFile(Uri.parse(poster), "r")).use { it.readBytes() }
|
||||
assertArrayEquals(imageBytes, localBytes)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun traversalUnknownOversizeAndMalformedArtworkAreRejectedWithoutDroppingMetadata() {
|
||||
val store = SystemShelfArtworkStore(context.cacheDir)
|
||||
assertNull(store.resolve(Uri.parse("content://${SystemShelfArtworkProvider.AUTHORITY}/art/../../private")))
|
||||
assertNull(store.resolve(Uri.parse("content://${SystemShelfArtworkProvider.AUTHORITY}/art/${"a".repeat(64)}/${"b".repeat(32)}.art")))
|
||||
|
||||
withServer("image/png", ByteArray(SystemShelfArtworkStore.MAX_IMAGE_BYTES + 1)) { source ->
|
||||
val provider = WatchNextProvider(context)
|
||||
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(source))))
|
||||
assertNull(tvProvider.inserted.single().getAsString(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI))
|
||||
assertEquals("Private title", tvProvider.inserted.single().getAsString(TvContractCompat.WatchNextPrograms.COLUMN_TITLE))
|
||||
}
|
||||
|
||||
tvProvider.inserted.clear()
|
||||
withServer("image/png", "not an image".toByteArray()) { source ->
|
||||
val provider = WatchNextProvider(context)
|
||||
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(source))))
|
||||
assertNull(tvProvider.inserted.single().getAsString(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI))
|
||||
}
|
||||
|
||||
tvProvider.inserted.clear()
|
||||
withServer("text/plain", imageBytes) { source ->
|
||||
val provider = WatchNextProvider(context)
|
||||
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(source))))
|
||||
assertNull(tvProvider.inserted.single().getAsString(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI))
|
||||
}
|
||||
|
||||
tvProvider.inserted.clear()
|
||||
withServer("image/png", imageBytes, delayMillis = 3_000) { source ->
|
||||
val provider = WatchNextProvider(context)
|
||||
assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(source))))
|
||||
assertNull(tvProvider.inserted.single().getAsString(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleGenerationCannotCommitAndClearRemovesRowsGrantsAndFiles() {
|
||||
withServer("image/png", imageBytes) { source ->
|
||||
val provider = WatchNextProvider(context)
|
||||
assertTrue(provider.syncWatchNextPrograms("owner-a", 3, listOf(item(source))))
|
||||
assertFalse(provider.syncWatchNextPrograms("owner-old", 2, listOf(item(source))))
|
||||
assertTrue(context.cacheDir.resolve("system_shelf_artwork").walkTopDown().any { it.isFile })
|
||||
|
||||
assertTrue(provider.clearAll("owner-a", 4))
|
||||
assertTrue(tvProvider.deleteCount >= 2)
|
||||
assertFalse(context.cacheDir.resolve("system_shelf_artwork").exists())
|
||||
assertTrue(context.getSharedPreferences("system_shelf_state", 0).getStringSet("granted_uris", null).isNullOrEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun packageUpdateCleanupDeletesLegacyRowsAndOwnedFiles() {
|
||||
context.cacheDir.resolve("system_shelf_artwork/legacy").apply { mkdirs() }.resolve("legacy.art").writeBytes(imageBytes)
|
||||
val receiver = SystemShelfUpdateReceiver(Executor { command -> command.run() })
|
||||
receiver.onReceive(context, Intent(Intent.ACTION_MY_PACKAGE_REPLACED))
|
||||
|
||||
assertEquals(1, tvProvider.deleteCount)
|
||||
assertFalse(context.cacheDir.resolve("system_shelf_artwork").exists())
|
||||
}
|
||||
|
||||
private fun item(source: String) = WatchNextProvider.WatchNextItem(
|
||||
contentId = "plezy_server_item",
|
||||
title = "Private title",
|
||||
episodeTitle = null,
|
||||
description = "Private summary",
|
||||
posterSourceUri = source,
|
||||
type = TvContractCompat.WatchNextPrograms.TYPE_MOVIE,
|
||||
duration = 100,
|
||||
lastPlaybackPosition = 10,
|
||||
lastEngagementTime = 1,
|
||||
seriesTitle = null,
|
||||
seasonNumber = null,
|
||||
episodeNumber = null
|
||||
)
|
||||
|
||||
private fun withServer(
|
||||
contentType: String,
|
||||
body: ByteArray,
|
||||
delayMillis: Long = 0,
|
||||
block: (String) -> Unit
|
||||
) {
|
||||
val server = ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))
|
||||
val responder = thread(start = true, name = "system-shelf-test-http") {
|
||||
server.accept().use { socket ->
|
||||
val reader = socket.getInputStream().bufferedReader()
|
||||
while (reader.readLine()?.isNotEmpty() == true) {
|
||||
// Consume the local deterministic request headers.
|
||||
}
|
||||
if (delayMillis > 0) Thread.sleep(delayMillis)
|
||||
val headers = (
|
||||
"HTTP/1.1 200 OK\r\n" +
|
||||
"Content-Type: $contentType\r\n" +
|
||||
"Content-Length: ${body.size}\r\n" +
|
||||
"Connection: close\r\n\r\n"
|
||||
).toByteArray()
|
||||
socket.getOutputStream().use { output ->
|
||||
output.write(headers)
|
||||
output.write(body)
|
||||
output.flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
block("http://127.0.0.1:${server.localPort}/art")
|
||||
responder.join(5_000)
|
||||
} finally {
|
||||
server.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class CapturingTvProvider : ContentProvider() {
|
||||
val inserted = mutableListOf<ContentValues>()
|
||||
var deleteCount = 0
|
||||
|
||||
override fun onCreate(): Boolean = true
|
||||
override fun insert(uri: Uri, values: ContentValues?): Uri {
|
||||
inserted += ContentValues(values)
|
||||
return uri.buildUpon().appendPath(inserted.size.toString()).build()
|
||||
}
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
|
||||
deleteCount++
|
||||
inserted.clear()
|
||||
return 1
|
||||
}
|
||||
override fun applyBatch(operations: ArrayList<ContentProviderOperation>): Array<ContentProviderResult> = super.applyBatch(operations)
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user