fix(ios): preserve folder grants and surface picker failures

This commit is contained in:
zarzet committed 2026-10-11 14:34:35 +07:00
1 parent 118c42c4ff
commit c84fc335ac
8 files changed
+292 -28

No files matched your search

+5 -2
View File
@@ -1990,8 +1990,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
return false;
}
if (picked == null) return false;
final validation = validateIosPath(picked.path);
if (picked == null || !mounted) return false;
final validation = validateIosPath(
picked.path,
bookmark: picked.bookmark,
);
if (!validation.isValid) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
+16 -12
View File
@@ -1,4 +1,5 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
import 'package:spotiflac_android/widgets/app_switch.dart';
@@ -476,9 +477,9 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
}
Future<void> _pickDirectory(BuildContext context, WidgetRef ref) async {
if (Platform.isIOS) {
if (defaultTargetPlatform == TargetPlatform.iOS) {
_showIOSDirectoryOptions(context, ref);
} else if (Platform.isAndroid) {
} else if (defaultTargetPlatform == TargetPlatform.android) {
_showAndroidDirectoryOptions(context, ref);
}
}
@@ -632,33 +633,36 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
try {
picked = await PlatformBridge.pickIosDirectory();
} catch (e) {
if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar(
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
ctx.l10n.snackbarFolderPickerFailed(
ctx.friendlyError(e),
context.l10n.snackbarFolderPickerFailed(
context.friendlyError(e),
),
),
backgroundColor: Theme.of(ctx).colorScheme.error,
backgroundColor: Theme.of(context).colorScheme.error,
duration: const Duration(seconds: 4),
),
);
}
return;
}
if (picked == null) return;
if (picked == null || !context.mounted) return;
final validation = validateIosPath(picked.path);
final validation = validateIosPath(
picked.path,
bookmark: picked.bookmark,
);
if (!validation.isValid) {
if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar(
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
validation.errorReason ??
context.l10n.setupIcloudNotSupported,
),
backgroundColor: Theme.of(ctx).colorScheme.error,
backgroundColor: Theme.of(context).colorScheme.error,
duration: const Duration(seconds: 4),
),
);
+4 -2
View File
@@ -505,8 +505,10 @@ class _SetupScreenState extends ConsumerState<SetupScreen>
return;
}
// Validate the selected path is writable
final validation = validateIosPath(picked.path);
final validation = validateIosPath(
picked.path,
bookmark: picked.bookmark,
);
if (!validation.isValid) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
+19 -7
View File
@@ -2780,13 +2780,25 @@ class PlatformBridge {
/// active; creating it later from a bare path loses the grant.
/// Returns null when the user cancels; throws on picker/bookmark failure.
static Future<IosPickedDirectory?> pickIosDirectory() async {
final result = await _channel.invokeMethod('pickIosDirectory');
final map = _decodeNullableMapResult(result, 'pickIosDirectory');
if (map == null) return null;
final path = map['path'] as String? ?? '';
final bookmark = map['bookmark'] as String? ?? '';
if (path.isEmpty || bookmark.isEmpty) return null;
return IosPickedDirectory(path: path, bookmark: bookmark);
_log.i('Opening iOS folder picker');
try {
final result = await _channel.invokeMethod('pickIosDirectory');
final map = _decodeNullableMapResult(result, 'pickIosDirectory');
if (map == null) {
_log.i('iOS folder selection cancelled');
return null;
}
final path = map['path'] as String? ?? '';
final bookmark = map['bookmark'] as String? ?? '';
if (!path.startsWith('/') || bookmark.trim().isEmpty) {
throw const FormatException('Invalid iOS folder selection');
}
_log.i('iOS folder selected with an access bookmark');
return IosPickedDirectory(path: path, bookmark: bookmark);
} catch (error, stack) {
_log.e('iOS folder selection failed', error, stack);
rethrow;
}
}
/// Resolve a base64-encoded iOS security-scoped bookmark and start accessing
+7 -5
View File
@@ -174,11 +174,9 @@ class IosPathValidationResult {
}
/// Validates an iOS path and returns detailed information about the result.
IosPathValidationResult validateIosPath(String path) {
if (!Platform.isIOS) {
return const IosPathValidationResult(isValid: true);
}
/// A bookmark supplied by the native picker grants access independently of
/// the folder's path shape. Actual access is checked when resolving that grant.
IosPathValidationResult validateIosPath(String path, {String bookmark = ''}) {
if (path.isEmpty) {
return const IosPathValidationResult(
isValid: false,
@@ -194,6 +192,10 @@ IosPathValidationResult validateIosPath(String path) {
);
}
if (bookmark.trim().isNotEmpty) {
return const IosPathValidationResult(isValid: true);
}
if (_iosContainerRootPattern.hasMatch(path)) {
return const IosPathValidationResult(
isValid: false,
+29
View File
@@ -2,6 +2,35 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/utils/file_access.dart';
void main() {
group('validateIosPath', () {
const cloud =
'/private/var/mobile/Library/Mobile Documents/com~apple~CloudDocs/Music';
test('a Files grant takes precedence over the legacy path restriction', () {
expect(validateIosPath(cloud).isValid, isFalse);
expect(
validateIosPath(cloud, bookmark: 'native-access-bookmark').isValid,
isTrue,
);
expect(validateIosPath(cloud, bookmark: ' ').isValid, isFalse);
});
test('a bookmark still requires an absolute nonempty path', () {
for (final path in ['', 'relative/Music']) {
expect(
validateIosPath(path, bookmark: 'native-access-bookmark').isValid,
isFalse,
);
}
});
test('unbookmarked local folders retain their path validation', () {
const container = '/private/var/mobile/Containers/Data/Application/ABC';
expect(validateIosPath(container).isValid, isFalse);
expect(validateIosPath('$container/Documents/Music').isValid, isTrue);
});
});
group('shouldValidateIosOutputDir', () {
test('validates a non-empty app-folder path on iOS', () {
expect(
+181
View File
@@ -0,0 +1,181 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/settings/files_settings_page.dart';
import 'package:spotiflac_android/screens/setup_screen.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
class _SetupSettings extends SettingsNotifier {
@override
AppSettings build() => const AppSettings();
@override
Future<void> ensureLoaded() async {}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const backend = MethodChannel('com.zarz.spotiflac/backend');
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
late Completer<Map<String, String>?> picker;
late SharedPreferences prefs;
var pickerCalls = 0;
setUp(() async {
SharedPreferences.setMockInitialValues({});
prefs = await SharedPreferences.getInstance();
pickerCalls = 0;
messenger.setMockMethodCallHandler(backend, (call) async {
expect(call.method, 'pickIosDirectory');
pickerCalls++;
return picker.future;
});
});
tearDown(() {
messenger.setMockMethodCallHandler(backend, null);
});
Future<ProviderContainer> openPicker(
WidgetTester tester, {
bool mornye = false,
}) async {
picker = Completer<Map<String, String>?>();
tester.view.physicalSize = const Size(820, 1180);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final container = ProviderContainer(
overrides: [
initialSettingsProvider.overrideWithValue(
const AppSettings(downloadDirectory: '/old/Music'),
),
],
);
addTearDown(container.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: MaterialApp(
theme: mornye ? MornyeTheme.build(Brightness.light) : ThemeData(),
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const FilesSettingsPage(),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Download Directory'));
await tester.pumpAndSettle();
await tester.tap(find.text('Choose from Files'));
await tester.pump(const Duration(milliseconds: 300));
await tester.pumpAndSettle();
// The native reply arrives after its originating sheet has been disposed.
expect(find.text('Choose from Files'), findsNothing);
expect(pickerCalls, 1);
return container;
}
for (final mornye in [false, true]) {
testWidgets(
'picker failure stays visible after the sheet closes ($mornye)',
(tester) async {
final container = await openPicker(tester, mornye: mornye);
picker.completeError(
PlatformException(code: 'BOOKMARK_FAILED', message: 'Access denied'),
);
await tester.pumpAndSettle();
expect(
find.textContaining('Failed to open folder picker:'),
findsOneWidget,
);
expect(
container.read(settingsProvider).downloadDirectory,
'/old/Music',
);
expect(tester.takeException(), isNull);
},
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
);
}
testWidgets('a granted folder is saved even with a cloud-shaped path', (
tester,
) async {
final container = await openPicker(tester);
const path =
'/private/var/mobile/Library/Mobile Documents/com~apple~CloudDocs/Music';
picker.complete({'path': path, 'bookmark': 'native-access-bookmark'});
await tester.pumpAndSettle();
final settings = container.read(settingsProvider);
expect(settings.downloadDirectory, path);
expect(settings.downloadDirectoryBookmark, 'native-access-bookmark');
expect(prefs.getString('app_settings'), contains('native-access-bookmark'));
expect(tester.takeException(), isNull);
}, variant: TargetPlatformVariant.only(TargetPlatform.iOS));
testWidgets('cancelling Files preserves the previous download folder', (
tester,
) async {
final container = await openPicker(tester);
picker.complete(null);
await tester.pumpAndSettle();
expect(container.read(settingsProvider).downloadDirectory, '/old/Music');
expect(find.textContaining('Failed to open folder picker:'), findsNothing);
expect(tester.takeException(), isNull);
}, variant: TargetPlatformVariant.only(TargetPlatform.iOS));
testWidgets('setup retains the chosen folder and its grant in the draft', (
tester,
) async {
picker = Completer<Map<String, String>?>();
const permissions = MethodChannel(
'flutter.baseflow.com/permissions/methods',
);
messenger.setMockMethodCallHandler(permissions, (_) async => 1);
addTearDown(() => messenger.setMockMethodCallHandler(permissions, null));
await prefs.setString('setup_progress_v1', '{"step":3}');
tester.view.physicalSize = const Size(820, 1180);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
ProviderScope(
overrides: [settingsProvider.overrideWith(_SetupSettings.new)],
child: const MaterialApp(
locale: Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: SetupScreen(),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Select Folder'), findsOneWidget);
await tester.tap(find.text('Select Folder'));
// Setup keeps its loading indicator active while the options sheet is open.
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
await tester.tap(find.text('Choose from Files'));
await tester.pump(const Duration(milliseconds: 300));
await tester.pumpAndSettle();
expect(pickerCalls, 1);
const path =
'/private/var/mobile/Library/Mobile Documents/com~apple~CloudDocs/Music';
picker.complete({'path': path, 'bookmark': 'native-access-bookmark'});
await tester.pumpAndSettle();
final draft = jsonDecode(prefs.getString('setup_progress_v1')!) as Map;
expect(draft['directory'], path);
expect(draft['bookmark'], 'native-access-bookmark');
expect(draft['step'], 3);
expect(tester.takeException(), isNull);
}, variant: TargetPlatformVariant.only(TargetPlatform.iOS));
}
+31
View File
@@ -13,6 +13,37 @@ void main() {
.setMockMethodCallHandler(backendChannel, null);
});
test(
'iOS folder picker distinguishes cancellation from invalid replies',
() async {
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(backendChannel, (_) async => null);
expect(await PlatformBridge.pickIosDirectory(), isNull);
for (final reply in [
<String, String>{},
{'path': '/external/Music'},
{'path': 'relative/Music', 'bookmark': 'grant'},
{'path': '/external/Music', 'bookmark': ' '},
]) {
messenger.setMockMethodCallHandler(backendChannel, (_) async => reply);
await expectLater(
PlatformBridge.pickIosDirectory(),
throwsA(isA<FormatException>()),
);
}
messenger.setMockMethodCallHandler(backendChannel, (call) async {
expect(call.method, 'pickIosDirectory');
return {'path': '/external/Music', 'bookmark': 'grant'};
});
final selected = await PlatformBridge.pickIosDirectory();
expect(selected?.path, '/external/Music');
expect(selected?.bookmark, 'grant');
},
);
test('complete metadata forwards descriptor format hint', () async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(backendChannel, (call) async {