From 37258a6160fc33c1bb6a4c17294c8e1ed336346d Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:07:38 +0700 Subject: [PATCH] fix(setup): recover folder permissions after Android restarts --- .../spotiflac/SetupFolderPickerStoreTest.kt | 51 ++++ .../kotlin/com/zarz/spotiflac/MainActivity.kt | 46 ++- .../zarz/spotiflac/SetupFolderPickerStore.kt | 27 ++ lib/providers/settings_provider.dart | 30 +- lib/screens/setup_screen.dart | 227 ++++++++++++--- lib/services/platform_bridge.dart | 27 +- test/settings_persistence_test.dart | 88 ++++++ test/setup_screen_recovery_test.dart | 270 ++++++++++++++++++ 8 files changed, 713 insertions(+), 53 deletions(-) create mode 100644 android/app/src/androidTest/kotlin/com/zarz/spotiflac/SetupFolderPickerStoreTest.kt create mode 100644 android/app/src/main/kotlin/com/zarz/spotiflac/SetupFolderPickerStore.kt create mode 100644 test/setup_screen_recovery_test.dart diff --git a/android/app/src/androidTest/kotlin/com/zarz/spotiflac/SetupFolderPickerStoreTest.kt b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/SetupFolderPickerStoreTest.kt new file mode 100644 index 00000000..0a450254 --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/SetupFolderPickerStoreTest.kt @@ -0,0 +1,51 @@ +package com.zarz.spotiflac + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.After +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SetupFolderPickerStoreTest { + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val name = "setup_folder_picker_test_${System.nanoTime()}" + private fun store() = SetupFolderPickerStore( + context.getSharedPreferences(name, Context.MODE_PRIVATE), + ) + + @After + fun cleanup() { + context.deleteSharedPreferences(name) + } + + @Test + fun resultSurvivesStoreRecreationUntilAcknowledged() { + val payload = "{\"tree_uri\":\"content://test/tree/music\"}" + store().begin(true) + assertTrue(store().complete(payload)) + assertEquals(payload, store().read()) + store().acknowledge("stale result") + assertEquals(payload, store().read()) + store().acknowledge(payload) + assertNull(store().read()) + } + + @Test + fun cancellationIsRetainedSoRestoredSetupCanStopWaiting() { + store().begin(true) + assertTrue(store().complete(null)) + assertEquals("{}", store().read()) + store().acknowledge("{}") + assertNull(store().read()) + } + + @Test + fun otherFolderPickersDoNotChangeSetup() { + store().begin(false) + assertFalse(store().complete("{\"tree_uri\":\"other\"}")) + assertNull(store().read()) + } +} diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index cd3f8204..34d85d2a 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -103,6 +103,8 @@ class MainActivity: FlutterFragmentActivity() { "resumeLibraryScan", "parseCueSheet", "pickSafTree", + "readSetupFolderSelection", + "acknowledgeSetupFolderSelection", "safExists", "safExistsBatch", "isSafTreeAccessible", @@ -153,7 +155,9 @@ class MainActivity: FlutterFragmentActivity() { private var libraryStorageReceiver: BroadcastReceiver? = null private val pendingSessionGrantEvents = mutableListOf>() private var pendingVerificationNotification: String? = null - private var pendingSafTreeResult: MethodChannel.Result? = null + private val setupFolderPickerStore by lazy { + SetupFolderPickerStore(getSharedPreferences("setup_folder_picker", Context.MODE_PRIVATE)) + } internal val safScanLock = Any() internal var safScanProgress = SafScanProgress() private var downloadProgressStreamJob: Job? = null @@ -173,18 +177,33 @@ class MainActivity: FlutterFragmentActivity() { private val safTreeLauncher = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { activityResult -> - val result = pendingSafTreeResult ?: return@registerForActivityResult + val result = pendingSafTreeResult pendingSafTreeResult = null + fun deliver(payload: String?) { + // Android can return to a new process with no Dart method reply. + // Persist the grant/result anyway, then notify the restored setup. + try { + if (setupFolderPickerStore.complete(payload)) { + backendChannel?.invokeMethod("setupFolderSelectionChanged", null) + } + } catch (e: Exception) { + android.util.Log.e("SpotiFLAC", "Could not retain setup folder selection", e) + result?.error("saf_save_failed", "Could not save selected folder", null) + return + } + result?.success(payload) + } + if (activityResult.resultCode != Activity.RESULT_OK) { - result.success(null) + deliver(null) return@registerForActivityResult } val data = activityResult.data val uri = data?.data if (uri == null) { - result.success(null) + deliver(null) return@registerForActivityResult } @@ -216,7 +235,7 @@ class MainActivity: FlutterFragmentActivity() { storageId != null && storageId != "primary" }, ) - result.success(payload.toString()) + deliver(payload.toString()) } private fun resolveSafStorageId(treeUri: Uri): String? { @@ -275,6 +294,9 @@ class MainActivity: FlutterFragmentActivity() { ) companion object { + // audio_service keeps the engine across Activity recreation. Retain + // its outstanding picker reply for the same lifetime, not the Activity. + private var pendingSafTreeResult: MethodChannel.Result? = null private const val SAFE_API_FOR_IMPELLER = 29 private val PROBLEMATIC_GPU_PATTERNS = listOf( @@ -956,6 +978,11 @@ class MainActivity: FlutterFragmentActivity() { } override fun onDestroy() { + if (isFinishing) { + val pending = pendingSafTreeResult + pendingSafTreeResult = null + pending?.success(null) + } concertCalendarChannel?.setMethodCallHandler(null) concertCalendarChannel = null audioOutputChannel?.setMethodCallHandler(null) @@ -1198,12 +1225,14 @@ class MainActivity: FlutterFragmentActivity() { result.error("saf_unavailable", "No folder picker available on this device", null) return@launch } + setupFolderPickerStore.begin(call.argument("for_setup") == true) pendingSafTreeResult = result try { android.util.Log.i("SpotiFLAC", "Launching SAF picker via $resolver") safTreeLauncher.launch(intent) } catch (e: Exception) { pendingSafTreeResult = null + setupFolderPickerStore.complete(null) android.util.Log.e("SpotiFLAC", "Failed to launch SAF picker: ${e.message}", e) result.error( "saf_launch_failed", @@ -1212,6 +1241,13 @@ class MainActivity: FlutterFragmentActivity() { ) } } + "readSetupFolderSelection" -> { + result.success(setupFolderPickerStore.read()) + } + "acknowledgeSetupFolderSelection" -> { + setupFolderPickerStore.acknowledge(call.argument("payload") ?: "") + result.success(null) + } "safExists" -> { val uriStr = call.argument("uri") ?: "" val exists = withContext(Dispatchers.IO) { diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/SetupFolderPickerStore.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/SetupFolderPickerStore.kt new file mode 100644 index 00000000..615c53e2 --- /dev/null +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/SetupFolderPickerStore.kt @@ -0,0 +1,27 @@ +package com.zarz.spotiflac + +import android.content.SharedPreferences + +/** A setup folder result survives process death until Dart saves its draft. */ +internal class SetupFolderPickerStore(private val preferences: SharedPreferences) { + fun begin(forSetup: Boolean) { + check(preferences.edit().clear().putBoolean("pending", forSetup).commit()) { + "Could not save folder picker state" + } + } + + fun complete(payload: String?): Boolean { + if (!preferences.getBoolean("pending", false)) return false + check(preferences.edit().putBoolean("pending", false) + .putString("result", payload ?: "{}").commit()) { + "Could not save selected setup folder" + } + return true + } + + fun read(): String? = preferences.getString("result", null) + + fun acknowledge(payload: String) { + if (read() == payload) preferences.edit().remove("result").commit() + } +} diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 9c8f5d09..9962e950 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -755,9 +755,33 @@ class SettingsNotifier extends Notifier { _syncLyricsSettingsToBackend(); } - void setFirstLaunchComplete() { - state = state.copyWith(isFirstLaunch: false); - _saveSettings(); + Future setFirstLaunchComplete() async { + // The router leaves setup as soon as this flag changes. Persist the final + // folder settings first so a restart cannot send the user back to setup. + await ensureLoaded(); + final prefs = await _prefs; + while (_saveSettingsFuture != null) { + await _saveSettingsFuture; + } + final saved = Completer(); + _saveSettingsFuture = saved.future; + try { + do { + _saveQueued = false; + final completed = state.copyWith(isFirstLaunch: false); + if (!await prefs.setString( + _settingsKey, + jsonEncode(completed.toJson()), + )) { + throw StateError('Could not save completed setup'); + } + // Include settings changed during the write before publishing the flag. + if (!_saveQueued && ref.mounted) state = completed; + } while (_saveQueued); + } finally { + _saveSettingsFuture = null; + saved.complete(); + } } void setCheckForUpdates(bool enabled) { diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index 840652ab..f66f7d9e 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -1,4 +1,7 @@ +import 'dart:async'; +import 'dart:convert'; 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_snack_bar.dart'; @@ -7,6 +10,7 @@ import 'package:permission_handler/permission_handler.dart'; import 'package:path_provider/path_provider.dart'; import 'package:go_router/go_router.dart'; import 'package:device_info_plus/device_info_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/constants/language_choices.dart'; @@ -29,14 +33,23 @@ class SetupScreen extends ConsumerStatefulWidget { ConsumerState createState() => _SetupScreenState(); } -class _SetupScreenState extends ConsumerState { - final PageController _pageController = PageController(); +class _SetupScreenState extends ConsumerState + with WidgetsBindingObserver { + static const _progressKey = 'setup_progress_v1'; + final _prefs = SharedPreferences.getInstance(); + final _platform = defaultTargetPlatform; + PageController? _pageController; final ScrollController _languageScrollController = ScrollController(); + late final Future _initialization; + StreamSubscription? _folderSelectionSubscription; + Future? _folderRecovery; + bool _initializing = true; int _currentStep = 0; String _selectedLocale = 'system'; bool _storagePermissionGranted = false; bool _notificationPermissionGranted = false; + bool _notificationPermissionSkipped = false; String? _selectedDirectory; String? _selectedDirectoryBookmark; String? _selectedTreeUri; @@ -48,31 +61,108 @@ class _SetupScreenState extends ConsumerState { @override void initState() { super.initState(); - _initDeviceInfo(); + WidgetsBinding.instance.addObserver(this); + _initialization = _initDeviceInfo(); + if (_platform == TargetPlatform.android) { + _folderSelectionSubscription = PlatformBridge.setupFolderSelectionEvents() + .listen((_) => unawaited(_refreshAfterResume())); + } } @override void dispose() { - _pageController.dispose(); + WidgetsBinding.instance.removeObserver(this); + unawaited(_folderSelectionSubscription?.cancel()); + _pageController?.dispose(); _languageScrollController.dispose(); super.dispose(); } Future _initDeviceInfo() async { - if (Platform.isAndroid) { - final deviceInfo = DeviceInfoPlugin(); - final androidInfo = await deviceInfo.androidInfo; - if (!mounted) return; - setState(() { + try { + await ref.read(settingsProvider.notifier).ensureLoaded(); + if (_platform == TargetPlatform.android) { + final androidInfo = await DeviceInfoPlugin().androidInfo; _androidSdkVersion = androidInfo.version.sdkInt; - }); + } + final prefs = await _prefs; + if (!mounted) return; + _selectedLocale = ref.read(settingsProvider).locale; + final saved = prefs.getString(_progressKey); + if (saved != null) { + final draft = jsonDecode(saved) as Map; + _currentStep = ((draft['step'] as int?) ?? 0).clamp(0, _totalSteps - 1); + _selectedDirectory = draft['directory'] as String?; + _selectedDirectoryBookmark = draft['bookmark'] as String?; + _selectedTreeUri = draft['treeUri'] as String?; + _notificationPermissionSkipped = draft['skipNotifications'] == true; + } + await _checkInitialPermissions(); + if (_platform == TargetPlatform.android) await _restoreSelectedFolder(); + } catch (e, stack) { + _log.e('Failed to restore setup progress', e, stack); + } finally { + if (mounted) { + _pageController = PageController(initialPage: _currentStep); + setState(() => _initializing = false); + } } + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + unawaited(_refreshAfterResume()); + } + } + + Future _refreshAfterResume() async { + await _initialization; if (!mounted) return; - await _checkInitialPermissions(); + try { + await _checkInitialPermissions(); + if (_platform == TargetPlatform.android) await _restoreSelectedFolder(); + } catch (e, stack) { + _log.e('Failed to refresh setup permissions', e, stack); + } + } + + Future _saveProgress({int? step}) async { + final draft = jsonEncode({ + 'step': step ?? _currentStep, + 'directory': _selectedDirectory, + 'bookmark': _selectedDirectoryBookmark, + 'treeUri': _selectedTreeUri, + 'skipNotifications': _notificationPermissionSkipped, + }); + if (!await (await _prefs).setString(_progressKey, draft)) { + throw StateError('Could not save setup progress'); + } + } + + Future _restoreSelectedFolder() => _folderRecovery ??= + _readSelectedFolder().whenComplete(() => _folderRecovery = null); + + Future _readSelectedFolder() async { + final payload = await PlatformBridge.readSetupFolderSelection(); + if (payload == null || !mounted) return; + final result = jsonDecode(payload) as Map; + final uri = result['tree_uri'] as String? ?? ''; + if (uri.isNotEmpty) { + final name = result['display_name'] as String? ?? ''; + setState(() { + _selectedTreeUri = uri; + _selectedDirectory = name.isEmpty ? uri : name; + }); + await _saveProgress(); + } + // Acknowledge only after the recovered selection is durable in Dart. + await PlatformBridge.acknowledgeSetupFolderSelection(payload); + if (mounted) setState(() => _isLoading = false); } Future _checkInitialPermissions() async { - if (Platform.isIOS) { + if (_platform == TargetPlatform.iOS) { final notificationStatus = await Permission.notification.status; if (mounted) { setState(() { @@ -81,7 +171,7 @@ class _SetupScreenState extends ConsumerState { notificationStatus.isGranted || notificationStatus.isProvisional; }); } - } else if (Platform.isAndroid) { + } else if (_platform == TargetPlatform.android) { bool storageGranted = false; if (_androidSdkVersion >= 33) { @@ -115,13 +205,16 @@ class _SetupScreenState extends ConsumerState { } Future _requestStoragePermission() async { + if (_isLoading) return; final permissionAudio = context.l10n.permissionAudio; final permissionStorage = context.l10n.permissionStorage; setState(() => _isLoading = true); try { - if (Platform.isIOS) { + await _saveProgress(); + if (!mounted) return; + if (_platform == TargetPlatform.iOS) { setState(() => _storagePermissionGranted = true); - } else if (Platform.isAndroid) { + } else if (_platform == TargetPlatform.android) { bool allGranted = false; if (_androidSdkVersion >= 33) { @@ -155,6 +248,7 @@ class _SetupScreenState extends ConsumerState { } } + if (!mounted) return; setState(() => _storagePermissionGranted = allGranted); if (!allGranted && mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -167,7 +261,7 @@ class _SetupScreenState extends ConsumerState { } catch (e) { debugPrint('Permission error: $e'); } finally { - setState(() => _isLoading = false); + if (mounted) setState(() => _isLoading = false); } } @@ -196,11 +290,15 @@ class _SetupScreenState extends ConsumerState { } Future _requestNotificationPermission() async { + if (_isLoading) return; final permissionNotification = context.l10n.permissionNotification; setState(() => _isLoading = true); try { - if (Platform.isIOS) { + await _saveProgress(); + if (!mounted) return; + if (_platform == TargetPlatform.iOS) { final status = await Permission.notification.request(); + if (!mounted) return; if (status.isGranted || status.isProvisional) { setState(() => _notificationPermissionGranted = true); } else if (status.isPermanentlyDenied) { @@ -208,6 +306,7 @@ class _SetupScreenState extends ConsumerState { } } else if (_androidSdkVersion >= 33) { final status = await Permission.notification.request(); + if (!mounted) return; if (status.isGranted) { setState(() => _notificationPermissionGranted = true); } else if (status.isPermanentlyDenied) { @@ -216,8 +315,10 @@ class _SetupScreenState extends ConsumerState { } else { setState(() => _notificationPermissionGranted = true); } + } catch (e, stack) { + _log.e('Failed to request notification permission', e, stack); } finally { - setState(() => _isLoading = false); + if (mounted) setState(() => _isLoading = false); } } @@ -248,18 +349,24 @@ class _SetupScreenState extends ConsumerState { } Future _selectDirectory() async { + if (_isLoading) return; setState(() => _isLoading = true); try { - if (Platform.isIOS) { + // Persist before leaving Flutter: the OS may destroy this screen while + // the external picker is visible, including on Android 13 devices. + await _saveProgress(); + if (!mounted) return; + if (_platform == TargetPlatform.iOS) { await _showIOSDirectoryOptions(); - } else if (Platform.isAndroid) { + } else if (_platform == TargetPlatform.android) { Map? result; try { - result = await PlatformBridge.pickSafTree(); + result = await PlatformBridge.pickSafTree(forSetup: true); } catch (e) { _log.w('Failed to open Android SAF picker: $e'); if (mounted) showCannotOpenFileSnackBar(context, e); } + if (!mounted) return; if (result != null) { final treeUri = result['tree_uri'] as String? ?? ''; final displayName = result['display_name'] as String? ?? ''; @@ -270,8 +377,11 @@ class _SetupScreenState extends ConsumerState { ? displayName : treeUri; }); + await _saveProgress(); } } + await _restoreSelectedFolder(); + if (!mounted) return; // Android fallback if user cancelled SAF picker if (_selectedTreeUri == null || _selectedTreeUri!.isEmpty) { @@ -298,16 +408,21 @@ class _SetupScreenState extends ConsumerState { ), ); if (useDefault == true) { + if (!mounted) return; setState(() { _selectedTreeUri = ''; _selectedDirectory = defaultDir; }); + await _saveProgress(); } } } } + } catch (e, stack) { + _log.e('Failed to select setup folder', e, stack); + if (mounted) showCannotOpenFileSnackBar(context, e); } finally { - setState(() => _isLoading = false); + if (mounted) setState(() => _isLoading = false); } } @@ -343,11 +458,13 @@ class _SetupScreenState extends ConsumerState { title: Text(context.l10n.setupAppDocumentsFolder), onTap: () async { final dir = await _getDefaultDirectory(); + if (!mounted) return; setState(() { _selectedDirectory = dir; _selectedDirectoryBookmark = null; }); if (ctx.mounted) Navigator.pop(ctx); + await _saveProgress(); }, ), ListTile( @@ -407,10 +524,12 @@ class _SetupScreenState extends ConsumerState { } final pickedDir = picked; + if (!mounted) return; setState(() { _selectedDirectory = pickedDir.path; _selectedDirectoryBookmark = pickedDir.bookmark; }); + await _saveProgress(); }, ), const SizedBox(height: 16), @@ -421,7 +540,7 @@ class _SetupScreenState extends ConsumerState { } Future _getDefaultDirectory() async { - if (Platform.isAndroid) { + if (_platform == TargetPlatform.android) { final downloadDir = Directory('/storage/emulated/0/Download/SpotiFLAC'); try { if (!await downloadDir.exists()) { @@ -441,10 +560,12 @@ class _SetupScreenState extends ConsumerState { setState(() => _isLoading = true); try { - if (!Platform.isAndroid || + if (_platform != TargetPlatform.android || _selectedTreeUri == null || _selectedTreeUri!.isEmpty) { - final iosBookmark = Platform.isIOS ? _selectedDirectoryBookmark : null; + final iosBookmark = _platform == TargetPlatform.iOS + ? _selectedDirectoryBookmark + : null; if (iosBookmark == null || iosBookmark.isEmpty) { final dir = Directory(_selectedDirectory!); if (!await dir.exists()) { @@ -469,7 +590,8 @@ class _SetupScreenState extends ConsumerState { ); } - ref.read(settingsProvider.notifier).setFirstLaunchComplete(); + await ref.read(settingsProvider.notifier).setFirstLaunchComplete(); + await (await _prefs).remove(_progressKey); if (mounted) context.go('/tutorial'); } catch (e) { @@ -481,7 +603,7 @@ class _SetupScreenState extends ConsumerState { ); } } finally { - setState(() => _isLoading = false); + if (mounted) setState(() => _isLoading = false); } } @@ -494,20 +616,31 @@ class _SetupScreenState extends ConsumerState { } if (canProceed) { - _pageController.nextPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - setState(() => _currentStep++); + unawaited(_goToStep(_currentStep + 1)); } } void _prevPage() { - _pageController.previousPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - setState(() => _currentStep--); + unawaited(_goToStep(_currentStep - 1)); + } + + Future _goToStep(int step) async { + if (_isLoading) return; + setState(() => _isLoading = true); + try { + await _saveProgress(step: step); + if (!mounted) return; + setState(() => _currentStep = step); + await _pageController?.animateToPage( + step, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } catch (e, stack) { + _log.e('Failed to save setup step', e, stack); + } finally { + if (mounted) setState(() => _isLoading = false); + } } bool _isStepCompleted(int step) { @@ -521,7 +654,8 @@ class _SetupScreenState extends ConsumerState { case 0: return _storagePermissionGranted; case 1: - return _notificationPermissionGranted; + return _notificationPermissionGranted || + _notificationPermissionSkipped; case 2: return _selectedDirectory != null; } @@ -539,6 +673,9 @@ class _SetupScreenState extends ConsumerState { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + if (_initializing) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } final progress = (_currentStep + 1) / _totalSteps; @@ -615,7 +752,9 @@ class _SetupScreenState extends ConsumerState { ), floatingActionButton: _currentStep < _totalSteps - 1 ? FloatingActionButton.extended( - onPressed: _isStepCompleted(_currentStep) ? _nextPage : null, + onPressed: !_isLoading && _isStepCompleted(_currentStep) + ? _nextPage + : null, label: Row( children: [ Text(context.l10n.setupNext), @@ -889,7 +1028,7 @@ class _SetupScreenState extends ConsumerState { title: context.l10n.setupNotificationEnable, description: context.l10n.setupNotificationBackgroundDescription, icon: Icons.notifications, - child: _notificationPermissionGranted + child: _notificationPermissionGranted || _notificationPermissionSkipped ? _SuccessCard( text: context.l10n.setupNotificationGranted, colorScheme: colorScheme, @@ -908,8 +1047,10 @@ class _SetupScreenState extends ConsumerState { ), ), TextButton( - onPressed: () => - setState(() => _notificationPermissionGranted = true), + onPressed: () async { + setState(() => _notificationPermissionSkipped = true); + await _saveProgress(); + }, child: Text(context.l10n.setupSkipForNow), ), ], @@ -920,7 +1061,7 @@ class _SetupScreenState extends ConsumerState { Widget _buildDirectoryStep(ColorScheme colorScheme) { return _StepLayout( title: context.l10n.setupFolderChoose, - description: Platform.isAndroid + description: _platform == TargetPlatform.android ? context.l10n.storageDownloadFolderHint : context.l10n.setupFolderDescription, icon: Icons.create_new_folder, diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 65eb8188..4af420a5 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -320,6 +320,8 @@ class PlatformBridge { StreamController.broadcast(); static final StreamController _verificationNotificationEvents = StreamController.broadcast(); + static final StreamController _setupFolderSelectionEvents = + StreamController.broadcast(); static final StreamController> _iosBackgroundDownloadExpirationEvents = StreamController>.broadcast(); @@ -345,6 +347,11 @@ class PlatformBridge { return _verificationNotificationEvents.stream; } + static Stream setupFolderSelectionEvents() { + _ensureBackendEventHandler(); + return _setupFolderSelectionEvents.stream; + } + static Future consumeVerificationNotification() => _channel.invokeMethod('consumeVerificationNotification'); @@ -358,6 +365,9 @@ class PlatformBridge { _backendEventHandlerInstalled = true; _channel.setMethodCallHandler((call) async { switch (call.method) { + case 'setupFolderSelectionChanged': + _setupFolderSelectionEvents.add(null); + return null; case 'libraryScanError': final args = call.arguments; if (args is Map) { @@ -999,11 +1009,24 @@ class PlatformBridge { return result as String; } - static Future?> pickSafTree() async { - final result = await _channel.invokeMethod('pickSafTree'); + static Future?> pickSafTree({ + bool forSetup = false, + }) async { + final result = await _channel.invokeMethod( + 'pickSafTree', + forSetup ? {'for_setup': true} : null, + ); return _decodeNullableMapResult(result, 'pickSafTree'); } + static Future readSetupFolderSelection() => + _channel.invokeMethod('readSetupFolderSelection'); + + static Future acknowledgeSetupFolderSelection(String payload) => + _channel.invokeMethod('acknowledgeSetupFolderSelection', { + 'payload': payload, + }); + static Future safExists(String uri) async { final result = await _channel.invokeMethod('safExists', {'uri': uri}); return result as bool; diff --git a/test/settings_persistence_test.dart b/test/settings_persistence_test.dart index 38341ec2..14aec85f 100644 --- a/test/settings_persistence_test.dart +++ b/test/settings_persistence_test.dart @@ -40,9 +40,97 @@ class _Settings extends SettingsNotifier { @override AppSettings build() => const AppSettings(); + + @override + Future ensureLoaded() async {} } void main() { + test( + 'setup completes only after folder and completion flag are durable', + () async { + final preferences = _Preferences(); + final container = ProviderContainer( + overrides: [ + settingsProvider.overrideWith(() => _Settings(preferences)), + ], + ); + addTearDown(container.dispose); + final notifier = container.read(settingsProvider.notifier); + notifier.setStorageMode('saf'); + await preferences.whenStarted(0); + notifier.setDownloadTreeUri( + 'content://test/tree/music', + displayName: 'Music', + ); + final finish = notifier.setFirstLaunchComplete(); + expect(container.read(settingsProvider).isFirstLaunch, isTrue); + preferences.release(0).complete(true); + await preferences.whenStarted(1); + preferences.release(1).complete(true); + await preferences.whenStarted(2); + expect(preferences.writes.last['isFirstLaunch'], isFalse); + expect( + preferences.writes.last['downloadTreeUri'], + 'content://test/tree/music', + ); + expect(container.read(settingsProvider).isFirstLaunch, isTrue); + preferences.release(2).complete(true); + await finish; + expect(container.read(settingsProvider).isFirstLaunch, isFalse); + }, + ); + + test('failed setup persistence keeps the user in setup', () async { + final preferences = _Preferences(); + final container = ProviderContainer( + overrides: [settingsProvider.overrideWith(() => _Settings(preferences))], + ); + addTearDown(container.dispose); + final notifier = container.read(settingsProvider.notifier); + final finish = notifier.setFirstLaunchComplete(); + final failed = expectLater(finish, throwsStateError); + await preferences.whenStarted(0); + preferences.release(0).complete(false); + await failed; + expect(container.read(settingsProvider).isFirstLaunch, isTrue); + }); + + test( + 'settings changed during setup completion cannot overwrite its flag', + () async { + final preferences = _Preferences(); + final container = ProviderContainer( + overrides: [ + settingsProvider.overrideWith(() => _Settings(preferences)), + ], + ); + addTearDown(container.dispose); + final notifier = container.read(settingsProvider.notifier); + final finish = notifier.setFirstLaunchComplete(); + await preferences.whenStarted(0); + notifier.setDownloadTreeUri( + 'content://test/tree/latest', + displayName: 'Latest', + ); + preferences.release(0).complete(true); + await preferences.whenStarted(1); + expect(container.read(settingsProvider).isFirstLaunch, isTrue); + expect(preferences.writes.last['isFirstLaunch'], isFalse); + expect( + preferences.writes.last['downloadTreeUri'], + 'content://test/tree/latest', + ); + preferences.release(1).complete(true); + await finish; + expect(container.read(settingsProvider).isFirstLaunch, isFalse); + expect( + container.read(settingsProvider).downloadTreeUri, + 'content://test/tree/latest', + ); + }, + ); + test('queued save callers await the complete coalescing drain', () async { final preferences = _Preferences(); final container = ProviderContainer( diff --git a/test/setup_screen_recovery_test.dart b/test/setup_screen_recovery_test.dart new file mode 100644 index 00000000..4e3640b0 --- /dev/null +++ b/test/setup_screen_recovery_test.dart @@ -0,0 +1,270 @@ +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:permission_handler/permission_handler.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/setup_screen.dart'; + +class _Settings extends SettingsNotifier { + @override + AppSettings build() => const AppSettings(); + + @override + Future ensureLoaded() async {} +} + +const _backend = MethodChannel('com.zarz.spotiflac/backend'); +const _device = MethodChannel('dev.fluttercommunity.plus/device_info'); +const _permissions = MethodChannel('flutter.baseflow.com/permissions/methods'); +const _treeUri = + 'content://com.android.externalstorage.documents/tree/primary%3AMusic'; +const _selection = '{"tree_uri":"$_treeUri","display_name":"Music"}'; + +Widget _setup() => ProviderScope( + overrides: [settingsProvider.overrideWith(_Settings.new)], + child: const MaterialApp( + locale: Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: SetupScreen(), + ), +); + +void main() { + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + final messenger = binding.defaultBinaryMessenger; + late SharedPreferences prefs; + late Completer picker; + String? retainedSelection; + var pickerCalls = 0; + var permissionRequests = 0; + var audioGranted = true; + var notificationsGranted = true; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + retainedSelection = null; + pickerCalls = permissionRequests = 0; + audioGranted = notificationsGranted = true; + messenger.setMockMethodCallHandler( + _device, + (_) async => { + 'version': { + 'sdkInt': 33, + 'release': '13', + 'codename': 'REL', + 'incremental': '1', + }, + for (final key in [ + 'board', + 'bootloader', + 'brand', + 'device', + 'display', + 'fingerprint', + 'hardware', + 'host', + 'id', + 'manufacturer', + 'model', + 'product', + 'tags', + 'type', + ]) + key: 'test', + 'isPhysicalDevice': false, + 'isLowRamDevice': false, + 'freeDiskSize': 1000, + 'totalDiskSize': 2000, + 'physicalRamSize': 8000, + 'availableRamSize': 4000, + }, + ); + messenger.setMockMethodCallHandler(_permissions, (call) async { + if (call.method == 'checkPermissionStatus') { + final granted = call.arguments == Permission.notification.value + ? notificationsGranted + : audioGranted; + return granted + ? PermissionStatus.granted.index + : PermissionStatus.denied.index; + } + if (call.method == 'requestPermissions') permissionRequests++; + throw StateError('Unexpected permission request: ${call.method}'); + }); + messenger.setMockMethodCallHandler(_backend, (call) async { + switch (call.method) { + case 'pickSafTree': + picker = Completer(); + pickerCalls++; + expect(call.arguments, {'for_setup': true}); + final draft = + jsonDecode(prefs.getString('setup_progress_v1')!) + as Map; + expect(draft['step'], 4); + return picker.future; + case 'readSetupFolderSelection': + return retainedSelection; + case 'acknowledgeSetupFolderSelection': + expect((call.arguments as Map)['payload'], retainedSelection); + if (retainedSelection == _selection) { + // The native copy must not be removed before the Dart draft is safe. + final draft = + jsonDecode(prefs.getString('setup_progress_v1')!) + as Map; + expect(draft['treeUri'], _treeUri); + } + retainedSelection = null; + return null; + default: + return null; + } + }); + }); + + tearDown(() { + for (final channel in [_device, _permissions, _backend]) { + messenger.setMockMethodCallHandler(channel, null); + } + }); + + Future openFolderStep(WidgetTester tester) async { + tester.view.physicalSize = const Size(430, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget(_setup()); + await tester.pumpAndSettle(); + expect(find.text('1/5'), findsOneWidget); + for (var step = 0; step < 4; step++) { + await tester.tap(find.text('Next')); + await tester.pumpAndSettle(); + } + expect(find.text('5/5'), findsOneWidget); + } + + testWidgets('normal picker return saves folder without restarting setup', ( + tester, + ) async { + await openFolderStep(tester); + await tester.tap(find.text('Select Folder')); + await tester.pump(); + retainedSelection = _selection; + picker.complete(_selection); + await tester.pumpAndSettle(); + expect(find.text('5/5'), findsOneWidget); + expect(find.text('Music'), findsOneWidget); + expect(retainedSelection, isNull); + expect(pickerCalls, 1); + expect(permissionRequests, 0); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'recreated setup recovers folder even when the old reply is lost', + (tester) async { + await openFolderStep(tester); + await tester.tap(find.text('Select Folder')); + await tester.pump(); + expect(pickerCalls, 1); + await tester.pumpWidget(const SizedBox()); + retainedSelection = _selection; + await tester.pumpWidget(_setup()); + await tester.pumpAndSettle(); + expect(find.text('5/5'), findsOneWidget); + expect(find.text('Music'), findsOneWidget); + expect(retainedSelection, isNull); + expect(permissionRequests, 0); + // A late reply must not call setState on the disposed setup screen. + picker.complete(_selection); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + // Check the next cold start as well, after native acknowledgement. + await tester.pumpWidget(const SizedBox()); + await tester.pumpWidget(_setup()); + await tester.pumpAndSettle(); + expect(find.text('5/5'), findsOneWidget); + expect(find.text('Music'), findsOneWidget); + }, + ); + + testWidgets('result arriving after recreation is recovered by native event', ( + tester, + ) async { + await prefs.setString('setup_progress_v1', '{"step":4}'); + await tester.pumpWidget(_setup()); + await tester.pumpAndSettle(); + expect(find.text('5/5'), findsOneWidget); + retainedSelection = _selection; + await messenger.handlePlatformMessage( + _backend.name, + const StandardMethodCodec().encodeMethodCall( + const MethodCall('setupFolderSelectionChanged'), + ), + (_) {}, + ); + await tester.pumpAndSettle(); + expect(find.text('Music'), findsOneWidget); + expect(retainedSelection, isNull); + }); + + testWidgets('cancelled result preserves step and permits a new selection', ( + tester, + ) async { + await prefs.setString('setup_progress_v1', '{"step":4}'); + retainedSelection = '{}'; + await tester.pumpWidget(_setup()); + await tester.pumpAndSettle(); + expect(find.text('5/5'), findsOneWidget); + expect(find.text('Select Folder'), findsOneWidget); + expect(retainedSelection, isNull); + await tester.tap(find.text('Select Folder')); + await tester.pump(); + expect(pickerCalls, 1); + picker.complete(_selection); + await tester.pumpAndSettle(); + expect(find.text('Music'), findsOneWidget); + }); + + testWidgets('resume rechecks granted permissions without requesting again', ( + tester, + ) async { + await prefs.setString('setup_progress_v1', '{"step":2}'); + audioGranted = false; + await tester.pumpWidget(_setup()); + await tester.pumpAndSettle(); + expect(find.text('Grant Permission'), findsOneWidget); + binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + audioGranted = true; + binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pumpAndSettle(); + expect(find.text('Storage Permission Granted!'), findsOneWidget); + expect(permissionRequests, 0); + expect(find.text('3/5'), findsOneWidget); + }); + + testWidgets('skipping notifications survives reopening setup', ( + tester, + ) async { + await prefs.setString('setup_progress_v1', '{"step":3}'); + notificationsGranted = false; + await tester.pumpWidget(_setup()); + await tester.pumpAndSettle(); + await tester.tap(find.text('Skip for now')); + await tester.pumpAndSettle(); + await tester.pumpWidget(const SizedBox()); + await tester.pumpWidget(_setup()); + await tester.pumpAndSettle(); + await tester.tap(find.text('Next')); + await tester.pumpAndSettle(); + expect(find.text('5/5'), findsOneWidget); + expect(permissionRequests, 0); + }); +}