From f953c46f810e6e87780b5b9de335b1974884e9f8 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 27 Sep 2026 01:44:29 +0700 Subject: [PATCH] fix(ios): scan app documents without the Files folder picker --- .../settings/library_settings_page.dart | 27 ++-- lib/widgets/ios_library_folder_sheet.dart | 52 +++++++ test/ios_library_folder_sheet_test.dart | 138 ++++++++++++++++++ 3 files changed, 204 insertions(+), 13 deletions(-) create mode 100644 lib/widgets/ios_library_folder_sheet.dart create mode 100644 test/ios_library_folder_sheet_test.dart diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index 01a52170..51b2f9e4 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -17,6 +17,7 @@ import 'package:spotiflac_android/widgets/duplicate_review_sheet.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; +import 'package:spotiflac_android/widgets/ios_library_folder_sheet.dart'; class LibrarySettingsPage extends ConsumerStatefulWidget { const LibrarySettingsPage({super.key}); @@ -137,15 +138,16 @@ class _LibrarySettingsPageState extends ConsumerState { } } } else { - // Legacy: request permission and use file picker for older Android / iOS + // Older Android needs storage permission; iOS chooses internal or Files. if (!_hasStoragePermission) { final granted = await _requestStoragePermission(); if (!granted) return; } if (Platform.isIOS) { + if (!mounted) return; IosPickedDirectory? picked; try { - picked = await PlatformBridge.pickIosDirectory(); + picked = await showIosLibraryFolderSheet(context); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -160,17 +162,16 @@ class _LibrarySettingsPageState extends ConsumerState { } return; } - if (picked != null) { - final source = await ref - .read(localLibraryProvider.notifier) - .addSource( - path: picked.path, - displayName: picked.path, - bookmark: picked.bookmark, - ); - await ref - .read(localLibraryProvider.notifier) - .startSourceScan(source.id); + if (picked != null && mounted) { + final library = ref.read(localLibraryProvider.notifier); + final source = await library.addSource( + path: picked.path, + displayName: picked.bookmark.isEmpty + ? context.l10n.setupAppDocumentsFolder + : picked.path, + bookmark: picked.bookmark, + ); + await library.startSourceScan(source.id); } return; } diff --git a/lib/widgets/ios_library_folder_sheet.dart b/lib/widgets/ios_library_folder_sheet.dart new file mode 100644 index 00000000..38940be2 --- /dev/null +++ b/lib/widgets/ios_library_folder_sheet.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; + +enum _LibraryFolderLocation { documents, files } + +/// App-owned music does not need a grant from the system Files picker. +Future showIosLibraryFolderSheet( + BuildContext context, +) async { + final location = await showAppModalBottomSheet<_LibraryFolderLocation>( + context: context, + useRootNavigator: true, + builder: (context) => AppBottomSheet( + title: context.l10n.libraryAddFolder, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AppSheetOption( + leading: const Icon(Icons.folder_special_outlined), + title: Text(context.l10n.setupAppDocumentsFolder), + subtitle: Text(context.l10n.setupAppDocumentsFolderSubtitle), + onTap: () => + Navigator.pop(context, _LibraryFolderLocation.documents), + ), + AppSheetOption( + leading: const Icon(Icons.folder_open_outlined), + title: Text(context.l10n.setupChooseFromFiles), + subtitle: Text(context.l10n.setupChooseFromFilesSubtitle), + onTap: () => Navigator.pop(context, _LibraryFolderLocation.files), + ), + const SizedBox(height: 8), + ], + ), + ), + ), + ); + if (location == null || !context.mounted) return null; + if (location == _LibraryFolderLocation.files) { + // Keep the native picker and its bookmark for folders outside our sandbox. + return PlatformBridge.pickIosDirectory(); + } + + // Scan Documents recursively, including the SpotiFLAC subfolder created by + // setup and music the user copied into the app through Files. No bookmark is + // needed: app-owned paths can be rebased when iOS relocates the container. + final documents = await getApplicationDocumentsDirectory(); + return IosPickedDirectory(path: documents.path, bookmark: ''); +} diff --git a/test/ios_library_folder_sheet_test.dart b/test/ios_library_folder_sheet_test.dart new file mode 100644 index 00000000..84b073a0 --- /dev/null +++ b/test/ios_library_folder_sheet_test.dart @@ -0,0 +1,138 @@ +import 'dart:io'; + +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:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/ios_library_folder_sheet.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const backend = MethodChannel('com.zarz.spotiflac/backend'); + const paths = MethodChannel('plugins.flutter.io/path_provider'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + late Directory root; + late Directory documents; + late List calls; + IosPickedDirectory? selection; + Object? failure; + + setUp(() async { + root = await Directory.systemTemp.createTemp('ios-library-picker-'); + documents = Directory('${root.path}/Documents'); + calls = []; + selection = null; + failure = null; + messenger.setMockMethodCallHandler(paths, (_) async => documents.path); + messenger.setMockMethodCallHandler(backend, (call) async { + calls.add(call.method); + return {'path': '/external/Music', 'bookmark': 'granted-folder-bookmark'}; + }); + }); + tearDown(() async { + messenger.setMockMethodCallHandler(paths, null); + messenger.setMockMethodCallHandler(backend, null); + await root.delete(recursive: true); + }); + + Future openSheet(WidgetTester tester, {bool mornye = false}) async { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: mornye ? MornyeTheme.build(Brightness.dark) : ThemeData(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () async { + try { + selection = await showIosLibraryFolderSheet(context); + } catch (error) { + failure = error; + } + }, + child: const Text('Add folder'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Add folder')); + await tester.pumpAndSettle(); + } + + for (final mornye in [false, true]) { + testWidgets('internal music bypasses Files (Mornye=$mornye)', ( + tester, + ) async { + final music = File('${documents.path}/SpotiFLAC/example.flac'); + music.parent.createSync(recursive: true); + music.writeAsBytesSync([1, 2, 3]); + await openSheet(tester, mornye: mornye); + expect(find.text('Choose from Files'), findsOneWidget); + await tester.tap(find.text('App Documents Folder')); + await tester.pumpAndSettle(); + expect(selection?.path, documents.path); + expect(selection?.bookmark, isEmpty); + expect(calls, isEmpty); + expect(music.readAsBytesSync(), [1, 2, 3]); + expect(failure, isNull); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('external selection retains its native access bookmark', ( + tester, + ) async { + await openSheet(tester); + await tester.tap(find.text('Choose from Files')); + await tester.pumpAndSettle(); + expect(calls, ['pickIosDirectory']); + expect(selection?.path, '/external/Music'); + expect(selection?.bookmark, 'granted-folder-bookmark'); + expect(failure, isNull); + }); + + testWidgets('dismissing the choice does not select or create a folder', ( + tester, + ) async { + await openSheet(tester); + await tester.tapAt(const Offset(10, 10)); + await tester.pumpAndSettle(); + expect(selection, isNull); + expect(calls, isEmpty); + expect(documents.existsSync(), isFalse); + }); + + testWidgets('cancelling Files leaves the library selection unchanged', ( + tester, + ) async { + messenger.setMockMethodCallHandler(backend, (_) async => null); + await openSheet(tester); + await tester.tap(find.text('Choose from Files')); + await tester.pumpAndSettle(); + expect(selection, isNull); + expect(failure, isNull); + expect(documents.existsSync(), isFalse); + }); + + testWidgets('Files errors return to the caller for a visible error', ( + tester, + ) async { + messenger.setMockMethodCallHandler(backend, (_) async { + throw PlatformException(code: 'BOOKMARK_FAILED'); + }); + await openSheet(tester); + await tester.tap(find.text('Choose from Files')); + await tester.pumpAndSettle(); + expect(selection, isNull); + expect(failure, isA()); + expect(documents.existsSync(), isFalse); + }); +}