From e0bf66eea83ea60db1fa0f67f0ef942cbf956859 Mon Sep 17 00:00:00 2001
From: edde746 <86283021+edde746@users.noreply.github.com>
Date: Thu, 23 Jul 2026 02:18:03 +0200
Subject: [PATCH] fix(runtime): harden application service boundaries
---
android/app/src/main/AndroidManifest.xml | 12 +
.../plezy/exoplayer/ExoPlayerPlugin.kt | 144 +-
.../com/edde746/plezy/mpv/MpvPlayerCore.kt | 100 +-
.../com/edde746/plezy/mpv/MpvPlayerPlugin.kt | 28 +-
.../watchnext/SystemShelfArtworkProvider.kt | 174 ++
.../watchnext/SystemShelfUpdateReceiver.kt | 30 +
.../plezy/watchnext/WatchNextPlugin.kt | 108 +-
.../plezy/watchnext/WatchNextProvider.kt | 252 ++-
.../plezy/exoplayer/ExoPlayerPluginTest.kt | 193 +++
.../edde746/plezy/mpv/MpvPlayerPluginTest.kt | 225 ++-
.../plezy/watchnext/WatchNextProviderTest.kt | 198 +++
ios/Runner/MpvPlayer/MpvPlayerCore.swift | 13 +-
ios/Runner/MpvPlayer/MpvPlayerPlugin.swift | 6 +-
ios/RunnerTests/RunnerTests.swift | 206 +++
lib/connection/connection_registry.dart | 94 +-
lib/database/app_database.dart | 1025 ++++++++++--
lib/database/app_database.g.dart | 231 ++-
lib/database/download_operations.dart | 464 +++++-
lib/database/tables.dart | 3 +
.../tvos_database_recovery_store.dart | 562 +++++++
lib/i18n/bg.i18n.json | 9 +-
lib/i18n/da.i18n.json | 9 +-
lib/i18n/de.i18n.json | 9 +-
lib/i18n/en.i18n.json | 9 +-
lib/i18n/es.i18n.json | 9 +-
lib/i18n/fr.i18n.json | 9 +-
lib/i18n/it.i18n.json | 9 +-
lib/i18n/ja.i18n.json | 9 +-
lib/i18n/ko.i18n.json | 9 +-
lib/i18n/nb.i18n.json | 9 +-
lib/i18n/nl.i18n.json | 9 +-
lib/i18n/pl.i18n.json | 9 +-
lib/i18n/pt.i18n.json | 9 +-
lib/i18n/ru.i18n.json | 9 +-
lib/i18n/strings.g.dart | 2 +-
lib/i18n/strings_bg.g.dart | 20 +-
lib/i18n/strings_da.g.dart | 20 +-
lib/i18n/strings_de.g.dart | 20 +-
lib/i18n/strings_en.g.dart | 36 +-
lib/i18n/strings_es.g.dart | 20 +-
lib/i18n/strings_fr.g.dart | 20 +-
lib/i18n/strings_it.g.dart | 20 +-
lib/i18n/strings_ja.g.dart | 20 +-
lib/i18n/strings_ko.g.dart | 20 +-
lib/i18n/strings_nb.g.dart | 20 +-
lib/i18n/strings_nl.g.dart | 20 +-
lib/i18n/strings_pl.g.dart | 20 +-
lib/i18n/strings_pt.g.dart | 20 +-
lib/i18n/strings_ru.g.dart | 20 +-
lib/i18n/strings_sv.g.dart | 20 +-
lib/i18n/strings_zh.g.dart | 20 +-
lib/i18n/sv.i18n.json | 9 +-
lib/i18n/zh.i18n.json | 9 +-
lib/main.dart | 94 +-
lib/media/download_resolution.dart | 11 +-
lib/media/live_tv_support.dart | 5 +-
lib/media/media_server_client.dart | 85 +-
lib/media/media_source_info.dart | 6 +-
lib/media/media_version.dart | 32 +-
lib/models/livetv_channel.dart | 5 +-
lib/navigation/profile_session_screen.dart | 13 +-
lib/profiles/active_profile_binder.dart | 33 +-
lib/profiles/active_profile_provider.dart | 256 ++-
lib/profiles/plex_home_service.dart | 292 +++-
lib/profiles/profile_activation.dart | 171 +-
lib/profiles/profile_connection_cleanup.dart | 48 +-
lib/profiles/profile_connection_registry.dart | 130 +-
lib/profiles/profile_registry.dart | 44 +-
lib/providers/discover_provider.dart | 13 +-
lib/providers/download_metadata_store.dart | 65 +-
lib/providers/download_provider.dart | 328 +++-
lib/providers/trackers_provider.dart | 77 +-
lib/providers/user_profile_provider.dart | 44 +-
lib/providers/watch_state_store.dart | 28 +-
lib/screens/auth/plex_pin_auth_flow.dart | 6 +-
lib/screens/auth_screen.dart | 59 +-
.../base_media_list_detail_screen.dart | 7 +-
.../libraries/tabs/library_browse_tab.dart | 10 +-
lib/screens/livetv/live_tv_screen.dart | 108 +-
.../profile/profile_detail_screen.dart | 76 +-
lib/screens/profile/profile_teardown.dart | 129 +-
lib/screens/settings/add_jellyfin_screen.dart | 22 +-
.../settings/connection_persistence.dart | 136 +-
lib/screens/settings/settings_screen.dart | 23 +-
.../video_player/frame_rate_matcher.dart | 18 +-
.../video_player/live_timeline_report.dart | 29 +
.../video_player/media_control_router.dart | 80 +
lib/screens/video_player/parts/build.dart | 58 +-
.../video_player/parts/companion_remote.dart | 72 +-
.../parts/episode_navigation.dart | 61 +-
lib/screens/video_player/parts/live_tv.dart | 29 +-
.../video_player/parts/media_controls.dart | 19 +-
.../video_player/parts/playback_open.dart | 23 +-
.../video_player/parts/playback_prompts.dart | 7 +-
.../video_player/parts/playback_services.dart | 236 ++-
.../video_player/parts/playback_start.dart | 5 +-
.../video_player/parts/watch_together.dart | 13 +-
.../video_player/wakelock_controller.dart | 48 +
lib/screens/video_player_screen.dart | 258 ++-
lib/services/ambient_lighting_service.dart | 10 +-
lib/services/api_cache.dart | 8 +-
.../lan_discovery_service.dart | 70 +-
lib/services/download_artwork_service.dart | 33 +-
lib/services/download_manager_service.dart | 1197 ++++++++++----
lib/services/external_player_service.dart | 1 +
lib/services/jellyfin_api_cache.dart | 13 +-
lib/services/jellyfin_cache_resolver.dart | 74 +
lib/services/jellyfin_client.dart | 33 +-
.../jellyfin_client/parts/browse.dart | 27 +-
.../parts/images_downloads.dart | 117 +-
.../jellyfin_client/parts/live_tv.dart | 83 +-
.../jellyfin_client/parts/playback.dart | 373 +++--
.../jellyfin_client/parts/playlists.dart | 87 +-
lib/services/jellyfin_endpoint_discovery.dart | 119 +-
lib/services/jellyfin_media_info_mapper.dart | 29 +-
.../jellyfin_sequential_launcher.dart | 36 +-
lib/services/keyboard_shortcuts_service.dart | 67 +-
lib/services/media_controls_manager.dart | 19 +-
.../media_list_playback_launcher.dart | 139 +-
lib/services/multi_server_manager.dart | 175 +-
.../music/music_playback_service_impl.dart | 1 +
lib/services/offline_watch_sync_service.dart | 18 +-
lib/services/play_queue_launcher.dart | 33 +-
.../playback_initialization_types.dart | 16 +-
lib/services/playback_progress_tracker.dart | 1 +
lib/services/playlist_items_loader.dart | 6 +-
lib/services/plex_api_cache.dart | 48 +-
lib/services/plex_client.dart | 417 +++--
.../plex_client/parts/collections.dart | 32 +-
lib/services/plex_client/parts/live_tv.dart | 28 +-
.../plex_client/parts/metadata_edit.dart | 2 +-
lib/services/plex_mappers.dart | 6 +-
lib/services/plex_playback_mapper.dart | 1 +
lib/services/saf_storage_service.dart | 74 +-
lib/services/settings_export_service.dart | 392 +++--
lib/services/sleep_timer_service.dart | 10 +-
lib/services/system_shelf_service.dart | 225 ++-
lib/services/track_manager.dart | 95 +-
lib/services/track_selection_service.dart | 35 +-
.../trackers/anilist/anilist_client.dart | 8 +-
.../trackers/mal/mal_auth_service.dart | 2 +-
lib/services/trackers/mal/mal_client.dart | 4 +-
lib/services/trackers/oauth_proxy_client.dart | 13 +-
.../trackers/simkl/simkl_auth_service.dart | 2 +-
lib/services/trackers/simkl/simkl_client.dart | 2 +-
lib/services/trackers/tracker_exceptions.dart | 11 +-
lib/services/trakt/trakt_auth_service.dart | 4 +-
lib/services/trakt/trakt_client.dart | 2 +-
lib/services/trakt/trakt_sync_service.dart | 86 +-
lib/services/update_service.dart | 49 +-
lib/services/video_volume_controller.dart | 224 +++
lib/utils/active_client_scope.dart | 83 +-
lib/utils/app_logger.dart | 28 +-
lib/utils/endpoint_race.dart | 38 +-
lib/utils/failover_http_client.dart | 92 +-
lib/utils/latest_async_write.dart | 39 +
lib/utils/live_tv_player_navigation.dart | 18 +-
lib/utils/log_redaction_manager.dart | 291 +++-
lib/utils/media_server_http_client.dart | 9 +
lib/utils/provider_extensions.dart | 12 +-
lib/utils/video_player_navigation.dart | 149 +-
lib/utils/watch_state_notifier.dart | 2 +
lib/widgets/library_management_sheet.dart | 11 +-
lib/widgets/media_context_menu.dart | 7 +-
.../desktop_video_controls.dart | 18 +-
.../video_controls/mobile_video_controls.dart | 8 +-
.../video_controls/parts/key_events.dart | 17 +
lib/widgets/video_controls/parts/markers.dart | 2 +
.../video_controls/parts/navigation.dart | 6 +-
.../video_controls/parts/playback_input.dart | 1 +
.../video_controls/parts/track_controls.dart | 37 +-
.../video_controls/parts/visibility.dart | 14 +-
.../video_controls/sheets/chapter_sheet.dart | 7 +-
.../sheets/video_settings_sheet.dart | 120 +-
.../video_controls/video_controls.dart | 24 +-
.../video_controls/widgets/content_strip.dart | 11 +-
.../widgets/live_timeline_bar.dart | 110 +-
.../widgets/sync_offset_control.dart | 61 +-
.../widgets/track_chapter_controls.dart | 1 +
.../widgets/video_controls_header.dart | 19 +-
.../widgets/volume_control.dart | 125 +-
linux/runner/CMakeLists.txt | 50 +
linux/runner/mpv/mpv_player.cc | 308 +++-
linux/runner/mpv/mpv_player.h | 63 +-
linux/runner/mpv/mpv_player_lifecycle_test.cc | 234 +++
linux/runner/mpv/mpv_plugin.cc | 15 +-
macos/Runner/MpvPlayer/MpvPlayerCore.swift | 28 +-
macos/Runner/MpvPlayer/MpvPlayerPlugin.swift | 3 +-
macos/RunnerTests/RunnerTests.swift | 206 +++
packages/saf_util/android/build.gradle.kts | 101 +-
.../saf_util/FileDescriptorRegistry.kt | 59 +
.../saf_util/PersistedPermissionResolver.kt | 79 +
.../fluttercavalry/saf_util/SafUtilPlugin.kt | 121 +-
.../saf_util/VideoFrameExtractor.kt | 25 +
.../SafUtilPersistedPermissionTest.kt | 266 +++
.../saf_util/SafUtilPluginTest.kt | 141 ++
packages/saf_util/lib/saf_util.dart | 31 +-
.../saf_util/lib/saf_util_method_channel.dart | 18 +
.../lib/saf_util_platform_interface.dart | 20 +
packages/wakelock_plus/lib/assets/no_sleep.js | 127 +-
.../lib/src/wakelock_plus_linux_plugin.dart | 118 +-
packages/wakelock_plus/pubspec.yaml | 7 +-
.../test/wakelock_plus_linux_plugin_test.dart | 235 +++
.../test/wakelock_plus_web_plugin_test.dart | 325 ++++
.../apple/MpvPlayer/MpvAudioPlayerCore.swift | 11 +-
.../apple/MpvPlayer/MpvPlayerCoreBase.swift | 308 ++--
.../MpvPlayer/MpvPlayerPluginShared.swift | 21 +-
shared/mpv/mpv_player_common.h | 19 +
shared/mpv/mpv_player_common_test.cpp | 28 +
test/database/app_database_test.dart | 972 ++++++++++-
test/database/download_operations_test.dart | 495 +++++-
.../database/tvos_database_recovery_test.dart | 1095 +++++++++++++
.../media/media_server_client_cache_test.dart | 188 +++
test/media/media_version_test.dart | 71 +
test/models/livetv_channel_test.dart | 39 +-
test/mpv/player_native_bridge_test.dart | 110 ++
.../profile_session_screen_test.dart | 27 +-
test/profiles/active_profile_binder_test.dart | 9 +
.../active_profile_provider_test.dart | 111 ++
test/profiles/plex_home_service_test.dart | 465 ++++++
test/profiles/profile_activation_test.dart | 688 ++++++++
.../profile_connection_cleanup_test.dart | 9 +
test/providers/discover_provider_test.dart | 69 +-
test/providers/download_provider_test.dart | 1132 ++++++++++++-
test/providers/trackers_provider_test.dart | 246 ++-
.../trakt_account_provider_test.dart | 4 +
.../providers/user_profile_provider_test.dart | 178 ++-
test/providers/watch_state_store_test.dart | 27 +-
test/screens/discover_screen_test.dart | 2 +
.../libraries/library_browse_music_test.dart | 24 +-
.../library_collections_tab_test.dart | 3 +-
.../libraries/library_playlists_tab_test.dart | 3 +-
test/screens/livetv/live_tv_screen_test.dart | 254 +++
test/screens/playlist_detail_screen_test.dart | 97 +-
.../profile/profile_teardown_test.dart | 364 +++++
.../settings/add_jellyfin_screen_test.dart | 366 +++++
.../settings/connection_persistence_test.dart | 472 ++++++
.../settings/settings_screen_test.dart | 128 +-
.../screens/setup_database_recovery_test.dart | 78 +
.../companion_remote_callbacks_test.dart | 112 ++
.../video_player/frame_rate_matcher_test.dart | 60 +
.../live_timeline_report_test.dart | 208 +++
.../media_control_router_test.dart | 69 +
.../player_initialization_lifecycle_test.dart | 118 ++
.../wakelock_controller_test.dart | 176 ++
.../ambient_lighting_service_test.dart | 82 +
...ion_remote_lan_discovery_service_test.dart | 263 +++
.../data_aggregation_bridge_test.dart | 19 +-
.../download_artwork_service_test.dart | 37 +-
.../download_manager_service_test.dart | 1424 ++++++++++++++++-
.../download_storage_service_test.dart | 33 +-
.../external_player_service_test.dart | 35 +-
test/services/jellyfin_api_cache_test.dart | 25 +
.../jellyfin_client_failures_test.dart | 435 ++++-
test/services/jellyfin_client_urls_test.dart | 863 ++++++++--
.../jellyfin_endpoint_discovery_test.dart | 102 +-
.../jellyfin_favorites_isolation_test.dart | 36 +-
.../jellyfin_live_tv_favorites_test.dart | 214 +++
test/services/jellyfin_media_info_test.dart | 25 +-
.../jellyfin_playlist_diagnostics_test.dart | 101 ++
.../jellyfin_sequential_launcher_test.dart | 276 +++-
.../keyboard_shortcuts_service_test.dart | 218 ++-
.../live_tv_playback_session_test.dart | 64 +-
.../services/media_controls_manager_test.dart | 103 ++
.../multi_server_manager_progress_test.dart | 13 +-
test/services/multi_server_manager_test.dart | 1036 +++++++++++-
.../music/music_playback_service_test.dart | 14 +-
.../offline_watch_sync_service_test.dart | 87 +
test/services/play_queue_launcher_test.dart | 138 +-
.../playback_progress_tracker_test.dart | 285 +++-
test/services/plex_api_cache_test.dart | 92 +-
.../plex_client_http_contract_test.dart | 452 +++++-
test/services/plex_home_retry_test.dart | 10 +
test/services/plex_live_tv_support_test.dart | 49 +
test/services/plex_mappers_test.dart | 119 ++
.../plex_playback_data_request_test.dart | 371 ++++-
test/services/plex_playback_mapper_test.dart | 3 +
.../settings_export_service_test.dart | 871 +++++-----
test/services/sleep_timer_service_test.dart | 157 +-
test/services/system_shelf_service_test.dart | 170 ++
test/services/track_manager_test.dart | 260 ++-
.../tracker_error_diagnostics_test.dart | 354 ++++
test/services/update_service_test.dart | 98 ++
.../video_volume_controller_test.dart | 451 ++++++
.../test_helpers/backend_client_fixtures.dart | 6 +-
test/utils/active_client_scope_test.dart | 35 +
test/utils/app_logger_test.dart | 97 ++
test/utils/endpoint_race_test.dart | 54 +
test/utils/failover_http_client_test.dart | 145 +-
.../utils/live_tv_player_navigation_test.dart | 82 +
test/utils/log_redaction_manager_test.dart | 172 +-
test/utils/provider_extensions_test.dart | 118 ++
test/utils/video_player_navigation_test.dart | 121 +-
test/widgets/chapter_sheet_test.dart | 124 ++
test/widgets/cycling_media_backdrop_test.dart | 27 +-
.../library_management_sheet_test.dart | 129 +-
test/widgets/live_timeline_bar_test.dart | 239 +++
test/widgets/media_context_menu_test.dart | 105 +-
test/widgets/player_queue_spoilers_test.dart | 104 +-
test/widgets/video_controls_header_test.dart | 100 ++
test/widgets/video_controls_test.dart | 219 ++-
test/widgets/video_settings_sheet_test.dart | 73 +-
test/widgets/volume_control_test.dart | 169 +-
tvos/TopShelfExtension/TopShelfProvider.swift | 96 +-
windows/runner/CMakeLists.txt | 45 +
windows/runner/mpv/mpv_player.cpp | 2 +-
windows/runner/mpv/mpv_player.h | 2 +
.../mpv/mpv_player_property_contract_test.cpp | 64 +
windows/runner/mpv/mpv_plugin.cpp | 15 +-
309 files changed, 32574 insertions(+), 4369 deletions(-)
create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/watchnext/SystemShelfArtworkProvider.kt
create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/watchnext/SystemShelfUpdateReceiver.kt
create mode 100644 android/app/src/test/kotlin/com/edde746/plezy/watchnext/WatchNextProviderTest.kt
create mode 100644 lib/database/tvos_database_recovery_store.dart
create mode 100644 lib/screens/video_player/live_timeline_report.dart
create mode 100644 lib/screens/video_player/media_control_router.dart
create mode 100644 lib/screens/video_player/wakelock_controller.dart
create mode 100644 lib/services/video_volume_controller.dart
create mode 100644 lib/utils/latest_async_write.dart
create mode 100644 linux/runner/mpv/mpv_player_lifecycle_test.cc
create mode 100644 packages/saf_util/android/src/main/kotlin/com/fluttercavalry/saf_util/FileDescriptorRegistry.kt
create mode 100644 packages/saf_util/android/src/main/kotlin/com/fluttercavalry/saf_util/PersistedPermissionResolver.kt
create mode 100644 packages/saf_util/android/src/main/kotlin/com/fluttercavalry/saf_util/VideoFrameExtractor.kt
create mode 100644 packages/saf_util/android/src/test/kotlin/com/fluttercavalry/saf_util/SafUtilPersistedPermissionTest.kt
create mode 100644 packages/wakelock_plus/test/wakelock_plus_linux_plugin_test.dart
create mode 100644 packages/wakelock_plus/test/wakelock_plus_web_plugin_test.dart
create mode 100644 test/database/tvos_database_recovery_test.dart
create mode 100644 test/media/media_server_client_cache_test.dart
create mode 100644 test/media/media_version_test.dart
create mode 100644 test/profiles/profile_activation_test.dart
create mode 100644 test/screens/livetv/live_tv_screen_test.dart
create mode 100644 test/screens/profile/profile_teardown_test.dart
create mode 100644 test/screens/settings/connection_persistence_test.dart
create mode 100644 test/screens/setup_database_recovery_test.dart
create mode 100644 test/screens/video_player/companion_remote_callbacks_test.dart
create mode 100644 test/screens/video_player/frame_rate_matcher_test.dart
create mode 100644 test/screens/video_player/live_timeline_report_test.dart
create mode 100644 test/screens/video_player/media_control_router_test.dart
create mode 100644 test/screens/video_player/player_initialization_lifecycle_test.dart
create mode 100644 test/screens/video_player/wakelock_controller_test.dart
create mode 100644 test/services/ambient_lighting_service_test.dart
create mode 100644 test/services/companion_remote_lan_discovery_service_test.dart
create mode 100644 test/services/jellyfin_live_tv_favorites_test.dart
create mode 100644 test/services/jellyfin_playlist_diagnostics_test.dart
create mode 100644 test/services/media_controls_manager_test.dart
create mode 100644 test/services/system_shelf_service_test.dart
create mode 100644 test/services/trackers/tracker_error_diagnostics_test.dart
create mode 100644 test/services/update_service_test.dart
create mode 100644 test/services/video_volume_controller_test.dart
create mode 100644 test/utils/app_logger_test.dart
create mode 100644 test/widgets/chapter_sheet_test.dart
create mode 100644 test/widgets/live_timeline_bar_test.dart
create mode 100644 test/widgets/video_controls_header_test.dart
create mode 100644 windows/runner/mpv/mpv_player_property_contract_test.cpp
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 0a97a2e8..0881017a 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -88,6 +88,18 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider_paths" />
+
+
+
+
+
+
+ val currentOutcome = if (
+ usingMpvFallback &&
+ generation == sessionGeneration &&
+ activity === currentActivity &&
+ mpvCore === core
+ ) {
+ outcome
+ } else {
+ Result.failure(IllegalStateException("MPV fallback unavailable"))
+ }
+ completeMpvPropertyResult(result, currentOutcome, successValue)
+ }
+ }
+ }
+
+ private fun handlePlay(result: MethodChannel.Result) {
+ if (usingMpvFallback) {
+ handleFallbackMpvProperty("pause", "no", result)
+ return
+ }
+ activity?.runOnUiThread {
+ playerCore?.play()
result.success(null)
} ?: result.success(null)
}
private fun handlePause(result: MethodChannel.Result) {
+ if (usingMpvFallback) {
+ handleFallbackMpvProperty("pause", "yes", result)
+ return
+ }
activity?.runOnUiThread {
- if (usingMpvFallback) {
- mpvCore?.setProperty("pause", "yes")
- } else {
- playerCore?.pause()
- }
+ playerCore?.pause()
result.success(null)
} ?: result.success(null)
}
@@ -397,12 +439,12 @@ class ExoPlayerPlugin :
return
}
+ if (usingMpvFallback) {
+ handleFallbackMpvProperty("volume", volume.toString(), result)
+ return
+ }
activity?.runOnUiThread {
- if (usingMpvFallback) {
- mpvCore?.setProperty("volume", volume.toString())
- } else {
- playerCore?.setVolume(volume / 100f) // Convert 0-100 to 0-1
- }
+ playerCore?.setVolume(volume / 100f) // Convert 0-100 to 0-1
result.success(null)
} ?: result.success(null)
}
@@ -415,12 +457,12 @@ class ExoPlayerPlugin :
return
}
+ if (usingMpvFallback) {
+ handleFallbackMpvProperty("speed", rate.toString(), result)
+ return
+ }
activity?.runOnUiThread {
- if (usingMpvFallback) {
- mpvCore?.setProperty("speed", rate.toString())
- } else {
- playerCore?.setPlaybackSpeed(rate)
- }
+ playerCore?.setPlaybackSpeed(rate)
result.success(null)
} ?: result.success(null)
}
@@ -433,13 +475,13 @@ class ExoPlayerPlugin :
return
}
+ if (usingMpvFallback) {
+ // After fallback, track IDs come from mpv's track-list (already 1-indexed)
+ handleFallbackMpvProperty("aid", trackId, result)
+ return
+ }
activity?.runOnUiThread {
- if (usingMpvFallback) {
- // After fallback, track IDs come from mpv's track-list (already 1-indexed)
- mpvCore?.setProperty("aid", trackId)
- } else {
- playerCore?.selectAudioTrack(trackId)
- }
+ playerCore?.selectAudioTrack(trackId)
result.success(null)
} ?: result.success(null)
}
@@ -448,12 +490,12 @@ class ExoPlayerPlugin :
val trackId = call.argument("trackId")
// trackId can be null or "no" to disable subtitles
+ if (usingMpvFallback) {
+ handleFallbackMpvProperty("sid", trackId ?: "no", result)
+ return
+ }
activity?.runOnUiThread {
- if (usingMpvFallback) {
- mpvCore?.setProperty("sid", trackId ?: "no")
- } else {
- playerCore?.selectSubtitleTrack(trackId)
- }
+ playerCore?.selectSubtitleTrack(trackId)
result.success(null)
} ?: result.success(null)
}
@@ -705,8 +747,7 @@ class ExoPlayerPlugin :
val audioSpdif = if (enabled) "ac3,eac3,dts,dts-hd,truehd" else ""
pendingMpvProperties["audio-spdif"] = audioSpdif
if (usingMpvFallback) {
- mpvCore?.setProperty("audio-spdif", audioSpdif)
- result.success(true)
+ handleFallbackMpvProperty("audio-spdif", audioSpdif, result, true)
return
}
activity?.runOnUiThread {
@@ -724,24 +765,23 @@ class ExoPlayerPlugin :
return
}
- // Apply sync offsets to ExoPlayer when active
- if (!usingMpvFallback) {
- when (name) {
- "audio-delay" -> playerCore?.setAudioDelay(value.toDoubleOrNull() ?: 0.0)
- "sub-delay" -> playerCore?.setSubtitleDelay(value.toDoubleOrNull() ?: 0.0)
- // mpv semantics mirrored on the libass overlay: anchor non-positioned ASS
- // events to the visible screen (Dart sets 'yes' for cover mode / zoom > 1)
- "sub-ass-force-margins" -> playerCore?.setAssForceMargins(value == "yes")
- "force-seekable" -> playerCore?.setForceSeekable(value == "yes")
- }
+ if (usingMpvFallback) {
+ handleFallbackMpvProperty(name, value, result)
+ return
}
- if (usingMpvFallback) {
- mpvCore?.setProperty(name, value)
- } else {
- // Store for later application if ExoPlayer falls back to MPV
- pendingMpvProperties[name] = value
+ // Apply sync offsets to ExoPlayer when active
+ when (name) {
+ "audio-delay" -> playerCore?.setAudioDelay(value.toDoubleOrNull() ?: 0.0)
+ "sub-delay" -> playerCore?.setSubtitleDelay(value.toDoubleOrNull() ?: 0.0)
+ // mpv semantics mirrored on the libass overlay: anchor non-positioned ASS
+ // events to the visible screen (Dart sets 'yes' for cover mode / zoom > 1)
+ "sub-ass-force-margins" -> playerCore?.setAssForceMargins(value == "yes")
+ "force-seekable" -> playerCore?.setForceSeekable(value == "yes")
}
+
+ // Before fallback this is queue acceptance, not a completed MPV write.
+ pendingMpvProperties[name] = value
result.success(null)
}
@@ -867,7 +907,11 @@ class ExoPlayerPlugin :
}
for ((propName, propValue) in pendingProps) {
- core.setProperty(propName, propValue)
+ core.setProperty(propName, propValue) { outcome ->
+ if (outcome.isFailure) {
+ Log.w(TAG, "Failed to replay queued MPV property")
+ }
+ }
}
// Re-observe exactly what Dart registered via observeProperty, so the
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt
index 3fb61d63..5a70c9fd 100644
--- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt
+++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt
@@ -35,10 +35,19 @@ import kotlinx.coroutines.sync.withLock
* configured before init to never open a video output (`vid=no`,
* `force-window=no`, `audio-display=no`, plus `gapless-audio=weak`).
*/
-class MpvPlayerCore(
+class MpvPlayerCore private constructor(
private val context: Context,
- private val audioOnly: Boolean = false
+ private val audioOnly: Boolean,
+ private val propertyWriterOverride: (suspend (String, String) -> Unit)?,
+ initializedForTesting: Boolean
) : SurfaceHolder.Callback {
+ constructor(context: Context, audioOnly: Boolean = false) : this(context, audioOnly, null, false)
+
+ internal constructor(
+ context: Context,
+ audioOnly: Boolean,
+ propertyWriter: (suspend (String, String) -> Unit)?
+ ) : this(context, audioOnly, propertyWriter, true)
companion object {
private const val TAG = "MpvPlayerCore"
@@ -71,6 +80,10 @@ class MpvPlayerCore(
var isInitialized: Boolean = false
private set
+ init {
+ if (initializedForTesting) isInitialized = true
+ }
+
@Volatile private var player: MpvPlayer? = null
private var scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val endFileDiagnostics = MpvEndFileDiagnostics()
@@ -691,43 +704,66 @@ class MpvPlayerCore(
// Public API
- fun setProperty(name: String, value: String, onComplete: ((Boolean) -> Unit)? = null) {
+ fun setProperty(name: String, value: String, onComplete: ((Result) -> Unit)? = null) {
if (!isInitialized || disposing || !scope.isActive) {
- onComplete?.invoke(false)
+ onComplete?.invoke(Result.failure(IllegalStateException("MPV core unavailable")))
return
}
- if (name == "pause") {
- val paused = normalizePauseValue(value)
- if (paused == true) {
- cachedPaused = true
- pausedForSurfaceLoss = false
- resumeBlockedByPublicPause = true
- deferredResumeRequested = false
- Log.d(TAG, "Public pause state updated: paused=true")
- } else if (paused == false) {
+
+ val paused = if (name == "pause") normalizePauseValue(value) else null
+ if (paused == false && !hasReadyVideoOutput()) {
+ runOnMain {
+ if (!isInitialized || disposing || !scope.isActive) {
+ onComplete?.invoke(Result.failure(CancellationException("MPV core unavailable")))
+ return@runOnMain
+ }
resumeBlockedByPublicPause = false
- if (!hasReadyVideoOutput()) {
- deferredResumeRequested = true
- Log.d(TAG, "Deferring public resume until video output is ready")
- onComplete?.invoke(true)
- return
- }
- cachedPaused = false
- pausedForSurfaceLoss = false
- Log.d(TAG, "Public pause state updated: paused=false")
+ deferredResumeRequested = true
+ Log.d(TAG, "Deferring public resume until video output is ready")
+ onComplete?.invoke(Result.success(Unit))
}
+ return
}
- scope.launch(mpvWriteDispatcher) {
- var success = false
- try {
- player?.setProperty(name, value)
- success = true
- } catch (e: Exception) {
- Log.w(TAG, "setProperty($name) failed", e)
- } finally {
- withContext(NonCancellable + Dispatchers.Main) {
- onComplete?.invoke(success)
+
+ scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
+ val writeResult = try {
+ val writer = propertyWriterOverride
+ if (writer != null) {
+ writer(name, value)
+ } else {
+ val currentPlayer = player ?: throw IllegalStateException("MPV player unavailable")
+ currentPlayer.setProperty(name, value)
}
+ Result.success(Unit)
+ } catch (error: CancellationException) {
+ Result.failure(error)
+ } catch (error: Exception) {
+ Log.w(TAG, "MPV property write failed")
+ Result.failure(error)
+ }
+
+ withContext(NonCancellable + Dispatchers.Main) {
+ val completion = if (disposing || !isInitialized) {
+ Result.failure(CancellationException("MPV core unavailable"))
+ } else {
+ writeResult
+ }
+ if (completion.isSuccess) {
+ if (paused == true) {
+ cachedPaused = true
+ pausedForSurfaceLoss = false
+ resumeBlockedByPublicPause = true
+ deferredResumeRequested = false
+ Log.d(TAG, "Public pause state updated: paused=true")
+ } else if (paused == false) {
+ cachedPaused = false
+ pausedForSurfaceLoss = false
+ resumeBlockedByPublicPause = false
+ deferredResumeRequested = false
+ Log.d(TAG, "Public pause state updated: paused=false")
+ }
+ }
+ onComplete?.invoke(completion)
}
}
}
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt
index f7b07bb3..18ef497b 100644
--- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt
+++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt
@@ -13,6 +13,26 @@ import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
+internal fun completeMpvPropertyResult(
+ result: MethodChannel.Result,
+ outcome: Result,
+ successValue: Any? = null
+) {
+ if (outcome.isSuccess) {
+ result.success(successValue)
+ } else {
+ result.error(
+ "SET_PROPERTY_FAILED",
+ "MPV property write was rejected or cancelled",
+ null
+ )
+ }
+}
+
+internal fun completeMpvPropertyNotInitialized(result: MethodChannel.Result) {
+ result.error("NOT_INITIALIZED", "Player not initialized", null)
+}
+
/**
* Channel plumbing for [MpvPlayerCore]. The default instance is the video
* player; the [audioOnly] instance (see [MpvAudioPlayerPlugin]) drives the
@@ -261,13 +281,13 @@ open class MpvPlayerPlugin(
}
val core = playerCore
- if (core == null) {
- result.success(null)
+ if (core?.isInitialized != true) {
+ completeMpvPropertyNotInitialized(result)
return
}
- core.setProperty(name, value) {
- result.success(null)
+ core.setProperty(name, value) { outcome ->
+ completeMpvPropertyResult(result, outcome)
}
}
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/SystemShelfArtworkProvider.kt b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/SystemShelfArtworkProvider.kt
new file mode 100644
index 00000000..bdb98fb1
--- /dev/null
+++ b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/SystemShelfArtworkProvider.kt
@@ -0,0 +1,174 @@
+package com.edde746.plezy.watchnext
+
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.database.Cursor
+import android.graphics.BitmapFactory
+import android.net.Uri
+import android.os.ParcelFileDescriptor
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.io.FileNotFoundException
+import java.net.HttpURLConnection
+import java.net.URL
+import java.security.MessageDigest
+import java.util.UUID
+
+class SystemShelfArtworkProvider : ContentProvider() {
+ companion object {
+ const val AUTHORITY = "com.edde746.plezy.systemshelf.artwork"
+ }
+
+ override fun onCreate(): Boolean = context != null
+
+ override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor {
+ if (mode != "r") throw FileNotFoundException("Read-only artwork")
+ val appContext = context ?: throw FileNotFoundException("Provider unavailable")
+ val file = SystemShelfArtworkStore(appContext.cacheDir).resolve(uri)
+ ?: throw FileNotFoundException("Unknown artwork")
+ return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
+ }
+
+ override fun getType(uri: Uri): String? = if (uri.authority == AUTHORITY) "image/*" else null
+ override fun query(
+ uri: Uri,
+ projection: Array?,
+ selection: String?,
+ selectionArgs: Array?,
+ sortOrder: String?
+ ): Cursor? = null
+ override fun insert(uri: Uri, values: ContentValues?): Uri? = null
+ override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int = 0
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0
+}
+
+internal class SystemShelfArtworkStore(private val cacheDir: File) {
+ companion object {
+ const val MAX_IMAGE_BYTES = 2 * 1024 * 1024
+ const val MAX_SYNC_BYTES = 8 * 1024 * 1024
+ const val MAX_ITEMS = 20
+ const val CONNECT_TIMEOUT_MS = 2_500
+ const val READ_TIMEOUT_MS = 2_500
+ private val opaquePart = Regex("^[a-f0-9]{64}$")
+ private val artworkKey = Regex("^[a-f0-9]{32}\\.art$")
+ }
+
+ data class Materialized(val key: String, val uri: Uri, val file: File)
+ class Budget(var remaining: Int = MAX_SYNC_BYTES)
+
+ private val root: File get() = File(cacheDir, "system_shelf_artwork")
+
+ fun materialize(ownerId: String, source: String, budget: Budget): Materialized? {
+ if (ownerId.isBlank() || budget.remaining <= 0) return null
+ val url = runCatching { URL(source) }.getOrNull() ?: return null
+ if (url.protocol != "https" && url.protocol != "http") return null
+ val connection = (url.openConnection() as? HttpURLConnection) ?: return null
+ return try {
+ connection.instanceFollowRedirects = true
+ connection.connectTimeout = CONNECT_TIMEOUT_MS
+ connection.readTimeout = READ_TIMEOUT_MS
+ connection.useCaches = false
+ connection.setRequestProperty("Accept", "image/*")
+ val status = connection.responseCode
+ if (status !in 200..299) return null
+ if (connection.url.protocol != "https" && connection.url.protocol != "http") return null
+ if (!connection.contentType.orEmpty().substringBefore(';').trim().startsWith("image/")) return null
+ val contentLength = connection.contentLengthLong
+ val cap = minOf(MAX_IMAGE_BYTES, budget.remaining)
+ if (contentLength > cap) return null
+ val bytes = connection.inputStream.use { input ->
+ val output = ByteArrayOutputStream(minOf(if (contentLength > 0) contentLength.toInt() else 32 * 1024, cap))
+ val buffer = ByteArray(16 * 1024)
+ var total = 0
+ while (true) {
+ val read = input.read(buffer)
+ if (read < 0) break
+ total += read
+ if (total > cap) return null
+ output.write(buffer, 0, read)
+ }
+ output.toByteArray()
+ }
+ if (!isSupportedImage(bytes)) return null
+ val ownerKey = sha256(ownerId)
+ val directory = File(root, ownerKey)
+ if (!directory.mkdirs() && !directory.isDirectory) return null
+ val key = UUID.randomUUID().toString().replace("-", "") + ".art"
+ val staged = File(directory, ".$key.tmp")
+ staged.outputStream().use { output ->
+ output.write(bytes)
+ output.flush()
+ output.fd.sync()
+ }
+ val destination = File(directory, key)
+ if (!staged.renameTo(destination)) {
+ staged.delete()
+ return null
+ }
+ budget.remaining -= bytes.size
+ Materialized(key, contentUri(ownerKey, key), destination)
+ } catch (_: Exception) {
+ null
+ } finally {
+ connection.disconnect()
+ }
+ }
+
+ fun contentUri(ownerKey: String, key: String): Uri = Uri.Builder()
+ .scheme("content")
+ .authority(SystemShelfArtworkProvider.AUTHORITY)
+ .appendPath("art")
+ .appendPath(ownerKey)
+ .appendPath(key)
+ .build()
+
+ fun resolve(uri: Uri): File? {
+ if (uri.scheme != "content" || uri.authority != SystemShelfArtworkProvider.AUTHORITY) return null
+ val segments = uri.pathSegments
+ if (segments.size != 3 || segments[0] != "art") return null
+ val owner = segments[1]
+ val key = segments[2]
+ if (!opaquePart.matches(owner) || !artworkKey.matches(key)) return null
+ val canonicalRoot = root.canonicalFile
+ val candidate = File(File(canonicalRoot, owner), key).canonicalFile
+ if (candidate.parentFile?.parentFile != canonicalRoot || !candidate.isFile) return null
+ return candidate
+ }
+
+ fun deleteExcept(keep: Set) {
+ val canonicalKeep = keep.mapTo(HashSet()) { it.canonicalFile }
+ root.listFiles()?.forEach { ownerDirectory ->
+ ownerDirectory.listFiles()?.forEach { file ->
+ if (file.canonicalFile !in canonicalKeep) file.delete()
+ }
+ if (ownerDirectory.listFiles().isNullOrEmpty()) ownerDirectory.delete()
+ }
+ }
+
+ fun deleteAll(): Boolean = !root.exists() || root.deleteRecursively()
+
+ private fun isSupportedImage(bytes: ByteArray): Boolean {
+ if (bytes.size < 4) return false
+ val png = bytes.size >= 8 &&
+ bytes[0] == 0x89.toByte() &&
+ bytes[1] == 0x50.toByte() &&
+ bytes[2] == 0x4e.toByte() &&
+ bytes[3] == 0x47.toByte()
+ val jpeg = bytes[0] == 0xff.toByte() && bytes[1] == 0xd8.toByte() && bytes[2] == 0xff.toByte()
+ val gif = bytes[0] == 0x47.toByte() && bytes[1] == 0x49.toByte() && bytes[2] == 0x46.toByte()
+ val webp = bytes.size >= 12 &&
+ bytes.copyOfRange(0, 4).contentEquals("RIFF".toByteArray()) &&
+ bytes.copyOfRange(8, 12).contentEquals("WEBP".toByteArray())
+ if (!png && !jpeg && !gif && !webp) return false
+
+ val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
+ BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
+ val width = options.outWidth
+ val height = options.outHeight
+ return width in 1..4096 && height in 1..4096 && width.toLong() * height <= 16_777_216L
+ }
+
+ private fun sha256(value: String): String = MessageDigest.getInstance("SHA-256")
+ .digest(value.toByteArray(Charsets.UTF_8))
+ .joinToString("") { byte -> "%02x".format(byte) }
+}
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
new file mode 100644
index 00000000..f3a0bbef
--- /dev/null
+++ b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/SystemShelfUpdateReceiver.kt
@@ -0,0 +1,30 @@
+package com.edde746.plezy.watchnext
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import java.util.concurrent.Executor
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+
+/** Scrubs unversioned rows that may contain legacy authenticated poster URLs. */
+class SystemShelfUpdateReceiver private constructor(
+ private val executor: Executor,
+ private val ownsExecutor: Boolean
+) : BroadcastReceiver() {
+ constructor() : this(Executors.newSingleThreadExecutor(), true)
+ internal constructor(executor: Executor) : this(executor, false)
+
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) return
+ val pending = goAsync()
+ executor.execute {
+ try {
+ WatchNextProvider(context.applicationContext).clearLegacyOnPackageUpdate()
+ } 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 b4bb31d9..27720db5 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
@@ -12,30 +12,23 @@ import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.Executors
-/**
- * Flutter plugin for Android TV Watch Next integration.
- * Syncs Plex "On Deck" content to the Android TV launcher's Watch Next row.
- */
+/** Flutter bridge for profile-owned Android TV Watch Next mutations. */
class WatchNextPlugin :
FlutterPlugin,
MethodChannel.MethodCallHandler {
-
companion object {
private const val TAG = "WatchNextPlugin"
private const val METHOD_CHANNEL = "com.plezy/watch_next"
-
+ private const val SCHEMA_VERSION = 2
private var pendingDeepLink: String? = null
- /**
- * Parse a Watch Next deep link intent.
- * Returns the content ID if this was a Watch Next intent, null otherwise.
- */
fun handleIntent(intent: Intent?): String? {
val data = intent?.data ?: return null
- if (data.scheme == "plezy" && data.authority == "play") {
- return data.getQueryParameter("content_id")
+ return if (data.scheme == "plezy" && data.authority == "play") {
+ data.getQueryParameter("content_id")
+ } else {
+ null
}
- return null
}
}
@@ -63,7 +56,7 @@ class WatchNextPlugin :
when (call.method) {
"isSupported" -> handleIsSupported(result)
"sync" -> handleSync(call, result)
- "clear" -> handleClear(result)
+ "clear" -> handleClear(call, result)
"remove" -> handleRemove(call, result)
"getInitialDeepLink" -> handleGetInitialDeepLink(result)
else -> result.notImplemented()
@@ -72,52 +65,43 @@ class WatchNextPlugin :
private fun handleIsSupported(result: MethodChannel.Result) {
val context = applicationContext
- if (context == null) {
- result.success(false)
- return
- }
- result.success(context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
+ result.success(context?.packageManager?.hasSystemFeature(PackageManager.FEATURE_LEANBACK) == true)
+ }
+
+ private fun ownerArguments(call: MethodCall): Pair? {
+ if (call.argument("schemaVersion")?.toInt() != SCHEMA_VERSION) return null
+ val owner = call.argument("ownerId")?.takeIf(String::isNotBlank) ?: return null
+ val generation = call.argument("generation")?.toLong()?.takeIf { it > 0 } ?: return null
+ return owner to generation
}
private fun handleSync(call: MethodCall, result: MethodChannel.Result) {
- val provider = watchNextProvider
- if (provider == null) {
- result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
- return
- }
-
+ val provider = watchNextProvider ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
+ val (owner, generation) = ownerArguments(call)
+ ?: return result.error("INVALID_ARGS", "Invalid shelf envelope", null)
val itemsData = call.argument>>("items")
- if (itemsData == null) {
- result.error("INVALID_ARGS", "Missing 'items' argument", null)
- return
+ ?: return result.error("INVALID_ARGS", "Missing items", null)
+ if (itemsData.size > SystemShelfArtworkStore.MAX_ITEMS) {
+ return result.error("INVALID_ARGS", "Too many items", null)
}
-
- val items = itemsData.mapNotNull { parseWatchNextItem(it) }
- executeOnIo(result) { provider.syncWatchNextPrograms(items) }
+ val items = itemsData.mapNotNull(::parseWatchNextItem)
+ executeOnIo(result) { provider.syncWatchNextPrograms(owner, generation, items) }
}
- private fun handleClear(result: MethodChannel.Result) {
- val provider = watchNextProvider
- if (provider == null) {
- result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
- return
- }
- executeOnIo(result) { provider.clearAll() }
+ private fun handleClear(call: MethodCall, result: MethodChannel.Result) {
+ val provider = watchNextProvider ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
+ val (owner, generation) = ownerArguments(call)
+ ?: return result.error("INVALID_ARGS", "Invalid shelf envelope", null)
+ executeOnIo(result) { provider.clearAll(owner, generation) }
}
private fun handleRemove(call: MethodCall, result: MethodChannel.Result) {
- val provider = watchNextProvider
- if (provider == null) {
- result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
- return
- }
-
+ val provider = watchNextProvider ?: return result.error("NOT_INITIALIZED", "Provider unavailable", null)
+ val (owner, generation) = ownerArguments(call)
+ ?: return result.error("INVALID_ARGS", "Invalid shelf envelope", null)
val contentId = call.argument("contentId")
- if (contentId == null) {
- result.error("INVALID_ARGS", "Missing 'contentId' argument", null)
- return
- }
- executeOnIo(result) { provider.removeItem(contentId) }
+ ?: return result.error("INVALID_ARGS", "Missing contentId", null)
+ executeOnIo(result) { provider.removeItem(owner, generation, contentId) }
}
private fun executeOnIo(result: MethodChannel.Result, block: () -> Any?) {
@@ -126,12 +110,12 @@ class WatchNextPlugin :
try {
val value = block()
mainHandler.post { result.success(value) }
- } catch (e: Exception) {
- Log.e(TAG, "IO operation failed: ${e.message}", e)
- mainHandler.post { result.error("IO_ERROR", e.message, null) }
+ } catch (_: Exception) {
+ Log.e(TAG, "System shelf IO operation failed")
+ mainHandler.post { result.error("IO_ERROR", "System shelf operation failed", null) }
}
}
- } catch (e: java.util.concurrent.RejectedExecutionException) {
+ } catch (_: java.util.concurrent.RejectedExecutionException) {
result.error("SHUTDOWN", "Plugin is shutting down", null)
}
}
@@ -143,22 +127,18 @@ class WatchNextPlugin :
}
private fun parseWatchNextItem(data: Map): WatchNextProvider.WatchNextItem? {
- val contentId = data["contentId"] as? String ?: return null
+ val contentId = (data["contentId"] as? String)?.takeIf(String::isNotBlank) ?: return null
val title = data["title"] as? String ?: return null
-
- val typeString = data["type"] as? String ?: "movie"
- val type = when (typeString.lowercase()) {
+ val type = when ((data["type"] as? String)?.lowercase()) {
"episode" -> TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE
- "movie" -> TvContractCompat.WatchNextPrograms.TYPE_MOVIE
else -> TvContractCompat.WatchNextPrograms.TYPE_MOVIE
}
-
return WatchNextProvider.WatchNextItem(
contentId = contentId,
title = title,
episodeTitle = data["episodeTitle"] as? String,
description = data["description"] as? String,
- posterUri = data["posterUri"] as? String,
+ posterSourceUri = data["posterSourceUri"] as? String,
type = type,
duration = (data["duration"] as? Number)?.toLong() ?: 0L,
lastPlaybackPosition = (data["lastPlaybackPosition"] as? Number)?.toLong() ?: 0L,
@@ -169,16 +149,12 @@ class WatchNextPlugin :
)
}
- /**
- * Store a deep link content ID for delivery to Flutter.
- * Called from MainActivity on intent receipt.
- */
fun notifyDeepLink(contentId: String) {
pendingDeepLink = contentId
try {
methodChannel.invokeMethod("onWatchNextTap", mapOf("contentId" to contentId))
- } catch (e: Exception) {
- Log.d(TAG, "Method channel not ready, stored as pending deep link")
+ } catch (_: Exception) {
+ Log.d(TAG, "Method channel not ready; deep link retained")
}
}
}
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 1caef1e8..ccb63104 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
@@ -3,19 +3,19 @@ package com.edde746.plezy.watchnext
import android.content.ContentProviderOperation
import android.content.ContentUris
import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
import android.net.Uri
import android.util.Log
import androidx.tvprovider.media.tv.TvContractCompat
import androidx.tvprovider.media.tv.WatchNextProgram
-/**
- * Wraps Android TvProvider API for Watch Next row integration.
- * Manages WatchNextProgram entries for Plex "On Deck" content.
- */
+/** Owns Plezy's durable Android TV Watch Next rows and their local artwork. */
class WatchNextProvider(private val context: Context) {
-
companion object {
private const val TAG = "WatchNextProvider"
+ private const val PREFS = "system_shelf_state"
+ private const val GRANTED_URIS = "granted_uris"
}
data class WatchNextItem(
@@ -23,7 +23,7 @@ class WatchNextProvider(private val context: Context) {
val title: String,
val episodeTitle: String?,
val description: String?,
- val posterUri: String?,
+ val posterSourceUri: String?,
val type: Int,
val duration: Long,
val lastPlaybackPosition: Long,
@@ -33,132 +33,208 @@ class WatchNextProvider(private val context: Context) {
val episodeNumber: Int?
)
- /**
- * Sync items to Watch Next row.
- * Uses applyBatch to delete + insert in a single transaction so the
- * launcher receives one content-change notification with the full set.
- */
- fun syncWatchNextPrograms(items: List): Boolean = try {
- val ops = ArrayList()
+ internal data class PreparedWatchNextItem(val metadata: WatchNextItem, val localPosterUri: Uri?)
- ops.add(
- ContentProviderOperation.newDelete(
- TvContractCompat.WatchNextPrograms.CONTENT_URI
- ).build()
- )
+ private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ private val artwork = SystemShelfArtworkStore(context.cacheDir)
+ private var currentOwner = ""
+ private var currentGeneration = 0L
- for (item in items) {
- val program = buildProgram(item)
- ops.add(
- ContentProviderOperation.newInsert(
- TvContractCompat.WatchNextPrograms.CONTENT_URI
- ).withValues(program.toContentValues()).build()
- )
+ /** Materializes transient art, then atomically replaces the durable rows. */
+ fun syncWatchNextPrograms(ownerId: String, generation: Long, items: List): Boolean {
+ if (!accepts(ownerId, generation) || items.size > SystemShelfArtworkStore.MAX_ITEMS) return false
+
+ val oldUris = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty().mapNotNull(Uri::parse).toSet()
+ val oldFiles = oldUris.mapNotNullTo(HashSet()) { artwork.resolve(it) }
+ val budget = SystemShelfArtworkStore.Budget()
+ val prepared = items.map { item ->
+ val materialized = item.posterSourceUri?.let { artwork.materialize(ownerId, it, budget) }
+ PreparedWatchNextItem(item, materialized?.uri)
+ }
+ if (!accepts(ownerId, generation)) {
+ artwork.deleteExcept(oldFiles)
+ return false
}
- context.contentResolver.applyBatch(TvContractCompat.AUTHORITY, ops)
- Log.d(TAG, "Synced ${items.size} Watch Next entries")
- true
- } catch (e: Exception) {
- Log.e(TAG, "Failed to sync Watch Next programs", e)
- false
+ val newUris = prepared.mapNotNullTo(LinkedHashSet()) { it.localPosterUri }
+ grantReadAccess(newUris)
+ val committed = replaceRows(prepared)
+ if (!committed) {
+ revokeReadAccess(newUris - oldUris)
+ artwork.deleteExcept(oldFiles)
+ return false
+ }
+
+ prefs.edit()
+ .putStringSet(GRANTED_URIS, newUris.mapTo(LinkedHashSet(), Uri::toString))
+ .commit()
+ currentOwner = ownerId
+ currentGeneration = generation
+ revokeReadAccess(oldUris - newUris)
+ artwork.deleteExcept(prepared.mapNotNullTo(HashSet()) { it.localPosterUri?.let(artwork::resolve) })
+ return true
}
- fun clearAll(): Boolean = try {
- context.contentResolver.delete(
- TvContractCompat.WatchNextPrograms.CONTENT_URI,
- null,
- null
- )
- true
- } catch (e: Exception) {
- Log.e(TAG, "Failed to clear Watch Next entries", e)
- false
+ /** Deletes rows first, then grants, then owned files. */
+ fun clearAll(ownerId: String, generation: Long): Boolean {
+ if (!acceptsClear(ownerId, generation)) return false
+ val rowsCleared = deleteRows()
+ if (!rowsCleared) return false
+ val uris = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty().mapNotNull(Uri::parse).toSet()
+ revokeReadAccess(uris)
+ artwork.deleteAll()
+ prefs.edit().remove(GRANTED_URIS).commit()
+ currentOwner = ""
+ currentGeneration = generation
+ return true
}
- fun removeItem(contentId: String): Boolean {
+ /** Package replacement is a clean cutover: remote legacy rows cannot survive. */
+ fun clearLegacyOnPackageUpdate(): Boolean {
+ val rowsCleared = deleteRows()
+ val uris = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty().mapNotNull(Uri::parse).toSet()
+ revokeReadAccess(uris)
+ artwork.deleteAll()
+ prefs.edit().clear().commit()
+ currentOwner = ""
+ currentGeneration = 0
+ return rowsCleared
+ }
+
+ fun removeItem(ownerId: String, generation: Long, contentId: String): Boolean {
+ if (!accepts(ownerId, generation)) return false
return try {
val cursor = context.contentResolver.query(
TvContractCompat.WatchNextPrograms.CONTENT_URI,
arrayOf(
TvContractCompat.WatchNextPrograms._ID,
- TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID
+ TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID,
+ TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI
),
null,
null,
null
)
-
cursor?.use {
val idIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms._ID)
val providerIdIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID)
-
+ val posterIndex = it.getColumnIndex(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI)
if (idIndex < 0 || providerIdIndex < 0) return false
-
while (it.moveToNext()) {
if (it.getString(providerIdIndex) == contentId) {
- val id = it.getLong(idIndex)
- val deleteUri = ContentUris.withAppendedId(
- TvContractCompat.WatchNextPrograms.CONTENT_URI,
- id
- )
+ val deleteUri = ContentUris.withAppendedId(TvContractCompat.WatchNextPrograms.CONTENT_URI, it.getLong(idIndex))
context.contentResolver.delete(deleteUri, null, null)
+ if (posterIndex >= 0) {
+ val poster = it.getString(posterIndex)?.let(Uri::parse)
+ if (poster != null) {
+ revokeReadAccess(setOf(poster))
+ artwork.resolve(poster)?.delete()
+ val remaining = prefs.getStringSet(GRANTED_URIS, emptySet()).orEmpty() - poster.toString()
+ prefs.edit().putStringSet(GRANTED_URIS, remaining).commit()
+ }
+ }
return true
}
}
}
false
- } catch (e: Exception) {
- Log.e(TAG, "Failed to remove Watch Next item: $contentId", e)
+ } catch (_: Exception) {
+ Log.e(TAG, "Failed to remove Watch Next item")
false
}
}
- private fun buildProgram(item: WatchNextItem): WatchNextProgram {
- val watchNextType = if (item.lastPlaybackPosition > 0) {
+ private fun accepts(ownerId: String, generation: Long): Boolean {
+ if (ownerId.isBlank() || generation <= 0) return false
+ return generation > currentGeneration || generation == currentGeneration && currentOwner == ownerId
+ }
+
+ private fun acceptsClear(ownerId: String, generation: Long): Boolean {
+ if (ownerId.isBlank() || generation <= 0 || generation < currentGeneration) return false
+ return generation > currentGeneration || currentOwner.isEmpty() || currentOwner == ownerId
+ }
+
+ private fun replaceRows(items: List): Boolean = try {
+ val operations = ArrayList(items.size + 1)
+ operations += ContentProviderOperation.newDelete(TvContractCompat.WatchNextPrograms.CONTENT_URI).build()
+ items.forEach { item ->
+ operations += ContentProviderOperation.newInsert(TvContractCompat.WatchNextPrograms.CONTENT_URI)
+ .withValues(buildProgram(item).toContentValues())
+ .build()
+ }
+ context.contentResolver.applyBatch(TvContractCompat.AUTHORITY, operations)
+ true
+ } catch (_: Exception) {
+ Log.e(TAG, "Failed to sync Watch Next programs")
+ false
+ }
+
+ private fun deleteRows(): Boolean = try {
+ context.contentResolver.delete(TvContractCompat.WatchNextPrograms.CONTENT_URI, null, null)
+ true
+ } catch (_: Exception) {
+ Log.e(TAG, "Failed to clear Watch Next entries")
+ false
+ }
+
+ private fun consumerPackages(): Set {
+ val packages = LinkedHashSet()
+ context.packageManager.resolveContentProvider(TvContractCompat.AUTHORITY, PackageManager.MATCH_ALL)?.packageName
+ ?.let(packages::add)
+ val launcherIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LEANBACK_LAUNCHER)
+ context.packageManager.queryIntentActivities(launcherIntent, PackageManager.MATCH_ALL)
+ .mapTo(packages) { it.activityInfo.packageName }
+ return packages
+ }
+
+ private fun grantReadAccess(uris: Set) {
+ val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
+ consumerPackages().forEach { packageName ->
+ uris.forEach { uri ->
+ runCatching { context.grantUriPermission(packageName, uri, flags) }
+ }
+ }
+ }
+
+ private fun revokeReadAccess(uris: Set) {
+ val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
+ uris.forEach { uri -> runCatching { context.revokeUriPermission(uri, flags) } }
+ }
+
+ internal fun buildProgram(item: PreparedWatchNextItem): WatchNextProgram {
+ val metadata = item.metadata
+ val watchNextType = if (metadata.lastPlaybackPosition > 0) {
TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE
} else {
TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_NEXT
}
-
val builder = WatchNextProgram.Builder()
- .setType(item.type)
+ .setType(metadata.type)
.setWatchNextType(watchNextType)
- .setTitle(item.title)
- .setInternalProviderId(item.contentId)
- .setLastEngagementTimeUtcMillis(item.lastEngagementTime)
+ .setTitle(metadata.title)
+ .setInternalProviderId(metadata.contentId)
+ .setLastEngagementTimeUtcMillis(metadata.lastEngagementTime)
- item.description?.let { builder.setDescription(it) }
-
- item.posterUri?.let { uri ->
- try {
- builder.setPosterArtUri(Uri.parse(uri))
- builder.setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9)
- } catch (e: Exception) {
- Log.w(TAG, "Failed to parse poster URI: $uri", e)
+ metadata.description?.let(builder::setDescription)
+ item.localPosterUri?.let { uri ->
+ builder.setPosterArtUri(uri)
+ builder.setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9)
+ }
+ if (metadata.duration > 0) {
+ builder.setDurationMillis(metadata.duration.coerceAtMost(Int.MAX_VALUE.toLong()).toInt())
+ if (metadata.lastPlaybackPosition > 0) {
+ builder.setLastPlaybackPositionMillis(metadata.lastPlaybackPosition.coerceAtMost(Int.MAX_VALUE.toLong()).toInt())
}
}
-
- if (item.duration > 0) {
- builder.setDurationMillis(item.duration.toInt())
- if (item.lastPlaybackPosition > 0) {
- builder.setLastPlaybackPositionMillis(item.lastPlaybackPosition.toInt())
- }
+ if (metadata.type == TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE) {
+ metadata.episodeTitle?.let(builder::setEpisodeTitle)
+ metadata.seasonNumber?.let(builder::setSeasonNumber)
+ metadata.episodeNumber?.let(builder::setEpisodeNumber)
}
-
- if (item.type == TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE) {
- item.episodeTitle?.let { builder.setEpisodeTitle(it) }
- item.seasonNumber?.let { builder.setSeasonNumber(it) }
- item.episodeNumber?.let { builder.setEpisodeNumber(it) }
- }
-
- val intentUri = Uri.Builder()
- .scheme("plezy")
- .authority("play")
- .appendQueryParameter("content_id", item.contentId)
- .build()
- builder.setIntentUri(intentUri)
-
+ builder.setIntentUri(
+ Uri.Builder().scheme("plezy").authority("play")
+ .appendQueryParameter("content_id", metadata.contentId).build()
+ )
return builder.build()
}
}
diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPluginTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPluginTest.kt
index d47bb6e7..b7431392 100644
--- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPluginTest.kt
+++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPluginTest.kt
@@ -1,15 +1,20 @@
package com.edde746.plezy.exoplayer
+import android.app.Activity
import android.os.Looper
+import com.edde746.plezy.mpv.MpvPlayerCore
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
+import java.util.concurrent.CancellationException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicInteger
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
+import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
@@ -40,6 +45,119 @@ class ExoPlayerPluginTest {
assertEquals(mapOf("playerType" to "mpv"), result.successValue)
}
+ @Test
+ fun fallbackPropertyHandlersWaitForAcceptedWritesAndReplyOnce() {
+ for (case in fallbackPropertyCases()) {
+ val writes = mutableListOf>()
+ val plugin = fallbackPlugin { name, value -> writes += name to value }
+ val result = RecordingResult()
+
+ plugin.onMethodCall(MethodCall(case.method, case.arguments), result)
+ awaitCompletion(result)
+
+ assertEquals(listOf(case.expectedWrite), writes)
+ assertEquals(case.successValue, result.successValue)
+ assertEquals(1, result.completionCount)
+ assertEquals(null, result.errorCode)
+ }
+ }
+
+ @Test
+ fun fallbackPropertyHandlersMapRejectedWritesToBoundedErrorsOnce() {
+ for (case in fallbackPropertyCases()) {
+ val writes = AtomicInteger()
+ val plugin = fallbackPlugin { _, _ ->
+ writes.incrementAndGet()
+ error("secret-fallback-value")
+ }
+ val result = RecordingResult()
+
+ plugin.onMethodCall(MethodCall(case.method, case.arguments), result)
+ awaitCompletion(result)
+
+ assertEquals(1, writes.get())
+ assertEquals(1, result.completionCount)
+ assertEquals("SET_PROPERTY_FAILED", result.errorCode)
+ assertEquals("MPV property write was rejected or cancelled", result.errorMessage)
+ assertTrue(result.errorMessage?.contains("secret-fallback-value") == false)
+ assertEquals(null, result.successValue)
+ assertEquals(null, result.errorDetails)
+ }
+ }
+
+ @Test
+ fun fallbackCancellationReturnsSetPropertyFailedOnce() {
+ val plugin = fallbackPlugin { _, _ ->
+ throw CancellationException("secret-cancellation")
+ }
+ val result = RecordingResult()
+
+ plugin.onMethodCall(
+ MethodCall("setMpvProperty", mapOf("name" to "custom", "value" to "secret")),
+ result
+ )
+ awaitCompletion(result)
+
+ assertEquals(1, result.completionCount)
+ assertEquals("SET_PROPERTY_FAILED", result.errorCode)
+ assertTrue(result.errorMessage?.contains("secret") == false)
+ assertEquals(null, result.successValue)
+ }
+
+ @Test
+ fun fallbackWithoutCoreReturnsNotInitializedOnce() {
+ val plugin = ExoPlayerPlugin()
+ setField(plugin, "usingMpvFallback", true)
+ setField(plugin, "activity", Robolectric.buildActivity(Activity::class.java).setup().get())
+ val result = RecordingResult()
+
+ plugin.onMethodCall(MethodCall("pause", null), result)
+
+ assertEquals(1, result.completionCount)
+ assertEquals("NOT_INITIALIZED", result.errorCode)
+ assertEquals(null, result.successValue)
+ }
+
+ @Test
+ fun fallbackWithoutActivityReturnsNotInitializedOnce() {
+ val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
+ val core = MpvPlayerCore(activity, true) { _, _ -> Unit }
+ val plugin = ExoPlayerPlugin()
+ setField(plugin, "usingMpvFallback", true)
+ setField(plugin, "mpvCore", core)
+ val result = RecordingResult()
+
+ plugin.onMethodCall(MethodCall("play", null), result)
+
+ assertEquals(1, result.completionCount)
+ assertEquals("NOT_INITIALIZED", result.errorCode)
+ assertEquals(null, result.successValue)
+ }
+
+ @Test
+ fun genericPropertyBeforeFallbackIsAcceptedIntoLastWriteWinsPendingMap() {
+ val plugin = ExoPlayerPlugin()
+ val first = RecordingResult()
+ val second = RecordingResult()
+
+ plugin.onMethodCall(
+ MethodCall("setMpvProperty", mapOf("name" to "custom", "value" to "first")),
+ first
+ )
+ plugin.onMethodCall(
+ MethodCall("setMpvProperty", mapOf("name" to "custom", "value" to "second")),
+ second
+ )
+
+ @Suppress("UNCHECKED_CAST")
+ val pending = getField(plugin, "pendingMpvProperties") as Map
+ assertEquals(mapOf("custom" to "second"), pending)
+ assertEquals(1, first.completionCount)
+ assertEquals(1, second.completionCount)
+ assertEquals(null, first.errorCode)
+ assertEquals(null, second.errorCode)
+ }
+
@Test
fun eventCallbacksKeepTheSharedPlayerEnvelope() {
val plugin = ExoPlayerPlugin()
@@ -58,20 +176,95 @@ class ExoPlayerPluginTest {
)
}
+ private data class FallbackPropertyCase(
+ val method: String,
+ val arguments: Any?,
+ val expectedWrite: Pair,
+ val successValue: Any? = null
+ )
+
+ private fun fallbackPropertyCases() = listOf(
+ FallbackPropertyCase("play", null, "pause" to "no"),
+ FallbackPropertyCase("pause", null, "pause" to "yes"),
+ FallbackPropertyCase("setVolume", mapOf("volume" to 25), "volume" to "25.0"),
+ FallbackPropertyCase("setRate", mapOf("rate" to 1.5), "speed" to "1.5"),
+ FallbackPropertyCase("selectAudioTrack", mapOf("trackId" to "2"), "aid" to "2"),
+ FallbackPropertyCase("selectSubtitleTrack", emptyMap(), "sid" to "no"),
+ FallbackPropertyCase(
+ "setAudioPassthrough",
+ mapOf("enabled" to true),
+ "audio-spdif" to "ac3,eac3,dts,dts-hd,truehd",
+ true
+ ),
+ FallbackPropertyCase(
+ "setMpvProperty",
+ mapOf("name" to "custom", "value" to "value"),
+ "custom" to "value"
+ )
+ )
+
+ private fun fallbackPlugin(
+ writer: suspend (String, String) -> Unit
+ ): ExoPlayerPlugin {
+ val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
+ val core = MpvPlayerCore(activity, true, writer)
+ return ExoPlayerPlugin().also { plugin ->
+ setField(plugin, "activity", activity)
+ setField(plugin, "mpvCore", core)
+ setField(plugin, "usingMpvFallback", true)
+ }
+ }
+
+ private fun setField(plugin: ExoPlayerPlugin, name: String, value: Any?) {
+ plugin.javaClass.getDeclaredField(name).apply {
+ isAccessible = true
+ set(plugin, value)
+ }
+ }
+
+ private fun getField(plugin: ExoPlayerPlugin, name: String): Any? = plugin.javaClass.getDeclaredField(name).run {
+ isAccessible = true
+ get(plugin)
+ }
+
+ private fun awaitCompletion(result: RecordingResult) {
+ var completed = false
+ repeat(100) {
+ shadowOf(Looper.getMainLooper()).idle()
+ if (result.completed.await(10, TimeUnit.MILLISECONDS)) {
+ completed = true
+ return@repeat
+ }
+ }
+ shadowOf(Looper.getMainLooper()).idle()
+ assertTrue("fallback property result never completed", completed)
+ assertEquals(1, result.completionCount)
+ }
+
private class RecordingResult : MethodChannel.Result {
val completed = CountDownLatch(1)
var successValue: Any? = null
+ var errorCode: String? = null
+ var errorMessage: String? = null
+ var errorDetails: Any? = null
+ var completionCount: Int = 0
override fun success(result: Any?) {
+ completionCount++
successValue = result
completed.countDown()
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
+ completionCount++
+ this.errorCode = errorCode
+ this.errorMessage = errorMessage
+ this.errorDetails = errorDetails
completed.countDown()
}
override fun notImplemented() {
+ completionCount++
completed.countDown()
}
}
diff --git a/android/app/src/test/kotlin/com/edde746/plezy/mpv/MpvPlayerPluginTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/mpv/MpvPlayerPluginTest.kt
index d96b2916..6d430fac 100644
--- a/android/app/src/test/kotlin/com/edde746/plezy/mpv/MpvPlayerPluginTest.kt
+++ b/android/app/src/test/kotlin/com/edde746/plezy/mpv/MpvPlayerPluginTest.kt
@@ -1,5 +1,7 @@
package com.edde746.plezy.mpv
+import android.app.Activity
+import android.os.Looper
import dev.jdtech.mpv.EndFileReason
import dev.jdtech.mpv.LogLevel
import dev.jdtech.mpv.LogMessage
@@ -7,12 +9,19 @@ import dev.jdtech.mpv.MpvEvent
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
+import java.util.concurrent.CancellationException
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicInteger
+import kotlinx.coroutines.suspendCancellableCoroutine
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
+import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
@RunWith(RobolectricTestRunner::class)
class MpvPlayerPluginTest {
@@ -30,6 +39,139 @@ class MpvPlayerPluginTest {
assertNull(result.successValue)
}
+ @Test
+ fun setPropertyWithoutCoreReportsNotInitializedForVideoAndAudio() {
+ for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) {
+ val result = RecordingResult()
+
+ plugin.onMethodCall(propertyCall(), result)
+
+ assertEquals("NOT_INITIALIZED", result.errorCode)
+ assertEquals(1, result.completionCount)
+ assertNull(result.successValue)
+ }
+ }
+
+ @Test
+ fun acceptedSetPropertyCompletesOnceForVideoAndAudio() {
+ for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) {
+ val writes = AtomicInteger()
+ installCore(plugin, testCore { _, _ -> writes.incrementAndGet() })
+ val result = RecordingResult()
+
+ plugin.onMethodCall(propertyCall(), result)
+ awaitCompletion(result)
+
+ assertEquals(1, writes.get())
+ assertEquals(1, result.completionCount)
+ assertNull(result.errorCode)
+ assertNull(result.successValue)
+ }
+ }
+
+ @Test
+ fun rejectedSetPropertyFailsOnceForVideoAndAudioWithoutLeakingPayload() {
+ for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) {
+ installCore(plugin, testCore { _, _ -> error("secret-property-value") })
+ val result = RecordingResult()
+
+ plugin.onMethodCall(propertyCall(), result)
+ awaitCompletion(result)
+
+ assertEquals(1, result.completionCount)
+ assertEquals("SET_PROPERTY_FAILED", result.errorCode)
+ assertEquals("MPV property write was rejected or cancelled", result.errorMessage)
+ assertTrue(result.errorMessage?.contains("secret-property-value") == false)
+ assertNull(result.successValue)
+ assertNull(result.errorDetails)
+ }
+ }
+
+ @Test
+ fun cancelledSetPropertyFailsOnceForVideoAndAudio() {
+ for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) {
+ installCore(plugin, testCore { _, _ -> throw CancellationException("secret-cancellation") })
+ val result = RecordingResult()
+
+ plugin.onMethodCall(propertyCall(), result)
+ awaitCompletion(result)
+
+ assertEquals(1, result.completionCount)
+ assertEquals("SET_PROPERTY_FAILED", result.errorCode)
+ assertTrue(result.errorMessage?.contains("secret-cancellation") == false)
+ assertNull(result.successValue)
+ }
+ }
+
+ @Test
+ fun coreReportsMissingPlayerDuringWriteAsFailure() {
+ val core = testCore(null)
+ var outcome: Result? = null
+
+ core.setProperty("volume", "50") { outcome = it }
+ awaitCondition { outcome != null }
+
+ assertTrue(outcome?.isFailure == true)
+ }
+
+ @Test
+ fun disposeCancelsQueuedPropertyWritesAndCompletesEachCallbackOnce() {
+ val firstStarted = CountDownLatch(1)
+ val core = testCore { name, _ ->
+ if (name == "first") {
+ suspendCancellableCoroutine {
+ firstStarted.countDown()
+ }
+ }
+ }
+ val outcomes = mutableListOf>()
+
+ core.setProperty("first", "value") { outcomes += it }
+ assertTrue(firstStarted.await(1, TimeUnit.SECONDS))
+ core.setProperty("second", "value") { outcomes += it }
+ core.dispose()
+ awaitCondition { outcomes.size == 2 }
+
+ assertEquals(2, outcomes.size)
+ assertTrue(outcomes.all { it.isFailure })
+ }
+
+ @Test
+ fun failedPauseLeavesAllPauseBookkeepingUnchanged() {
+ val core = testVideoCore { _, _ -> error("rejected") }
+ setBoolean(core, "cachedPaused", false)
+ setBoolean(core, "pausedForSurfaceLoss", true)
+ setBoolean(core, "resumeBlockedByPublicPause", false)
+ setBoolean(core, "deferredResumeRequested", true)
+ var outcome: Result? = null
+
+ core.setProperty("pause", "yes") { outcome = it }
+ awaitCondition { outcome != null }
+
+ assertTrue(outcome?.isFailure == true)
+ assertEquals(false, getBoolean(core, "cachedPaused"))
+ assertEquals(true, getBoolean(core, "pausedForSurfaceLoss"))
+ assertEquals(false, getBoolean(core, "resumeBlockedByPublicPause"))
+ assertEquals(true, getBoolean(core, "deferredResumeRequested"))
+ }
+
+ @Test
+ fun resumeWithoutReadyVideoOutputIsAcceptedAndDeferredWithoutWriting() {
+ val writes = AtomicInteger()
+ val core = testVideoCore { _, _ -> writes.incrementAndGet() }
+ setBoolean(core, "resumeBlockedByPublicPause", true)
+ var outcome: Result? = null
+
+ core.setProperty("pause", "no") { outcome = it }
+ awaitCondition { outcome != null }
+
+ assertTrue(outcome?.isSuccess == true)
+ assertEquals(0, writes.get())
+ assertEquals(false, getBoolean(core, "resumeBlockedByPublicPause"))
+ assertEquals(true, getBoolean(core, "deferredResumeRequested"))
+ assertEquals(true, getBoolean(core, "cachedPaused"))
+ }
+
@Test
fun disposeCompletesEveryPendingInitialization() {
val plugin = MpvPlayerPlugin()
@@ -53,9 +195,9 @@ class MpvPlayerPluginTest {
assertEquals(false, first.successValue)
assertEquals(false, second.successValue)
assertNull(dispose.successValue)
- assertTrue(first.completed)
- assertTrue(second.completed)
- assertTrue(dispose.completed)
+ assertEquals(1, first.completionCount)
+ assertEquals(1, second.completionCount)
+ assertEquals(1, dispose.completionCount)
assertEquals(0, pending.size)
}
@@ -125,22 +267,91 @@ class MpvPlayerPluginTest {
)
}
+ private fun propertyCall() = MethodCall(
+ "setProperty",
+ mapOf("name" to "volume", "value" to "50")
+ )
+
+ private fun testCore(
+ writer: (suspend (String, String) -> Unit)?
+ ): MpvPlayerCore = MpvPlayerCore(
+ Robolectric.buildActivity(Activity::class.java).setup().get(),
+ true,
+ writer
+ )
+
+ private fun testVideoCore(
+ writer: suspend (String, String) -> Unit
+ ): MpvPlayerCore = MpvPlayerCore(
+ Robolectric.buildActivity(Activity::class.java).setup().get(),
+ false,
+ writer
+ )
+
+ private fun installCore(plugin: MpvPlayerPlugin, core: MpvPlayerCore) {
+ MpvPlayerPlugin::class.java.getDeclaredField("playerCore").apply {
+ isAccessible = true
+ set(plugin, core)
+ }
+ }
+
+ private fun setBoolean(core: MpvPlayerCore, name: String, value: Boolean) {
+ MpvPlayerCore::class.java.getDeclaredField(name).apply {
+ isAccessible = true
+ setBoolean(core, value)
+ }
+ }
+
+ private fun getBoolean(core: MpvPlayerCore, name: String): Boolean = MpvPlayerCore::class.java.getDeclaredField(name).run {
+ isAccessible = true
+ getBoolean(core)
+ }
+
+ private fun awaitCompletion(result: RecordingResult) {
+ awaitCondition { result.completed.await(10, TimeUnit.MILLISECONDS) }
+ shadowOf(Looper.getMainLooper()).idle()
+ assertEquals(1, result.completionCount)
+ }
+
+ private fun awaitCondition(condition: () -> Boolean) {
+ var completed = false
+ repeat(100) {
+ shadowOf(Looper.getMainLooper()).idle()
+ if (condition()) {
+ completed = true
+ return@repeat
+ }
+ Thread.sleep(10)
+ }
+ assertTrue("asynchronous operation never completed", completed)
+ }
+
private class RecordingResult : MethodChannel.Result {
+ val completed = CountDownLatch(1)
var successValue: Any? = null
var errorCode: String? = null
- var completed: Boolean = false
+ var errorMessage: String? = null
+ var errorDetails: Any? = null
+ var completionCount: Int = 0
override fun success(result: Any?) {
- completed = true
+ completionCount++
successValue = result
+ completed.countDown()
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
- completed = true
+ completionCount++
this.errorCode = errorCode
+ this.errorMessage = errorMessage
+ this.errorDetails = errorDetails
+ completed.countDown()
}
- override fun notImplemented() = Unit
+ override fun notImplemented() {
+ completionCount++
+ completed.countDown()
+ }
}
private class RecordingEventSink : EventChannel.EventSink {
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
new file mode 100644
index 00000000..36ab8ecc
--- /dev/null
+++ b/android/app/src/test/kotlin/com/edde746/plezy/watchnext/WatchNextProviderTest.kt
@@ -0,0 +1,198 @@
+package com.edde746.plezy.watchnext
+
+import android.content.ContentProvider
+import android.content.ContentProviderOperation
+import android.content.ContentProviderResult
+import android.content.ContentValues
+import android.content.Intent
+import android.database.Cursor
+import android.net.Uri
+import android.os.ParcelFileDescriptor.AutoCloseInputStream
+import androidx.tvprovider.media.tv.TvContractCompat
+import java.net.InetAddress
+import java.net.ServerSocket
+import java.util.Base64
+import java.util.concurrent.Executor
+import kotlin.concurrent.thread
+import org.junit.After
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.shadows.ShadowContentResolver
+
+@RunWith(RobolectricTestRunner::class)
+class WatchNextProviderTest {
+ private val context get() = RuntimeEnvironment.getApplication()
+ private lateinit var tvProvider: CapturingTvProvider
+ private val imageBytes = Base64.getDecoder().decode(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
+ )
+
+ @Before
+ fun setUp() {
+ context.cacheDir.resolve("system_shelf_artwork").deleteRecursively()
+ context.getSharedPreferences("system_shelf_state", 0).edit().clear().commit()
+ tvProvider = CapturingTvProvider()
+ ShadowContentResolver.registerProviderInternal(TvContractCompat.AUTHORITY, tvProvider)
+ }
+
+ @After
+ fun tearDown() {
+ context.cacheDir.resolve("system_shelf_artwork").deleteRecursively()
+ }
+
+ @Test
+ fun syncPersistsOnlyGrantedLocalUriAndProviderReturnsValidatedBytes() {
+ withServer("image/png", imageBytes) { source ->
+ val provider = WatchNextProvider(context)
+ assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(source))))
+ assertEquals(1, tvProvider.inserted.size)
+ val stored = tvProvider.inserted.single()
+ val poster = stored.getAsString(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI)
+ assertTrue(poster.startsWith("content://${SystemShelfArtworkProvider.AUTHORITY}/art/"))
+ assertFalse(poster.contains("http"))
+
+ val artworkProvider = Robolectric.buildContentProvider(SystemShelfArtworkProvider::class.java).create().get()
+ val localBytes = AutoCloseInputStream(artworkProvider.openFile(Uri.parse(poster), "r")).use { it.readBytes() }
+ assertArrayEquals(imageBytes, localBytes)
+ }
+ }
+
+ @Test
+ fun traversalUnknownOversizeAndMalformedArtworkAreRejectedWithoutDroppingMetadata() {
+ val store = SystemShelfArtworkStore(context.cacheDir)
+ assertNull(store.resolve(Uri.parse("content://${SystemShelfArtworkProvider.AUTHORITY}/art/../../private")))
+ assertNull(store.resolve(Uri.parse("content://${SystemShelfArtworkProvider.AUTHORITY}/art/${"a".repeat(64)}/${"b".repeat(32)}.art")))
+
+ withServer("image/png", ByteArray(SystemShelfArtworkStore.MAX_IMAGE_BYTES + 1)) { source ->
+ val provider = WatchNextProvider(context)
+ assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(source))))
+ assertNull(tvProvider.inserted.single().getAsString(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI))
+ assertEquals("Private title", tvProvider.inserted.single().getAsString(TvContractCompat.WatchNextPrograms.COLUMN_TITLE))
+ }
+
+ tvProvider.inserted.clear()
+ withServer("image/png", "not an image".toByteArray()) { source ->
+ val provider = WatchNextProvider(context)
+ assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(source))))
+ assertNull(tvProvider.inserted.single().getAsString(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI))
+ }
+
+ tvProvider.inserted.clear()
+ withServer("text/plain", imageBytes) { source ->
+ val provider = WatchNextProvider(context)
+ assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(source))))
+ assertNull(tvProvider.inserted.single().getAsString(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI))
+ }
+
+ tvProvider.inserted.clear()
+ withServer("image/png", imageBytes, delayMillis = 3_000) { source ->
+ val provider = WatchNextProvider(context)
+ assertTrue(provider.syncWatchNextPrograms("owner-a", 1, listOf(item(source))))
+ assertNull(tvProvider.inserted.single().getAsString(TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI))
+ }
+ }
+
+ @Test
+ fun staleGenerationCannotCommitAndClearRemovesRowsGrantsAndFiles() {
+ withServer("image/png", imageBytes) { source ->
+ val provider = WatchNextProvider(context)
+ assertTrue(provider.syncWatchNextPrograms("owner-a", 3, listOf(item(source))))
+ assertFalse(provider.syncWatchNextPrograms("owner-old", 2, listOf(item(source))))
+ assertTrue(context.cacheDir.resolve("system_shelf_artwork").walkTopDown().any { it.isFile })
+
+ assertTrue(provider.clearAll("owner-a", 4))
+ assertTrue(tvProvider.deleteCount >= 2)
+ assertFalse(context.cacheDir.resolve("system_shelf_artwork").exists())
+ assertTrue(context.getSharedPreferences("system_shelf_state", 0).getStringSet("granted_uris", null).isNullOrEmpty())
+ }
+ }
+
+ @Test
+ fun packageUpdateCleanupDeletesLegacyRowsAndOwnedFiles() {
+ context.cacheDir.resolve("system_shelf_artwork/legacy").apply { mkdirs() }.resolve("legacy.art").writeBytes(imageBytes)
+ val receiver = SystemShelfUpdateReceiver(Executor { command -> command.run() })
+ receiver.onReceive(context, Intent(Intent.ACTION_MY_PACKAGE_REPLACED))
+
+ assertEquals(1, tvProvider.deleteCount)
+ assertFalse(context.cacheDir.resolve("system_shelf_artwork").exists())
+ }
+
+ private fun item(source: String) = WatchNextProvider.WatchNextItem(
+ contentId = "plezy_server_item",
+ title = "Private title",
+ episodeTitle = null,
+ description = "Private summary",
+ posterSourceUri = source,
+ type = TvContractCompat.WatchNextPrograms.TYPE_MOVIE,
+ duration = 100,
+ lastPlaybackPosition = 10,
+ lastEngagementTime = 1,
+ seriesTitle = null,
+ seasonNumber = null,
+ episodeNumber = null
+ )
+
+ private fun withServer(
+ contentType: String,
+ body: ByteArray,
+ delayMillis: Long = 0,
+ block: (String) -> Unit
+ ) {
+ val server = ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))
+ val responder = thread(start = true, name = "system-shelf-test-http") {
+ server.accept().use { socket ->
+ val reader = socket.getInputStream().bufferedReader()
+ while (reader.readLine()?.isNotEmpty() == true) {
+ // Consume the local deterministic request headers.
+ }
+ if (delayMillis > 0) Thread.sleep(delayMillis)
+ val headers = (
+ "HTTP/1.1 200 OK\r\n" +
+ "Content-Type: $contentType\r\n" +
+ "Content-Length: ${body.size}\r\n" +
+ "Connection: close\r\n\r\n"
+ ).toByteArray()
+ socket.getOutputStream().use { output ->
+ output.write(headers)
+ output.write(body)
+ output.flush()
+ }
+ }
+ }
+ try {
+ block("http://127.0.0.1:${server.localPort}/art")
+ responder.join(5_000)
+ } finally {
+ server.close()
+ }
+ }
+}
+
+private class CapturingTvProvider : ContentProvider() {
+ val inserted = mutableListOf()
+ var deleteCount = 0
+
+ override fun onCreate(): Boolean = true
+ override fun insert(uri: Uri, values: ContentValues?): Uri {
+ inserted += ContentValues(values)
+ return uri.buildUpon().appendPath(inserted.size.toString()).build()
+ }
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int {
+ deleteCount++
+ inserted.clear()
+ return 1
+ }
+ override fun applyBatch(operations: ArrayList): Array = super.applyBatch(operations)
+ override fun getType(uri: Uri): String? = null
+ override fun query(uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String?): Cursor? = null
+ override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int = 0
+}
diff --git a/ios/Runner/MpvPlayer/MpvPlayerCore.swift b/ios/Runner/MpvPlayer/MpvPlayerCore.swift
index d0dd968b..0d3a5d12 100644
--- a/ios/Runner/MpvPlayer/MpvPlayerCore.swift
+++ b/ios/Runner/MpvPlayer/MpvPlayerCore.swift
@@ -12,7 +12,6 @@ class MpvPlayerCore: MpvPlayerCoreBase {
private weak var window: UIWindow?
private var mainBlankView: UIView?
private var isVisible = false
- private var isDisposed = false
private static var activeDisplayCriteriaKey: String?
private var lastDisplayCriteriaMutation: DisplayCriteriaMutation = .skipped
#if os(tvOS)
@@ -777,11 +776,7 @@ class MpvPlayerCore: MpvPlayerCoreBase {
#endif
func dispose(preserveDisplayCriteria: Bool = false) {
- // Guard double-dispose: the plugin calls dispose() then drops the
- // strong ref, which fires deinit → dispose() again. The second call
- // would re-enter and crash on weak-ref formation during dealloc.
- guard !isDisposed else { return }
- isDisposed = true
+ guard beginDisposal() else { return }
#if os(tvOS)
if preserveDisplayCriteria {
@@ -867,7 +862,7 @@ class MpvPlayerCore: MpvPlayerCoreBase {
}
@objc private func enterBackground() {
- isBackgrounded = true
+ setBackgrounded(true)
if isPipActive || isPipStarting {
print("[MpvPlayerCore] Entering background - PiP active/starting, keeping video")
return
@@ -878,7 +873,7 @@ class MpvPlayerCore: MpvPlayerCoreBase {
}
@objc private func enterForeground() {
- isBackgrounded = false
+ setBackgrounded(false)
if isPipActive {
print("[MpvPlayerCore] Entering foreground - PiP active, skipping vid restore")
return
@@ -890,7 +885,7 @@ class MpvPlayerCore: MpvPlayerCoreBase {
#if os(iOS)
@objc private func sceneDidActivate() {
- isBackgrounded = false
+ setBackgrounded(false)
if isPipActive {
return
}
diff --git a/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift b/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift
index ac0f08d8..a6417a00 100644
--- a/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift
+++ b/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift
@@ -239,7 +239,8 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
isManualPipRequest = false
stopPipTimebaseSync()
if pause {
- playerCore?.setPropertyAsync("pause", value: "yes") { [weak self] _ in
+ playerCore?.setPropertyAsync("pause", value: "yes") { [weak self] propertyResult in
+ guard case .success = propertyResult else { return }
self?.pipController?.invalidatePlaybackState()
self?.syncPipTimebase()
}
@@ -485,7 +486,8 @@ extension MpvPlayerPlugin: MpvPipDelegate {
}
func pipSetPlaying(_ playing: Bool) {
- playerCore?.setPropertyAsync("pause", value: playing ? "no" : "yes") { [weak self] _ in
+ playerCore?.setPropertyAsync("pause", value: playing ? "no" : "yes") { [weak self] propertyResult in
+ guard case .success = propertyResult else { return }
self?.pipController?.invalidatePlaybackState()
self?.syncPipTimebase()
}
diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift
index e69de29b..f631f7e0 100644
--- a/ios/RunnerTests/RunnerTests.swift
+++ b/ios/RunnerTests/RunnerTests.swift
@@ -0,0 +1,206 @@
+import Flutter
+import XCTest
+
+@testable import Runner
+
+final class ControllablePropertyCore: MpvPlayerCoreBase {
+ var nextResult: Result?
+ private(set) var propertyCalls: [(String, String)] = []
+ private var pendingCompletion: ((Result) -> Void)?
+
+ override func setPropertyAsync(
+ _ name: String,
+ value: String,
+ completion: @escaping (Result) -> Void
+ ) {
+ propertyCalls.append((name, value))
+ if let nextResult {
+ self.nextResult = nil
+ completion(nextResult)
+ } else {
+ pendingCompletion = completion
+ }
+ }
+
+ func finish(_ result: Result) {
+ let completion = pendingCompletion
+ pendingCompletion = nil
+ completion?(result)
+ }
+}
+
+final class RecordingMpvPlugin: MpvPluginShared {
+ var coreBase: MpvPlayerCoreBase?
+ var eventSink: FlutterEventSink?
+ var nameToId: [String: Int] = [:]
+ private(set) var pauseHookValues: [String] = []
+
+ init(core: MpvPlayerCoreBase?) {
+ coreBase = core
+ }
+
+ func setPlayerVisible(_ visible: Bool, restoreOnWindowVisible: Bool) {}
+ func updatePlayerFrame() {}
+
+ func didSetPauseProperty(value: String) {
+ pauseHookValues.append(value)
+ }
+}
+
+final class MpvPlayerContractTests: XCTestCase {
+ private let failure = NSError(
+ domain: "MpvPlayerContractTests",
+ code: 1,
+ userInfo: [NSLocalizedDescriptionKey: "controlled failure"]
+ )
+
+ func testSharedSetPropertyMapsSuccessFailureMissingCoreAndInvalidArguments() {
+ let core = ControllablePropertyCore()
+ let plugin = RecordingMpvPlugin(core: core)
+
+ core.nextResult = .success(())
+ let success = invokeSetProperty(plugin, name: "pause", value: "no")
+ XCTAssertEqual(success.count, 1)
+ XCTAssertNil(success[0])
+ XCTAssertEqual(plugin.pauseHookValues, ["no"])
+
+ core.nextResult = .failure(failure)
+ let rejected = invokeSetProperty(plugin, name: "pause", value: "yes")
+ XCTAssertEqual(rejected.count, 1)
+ XCTAssertEqual((rejected[0] as? FlutterError)?.code, "SET_PROPERTY_FAILED")
+ XCTAssertEqual(plugin.pauseHookValues, ["no"])
+
+ plugin.coreBase = nil
+ let missing = invokeSetProperty(plugin, name: "volume", value: "50")
+ XCTAssertEqual(missing.count, 1)
+ XCTAssertEqual((missing[0] as? FlutterError)?.code, "NOT_INITIALIZED")
+
+ var invalidResults: [Any?] = []
+ plugin.handleSetProperty(
+ call: FlutterMethodCall(methodName: "setProperty", arguments: ["name": "pause"])
+ ) {
+ invalidResults.append($0)
+ }
+ XCTAssertEqual(invalidResults.count, 1)
+ XCTAssertEqual((invalidResults[0] as? FlutterError)?.code, "INVALID_ARGS")
+ }
+
+ func testRealSetPropertyValidInvalidNonexistentAndPauseCache() {
+ let core = MpvAudioPlayerCore()
+ XCTAssertTrue(core.initialize())
+ defer {
+ core.dispose()
+ core.queue.sync {}
+ }
+
+ XCTAssertSuccess(awaitProperty(core, name: "volume", value: "50"))
+ XCTAssertTrue(core.isPaused)
+
+ XCTAssertFailure(awaitProperty(core, name: "pause", value: "not-a-flag"))
+ XCTAssertTrue(core.isPaused, "A rejected raw pause write must not change the cache")
+
+ XCTAssertFailure(
+ awaitProperty(core, name: "plezy-property-does-not-exist", value: "ignored")
+ )
+ XCTAssertTrue(core.isPaused)
+
+ XCTAssertSuccess(awaitProperty(core, name: "pause", value: "no"))
+ XCTAssertFalse(core.isPaused, "The accepted pause write must commit before completion")
+ }
+
+ func testPendingSetPropertyIsCancelledExactlyOnceOnDispose() {
+ let core = MpvAudioPlayerCore()
+ XCTAssertTrue(core.initialize())
+
+ let queueEntered = expectation(description: "mpv queue blocked")
+ let releaseQueue = DispatchSemaphore(value: 0)
+ core.queue.async {
+ queueEntered.fulfill()
+ releaseQueue.wait()
+ }
+ wait(for: [queueEntered], timeout: 2)
+
+ let completion = expectation(description: "cancelled property completion")
+ completion.assertForOverFulfill = true
+ var completionCount = 0
+ core.setPropertyAsync("volume", value: "51") { result in
+ completionCount += 1
+ if case .success = result {
+ XCTFail("Disposal must fail an accepted-but-pending property request")
+ }
+ completion.fulfill()
+ }
+
+ core.dispose()
+ releaseQueue.signal()
+ wait(for: [completion], timeout: 2)
+ core.queue.sync {}
+ XCTAssertEqual(completionCount, 1)
+ XCTAssertFailure(awaitProperty(core, name: "volume", value: "52"))
+ }
+
+ func testRapidAudioCoreReplacementOwnsLifecycleOnce() {
+ for _ in 0..<5 {
+ autoreleasepool {
+ let core = MpvAudioPlayerCore()
+ XCTAssertTrue(core.initialize())
+ core.dispose()
+ core.dispose()
+ core.queue.sync {}
+ XCTAssertFalse(core.hasActiveMpv)
+ }
+ }
+ }
+
+ private func invokeSetProperty(
+ _ plugin: RecordingMpvPlugin,
+ name: String,
+ value: String
+ ) -> [Any?] {
+ var results: [Any?] = []
+ plugin.handleSetProperty(
+ call: FlutterMethodCall(
+ methodName: "setProperty",
+ arguments: ["name": name, "value": value]
+ )
+ ) {
+ results.append($0)
+ }
+ return results
+ }
+
+ private func awaitProperty(
+ _ core: MpvPlayerCoreBase,
+ name: String,
+ value: String
+ ) -> Result {
+ let completion = expectation(description: "set \(name)")
+ var propertyResult: Result?
+ core.setPropertyAsync(name, value: value) {
+ propertyResult = $0
+ completion.fulfill()
+ }
+ wait(for: [completion], timeout: 2)
+ return propertyResult ?? .failure(failure)
+ }
+
+ private func XCTAssertSuccess(
+ _ result: Result,
+ file: StaticString = #filePath,
+ line: UInt = #line
+ ) {
+ if case .failure(let error) = result {
+ XCTFail("Expected success, received \(error)", file: file, line: line)
+ }
+ }
+
+ private func XCTAssertFailure(
+ _ result: Result,
+ file: StaticString = #filePath,
+ line: UInt = #line
+ ) {
+ if case .success = result {
+ XCTFail("Expected failure", file: file, line: line)
+ }
+ }
+}
diff --git a/lib/connection/connection_registry.dart b/lib/connection/connection_registry.dart
index 59749acc..df894293 100644
--- a/lib/connection/connection_registry.dart
+++ b/lib/connection/connection_registry.dart
@@ -45,55 +45,61 @@ class ConnectionRegistry {
/// the row's current `isDefault` (so token/metadata refreshes don't clear
/// the default flag).
Future upsert(Connection connection) async {
- final existing = await (_db.select(_db.connections)..where((t) => t.id.equals(connection.id))).getSingleOrNull();
- final bool isDefault;
- if (existing != null) {
- isDefault = existing.isDefault;
- } else {
- final any =
- await (_db.selectOnly(_db.connections)
- ..addColumns([_db.connections.id])
- ..limit(1))
- .getSingleOrNull();
- isDefault = any == null;
- }
- final protectedConfig = await CredentialVault.protectConnectionConfig(
- connection.kind.id,
- connection.toConfigJson(),
- );
- final row = ConnectionsCompanion(
- id: Value(connection.id),
- kind: Value(connection.kind.id),
- displayName: Value(connection.displayName),
- configJson: Value(jsonEncode(protectedConfig)),
- isDefault: Value(isDefault),
- createdAt: Value(connection.createdAt.millisecondsSinceEpoch),
- lastAuthenticatedAt: Value(connection.lastAuthenticatedAt?.millisecondsSinceEpoch),
- );
- await _db.into(_db.connections).insertOnConflictUpdate(row);
+ await _db.runIdentityMutation(() async {
+ final existing = await (_db.select(_db.connections)..where((t) => t.id.equals(connection.id))).getSingleOrNull();
+ final bool isDefault;
+ if (existing != null) {
+ isDefault = existing.isDefault;
+ } else {
+ final any =
+ await (_db.selectOnly(_db.connections)
+ ..addColumns([_db.connections.id])
+ ..limit(1))
+ .getSingleOrNull();
+ isDefault = any == null;
+ }
+ final protectedConfig = await CredentialVault.protectConnectionConfig(
+ connection.kind.id,
+ connection.toConfigJson(),
+ );
+ final row = ConnectionsCompanion(
+ id: Value(connection.id),
+ kind: Value(connection.kind.id),
+ displayName: Value(connection.displayName),
+ configJson: Value(jsonEncode(protectedConfig)),
+ isDefault: Value(isDefault),
+ createdAt: Value(connection.createdAt.millisecondsSinceEpoch),
+ lastAuthenticatedAt: Value(connection.lastAuthenticatedAt?.millisecondsSinceEpoch),
+ );
+ await _db.into(_db.connections).insertOnConflictUpdate(row);
+ });
appLogger.d('ConnectionRegistry: upserted ${connection.kind.id}/${connection.id}');
}
/// Remove a stored connection. If the removed row was the default, the
/// oldest remaining connection (if any) becomes default.
Future remove(String id) async {
- await (_db.delete(_db.connections)..where((t) => t.id.equals(id))).go();
- final remaining = await (_db.select(_db.connections)..orderBy([(t) => OrderingTerm.asc(t.createdAt)])).get();
- if (remaining.isNotEmpty && !remaining.any((r) => r.isDefault)) {
- await (_db.update(
- _db.connections,
- )..where((t) => t.id.equals(remaining.first.id))).write(const ConnectionsCompanion(isDefault: Value(true)));
- }
+ await _db.runIdentityMutation(() async {
+ await (_db.delete(_db.connections)..where((t) => t.id.equals(id))).go();
+ final remaining = await (_db.select(_db.connections)..orderBy([(t) => OrderingTerm.asc(t.createdAt)])).get();
+ if (remaining.isNotEmpty && !remaining.any((r) => r.isDefault)) {
+ await (_db.update(
+ _db.connections,
+ )..where((t) => t.id.equals(remaining.first.id))).write(const ConnectionsCompanion(isDefault: Value(true)));
+ }
+ });
appLogger.d('ConnectionRegistry: removed $id');
}
/// Set [id] as the default connection. Clears the flag on all others.
Future setDefault(String id) async {
- await _db.transaction(() async {
- await _db.update(_db.connections).write(const ConnectionsCompanion(isDefault: Value(false)));
- await (_db.update(
- _db.connections,
- )..where((t) => t.id.equals(id))).write(const ConnectionsCompanion(isDefault: Value(true)));
+ await _db.runIdentityMutation(() async {
+ await _db.transaction(() async {
+ await _db.update(_db.connections).write(const ConnectionsCompanion(isDefault: Value(false)));
+ await (_db.update(
+ _db.connections,
+ )..where((t) => t.id.equals(id))).write(const ConnectionsCompanion(isDefault: Value(true)));
+ });
});
}
@@ -101,13 +107,17 @@ class ConnectionRegistry {
/// `lastAuthenticatedAt`). Used by the auth flow after a successful
/// silent refresh without touching the rest of the config.
Future recordAuthSuccess(String id, DateTime at) async {
- await (_db.update(_db.connections)..where((t) => t.id.equals(id))).write(
- ConnectionsCompanion(lastAuthenticatedAt: Value(at.millisecondsSinceEpoch)),
- );
+ await _db.runIdentityMutation(() async {
+ await (_db.update(_db.connections)..where((t) => t.id.equals(id))).write(
+ ConnectionsCompanion(lastAuthenticatedAt: Value(at.millisecondsSinceEpoch)),
+ );
+ });
}
Future clear() async {
- await _db.delete(_db.connections).go();
+ await _db.runIdentityMutation(() async {
+ await _db.delete(_db.connections).go();
+ });
}
/// All Plex accounts in insertion order. Convenience over
diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart
index 8adf33af..cae5d3e1 100644
--- a/lib/database/app_database.dart
+++ b/lib/database/app_database.dart
@@ -1,14 +1,22 @@
+import 'dart:async';
+import 'dart:convert';
import 'dart:io';
import '../media/ids.dart';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
+import 'package:shared_preferences/shared_preferences.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
import 'tables.dart';
+import 'plex_metadata_recovery.dart';
+import 'tvos_database_recovery_store.dart';
import '../models/download_models.dart';
+import '../services/base_shared_preferences_service.dart';
+import '../services/credential_vault.dart';
import '../utils/app_logger.dart';
+import '../utils/serial_future_queue.dart';
import '../utils/global_key_utils.dart';
part 'app_database.g.dart';
@@ -39,6 +47,13 @@ enum OfflineActionType {
};
}
+final class AppDatabaseBootstrap {
+ const AppDatabaseBootstrap({required this.database, required this.recoveryOutcome});
+
+ final AppDatabase database;
+ final TvosDatabaseRecoveryOutcome recoveryOutcome;
+}
+
@DriftDatabase(
tables: [
DownloadedMedia,
@@ -53,15 +68,426 @@ enum OfflineActionType {
],
)
class AppDatabase extends _$AppDatabase {
- AppDatabase() : super(_openConnection());
+ AppDatabase._(QueryExecutor executor, {TvosDatabaseRecoveryStore? recoveryStore})
+ : this._withRecovery(executor, recoveryStore);
/// Test-only constructor — inject an in-memory [QueryExecutor]
/// (e.g. `NativeDatabase.memory()`) so tests don't touch real disk.
@visibleForTesting
- AppDatabase.forTesting(super.e);
+ AppDatabase.forTesting(QueryExecutor executor, {TvosDatabaseRecoveryStore? recoveryStore})
+ : this._withRecovery(executor, recoveryStore);
+ AppDatabase._withRecovery(super.e, this._recoveryStore);
+
+ final TvosDatabaseRecoveryStore? _recoveryStore;
+ final SerialFutureQueue _durabilityQueue = SerialFutureQueue();
+ static final Object _durabilityZoneKey = Object();
+ static final SerialFutureQueue _tvosRecoveryQueue = SerialFutureQueue();
+
+ /// Resolves and opens the production database, then reconciles tvOS
+ /// recovery before returning it to startup consumers.
+ static Future open({
+ bool isTvos = const bool.fromEnvironment('TVOS_BUILD'),
+ File? databaseFile,
+ SharedPreferencesWithCache? preferences,
+ QueryExecutor Function(File file)? executorFactory,
+ TvosDatabaseRecoveryStore? recoveryStore,
+ TvosDatabaseRecoveryPriorInstallEvidence? priorInstallEvidence,
+ }) async {
+ final file = databaseFile ?? await _resolveProductionDatabaseFile();
+ if (!await file.parent.exists()) {
+ await file.parent.create(recursive: true);
+ }
+ if (databaseFile == null && !Platform.isAndroid && !Platform.isIOS && !await file.exists()) {
+ await migrateLegacyDesktopDatabase(target: file);
+ }
+
+ final databaseExisted = await file.exists();
+ if (isTvos && !databaseExisted) {
+ await _removeOrphanedDatabaseSidecars(file);
+ }
+
+ final prefs = preferences ?? await BaseSharedPreferencesService.sharedCache();
+ final store = recoveryStore ?? TvosDatabaseRecoveryStore(prefs, isTvos: isTvos);
+ final database = AppDatabase._((executorFactory ?? _createNativeDatabase)(file), recoveryStore: store);
+ try {
+ final outcome = await _tvosRecoveryQueue.run(
+ () => store.reconcile(
+ databaseExisted: databaseExisted,
+ readIdentity: database._readProtectedIdentityRecoveryRows,
+ readPending: database._readPendingRecoveryRows,
+ restore: database._restoreRecoverySnapshot,
+ hasPriorInstallEvidence:
+ priorInstallEvidence ??
+ () async {
+ return (prefs.getString('active_app_profile_id')?.isNotEmpty ?? false) ||
+ (prefs.getBool('profile_migration_v1_done') ?? false) ||
+ (prefs.getString('credential_vault_key_v1')?.isNotEmpty ?? false);
+ },
+ ),
+ );
+ return AppDatabaseBootstrap(database: database, recoveryOutcome: outcome);
+ } catch (_) {
+ await database.close();
+ rethrow;
+ }
+ }
+
+ /// Wraps one complete registry identity mutation. Nested registry helpers
+ /// share the outer commit and all identity/pending commits are serialized.
+ Future runIdentityMutation(Future Function() mutation) {
+ return _runDurableMutation(TvosDatabaseRecoveryGroup.identity, mutation);
+ }
+
+ /// Establishes a fresh committed recovery generation only after a user has
+ /// acknowledged [TvosDatabaseRecoveryOutcome.recoveryRequired] by starting
+ /// a new sign-in. This keeps invalid evidence blocking automatic bootstrap
+ /// while allowing the explicit recovery path to persist new identity rows.
+ Future acknowledgeTvosDatabaseRecoveryRequired() {
+ final store = _recoveryStore;
+ if (store == null || !store.isTvos) return Future.value();
+
+ return _durabilityQueue.run(
+ () => _tvosRecoveryQueue.run(
+ () => store.acknowledgeRecoveryRequired(
+ readIdentity: _readProtectedIdentityRecoveryRows,
+ readPending: _readPendingRecoveryRows,
+ ),
+ ),
+ );
+ }
+
+ Future _runPendingMutation(Future Function() mutation) {
+ return _runDurableMutation(TvosDatabaseRecoveryGroup.pending, mutation);
+ }
+
+ Future _runDurableMutation(TvosDatabaseRecoveryGroup group, Future Function() mutation) {
+ final store = _recoveryStore;
+ if (store == null || !store.isTvos) return mutation();
+ if (Zone.current[_durabilityZoneKey] == this) return mutation();
+
+ return _durabilityQueue.run(
+ () => _tvosRecoveryQueue.run(
+ () => runZoned(
+ () => store.runDurableMutation(
+ group: group,
+ mutation: mutation,
+ readIdentity: _readProtectedIdentityRecoveryRows,
+ readPending: _readPendingRecoveryRows,
+ ),
+ zoneValues: {_durabilityZoneKey: this},
+ ),
+ ),
+ );
+ }
+
+ /// Recovery preferences are a second persisted copy of identity rows. Run
+ /// the same credential-vault cutover before reading those rows so a legacy
+ /// plaintext database can never become an authoritative plaintext image.
+ Future