feat(tv): refresh the Watch Next row without the app open
The Watch Next row previously only updated while the app was in the foreground, so it drifted stale until the next launch. A WorkManager periodic job (6h, network-connected, KEEP) now runs a headless Flutter engine executing `systemShelfBackgroundMain`, which mirrors the cold-start profile bind from cached tokens (never prompting for a PIN), fetches Continue Watching through the existing multi-server aggregation, and republishes the shelf through the normal Watch Next pipeline. The job is armed by a committed foreground sync, cancelled when the shelf is cleared, and re-armed after boot or app update only when persisted shelf state exists. It skips entirely while a foreground engine holds the shelf lifecycle lease, both to defer to the live app and to avoid two engines sharing the database in one process. The Dart isolate always reports completion over `backgroundSyncComplete`; the worker hard-caps the run at 90 seconds and destroys the engine on the main thread.
This commit is contained in:
@@ -518,6 +518,10 @@ dependencies {
|
||||
// Android TV Watch Next integration
|
||||
implementation("androidx.tvprovider:tvprovider:1.1.0")
|
||||
|
||||
// Periodic Watch Next background refresh (ShelfRefreshWorker). Same version
|
||||
// background_downloader pins, so the merged classpath stays coherent.
|
||||
implementation("androidx.work:work-runtime-ktx:2.11.0")
|
||||
|
||||
// Media3 ExoPlayer for Android
|
||||
implementation("androidx.media3:media3-decoder:$media3Version")
|
||||
implementation("androidx.media3:media3-exoplayer:$media3Version")
|
||||
@@ -537,6 +541,7 @@ dependencies {
|
||||
// Real android.util.* implementations for tests exercising media3 classes
|
||||
// (MatroskaExtractor uses SparseArray, which is a no-op stub on plain JVM)
|
||||
testImplementation("org.robolectric:robolectric:4.16.1")
|
||||
testImplementation("androidx.work:work-testing:2.11.0")
|
||||
androidTestImplementation("androidx.test:runner:1.7.0")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.3.0")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.edde746.plezy.watchnext
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.util.Log
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import io.flutter.FlutterInjector
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.dart.DartExecutor
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/**
|
||||
* Owns the WorkManager registration for the periodic launcher-shelf refresh.
|
||||
*
|
||||
* Registration is best-effort by design: it runs inside the plugin's sync/clear
|
||||
* result path, and a WorkManager failure must not turn a committed shelf write
|
||||
* into a channel error.
|
||||
*/
|
||||
internal object ShelfRefreshScheduler {
|
||||
private const val TAG = "ShelfRefreshScheduler"
|
||||
internal const val WORK_NAME = "plezy_shelf_refresh"
|
||||
private const val REFRESH_INTERVAL_HOURS = 6L
|
||||
|
||||
fun schedule(context: Context) {
|
||||
try {
|
||||
val request = PeriodicWorkRequestBuilder<ShelfRefreshWorker>(REFRESH_INTERVAL_HOURS, TimeUnit.HOURS)
|
||||
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||
.build()
|
||||
WorkManager.getInstance(context.applicationContext)
|
||||
.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to schedule shelf refresh work", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel(context: Context) {
|
||||
try {
|
||||
WorkManager.getInstance(context.applicationContext).cancelUniqueWork(WORK_NAME)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to cancel shelf refresh work", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the Android TV Watch Next row while the app is not running.
|
||||
*
|
||||
* Boots a headless [FlutterEngine], runs the Dart `systemShelfBackgroundMain`
|
||||
* entrypoint (`lib/services/system_shelf_background.dart`), and resolves with
|
||||
* the bool that isolate reports through the `backgroundSyncComplete` method on
|
||||
* `com.plezy/watch_next`. The run is hard-capped at [RUN_TIMEOUT_MS]; the
|
||||
* engine is always destroyed on the main thread, timeout included.
|
||||
*/
|
||||
class ShelfRefreshWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
companion object {
|
||||
private const val TAG = "ShelfRefreshWorker"
|
||||
internal const val RUN_TIMEOUT_MS = 90_000L
|
||||
|
||||
/** Test seam: replaces the headless Flutter launch so tests never boot an engine. */
|
||||
@Volatile
|
||||
@VisibleForTesting
|
||||
internal var engineLauncherOverride: (suspend (Context) -> Boolean)? = null
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val context = applicationContext
|
||||
// Same support gate as WatchNextPlugin.handleIsSupported: no leanback
|
||||
// launcher, no Watch Next row worth refreshing.
|
||||
if (!context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)) return Result.success()
|
||||
// A live engine (the foreground app) owns the shelf and keeps it fresh
|
||||
// itself; a concurrent headless engine would only fight it for ownership.
|
||||
if (SystemShelfLifecycle.hasLiveLease()) return Result.success()
|
||||
|
||||
val launcher = engineLauncherOverride ?: ::runHeadlessShelfSync
|
||||
val success = try {
|
||||
withTimeoutOrNull(RUN_TIMEOUT_MS) { launcher(context) } ?: false
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Background shelf refresh failed", e)
|
||||
false
|
||||
}
|
||||
return if (success) Result.success() else Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runHeadlessShelfSync(context: Context): Boolean {
|
||||
val appContext = context.applicationContext
|
||||
val completion = CompletableDeferred<Boolean>()
|
||||
val engine = withContext(Dispatchers.Main) {
|
||||
val loader = FlutterInjector.instance().flutterLoader()
|
||||
if (!loader.initialized()) {
|
||||
loader.startInitialization(appContext)
|
||||
}
|
||||
loader.ensureInitializationComplete(appContext, null)
|
||||
// The engine constructor auto-registers GeneratedPluginRegistrant plugins
|
||||
// (shared_preferences, path_provider, connectivity, sqlite, ...);
|
||||
// WatchNextPlugin is app-local and must be added explicitly.
|
||||
val engine = FlutterEngine(appContext)
|
||||
try {
|
||||
WatchNextPlugin.backgroundSyncCompletionListener = { success ->
|
||||
completion.complete(success)
|
||||
}
|
||||
engine.plugins.add(WatchNextPlugin())
|
||||
engine.dartExecutor.executeDartEntrypoint(
|
||||
DartExecutor.DartEntrypoint(
|
||||
loader.findAppBundlePath(),
|
||||
"package:plezy/services/system_shelf_background.dart",
|
||||
"systemShelfBackgroundMain"
|
||||
)
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
WatchNextPlugin.backgroundSyncCompletionListener = null
|
||||
engine.destroy()
|
||||
throw e
|
||||
}
|
||||
engine
|
||||
}
|
||||
return try {
|
||||
completion.await()
|
||||
} finally {
|
||||
withContext(NonCancellable + Dispatchers.Main) {
|
||||
WatchNextPlugin.backgroundSyncCompletionListener = null
|
||||
engine.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,11 +22,18 @@ class SystemShelfUpdateReceiver private constructor(
|
||||
executor.execute {
|
||||
try {
|
||||
val provider = WatchNextProvider.forMaintenance(context.applicationContext)
|
||||
// Snapshot before maintenance: restoreReadGrants() re-commits the
|
||||
// granted-URI key even when no sync ever wrote it.
|
||||
val hadPriorSync = provider.hasPersistedShelfState()
|
||||
if (action == Intent.ACTION_MY_PACKAGE_REPLACED) {
|
||||
provider.migrateShelfSchema()
|
||||
} else {
|
||||
provider.restoreReadGrants()
|
||||
}
|
||||
// WorkManager normally survives reboots on its own; re-arming here
|
||||
// covers force-stop and update edge cases, and only for devices whose
|
||||
// persisted state says a shelf was actually synced before.
|
||||
if (hadPriorSync) ShelfRefreshScheduler.schedule(context.applicationContext)
|
||||
} finally {
|
||||
pending?.finish()
|
||||
if (ownsExecutor) (executor as ExecutorService).shutdown()
|
||||
|
||||
@@ -27,6 +27,13 @@ class WatchNextPlugin() :
|
||||
internal const val SCHEMA_VERSION = 3
|
||||
private var pendingDeepLink: String? = null
|
||||
|
||||
// Resolves ShelfRefreshWorker's headless run. The worker installs it
|
||||
// before launching the background engine and clears it on engine destroy;
|
||||
// a foreground engine never sets it, so its own channel calls are unaffected.
|
||||
@Volatile
|
||||
@JvmStatic
|
||||
var backgroundSyncCompletionListener: ((Boolean) -> Unit)? = null
|
||||
|
||||
fun handleIntent(intent: Intent?): String? {
|
||||
val data = intent?.data ?: return null
|
||||
return if (data.scheme == "plezy" && data.authority == "play") {
|
||||
@@ -104,6 +111,7 @@ class WatchNextPlugin() :
|
||||
"clear" -> handleClear(call, result)
|
||||
"remove" -> handleRemove(call, result)
|
||||
"getInitialDeepLink" -> handleGetInitialDeepLink(result)
|
||||
"backgroundSyncComplete" -> handleBackgroundSyncComplete(call, result)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
@@ -136,7 +144,12 @@ class WatchNextPlugin() :
|
||||
val provider = session.provider ?: return@executeOnIo false
|
||||
val ownership = provider.claimOwnership(owner, generation) ?: return@executeOnIo false
|
||||
if (!session.isOpen()) return@executeOnIo false
|
||||
provider.syncWatchNextPrograms(owner, generation, items, ownership, session::isOpen)
|
||||
val synced = provider.syncWatchNextPrograms(owner, generation, items, ownership, session::isOpen)
|
||||
// Only a committed shelf warrants the periodic background refresh; KEEP
|
||||
// makes re-arming from every foreground sync (and the headless worker's
|
||||
// own sync) idempotent.
|
||||
if (synced) ShelfRefreshScheduler.schedule(session.context)
|
||||
synced
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +163,10 @@ class WatchNextPlugin() :
|
||||
val provider = session.provider ?: return@executeOnIo false
|
||||
val ownership = provider.claimOwnership(owner, generation) ?: return@executeOnIo false
|
||||
if (!session.isOpen()) return@executeOnIo false
|
||||
provider.clearAll(owner, generation, ownership, session::isOpen)
|
||||
val cleared = provider.clearAll(owner, generation, ownership, session::isOpen)
|
||||
// A cleared shelf has nothing to refresh; the next successful sync re-arms.
|
||||
if (cleared) ShelfRefreshScheduler.cancel(session.context)
|
||||
cleared
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +208,12 @@ class WatchNextPlugin() :
|
||||
result.success(contentId)
|
||||
}
|
||||
|
||||
private fun handleBackgroundSyncComplete(call: MethodCall, result: MethodChannel.Result) {
|
||||
val success = call.arguments as? Boolean ?: false
|
||||
backgroundSyncCompletionListener?.invoke(success)
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
private fun parseWatchNextItem(data: Map<String, Any?>): WatchNextProvider.WatchNextItem? {
|
||||
val contentId = (data["contentId"] as? String)?.takeIf(String::isNotBlank) ?: return null
|
||||
val title = data["title"] as? String ?: return null
|
||||
|
||||
@@ -25,6 +25,7 @@ internal object SystemShelfLifecycle {
|
||||
private var claimToken = 0L
|
||||
private var currentOwner = ""
|
||||
private var currentGeneration = 0L
|
||||
private var leaseHeld = false
|
||||
|
||||
fun acquire(): Lease = acquireIf { true }!!
|
||||
|
||||
@@ -35,6 +36,7 @@ internal object SystemShelfLifecycle {
|
||||
claimToken += 1
|
||||
currentOwner = ""
|
||||
currentGeneration = 0
|
||||
leaseHeld = true
|
||||
Lease(token)
|
||||
}
|
||||
}
|
||||
@@ -45,11 +47,19 @@ internal object SystemShelfLifecycle {
|
||||
if (token == lease.token) {
|
||||
token += 1
|
||||
claimToken += 1
|
||||
leaseHeld = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True while the most recently acquired engine lease has not been
|
||||
* invalidated — i.e. a live engine (normally the UI) currently owns the
|
||||
* shelf. ShelfRefreshWorker checks this before booting a headless engine.
|
||||
*/
|
||||
fun hasLiveLease(): Boolean = synchronized(lock) { leaseHeld }
|
||||
|
||||
fun claim(lease: Lease, ownerId: String, generation: Long): Ownership? = synchronized(operationLock) {
|
||||
synchronized(lock) {
|
||||
if (
|
||||
@@ -130,6 +140,13 @@ class WatchNextProvider internal constructor(
|
||||
|
||||
internal fun claimOwnership(ownerId: String, generation: Long): SystemShelfLifecycle.Ownership? = lifecycleLease?.let { SystemShelfLifecycle.claim(it, ownerId, generation) }
|
||||
|
||||
/**
|
||||
* Whether a prior sync's committed state is still on disk. Cleared rows
|
||||
* ([clearAll]) and schema-migration wipes remove the granted-URI key, so
|
||||
* this distinguishes "user had a shelf" from "never synced / cleared".
|
||||
*/
|
||||
internal fun hasPersistedShelfState(): Boolean = prefs.contains(GRANTED_URIS)
|
||||
|
||||
internal fun syncWatchNextPrograms(
|
||||
ownerId: String,
|
||||
generation: Long,
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
package com.edde746.plezy.watchnext
|
||||
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.net.Uri
|
||||
import androidx.tvprovider.media.tv.TvContractCompat
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.testing.TestListenableWorkerBuilder
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
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.lang.reflect.Proxy
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executor
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.shadows.ShadowContentResolver
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ShelfRefreshWorkerTest {
|
||||
private val context: Context get() = RuntimeEnvironment.getApplication()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context.getSharedPreferences("system_shelf_state", 0).edit().clear().commit()
|
||||
context.cacheDir.resolve("system_shelf_artwork").deleteRecursively()
|
||||
ShadowContentResolver.registerProviderInternal(TvContractCompat.AUTHORITY, StubTvProvider())
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(context)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ShelfRefreshWorker.engineLauncherOverride = null
|
||||
// Leave no live lease behind for the next test in this sandbox.
|
||||
SystemShelfLifecycle.invalidate(SystemShelfLifecycle.acquire())
|
||||
context.cacheDir.resolve("system_shelf_artwork").deleteRecursively()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun successfulSyncSchedulesUniquePeriodicRefreshWithKeepAndNetworkConstraint() {
|
||||
val plugin = WatchNextPlugin()
|
||||
val binding = pluginBinding()
|
||||
plugin.onAttachedToEngine(binding)
|
||||
try {
|
||||
val first = ShelfRecordingResult()
|
||||
plugin.onMethodCall(syncCall(generation = 1), first)
|
||||
awaitResult(first)
|
||||
assertEquals(true, first.successValue)
|
||||
|
||||
val infos = uniqueWorkInfos()
|
||||
assertEquals(1, infos.size)
|
||||
val info = infos.single()
|
||||
assertEquals(WorkInfo.State.ENQUEUED, info.state)
|
||||
assertEquals(NetworkType.CONNECTED, info.constraints.requiredNetworkType)
|
||||
assertEquals(TimeUnit.HOURS.toMillis(6), info.periodicityInfo?.repeatIntervalMillis)
|
||||
|
||||
// KEEP: a second successful sync must not replace the pending request.
|
||||
val second = ShelfRecordingResult()
|
||||
plugin.onMethodCall(syncCall(generation = 2), second)
|
||||
awaitResult(second)
|
||||
assertEquals(true, second.successValue)
|
||||
assertEquals(info.id, uniqueWorkInfos().single().id)
|
||||
} finally {
|
||||
plugin.onDetachedFromEngine(binding)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearCancelsScheduledRefresh() {
|
||||
val plugin = WatchNextPlugin()
|
||||
val binding = pluginBinding()
|
||||
plugin.onAttachedToEngine(binding)
|
||||
try {
|
||||
val sync = ShelfRecordingResult()
|
||||
plugin.onMethodCall(syncCall(generation = 1), sync)
|
||||
awaitResult(sync)
|
||||
assertEquals(true, sync.successValue)
|
||||
assertEquals(WorkInfo.State.ENQUEUED, uniqueWorkInfos().single().state)
|
||||
|
||||
val clear = ShelfRecordingResult()
|
||||
plugin.onMethodCall(
|
||||
MethodCall("clear", mapOf("schemaVersion" to 3, "ownerId" to "owner-a", "generation" to 2L)),
|
||||
clear
|
||||
)
|
||||
awaitResult(clear)
|
||||
assertEquals(true, clear.successValue)
|
||||
assertEquals(WorkInfo.State.CANCELLED, uniqueWorkInfos().single().state)
|
||||
} finally {
|
||||
plugin.onDetachedFromEngine(binding)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun receiverDoesNotArmRefreshWithoutPersistedShelfState() {
|
||||
SystemShelfUpdateReceiver(directExecutor).onReceive(context, Intent(Intent.ACTION_BOOT_COMPLETED))
|
||||
assertTrue(uniqueWorkInfos().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun receiverRearmsRefreshOnBootWhenShelfStateIsPersisted() {
|
||||
// granted_uris is only written by a committed sync (clear removes it), so
|
||||
// its presence — even as an empty set — marks a prior sync.
|
||||
context.getSharedPreferences("system_shelf_state", 0).edit()
|
||||
.putStringSet("granted_uris", emptySet())
|
||||
.putInt("shelf_schema_version", 1)
|
||||
.commit()
|
||||
SystemShelfUpdateReceiver(directExecutor).onReceive(context, Intent(Intent.ACTION_BOOT_COMPLETED))
|
||||
assertEquals(WorkInfo.State.ENQUEUED, uniqueWorkInfos().single().state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun receiverRearmsRefreshOnPackageReplacedWhenShelfStateIsPersisted() {
|
||||
context.getSharedPreferences("system_shelf_state", 0).edit()
|
||||
.putStringSet("granted_uris", emptySet())
|
||||
.putInt("shelf_schema_version", 1)
|
||||
.commit()
|
||||
SystemShelfUpdateReceiver(directExecutor).onReceive(context, Intent(Intent.ACTION_MY_PACKAGE_REPLACED))
|
||||
assertEquals(WorkInfo.State.ENQUEUED, uniqueWorkInfos().single().state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun workerSkipsWithoutLaunchingEngineWhenLiveEngineHoldsLease() {
|
||||
shadowOf(context.packageManager).setSystemFeature(PackageManager.FEATURE_LEANBACK, true)
|
||||
val launched = AtomicBoolean(false)
|
||||
ShelfRefreshWorker.engineLauncherOverride = {
|
||||
launched.set(true)
|
||||
true
|
||||
}
|
||||
val lease = SystemShelfLifecycle.acquire()
|
||||
try {
|
||||
val result = TestListenableWorkerBuilder<ShelfRefreshWorker>(context).build().startWork().get()
|
||||
assertEquals(androidx.work.ListenableWorker.Result.success(), result)
|
||||
assertFalse(launched.get())
|
||||
} finally {
|
||||
SystemShelfLifecycle.invalidate(lease)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun workerSkipsWithoutLaunchingEngineOnNonLeanbackDevices() {
|
||||
val launched = AtomicBoolean(false)
|
||||
ShelfRefreshWorker.engineLauncherOverride = {
|
||||
launched.set(true)
|
||||
true
|
||||
}
|
||||
val result = TestListenableWorkerBuilder<ShelfRefreshWorker>(context).build().startWork().get()
|
||||
assertEquals(androidx.work.ListenableWorker.Result.success(), result)
|
||||
assertFalse(launched.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unattendedWorkerRunsInjectedLauncherAndMapsCompletionToResult() {
|
||||
shadowOf(context.packageManager).setSystemFeature(PackageManager.FEATURE_LEANBACK, true)
|
||||
val launched = AtomicBoolean(false)
|
||||
ShelfRefreshWorker.engineLauncherOverride = {
|
||||
launched.set(true)
|
||||
true
|
||||
}
|
||||
val success = TestListenableWorkerBuilder<ShelfRefreshWorker>(context).build().startWork().get()
|
||||
assertEquals(androidx.work.ListenableWorker.Result.success(), success)
|
||||
assertTrue(launched.get())
|
||||
|
||||
ShelfRefreshWorker.engineLauncherOverride = { false }
|
||||
val failure = TestListenableWorkerBuilder<ShelfRefreshWorker>(context).build().startWork().get()
|
||||
assertEquals(androidx.work.ListenableWorker.Result.retry(), failure)
|
||||
}
|
||||
|
||||
private val directExecutor = Executor { it.run() }
|
||||
|
||||
private fun uniqueWorkInfos(): List<WorkInfo> =
|
||||
WorkManager.getInstance(context).getWorkInfosForUniqueWork(ShelfRefreshScheduler.WORK_NAME).get()
|
||||
|
||||
private fun syncCall(generation: Long) = MethodCall(
|
||||
"sync",
|
||||
mapOf(
|
||||
"schemaVersion" to 3,
|
||||
"ownerId" to "owner-a",
|
||||
"generation" to generation,
|
||||
"items" to emptyList<Map<String, Any?>>()
|
||||
)
|
||||
)
|
||||
|
||||
private fun awaitResult(result: ShelfRecordingResult) {
|
||||
repeat(100) {
|
||||
shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||
if (result.completed.await(10, TimeUnit.MILLISECONDS)) return
|
||||
}
|
||||
assertTrue("Watch Next result never completed", false)
|
||||
}
|
||||
|
||||
private fun pluginBinding(): FlutterPlugin.FlutterPluginBinding {
|
||||
val messenger = Proxy.newProxyInstance(
|
||||
BinaryMessenger::class.java.classLoader,
|
||||
arrayOf(BinaryMessenger::class.java)
|
||||
) { _, _, _ -> null } as BinaryMessenger
|
||||
val constructor = FlutterPlugin.FlutterPluginBinding::class.java.constructors.single()
|
||||
val arguments = constructor.parameterTypes.map { type ->
|
||||
when {
|
||||
Context::class.java.isAssignableFrom(type) -> context
|
||||
BinaryMessenger::class.java.isAssignableFrom(type) -> messenger
|
||||
else -> null
|
||||
}
|
||||
}.toTypedArray()
|
||||
return constructor.newInstance(*arguments) as FlutterPlugin.FlutterPluginBinding
|
||||
}
|
||||
}
|
||||
|
||||
/** Just enough TV provider for an empty-items sync/clear to commit. */
|
||||
private class StubTvProvider : ContentProvider() {
|
||||
private val inserted = mutableListOf<ContentValues>()
|
||||
private var nextRowId = 1L
|
||||
|
||||
override fun onCreate(): Boolean = true
|
||||
|
||||
override fun insert(uri: Uri, values: ContentValues?): Uri {
|
||||
inserted += ContentValues(values)
|
||||
return uri.buildUpon().appendPath((nextRowId++).toString()).build()
|
||||
}
|
||||
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
|
||||
val deleted = inserted.size
|
||||
inserted.clear()
|
||||
return deleted
|
||||
}
|
||||
|
||||
override fun query(
|
||||
uri: Uri,
|
||||
projection: Array<out String>?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<out String>?,
|
||||
sortOrder: String?
|
||||
): Cursor = MatrixCursor(
|
||||
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
|
||||
)
|
||||
)
|
||||
|
||||
override fun getType(uri: Uri): String? = null
|
||||
|
||||
override fun update(
|
||||
uri: Uri,
|
||||
values: ContentValues?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<out String>?
|
||||
): Int = 0
|
||||
}
|
||||
|
||||
private class ShelfRecordingResult : MethodChannel.Result {
|
||||
val completed = CountDownLatch(1)
|
||||
var successValue: Any? = null
|
||||
|
||||
override fun success(result: Any?) {
|
||||
successValue = result
|
||||
completed.countDown()
|
||||
}
|
||||
|
||||
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
|
||||
completed.countDown()
|
||||
}
|
||||
|
||||
override fun notImplemented() {
|
||||
completed.countDown()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../connection/connection_registry.dart';
|
||||
import '../database/app_database.dart';
|
||||
import '../media/ids.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../profiles/active_profile_binder.dart';
|
||||
import '../profiles/active_profile_provider.dart';
|
||||
import '../profiles/plex_home_service.dart';
|
||||
import '../profiles/profile_connection_registry.dart';
|
||||
import '../profiles/profile_registry.dart';
|
||||
import '../providers/multi_server_provider.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import 'data_aggregation_service.dart';
|
||||
import 'jellyfin_api_cache.dart';
|
||||
import 'multi_server_manager.dart';
|
||||
import 'plex_api_cache.dart';
|
||||
import 'settings_service.dart';
|
||||
import 'storage_service.dart';
|
||||
import 'system_shelf_service.dart';
|
||||
|
||||
const MethodChannel _watchNextChannel = MethodChannel('com.plezy/watch_next');
|
||||
|
||||
/// Matches DiscoverProvider.continueWatchingPreviewLimit — the shelf mirrors
|
||||
/// the Continue Watching preview row.
|
||||
const int _shelfItemLimit = 20;
|
||||
|
||||
/// Android Watch Next background refresh entrypoint.
|
||||
///
|
||||
/// `ShelfRefreshWorker` runs this on a headless FlutterEngine via
|
||||
/// `DartEntrypoint` while no UI engine is alive, so the launcher row keeps
|
||||
/// tracking Continue Watching without the app in the foreground. The worker
|
||||
/// resolves its result from the terminal `backgroundSyncComplete` call this
|
||||
/// isolate always makes, and destroys the engine afterwards.
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> systemShelfBackgroundMain() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await runSystemShelfBackgroundSync(
|
||||
readActiveProfileId: () async => (await StorageService.getInstance()).getActiveProfileId(),
|
||||
openSession: openProductionSystemShelfSession,
|
||||
reportCompletion: _reportCompletion,
|
||||
);
|
||||
}
|
||||
|
||||
/// Everything the background sync needs from the app's cold-start stack once
|
||||
/// the active profile is bound. Production wiring lives in
|
||||
/// [openProductionSystemShelfSession]; tests inject handwritten fakes.
|
||||
class SystemShelfBackgroundSession {
|
||||
SystemShelfBackgroundSession({
|
||||
required this.profileId,
|
||||
required this.fetchContinueWatching,
|
||||
required this.clientForServer,
|
||||
required this.hideSpoilers,
|
||||
required this.dispose,
|
||||
});
|
||||
|
||||
/// Resolved owner id for the shelf session. May differ from the raw stored
|
||||
/// id when [ActiveProfileProvider] falls back during resolution; the shelf
|
||||
/// owner must match what the foreground app would publish under.
|
||||
final String profileId;
|
||||
final Future<List<MediaItem>> Function() fetchContinueWatching;
|
||||
final MediaServerClient? Function(ServerId serverId) clientForServer;
|
||||
final bool hideSpoilers;
|
||||
final Future<void> Function() dispose;
|
||||
}
|
||||
|
||||
/// Core of the background refresh, seamed for tests.
|
||||
///
|
||||
/// Always calls [reportCompletion] exactly once — the native worker blocks on
|
||||
/// that callback — and always disposes an opened session, success or not.
|
||||
/// Returns the reported success value.
|
||||
@visibleForTesting
|
||||
Future<bool> runSystemShelfBackgroundSync({
|
||||
required Future<String?> Function() readActiveProfileId,
|
||||
required Future<SystemShelfBackgroundSession?> Function(String profileId) openSession,
|
||||
required Future<void> Function(bool success) reportCompletion,
|
||||
SystemShelfService? shelfService,
|
||||
}) async {
|
||||
var success = false;
|
||||
SystemShelfBackgroundSession? session;
|
||||
try {
|
||||
final profileId = await readActiveProfileId();
|
||||
if (profileId == null || profileId.isEmpty) {
|
||||
appLogger.i('System shelf background sync skipped: no active profile');
|
||||
return false;
|
||||
}
|
||||
session = await openSession(profileId);
|
||||
if (session == null) return false;
|
||||
final openedSession = session;
|
||||
|
||||
final items = await openedSession.fetchContinueWatching();
|
||||
final syncable = items
|
||||
.where((item) {
|
||||
final serverId = item.serverId;
|
||||
return serverId != null && openedSession.clientForServer(ServerId(serverId)) != null;
|
||||
})
|
||||
.toList(growable: false);
|
||||
|
||||
final shelf = shelfService ?? SystemShelfService();
|
||||
shelf.beginProfileSession(openedSession.profileId);
|
||||
success = await shelf.syncFromContinueWatching(openedSession.profileId, syncable, (serverId) {
|
||||
final client = openedSession.clientForServer(serverId);
|
||||
if (client == null) throw StateError('No owning client available for $serverId');
|
||||
return client;
|
||||
}, hideSpoilers: openedSession.hideSpoilers);
|
||||
} catch (e, st) {
|
||||
appLogger.e('System shelf background sync failed', error: e, stackTrace: st);
|
||||
success = false;
|
||||
} finally {
|
||||
try {
|
||||
await session?.dispose();
|
||||
} catch (e, st) {
|
||||
appLogger.w('System shelf background session teardown failed', error: e, stackTrace: st);
|
||||
}
|
||||
await reportCompletion(success);
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
/// Builds the same stack `main.dart` assembles on cold start, minus UI:
|
||||
/// database + registries, [MultiServerManager]/[MultiServerProvider],
|
||||
/// [ActiveProfileProvider], and an [ActiveProfileBinder] whose PIN prompt
|
||||
/// always declines — a background isolate must never prompt, so a protected
|
||||
/// Plex Home profile simply fails its bind and this run completes false.
|
||||
///
|
||||
/// Returns null (after tearing down whatever was opened) when the device is
|
||||
/// offline, no connections are stored, no profile resolves, or the bind fails.
|
||||
Future<SystemShelfBackgroundSession?> openProductionSystemShelfSession(String profileId) async {
|
||||
// Mirror SetupScreen's offline fast path: with no network the binder would
|
||||
// only burn the worker's budget failing every connect. A probe failure is
|
||||
// treated as online, exactly like startup.
|
||||
try {
|
||||
final connectivity = await Connectivity().checkConnectivity().timeout(
|
||||
const Duration(seconds: 3),
|
||||
onTimeout: () => [ConnectivityResult.other],
|
||||
);
|
||||
if (connectivity.contains(ConnectivityResult.none)) {
|
||||
appLogger.i('System shelf background sync skipped: device is offline');
|
||||
return null;
|
||||
}
|
||||
} catch (_) {
|
||||
// connectivity_plus can throw on platforms without a network manager.
|
||||
}
|
||||
|
||||
final storage = await StorageService.getInstance();
|
||||
final settings = await SettingsService.getInstance();
|
||||
final bootstrap = await AppDatabase.open();
|
||||
final database = bootstrap.database;
|
||||
|
||||
MultiServerManager? serverManager;
|
||||
MultiServerProvider? multiServerProvider;
|
||||
ActiveProfileProvider? activeProfile;
|
||||
ActiveProfileBinder? binder;
|
||||
PlexHomeService? plexHome;
|
||||
|
||||
Future<void> tearDown() async {
|
||||
binder?.dispose();
|
||||
multiServerProvider?.dispose();
|
||||
final manager = serverManager;
|
||||
if (manager != null) {
|
||||
await manager.disconnectAllGracefully();
|
||||
manager.dispose();
|
||||
}
|
||||
activeProfile?.dispose();
|
||||
await plexHome?.dispose();
|
||||
await database.close();
|
||||
}
|
||||
|
||||
try {
|
||||
final connections = ConnectionRegistry(database);
|
||||
if ((await connections.list()).isEmpty) {
|
||||
appLogger.i('System shelf background sync skipped: no stored connections');
|
||||
await tearDown();
|
||||
return null;
|
||||
}
|
||||
|
||||
PlexApiCache.initialize(database);
|
||||
JellyfinApiCache.initialize(database);
|
||||
|
||||
final profileConnections = ProfileConnectionRegistry(database);
|
||||
final profileRegistry = ProfileRegistry(database);
|
||||
plexHome = PlexHomeService(connections: connections, profileConnections: profileConnections, storage: storage);
|
||||
await plexHome.start();
|
||||
|
||||
activeProfile = ActiveProfileProvider(
|
||||
registry: profileRegistry,
|
||||
plexHome: plexHome,
|
||||
connections: connections,
|
||||
profileConnections: profileConnections,
|
||||
storage: storage,
|
||||
);
|
||||
await activeProfile.initialize();
|
||||
final resolvedProfileId = activeProfile.activeId;
|
||||
if (resolvedProfileId == null) {
|
||||
appLogger.i('System shelf background sync skipped: stored profile id did not resolve');
|
||||
await tearDown();
|
||||
return null;
|
||||
}
|
||||
|
||||
serverManager = MultiServerManager();
|
||||
serverManager.onJellyfinConnectionUpdated = connections.upsert;
|
||||
final aggregation = DataAggregationService(serverManager);
|
||||
multiServerProvider = MultiServerProvider(serverManager, aggregation);
|
||||
|
||||
binder = ActiveProfileBinder(
|
||||
activeProfile: activeProfile,
|
||||
connections: connections,
|
||||
profileConnections: profileConnections,
|
||||
serverManager: serverManager,
|
||||
multiServerProvider: multiServerProvider,
|
||||
pinPrompt: (profile, {errorMessage}) async => null,
|
||||
);
|
||||
binder.start();
|
||||
final bound = await activeProfile.awaitBindingSettle();
|
||||
if (!bound) {
|
||||
appLogger.w('System shelf background sync skipped: profile bind failed');
|
||||
await tearDown();
|
||||
return null;
|
||||
}
|
||||
final manager = serverManager;
|
||||
|
||||
return SystemShelfBackgroundSession(
|
||||
profileId: resolvedProfileId,
|
||||
// Hidden-library filtering is deliberately skipped: it lives in the
|
||||
// profile-scoped HiddenLibrariesProvider subtree that only exists with
|
||||
// a UI session, and the foreground app re-syncs the shelf with the
|
||||
// filter applied on next launch, correcting any transient difference.
|
||||
fetchContinueWatching: () async => (await aggregation.getOnDeckFromAllServers(limit: _shelfItemLimit)).items,
|
||||
clientForServer: manager.getClient,
|
||||
hideSpoilers: settings.read(SettingsService.hideSpoilers),
|
||||
dispose: tearDown,
|
||||
);
|
||||
} catch (e) {
|
||||
await tearDown();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _reportCompletion(bool success) async {
|
||||
try {
|
||||
await _watchNextChannel.invokeMethod<void>('backgroundSyncComplete', success);
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to report background shelf sync completion', error: e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/ids.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_server_client.dart';
|
||||
import 'package:plezy/media/server_capabilities.dart';
|
||||
import 'package:plezy/services/system_shelf_background.dart';
|
||||
import 'package:plezy/services/system_shelf_service.dart';
|
||||
|
||||
import '../test_helpers/media_items.dart';
|
||||
|
||||
class _BackgroundClient implements MediaServerClient {
|
||||
@override
|
||||
ServerId get serverId => ServerId('server-a');
|
||||
|
||||
@override
|
||||
String get serverName => 'Server';
|
||||
|
||||
@override
|
||||
MediaBackend get backend => MediaBackend.plex;
|
||||
|
||||
@override
|
||||
ServerCapabilities get capabilities => ServerCapabilities.plex;
|
||||
|
||||
@override
|
||||
String thumbnailUrl(String? path, {int? width, int? height, bool cover = true}) =>
|
||||
'https://media.invalid$path?token=transient';
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
const channel = MethodChannel('test/system_shelf_background');
|
||||
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
|
||||
tearDown(() {
|
||||
messenger.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
|
||||
test('no active profile completes false without opening a session or touching the shelf', () async {
|
||||
var nativeCalls = 0;
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
nativeCalls++;
|
||||
return true;
|
||||
});
|
||||
final service = SystemShelfService.forTesting(channel: channel, isSupported: () async => true);
|
||||
final reported = <bool>[];
|
||||
var opened = false;
|
||||
|
||||
final result = await runSystemShelfBackgroundSync(
|
||||
readActiveProfileId: () async => null,
|
||||
openSession: (_) async {
|
||||
opened = true;
|
||||
return null;
|
||||
},
|
||||
reportCompletion: (success) async => reported.add(success),
|
||||
shelfService: service,
|
||||
);
|
||||
|
||||
expect(result, isFalse);
|
||||
expect(reported, [false]);
|
||||
expect(opened, isFalse);
|
||||
expect(nativeCalls, 0);
|
||||
expect(service.debugActiveOwner, isNull);
|
||||
});
|
||||
|
||||
test('an unopenable session (offline / no connections / failed bind) completes false without shelf calls', () async {
|
||||
var nativeCalls = 0;
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
nativeCalls++;
|
||||
return true;
|
||||
});
|
||||
final service = SystemShelfService.forTesting(channel: channel, isSupported: () async => true);
|
||||
final reported = <bool>[];
|
||||
|
||||
final result = await runSystemShelfBackgroundSync(
|
||||
readActiveProfileId: () async => 'profile-1',
|
||||
openSession: (_) async => null,
|
||||
reportCompletion: (success) async => reported.add(success),
|
||||
shelfService: service,
|
||||
);
|
||||
|
||||
expect(result, isFalse);
|
||||
expect(reported, [false]);
|
||||
expect(nativeCalls, 0);
|
||||
expect(service.debugActiveOwner, isNull);
|
||||
});
|
||||
|
||||
test('happy path begins the owner session and syncs only client-backed items', () async {
|
||||
final calls = <MethodCall>[];
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
calls.add(call);
|
||||
return true;
|
||||
});
|
||||
final service = SystemShelfService.forTesting(channel: channel, isSupported: () async => true);
|
||||
final client = _BackgroundClient();
|
||||
final reported = <bool>[];
|
||||
var disposed = false;
|
||||
final item = testMediaItem(id: 'movie-1', serverId: 'server-a', title: 'Movie');
|
||||
// No owning client — must be filtered out, mirroring DiscoverProvider.
|
||||
final orphan = testMediaItem(id: 'movie-2', serverId: 'server-gone', title: 'Orphan');
|
||||
|
||||
final result = await runSystemShelfBackgroundSync(
|
||||
readActiveProfileId: () async => 'profile-1',
|
||||
openSession: (profileId) async {
|
||||
expect(profileId, 'profile-1');
|
||||
return SystemShelfBackgroundSession(
|
||||
profileId: 'profile-1',
|
||||
fetchContinueWatching: () async => [item, orphan],
|
||||
clientForServer: (serverId) => serverId == 'server-a' ? client : null,
|
||||
hideSpoilers: false,
|
||||
dispose: () async {
|
||||
disposed = true;
|
||||
},
|
||||
);
|
||||
},
|
||||
reportCompletion: (success) async => reported.add(success),
|
||||
shelfService: service,
|
||||
);
|
||||
|
||||
expect(result, isTrue);
|
||||
expect(reported, [true]);
|
||||
expect(disposed, isTrue);
|
||||
expect(service.debugActiveOwner, 'profile-1');
|
||||
final sync = calls.singleWhere((call) => call.method == 'sync');
|
||||
final envelope = sync.arguments as Map;
|
||||
expect(envelope['ownerId'], 'profile-1');
|
||||
final items = envelope['items'] as List;
|
||||
expect(items, hasLength(1));
|
||||
expect((items.single as Map)['contentId'], 'plezy_server-a_movie-1');
|
||||
});
|
||||
|
||||
test('a failure inside the fetch completes false and still disposes the session', () async {
|
||||
var nativeCalls = 0;
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
nativeCalls++;
|
||||
return true;
|
||||
});
|
||||
final service = SystemShelfService.forTesting(channel: channel, isSupported: () async => true);
|
||||
final reported = <bool>[];
|
||||
var disposed = false;
|
||||
|
||||
final result = await runSystemShelfBackgroundSync(
|
||||
readActiveProfileId: () async => 'profile-1',
|
||||
openSession: (_) async => SystemShelfBackgroundSession(
|
||||
profileId: 'profile-1',
|
||||
fetchContinueWatching: () async => throw StateError('server exploded'),
|
||||
clientForServer: (_) => null,
|
||||
hideSpoilers: false,
|
||||
dispose: () async {
|
||||
disposed = true;
|
||||
},
|
||||
),
|
||||
reportCompletion: (success) async => reported.add(success),
|
||||
shelfService: service,
|
||||
);
|
||||
|
||||
expect(result, isFalse);
|
||||
expect(reported, [false]);
|
||||
expect(disposed, isTrue);
|
||||
expect(nativeCalls, 0);
|
||||
});
|
||||
|
||||
test('completion is still reported when session teardown itself throws', () async {
|
||||
messenger.setMockMethodCallHandler(channel, (call) async => true);
|
||||
final service = SystemShelfService.forTesting(channel: channel, isSupported: () async => true);
|
||||
final reported = <bool>[];
|
||||
|
||||
final result = await runSystemShelfBackgroundSync(
|
||||
readActiveProfileId: () async => 'profile-1',
|
||||
openSession: (_) async => SystemShelfBackgroundSession(
|
||||
profileId: 'profile-1',
|
||||
fetchContinueWatching: () async => [],
|
||||
clientForServer: (_) => null,
|
||||
hideSpoilers: false,
|
||||
dispose: () async => throw StateError('teardown failed'),
|
||||
),
|
||||
reportCompletion: (success) async => reported.add(success),
|
||||
shelfService: service,
|
||||
);
|
||||
|
||||
expect(result, isTrue);
|
||||
expect(reported, [true]);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user