diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 5b33b40f..c7e33d29 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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 diff --git a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/ShelfRefreshWorker.kt b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/ShelfRefreshWorker.kt deleted file mode 100644 index a1c5b5f6..00000000 --- a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/ShelfRefreshWorker.kt +++ /dev/null @@ -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(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() - 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() - } - } -} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/SystemShelfUpdateReceiver.kt b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/SystemShelfUpdateReceiver.kt index 0de058f8..fd5e9662 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/SystemShelfUpdateReceiver.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/SystemShelfUpdateReceiver.kt @@ -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) + } + } } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextPlugin.kt index 10845c8b..f0e6a725 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextPlugin.kt @@ -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): WatchNextProvider.WatchNextItem? { val contentId = (data["contentId"] as? String)?.takeIf(String::isNotBlank) ?: return null val title = data["title"] as? String ?: return null diff --git a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextProvider.kt b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextProvider.kt index 85dc02e6..c71a81fb 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextProvider.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextProvider.kt @@ -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, diff --git a/android/app/src/test/kotlin/com/edde746/plezy/watchnext/ShelfRefreshWorkerTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/watchnext/ShelfRefreshWorkerTest.kt deleted file mode 100644 index f3203bd4..00000000 --- a/android/app/src/test/kotlin/com/edde746/plezy/watchnext/ShelfRefreshWorkerTest.kt +++ /dev/null @@ -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(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(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(context).build().startWork().get() - assertEquals(androidx.work.ListenableWorker.Result.success(), success) - assertTrue(launched.get()) - - ShelfRefreshWorker.engineLauncherOverride = { false } - val failure = TestListenableWorkerBuilder(context).build().startWork().get() - assertEquals(androidx.work.ListenableWorker.Result.retry(), failure) - } - - private val directExecutor = Executor { it.run() } - - private fun uniqueWorkInfos(): List = 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>() - ) - ) - - 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() - 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?): Int { - val deleted = inserted.size - inserted.clear() - return deleted - } - - override fun query( - uri: Uri, - projection: Array?, - selection: String?, - selectionArgs: Array?, - 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? - ): 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() - } -} diff --git a/android/app/src/test/kotlin/com/edde746/plezy/watchnext/WatchNextProviderTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/watchnext/WatchNextProviderTest.kt index 7028e742..3b499a7a 100644 --- a/android/app/src/test/kotlin/com/edde746/plezy/watchnext/WatchNextProviderTest.kt +++ b/android/app/src/test/kotlin/com/edde746/plezy/watchnext/WatchNextProviderTest.kt @@ -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(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() +} diff --git a/lib/services/system_shelf_background.dart b/lib/services/system_shelf_background.dart deleted file mode 100644 index c10a329d..00000000 --- a/lib/services/system_shelf_background.dart +++ /dev/null @@ -1,248 +0,0 @@ -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 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> Function() fetchContinueWatching; - final MediaServerClient? Function(ServerId serverId) clientForServer; - final bool hideSpoilers; - final Future 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 runSystemShelfBackgroundSync({ - required Future Function() readActiveProfileId, - required Future Function(String profileId) openSession, - required Future 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 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 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 _reportCompletion(bool success) async { - try { - await _watchNextChannel.invokeMethod('backgroundSyncComplete', success); - } catch (e) { - appLogger.w('Failed to report background shelf sync completion', error: e); - } -} diff --git a/test/services/system_shelf_background_test.dart b/test/services/system_shelf_background_test.dart deleted file mode 100644 index b9a37aa5..00000000 --- a/test/services/system_shelf_background_test.dart +++ /dev/null @@ -1,187 +0,0 @@ -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 = []; - 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 = []; - - 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 = []; - messenger.setMockMethodCallHandler(channel, (call) async { - calls.add(call); - return true; - }); - final service = SystemShelfService.forTesting(channel: channel, isSupported: () async => true); - final client = _BackgroundClient(); - final reported = []; - 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 = []; - 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 = []; - - 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]); - }); -}