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:
edde746
2026-08-09 10:59:30 +02:00
parent 291a22a4a4
commit de76c0a515
8 changed files with 915 additions and 2 deletions
+5
View File
@@ -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()
}
}