fix: resolve Jellyfin, logout, playback, and Android regressions

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