mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 21:02:09 +02:00
fix(ios): scan app documents without the Files folder picker
This commit is contained in:
@@ -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<LibrarySettingsPage> {
|
||||
}
|
||||
}
|
||||
} 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<LibrarySettingsPage> {
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<IosPickedDirectory?> 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: '');
|
||||
}
|
||||
@@ -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<String> 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<void> 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<PlatformException>());
|
||||
expect(documents.existsSync(), isFalse);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user