From 586780956033fc599a71d7b5ac6b9e6526b0e6fc Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:10:26 +0200 Subject: [PATCH] fix: resolve Jellyfin, logout, playback, and Android regressions --- .github/workflows/ci.yml | 50 +++++++++++ .../kotlin/com/edde746/plezy/MainActivity.kt | 33 ------- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 11 ++- .../plezy/exoplayer/ExoPlayerPlugin.kt | 25 ++++-- lib/connection/plex_account_setup.dart | 7 +- lib/database/download_operations.dart | 4 + lib/media/live_tv_support.dart | 2 +- lib/media/server_capabilities.dart | 2 +- lib/providers/download_provider.dart | 25 ++++++ lib/screens/profile/profile_teardown.dart | 7 +- lib/screens/video_player/parts/lifecycle.dart | 2 - .../video_player/parts/media_controls.dart | 57 +----------- .../video_player/parts/playback_services.dart | 8 +- lib/screens/video_player_screen.dart | 4 - lib/services/app_foreground_service.dart | 19 ---- .../jellyfin_client/parts/watch_state.dart | 6 +- .../music/music_playback_service_impl.dart | 5 ++ lib/utils/json_utils.dart | 11 +-- packages/saf_util/android/build.gradle.kts | 1 + .../fluttercavalry/saf_util/SafUtilPlugin.kt | 27 ++++-- .../saf_util/SafUtilPluginTest.kt | 90 ++++++++++++++++--- test/utils/json_utils_test.dart | 13 ++- 22 files changed, 243 insertions(+), 166 deletions(-) delete mode 100644 lib/services/app_foreground_service.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e51eb1c0..d993a216 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,6 +139,56 @@ jobs: echo "No tests found, skipping test execution" fi + android-test: + name: Android JVM Unit Tests + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + flutter-version: "3.44.0" + cache: true + pub-cache: false + + - name: Cache Pub dependencies + uses: actions/cache@v4 + with: + path: | + ~/.pub-cache + key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }} + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Install dependencies + run: flutter pub get + + - name: Configure Android local properties + run: printf 'flutter.sdk=%s\nsdk.dir=%s\n' "$FLUTTER_ROOT" "$ANDROID_HOME" > android/local.properties + + - name: Run Android JVM unit tests + working-directory: android + run: ./gradlew :app:testDebugUnitTest :saf_util:testDebugUnitTest :libass:testDebugUnitTest -x :app:compileFlutterBuildDebug --continue + native-format: name: Native Formatting runs-on: ubuntu-latest diff --git a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt index 39aa4898..53897c5d 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt @@ -89,7 +89,6 @@ class MainActivity : FlutterActivity() { private val DEVICE_ADJUSTMENT_CHANNEL = "com.plezy/device_adjustment" private val TEXT_INPUT_CHANNEL = "com.plezy/text_input" private val APP_EXIT_CHANNEL = "com.plezy/app_exit" - private val APP_FOREGROUND_CHANNEL = "com.plezy/app_foreground" private var watchNextPlugin: WatchNextPlugin? = null private var nativeTextInputFocused = false private var pendingExternalPlayerResult: MethodChannel.Result? = null @@ -532,13 +531,6 @@ class MainActivity : FlutterActivity() { } } - MethodChannel(flutterEngine.dartExecutor.binaryMessenger, APP_FOREGROUND_CHANNEL).setMethodCallHandler { call, result -> - when (call.method) { - "requestForeground" -> result.success(requestForeground()) - else -> result.notImplemented() - } - } - // External player: open local video files with proper content:// URIs MethodChannel(flutterEngine.dartExecutor.binaryMessenger, EXTERNAL_PLAYER_CHANNEL).setMethodCallHandler { call, result -> when (call.method) { @@ -723,31 +715,6 @@ class MainActivity : FlutterActivity() { } } - private fun requestForeground(): Boolean = try { - val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager - activityManager.moveTaskToFront(taskId, 0) - true - } catch (e: Exception) { - Log.w(TAG, "Failed to move task to foreground", e) - try { - val launchIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply { - addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) - addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - if (launchIntent != null) { - startActivity(launchIntent) - true - } else { - false - } - } catch (launchError: Exception) { - Log.w(TAG, "Failed to start foreground activity", launchError) - false - } - } - private fun handleDeviceAdjustmentCall(method: String, arguments: Any?, result: MethodChannel.Result) { try { when (method) { diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index b87a45ce..e020de07 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -84,6 +84,7 @@ interface ExoPlayerDelegate : com.edde746.plezy.shared.PlayerDelegate { uri: String, headers: Map?, positionMs: Long, + playWhenReady: Boolean, errorMessage: String ): Boolean = false } @@ -1110,6 +1111,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { uri = currentMediaUri!!, headers = currentHeaders, positionMs = effectivePosition, + playWhenReady = exoPlayer?.playWhenReady ?: true, errorMessage = "Video track present but no decoder available" ) return @@ -1217,6 +1219,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { uri = currentMediaUri!!, headers = currentHeaders, positionMs = effectivePosition, + playWhenReady = exoPlayer?.playWhenReady ?: true, errorMessage = error.message ?: "Unknown error" ) ?: false @@ -2650,6 +2653,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { uri = uri, headers = currentHeaders, positionMs = effectivePosition, + playWhenReady = player.playWhenReady, errorMessage = "Decoder hang: $decoderName accepted input but produced no output" ) } @@ -2700,6 +2704,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { uri = uri, headers = currentHeaders, positionMs = player.currentPosition, + playWhenReady = player.playWhenReady, errorMessage = "Video track present but no decoder available" ) return @@ -2715,6 +2720,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { uri = uri, headers = currentHeaders, positionMs = player.currentPosition, + playWhenReady = player.playWhenReady, errorMessage = "Black screen detected: 0 video frames rendered after ${elapsed}ms" ) return @@ -3722,8 +3728,9 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { fun triggerFallback() { val uri = currentMediaUri ?: return - val pos = exoPlayer?.currentPosition ?: 0L - delegate?.onFormatUnsupported(uri, currentHeaders, pos, "debug: manual fallback trigger") + val player = exoPlayer + val pos = player?.currentPosition ?: 0L + delegate?.onFormatUnsupported(uri, currentHeaders, pos, player?.playWhenReady ?: true, "debug: manual fallback trigger") } // Cleanup diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt index 59348ef8..0419c664 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt @@ -302,9 +302,7 @@ class ExoPlayerPlugin : options.add("sid=no") options.add("secondary-sid=no") appendExternalSubtitleOptions(options, externalSubtitleSnapshot) - headers?.forEach { (key, value) -> - options.add("http-header-fields-append=$key: $value") - } + appendHttpHeaderOptions(options, headers) val optionsStr = options.joinToString(",") // Convert content:// URIs to fdclose:// for MPV (SAF SD card downloads) val mpvUri = openContentFd(uri)?.let { "fdclose://$it" } ?: uri @@ -827,6 +825,16 @@ class ExoPlayerPlugin : private fun escapeMpvPathListEntry(value: String): String = value.replace("\\", "\\\\").replace(":", "\\:") + private fun appendHttpHeaderOptions(options: MutableList, headers: Map?) { + if (headers.isNullOrEmpty()) return + + options.add("http-header-fields-clr=") + headers.forEach { (key, value) -> + val header = "$key: $value" + options.add("http-header-fields-append=%${header.toByteArray(Charsets.UTF_8).size}%$header") + } + } + /** * Configure a freshly initialized MPV fallback core: replay the properties * and observers Dart registered against the ExoPlayer session, then resume @@ -839,7 +847,8 @@ class ExoPlayerPlugin : uri: String, headers: Map?, positionMs: Long, - externalSubtitles: List>? + externalSubtitles: List>?, + playWhenReady: Boolean ) { // Snapshot Dart-registered state on main thread before clearing val pendingProps = pendingMpvProperties.toList() @@ -886,12 +895,11 @@ class ExoPlayerPlugin : val startSeconds = positionMs / 1000.0 val options = mutableListOf() options.add(if (positionMs > 0L) "start=$startSeconds" else "start=none") + if (!playWhenReady) options.add("pause=yes") options.add("sid=no") options.add("secondary-sid=no") appendExternalSubtitleOptions(options, externalSubtitles) - headers?.forEach { (key, value) -> - options.add("http-header-fields-append=$key: $value") - } + appendHttpHeaderOptions(options, headers) val optionsStr = options.joinToString(",") notifyBackendSwitched() core.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr)) @@ -919,6 +927,7 @@ class ExoPlayerPlugin : uri: String, headers: Map?, positionMs: Long, + playWhenReady: Boolean, errorMessage: String ): Boolean { if (usingMpvFallback || fallbackInProgress) { @@ -993,7 +1002,7 @@ class ExoPlayerPlugin : usingMpvFallback = true fallbackInProgress = false - setupMpvFallback(core, act, uri, headers, positionMs, fallbackExternalSubtitles) + setupMpvFallback(core, act, uri, headers, positionMs, fallbackExternalSubtitles, playWhenReady) } } catch (e: Exception) { fallbackInProgress = false diff --git a/lib/connection/plex_account_setup.dart b/lib/connection/plex_account_setup.dart index b471968a..e7ffc805 100644 --- a/lib/connection/plex_account_setup.dart +++ b/lib/connection/plex_account_setup.dart @@ -60,7 +60,12 @@ Future registerPlexAccountFromToken({ createdAt: DateTime.now(), lastAuthenticatedAt: DateTime.now(), ); - final existedBefore = await connections.get(connection.id) != null; + final legacyId = 'plex.${auth.clientIdentifier}'; + final existedBefore = + await connections.get(connection.id) != null || + (accountUuid.isNotEmpty && + legacyId != connection.id && + await connections.get(legacyId) is PlexAccountConnection); await connections.upsert(connection); if (accountUuid.isNotEmpty) { diff --git a/lib/database/download_operations.dart b/lib/database/download_operations.dart index b9bcd228..8001e032 100644 --- a/lib/database/download_operations.dart +++ b/lib/database/download_operations.dart @@ -27,6 +27,10 @@ extension DownloadDatabaseOperations on AppDatabase { await (delete(downloadOwners)..where((t) => t.profileId.equals(profileId))).go(); } + Future clearAllDownloadOwners() async { + await delete(downloadOwners).go(); + } + Future> getDownloadOwnerKeysForProfile(String profileId) async { if (profileId.isEmpty) return const {}; final rows = await (select(downloadOwners)..where((t) => t.profileId.equals(profileId))).get(); diff --git a/lib/media/live_tv_support.dart b/lib/media/live_tv_support.dart index 87e64220..466738ea 100644 --- a/lib/media/live_tv_support.dart +++ b/lib/media/live_tv_support.dart @@ -166,7 +166,7 @@ abstract class LiveTvSupport { /// Persist the favorites list (and order, where supported). Plex pushes /// to its cloud sync endpoint; Jellyfin POSTs/DELETEs the - /// `/Users/{userId}/FavoriteItems/{channelId}` flag and saves the order + /// `/UserFavoriteItems/{channelId}?userId=...` flag and saves the order /// locally. Future setFavoriteChannels(List channels); diff --git a/lib/media/server_capabilities.dart b/lib/media/server_capabilities.dart index 505dd753..485de4ef 100644 --- a/lib/media/server_capabilities.dart +++ b/lib/media/server_capabilities.dart @@ -61,7 +61,7 @@ class ServerCapabilities { final bool numericUserRating; /// Per-user favorite flag ("heart") on media items. Jellyfin exposes it via - /// `/Users/{userId}/FavoriteItems/{itemId}`; Plex has no equivalent. + /// `/UserFavoriteItems/{itemId}?userId=...`; Plex has no equivalent. final bool userFavorites; /// Hide an item from Continue Watching without changing watch state or diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index f44c6fa7..76723c32 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -194,6 +194,31 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin await _releaseDownloadsForProfileWhere(profileId, (_) => true); } + Future deleteAllDownloads() async { + final downloads = await _downloadManager.getAllDownloads(); + for (final row in downloads) { + await _downloadManager.deleteDownload(row.globalKey); + } + await _database.clearAllDownloadOwners(); + + try { + final artworkDirectory = await DownloadStorageService.instance.getArtworkDirectory(); + if (await artworkDirectory.exists()) { + await artworkDirectory.delete(recursive: true); + } + } catch (e, stackTrace) { + appLogger.w('Failed to delete shared download artwork directory', error: e, stackTrace: stackTrace); + } + + _downloads.clear(); + _metadata.clear(); + _artworkPaths.clear(); + _queueing.clear(); + _ownedDownloadKeys.clear(); + _deletionProgress.clear(); + safeNotifyListeners(); + } + /// Remove ownership rows for [profileId] that belong to the removed /// connection's public server ids. Physical files stay when any other valid /// owner remains. diff --git a/lib/screens/profile/profile_teardown.dart b/lib/screens/profile/profile_teardown.dart index 6ac6cd2f..551f39d5 100644 --- a/lib/screens/profile/profile_teardown.dart +++ b/lib/screens/profile/profile_teardown.dart @@ -230,6 +230,7 @@ Future logoutAllProfiles(BuildContext context) async { await companionRemote.resetForLogout(); await userProfileProvider.logout(); + await scope.downloads.deleteAllDownloads(); scope.multiServer.clearAllConnections(); // Drop the profile/connection rows so the next sign-in starts clean and // doesn't bind to stale tokens or orphaned profile rows. @@ -245,9 +246,9 @@ Future logoutAllProfiles(BuildContext context) async { // through the next sign-in's clients). await scope.database.clearAllWatchActions(); await scope.database.clearAllSyncRules(); - // The API cache is app-global and Plex rows are keyed by server only, so - // a later sign-in as a different user must not inherit them. - await ApiCache.instance.clearVolatile(); + // Downloads were removed above, so no pinned cache rows need to survive this + // app-global logout into the next sign-in. + await ApiCache.instance.clearAll(); await scope.hiddenLibraries?.refresh(); playbackState.clearShuffle(); diff --git a/lib/screens/video_player/parts/lifecycle.dart b/lib/screens/video_player/parts/lifecycle.dart index c0282f88..868eea52 100644 --- a/lib/screens/video_player/parts/lifecycle.dart +++ b/lib/screens/video_player/parts/lifecycle.dart @@ -28,7 +28,6 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState { 'hiddenForBackground': _hiddenForBackground, 'playerSuspendedForTvBackground': _playerSuspendedForTvBackground, 'mediaControlsSuspendedForTvBackground': _mediaControlsSuspendedForTvBackground, - 'pendingForegroundMediaResume': _resumeFromSuspendedMediaControlOnForeground, 'backend': _playerBackendLabel, }; if (action != null) { @@ -49,7 +48,6 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState { ' hiddenForBackground=$_hiddenForBackground' ' playerSuspendedForTvBackground=$_playerSuspendedForTvBackground' ' mediaControlsSuspendedForTvBackground=$_mediaControlsSuspendedForTvBackground' - ' pendingForegroundMediaResume=$_resumeFromSuspendedMediaControlOnForeground' ' backend=$_playerBackendLabel', ); } diff --git a/lib/screens/video_player/parts/media_controls.dart b/lib/screens/video_player/parts/media_controls.dart index 96e73a97..bfc8e715 100644 --- a/lib/screens/video_player/parts/media_controls.dart +++ b/lib/screens/video_player/parts/media_controls.dart @@ -24,52 +24,6 @@ extension _VideoPlayerMediaControlsMethods on VideoPlayerScreenState { _recordLifecycleState('media_controls', action: 'resumed:$reason'); } - bool _consumePendingTvBackgroundMediaControlResume() { - final shouldResume = _resumeFromSuspendedMediaControlOnForeground; - _resumeFromSuspendedMediaControlOnForeground = false; - _tvBackgroundMediaControlResumeTimer?.cancel(); - _tvBackgroundMediaControlResumeTimer = null; - return shouldResume; - } - - Future _requestForegroundResumeFromSuspendedMediaControl(String eventLabel) async { - if (!_mediaControlsSuspendedForTvBackground) return; - - _resumeFromSuspendedMediaControlOnForeground = true; - _tvBackgroundMediaControlResumeTimer?.cancel(); - _tvBackgroundMediaControlResumeTimer = Timer(const Duration(seconds: 8), () { - _tvBackgroundMediaControlResumeTimer = null; - if (!mounted || !_mediaControlsSuspendedForTvBackground) return; - _resumeFromSuspendedMediaControlOnForeground = false; - appLogger.d('Media control: deferred TV foreground resume expired before app resumed'); - unawaited( - Sentry.addBreadcrumb( - Breadcrumb( - message: 'TV media control foreground resume expired', - category: 'player.media_controls', - data: {'event': eventLabel}, - ), - ), - ); - }); - - unawaited( - Sentry.addBreadcrumb( - Breadcrumb( - message: 'TV media control requested foreground resume', - category: 'player.media_controls', - data: {'event': eventLabel}, - ), - ), - ); - - final foregrounded = await AppForegroundService.requestForeground(); - appLogger.d('Media control: requested app foreground for $eventLabel (success=$foregrounded)'); - if (!foregrounded && mounted && _mediaControlsSuspendedForTvBackground) { - _consumePendingTvBackgroundMediaControlResume(); - } - } - Future _syncMediaControlsAvailability() async { if (_mediaControlsSuspendedForTvBackground) return; @@ -104,8 +58,6 @@ extension _VideoPlayerMediaControlsMethods on VideoPlayerScreenState { Future _restoreMediaControlsAfterResume() async { if (!_isPlayerInitialized || !mounted) return; - final resumeRequestedByMediaControl = _consumePendingTvBackgroundMediaControlResume(); - unawaited(_setWakelock(player?.state.isActive ?? false)); final manager = _mediaControlsManager; @@ -123,16 +75,13 @@ extension _VideoPlayerMediaControlsMethods on VideoPlayerScreenState { if (!mounted || currentPlayer != player || currentPlayer == null) return; final wasPlayingBeforeInactive = _wasPlayingBeforeInactive; - if (wasPlayingBeforeInactive || resumeRequestedByMediaControl) { - final resumeReason = resumeRequestedByMediaControl - ? 'TV media control foreground request' - : 'returning from inactive state'; + if (wasPlayingBeforeInactive) { try { await _seekBackForRewind(currentPlayer); await _playWithPlaybackIntent(currentPlayer); - appLogger.d('Video resumed after $resumeReason'); + appLogger.d('Video resumed after returning from inactive state'); } catch (e) { - appLogger.w('Failed to resume playback after $resumeReason', error: e); + appLogger.w('Failed to resume playback after returning from inactive state', error: e); } finally { _wasPlayingBeforeInactive = false; } diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart index 38bc6616..2393b3e2 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -294,13 +294,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { _mediaControlSubscription = mediaControlsManager.controlEvents.listen((event) { final activePlayer = player; if (_mediaControlsSuspendedForTvBackground) { - final eventLabel = event.runtimeType.toString(); - if (activePlayer != null && (event is PlayEvent || event is TogglePlayPauseEvent)) { - appLogger.d('Media control: $eventLabel received while Android TV background-suspended'); - unawaited(_requestForegroundResumeFromSuspendedMediaControl(eventLabel)); - } else { - appLogger.d('Media control: $eventLabel ignored while Android TV background-suspended'); - } + appLogger.d('Media control: ${event.runtimeType} ignored while Android TV background-suspended'); return; } diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index fd999683..e5ee24ed 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -44,7 +44,6 @@ import '../services/discord_rpc_service.dart'; import '../services/trackers/tracker_coordinator.dart'; import '../services/trakt/trakt_scrobble_service.dart'; import '../services/episode_navigation_service.dart'; -import '../services/app_foreground_service.dart'; import '../services/apple_tv_remote_touch_service.dart'; import '../services/media_controls_manager.dart'; import '../services/playback_coordinator.dart'; @@ -378,7 +377,6 @@ class VideoPlayerScreenState extends State with WidgetsBindin bool _wasPlayingBeforeInactive = false; bool _hiddenForBackground = false; bool _mediaControlsSuspendedForTvBackground = false; - bool _resumeFromSuspendedMediaControlOnForeground = false; bool _resumeAfterAppleAudioSessionPause = false; DateTime? _lastPlaybackPauseAt; bool _autoPipEnabled = false; @@ -389,7 +387,6 @@ class VideoPlayerScreenState extends State with WidgetsBindin int _rewindOnResume = 0; Future _lifecycleTransition = Future.value(); String _playerBackendLabel = 'unknown'; - Timer? _tvBackgroundMediaControlResumeTimer; /// Android TV: release the native AV pipeline once the app stays /// backgrounded past this grace window. A merely paused player keeps its @@ -1171,7 +1168,6 @@ class VideoPlayerScreenState extends State with WidgetsBindin _serverStatusSubscription?.cancel(); _autoPlayTimer?.cancel(); - _tvBackgroundMediaControlResumeTimer?.cancel(); _tvBackgroundPlayerSuspendTimer?.cancel(); _stillWatchingTimer?.cancel(); diff --git a/lib/services/app_foreground_service.dart b/lib/services/app_foreground_service.dart deleted file mode 100644 index 8caff2d3..00000000 --- a/lib/services/app_foreground_service.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'dart:io' show Platform; - -import 'package:flutter/services.dart'; - -class AppForegroundService { - static const MethodChannel _channel = MethodChannel('com.plezy/app_foreground'); - - static Future requestForeground() async { - if (!Platform.isAndroid) return false; - - try { - return await _channel.invokeMethod('requestForeground') ?? false; - } on MissingPluginException { - return false; - } on PlatformException { - return false; - } - } -} diff --git a/lib/services/jellyfin_client/parts/watch_state.dart b/lib/services/jellyfin_client/parts/watch_state.dart index 123d4f0c..2c1e4daa 100644 --- a/lib/services/jellyfin_client/parts/watch_state.dart +++ b/lib/services/jellyfin_client/parts/watch_state.dart @@ -49,8 +49,10 @@ mixin _JellyfinWatchStateMethods on MediaServerCacheMixin { /// Toggle the per-user `IsFavorite` flag for [itemId]. Backs [setFavorite] /// and the live-TV favorite-channel adapter; works on any Jellyfin item. Future _setItemFavorite(String itemId, bool isFavorite) async { - final path = '/Users/${_segment(connection.userId)}/FavoriteItems/${_segment(itemId)}'; - final response = isFavorite ? await _http.post(path) : await _http.delete(path); + final path = '/UserFavoriteItems/${_segment(itemId)}'; + final response = isFavorite + ? await _http.post(path, queryParameters: {'userId': connection.userId}) + : await _http.delete(path, queryParameters: {'userId': connection.userId}); throwIfHttpError(response); } } diff --git a/lib/services/music/music_playback_service_impl.dart b/lib/services/music/music_playback_service_impl.dart index 5dd910a1..92f0762d 100644 --- a/lib/services/music/music_playback_service_impl.dart +++ b/lib/services/music/music_playback_service_impl.dart @@ -772,6 +772,11 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO if (player.state.completed) { // Parked at queue end: restart the current track. await player.seek(Duration.zero); + final currentTrack = _currentTrack; + final currentSource = _currentSource; + if (currentTrack != null && currentSource != null) { + _bindTrackServices(currentTrack, currentSource); + } unawaited(_armNext(_generation)); } await player.play(); diff --git a/lib/utils/json_utils.dart b/lib/utils/json_utils.dart index 43b81311..76e835e6 100644 --- a/lib/utils/json_utils.dart +++ b/lib/utils/json_utils.dart @@ -8,22 +8,23 @@ int? flexibleInt(Object? v) => switch (v) { _ => null, }; -/// Parse a value that may be [bool], [int] (0/1), or [String] ('1') to [bool]. +/// Parse a value that may be [bool], [int] (0/1), or [String] ('1'/'true'/'false') to [bool]. /// Returns `false` for `null` or unrecognised values. /// Handles Plex API responses where boolean fields may arrive as integers. bool flexibleBool(Object? v) => switch (v) { final bool b => b, final int n => n == 1, - final String s => s == '1', + final String s => s == '1' || s.toLowerCase() == 'true', _ => false, }; -/// Parse a value that may be [bool], [int] (0/1), or [String] ('1') to [bool]. -/// Returns `null` for `null` or unrecognised values. +/// Parse a value that may be [bool], [int] (0/1), or [String] ('1'/'true'/'false') to [bool]. +/// Returns `null` for `null` or unsupported non-string values; legacy string +/// values other than `'1'`/`'true'` map to `false`. bool? flexibleBoolNullable(Object? v) => switch (v) { final bool b => b, final int n => n == 1, - final String s => s == '1', + final String s => s == '1' || s.toLowerCase() == 'true', _ => null, }; diff --git a/packages/saf_util/android/build.gradle.kts b/packages/saf_util/android/build.gradle.kts index b0b3ef9b..d9fc5aee 100644 --- a/packages/saf_util/android/build.gradle.kts +++ b/packages/saf_util/android/build.gradle.kts @@ -56,6 +56,7 @@ android { testOptions { unitTests { isIncludeAndroidResources = true + isReturnDefaultValues = true all { it.useJUnitPlatform() diff --git a/packages/saf_util/android/src/main/kotlin/com/fluttercavalry/saf_util/SafUtilPlugin.kt b/packages/saf_util/android/src/main/kotlin/com/fluttercavalry/saf_util/SafUtilPlugin.kt index 6500c71f..93d296c3 100644 --- a/packages/saf_util/android/src/main/kotlin/com/fluttercavalry/saf_util/SafUtilPlugin.kt +++ b/packages/saf_util/android/src/main/kotlin/com/fluttercavalry/saf_util/SafUtilPlugin.kt @@ -13,6 +13,7 @@ import android.os.Build import android.os.ParcelFileDescriptor import android.provider.DocumentsContract import androidx.documentfile.provider.DocumentFile +import androidx.core.net.toUri import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding @@ -20,11 +21,11 @@ import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.Result +import io.flutter.plugin.common.PluginRegistry import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.io.File -import androidx.core.net.toUri /** SafUtilPlugin */ @@ -37,11 +38,15 @@ class SafUtilPlugin: FlutterPlugin, MethodCallHandler, ActivityAware { private lateinit var context: Context private var activity: Activity? = null + private var activityBinding: ActivityPluginBinding? = null private var pendingResult: Result? = null private var pendingArguments: PendingArguments? = null private val requestCodeOpenDocumentTree = 1001 private val requestCodeOpenFiles = 1002 + private val activityResultListener = PluginRegistry.ActivityResultListener { requestCode, resultCode, data -> + onActivityResult(requestCode, resultCode, data) + } /// Atomically takes ownership of the pending picker state. Every reply to a /// pending Result must go through this so no already-answered Result is ever @@ -61,21 +66,31 @@ class SafUtilPlugin: FlutterPlugin, MethodCallHandler, ActivityAware { } override fun onDetachedFromActivity() { - activity = null + detachFromActivityBinding() } override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { - activity = binding.activity + attachToActivity(binding) } override fun onAttachedToActivity(binding: ActivityPluginBinding) { + attachToActivity(binding) + } + + private fun attachToActivity(binding: ActivityPluginBinding) { + detachFromActivityBinding() + activityBinding = binding activity = binding.activity - binding.addActivityResultListener { requestCode, resultCode, data -> - onActivityResult(requestCode, resultCode, data) - } + binding.addActivityResultListener(activityResultListener) } override fun onDetachedFromActivityForConfigChanges() { + detachFromActivityBinding() + } + + private fun detachFromActivityBinding() { + activityBinding?.removeActivityResultListener(activityResultListener) + activityBinding = null activity = null } diff --git a/packages/saf_util/android/src/test/kotlin/com/fluttercavalry/saf_util/SafUtilPluginTest.kt b/packages/saf_util/android/src/test/kotlin/com/fluttercavalry/saf_util/SafUtilPluginTest.kt index 38e29c5e..8601fc18 100644 --- a/packages/saf_util/android/src/test/kotlin/com/fluttercavalry/saf_util/SafUtilPluginTest.kt +++ b/packages/saf_util/android/src/test/kotlin/com/fluttercavalry/saf_util/SafUtilPluginTest.kt @@ -1,27 +1,89 @@ package com.fluttercavalry.saf_util +import android.app.Activity +import android.content.Intent +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel -import org.mockito.Mockito +import io.flutter.plugin.common.PluginRegistry +import org.mockito.ArgumentCaptor +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoMoreInteractions +import org.mockito.Mockito.`when` +import kotlin.test.assertEquals import kotlin.test.Test -/* - * This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation. - * - * Once you have built the plugin's example app, you can run these tests from the command - * line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or - * you can run them directly from IDEs that support JUnit such as Android Studio. - */ - internal class SafUtilPluginTest { @Test - fun onMethodCall_getPlatformVersion_returnsExpectedValue() { + fun onMethodCall_unknownMethod_returnsNotImplemented() { val plugin = SafUtilPlugin() + val result = mock(MethodChannel.Result::class.java) - val call = MethodCall("getPlatformVersion", null) - val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) - plugin.onMethodCall(call, mockResult) + plugin.onMethodCall(MethodCall("unknown", null), result) - Mockito.verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE) + verify(result).notImplemented() + verifyNoMoreInteractions(result) + } + + @Test + fun pickDirectory_withoutActivity_returnsNoActivityError() { + val plugin = SafUtilPlugin() + val result = mock(MethodChannel.Result::class.java) + + plugin.onMethodCall(MethodCall("pickDirectory", null), result) + + verify(result).error("NO_ACTIVITY", "Activity is null", null) + verifyNoMoreInteractions(result) + } + + @Suppress("DEPRECATION") + @Test + fun pickDirectory_afterConfigChange_reattachesListenerAndClearsPendingResult() { + val plugin = SafUtilPlugin() + val firstActivity = RecordingActivity() + val firstBinding = mock(ActivityPluginBinding::class.java) + `when`(firstBinding.activity).thenReturn(firstActivity) + + plugin.onAttachedToActivity(firstBinding) + + val firstListenerCaptor = ArgumentCaptor.forClass(PluginRegistry.ActivityResultListener::class.java) + verify(firstBinding).addActivityResultListener(firstListenerCaptor.capture()) + + val firstResult = mock(MethodChannel.Result::class.java) + plugin.onMethodCall(MethodCall("pickDirectory", null), firstResult) + assertEquals(listOf(1001), firstActivity.startedRequestCodes) + + val secondResult = mock(MethodChannel.Result::class.java) + plugin.onMethodCall(MethodCall("pickDirectory", null), secondResult) + verify(secondResult).error("ALREADY_PICKING", "Another picker process is already in progress", null) + + plugin.onDetachedFromActivityForConfigChanges() + verify(firstBinding).removeActivityResultListener(firstListenerCaptor.value) + + val secondActivity = RecordingActivity() + val secondBinding = mock(ActivityPluginBinding::class.java) + `when`(secondBinding.activity).thenReturn(secondActivity) + + plugin.onReattachedToActivityForConfigChanges(secondBinding) + + val secondListenerCaptor = ArgumentCaptor.forClass(PluginRegistry.ActivityResultListener::class.java) + verify(secondBinding).addActivityResultListener(secondListenerCaptor.capture()) + + secondListenerCaptor.value.onActivityResult(1001, Activity.RESULT_CANCELED, null) + verify(firstResult).success(null) + + val thirdResult = mock(MethodChannel.Result::class.java) + plugin.onMethodCall(MethodCall("pickDirectory", null), thirdResult) + assertEquals(listOf(1001), secondActivity.startedRequestCodes) + } + + private class RecordingActivity : Activity() { + val startedRequestCodes = mutableListOf() + + @Deprecated("Deprecated in Android") + override fun startActivityForResult(intent: Intent?, requestCode: Int) { + startedRequestCodes.add(requestCode) + } } } diff --git a/test/utils/json_utils_test.dart b/test/utils/json_utils_test.dart index 6d5c15bd..4d67bad4 100644 --- a/test/utils/json_utils_test.dart +++ b/test/utils/json_utils_test.dart @@ -46,10 +46,12 @@ void main() { expect(flexibleBool(-1), isFalse); }); - test("maps '1' string to true, other strings to false", () { + test("maps '1' and true strings to true, other strings to false", () { expect(flexibleBool('1'), isTrue); + expect(flexibleBool('true'), isTrue); + expect(flexibleBool('TRUE'), isTrue); expect(flexibleBool('0'), isFalse); - expect(flexibleBool('true'), isFalse); + expect(flexibleBool('false'), isFalse); expect(flexibleBool(''), isFalse); }); @@ -72,10 +74,13 @@ void main() { expect(flexibleBoolNullable(2), isFalse); }); - test("maps '1' string to true, other strings to false", () { + test("maps '1' and true strings to true, false strings to false", () { expect(flexibleBoolNullable('1'), isTrue); + expect(flexibleBoolNullable('true'), isTrue); + expect(flexibleBoolNullable('TRUE'), isTrue); expect(flexibleBoolNullable('0'), isFalse); - expect(flexibleBoolNullable('true'), isFalse); + expect(flexibleBoolNullable('false'), isFalse); + expect(flexibleBoolNullable('FALSE'), isFalse); }); test('returns null for null and unsupported types', () {