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