diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4fe8b21e..c7ba7ea5 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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") } 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 new file mode 100644 index 00000000..a1c5b5f6 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/ShelfRefreshWorker.kt @@ -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(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 fc09c7af..0de058f8 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 @@ -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() 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 f0e6a725..10845c8b 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,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): 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 c71a81fb..85dc02e6 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,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, 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 new file mode 100644 index 00000000..e8bda07a --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/watchnext/ShelfRefreshWorkerTest.kt @@ -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(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/lib/services/system_shelf_background.dart b/lib/services/system_shelf_background.dart new file mode 100644 index 00000000..c10a329d --- /dev/null +++ b/lib/services/system_shelf_background.dart @@ -0,0 +1,248 @@ +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import '../connection/connection_registry.dart'; +import '../database/app_database.dart'; +import '../media/ids.dart'; +import '../media/media_item.dart'; +import '../media/media_server_client.dart'; +import '../profiles/active_profile_binder.dart'; +import '../profiles/active_profile_provider.dart'; +import '../profiles/plex_home_service.dart'; +import '../profiles/profile_connection_registry.dart'; +import '../profiles/profile_registry.dart'; +import '../providers/multi_server_provider.dart'; +import '../utils/app_logger.dart'; +import 'data_aggregation_service.dart'; +import 'jellyfin_api_cache.dart'; +import 'multi_server_manager.dart'; +import 'plex_api_cache.dart'; +import 'settings_service.dart'; +import 'storage_service.dart'; +import 'system_shelf_service.dart'; + +const MethodChannel _watchNextChannel = MethodChannel('com.plezy/watch_next'); + +/// Matches DiscoverProvider.continueWatchingPreviewLimit — the shelf mirrors +/// the Continue Watching preview row. +const int _shelfItemLimit = 20; + +/// Android Watch Next background refresh entrypoint. +/// +/// `ShelfRefreshWorker` runs this on a headless FlutterEngine via +/// `DartEntrypoint` while no UI engine is alive, so the launcher row keeps +/// tracking Continue Watching without the app in the foreground. The worker +/// resolves its result from the terminal `backgroundSyncComplete` call this +/// isolate always makes, and destroys the engine afterwards. +@pragma('vm:entry-point') +Future 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 new file mode 100644 index 00000000..b9a37aa5 --- /dev/null +++ b/test/services/system_shelf_background_test.dart @@ -0,0 +1,187 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/ids.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_server_client.dart'; +import 'package:plezy/media/server_capabilities.dart'; +import 'package:plezy/services/system_shelf_background.dart'; +import 'package:plezy/services/system_shelf_service.dart'; + +import '../test_helpers/media_items.dart'; + +class _BackgroundClient implements MediaServerClient { + @override + ServerId get serverId => ServerId('server-a'); + + @override + String get serverName => 'Server'; + + @override + MediaBackend get backend => MediaBackend.plex; + + @override + ServerCapabilities get capabilities => ServerCapabilities.plex; + + @override + String thumbnailUrl(String? path, {int? width, int? height, bool cover = true}) => + 'https://media.invalid$path?token=transient'; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('test/system_shelf_background'); + final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + tearDown(() { + messenger.setMockMethodCallHandler(channel, null); + }); + + test('no active profile completes false without opening a session or touching the shelf', () async { + var nativeCalls = 0; + messenger.setMockMethodCallHandler(channel, (call) async { + nativeCalls++; + return true; + }); + final service = SystemShelfService.forTesting(channel: channel, isSupported: () async => true); + final reported = []; + 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]); + }); +}