fix(tv): remove the background Watch Next refresh
2.13.0's ShelfRefreshWorker boots a second headless FlutterEngine in the app process to refresh the launcher row every six hours. Its foreground guard is checked only once at worker start, so launching the app during a run leaves two engines sharing a low-RAM TV for up to 90 seconds, and a failed run retries with backoff. Suspected of destabilizing the compositor on the 32-bit TCL panel in #1862. The tvOS Top Shelf live fetch is unaffected and stays. The foreground sync pipeline keeps the row fresh while the app runs, as before 2.13.0. Updated devices still carry the persisted periodic job, which would wake the process once more only to fail instantiating the deleted class; the package-replaced receiver now cancels it.
This commit is contained in:
@@ -516,8 +516,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.
|
||||
// Only used to cancel the legacy periodic shelf refresh job (2.13.0's
|
||||
// removed ShelfRefreshWorker) that WorkManager persisted on updated
|
||||
// devices. Same version background_downloader pins, so the merged
|
||||
// classpath stays coherent.
|
||||
implementation("androidx.work:work-runtime-ktx:2.11.0")
|
||||
|
||||
// Media3 ExoPlayer for Android
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
-7
@@ -3,6 +3,8 @@ package com.edde746.plezy.watchnext
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.work.WorkManager
|
||||
import java.util.concurrent.Executor
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
@@ -15,6 +17,19 @@ class SystemShelfUpdateReceiver private constructor(
|
||||
constructor() : this(Executors.newSingleThreadExecutor(), true)
|
||||
internal constructor(executor: Executor) : this(executor, false)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SystemShelfUpdateReceiver"
|
||||
|
||||
/**
|
||||
* Unique name of the periodic ShelfRefreshWorker job 2.13.0 shipped and
|
||||
* enqueued (KEEP, persisted by WorkManager). The worker is gone, so on an
|
||||
* updated device the persisted job would wake the process once more, fail
|
||||
* to instantiate the deleted class, and linger as a permanently failed
|
||||
* record; cancel it on the first update instead.
|
||||
*/
|
||||
internal const val LEGACY_SHELF_REFRESH_WORK = "plezy_shelf_refresh"
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val action = intent.action
|
||||
if (action != Intent.ACTION_MY_PACKAGE_REPLACED && action != Intent.ACTION_BOOT_COMPLETED) return
|
||||
@@ -22,22 +37,25 @@ 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) {
|
||||
cancelLegacyShelfRefreshWork(context.applicationContext)
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort by design: shelf maintenance must not die on a WorkManager failure. */
|
||||
private fun cancelLegacyShelfRefreshWork(context: Context) {
|
||||
try {
|
||||
WorkManager.getInstance(context).cancelUniqueWork(LEGACY_SHELF_REFRESH_WORK)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to cancel legacy shelf refresh work", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,13 +27,6 @@ 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") {
|
||||
@@ -111,7 +104,6 @@ class WatchNextPlugin() :
|
||||
"clear" -> handleClear(call, result)
|
||||
"remove" -> handleRemove(call, result)
|
||||
"getInitialDeepLink" -> handleGetInitialDeepLink(result)
|
||||
"backgroundSyncComplete" -> handleBackgroundSyncComplete(call, result)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
@@ -144,12 +136,7 @@ class WatchNextPlugin() :
|
||||
val provider = session.provider ?: return@executeOnIo false
|
||||
val ownership = provider.claimOwnership(owner, generation) ?: return@executeOnIo false
|
||||
if (!session.isOpen()) return@executeOnIo false
|
||||
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
|
||||
provider.syncWatchNextPrograms(owner, generation, items, ownership, session::isOpen)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,10 +150,7 @@ class WatchNextPlugin() :
|
||||
val provider = session.provider ?: return@executeOnIo false
|
||||
val ownership = provider.claimOwnership(owner, generation) ?: return@executeOnIo false
|
||||
if (!session.isOpen()) return@executeOnIo false
|
||||
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
|
||||
provider.clearAll(owner, generation, ownership, session::isOpen)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,12 +192,6 @@ 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,7 +25,6 @@ internal object SystemShelfLifecycle {
|
||||
private var claimToken = 0L
|
||||
private var currentOwner = ""
|
||||
private var currentGeneration = 0L
|
||||
private var leaseHeld = false
|
||||
|
||||
fun acquire(): Lease = acquireIf { true }!!
|
||||
|
||||
@@ -36,7 +35,6 @@ internal object SystemShelfLifecycle {
|
||||
claimToken += 1
|
||||
currentOwner = ""
|
||||
currentGeneration = 0
|
||||
leaseHeld = true
|
||||
Lease(token)
|
||||
}
|
||||
}
|
||||
@@ -47,19 +45,11 @@ 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 (
|
||||
@@ -140,13 +130,6 @@ 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,
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,13 @@ import android.database.MatrixCursor
|
||||
import android.net.Uri
|
||||
import android.os.ParcelFileDescriptor.AutoCloseInputStream
|
||||
import androidx.tvprovider.media.tv.TvContractCompat
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
@@ -720,6 +727,26 @@ class WatchNextProviderTest {
|
||||
assertFalse(context.cacheDir.resolve("system_shelf_artwork").exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun packageUpdateCancelsLegacyShelfRefreshWork() {
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(context)
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
SystemShelfUpdateReceiver.LEGACY_SHELF_REFRESH_WORK,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
PeriodicWorkRequestBuilder<LegacyShelfRefreshStandIn>(6, TimeUnit.HOURS).build()
|
||||
).result.get()
|
||||
|
||||
SystemShelfUpdateReceiver(Executor { command -> command.run() })
|
||||
.onReceive(context, Intent(Intent.ACTION_MY_PACKAGE_REPLACED))
|
||||
|
||||
val states = workManager
|
||||
.getWorkInfosForUniqueWork(SystemShelfUpdateReceiver.LEGACY_SHELF_REFRESH_WORK)
|
||||
.get()
|
||||
.map { it.state }
|
||||
assertEquals(listOf(WorkInfo.State.CANCELLED), states)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun providerFailureDeletesNewArtworkAndPreservesCommittedArtwork() {
|
||||
ScriptedHttpServer(
|
||||
@@ -1605,3 +1632,15 @@ private class ManualExecutorService : AbstractExecutorService() {
|
||||
tasks.removeFirst().run()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stands in for the removed ShelfRefreshWorker so the legacy periodic job can
|
||||
* be enqueued. Public because WorkManager's default factory instantiates
|
||||
* workers reflectively and cannot access a package-private class.
|
||||
*/
|
||||
class LegacyShelfRefreshStandIn(
|
||||
context: Context,
|
||||
params: WorkerParameters
|
||||
) : Worker(context, params) {
|
||||
override fun doWork(): Result = Result.success()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user