fix(setup): recover folder permissions after Android restarts

This commit is contained in:
zarzet committed 2026-10-09 18:07:38 +07:00
1 parent f870cafd07
commit 37258a6160
8 files changed
+713 -53

No files matched your search

@@ -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())
}
}
@@ -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<Map<String, Any>>()
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<Boolean>("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<String>("payload") ?: "")
result.success(null)
}
"safExists" -> {
val uriStr = call.argument<String>("uri") ?: ""
val exists = withContext(Dispatchers.IO) {
@@ -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()
}
}
+27 -3
View File
@@ -755,9 +755,33 @@ class SettingsNotifier extends Notifier<AppSettings> {
_syncLyricsSettingsToBackend();
}
void setFirstLaunchComplete() {
state = state.copyWith(isFirstLaunch: false);
_saveSettings();
Future<void> 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<void>();
_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) {
+184 -43
View File
@@ -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<SetupScreen> createState() => _SetupScreenState();
}
class _SetupScreenState extends ConsumerState<SetupScreen> {
final PageController _pageController = PageController();
class _SetupScreenState extends ConsumerState<SetupScreen>
with WidgetsBindingObserver {
static const _progressKey = 'setup_progress_v1';
final _prefs = SharedPreferences.getInstance();
final _platform = defaultTargetPlatform;
PageController? _pageController;
final ScrollController _languageScrollController = ScrollController();
late final Future<void> _initialization;
StreamSubscription<void>? _folderSelectionSubscription;
Future<void>? _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<SetupScreen> {
@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<void> _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<String, dynamic>;
_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<void> _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<void> _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<void> _restoreSelectedFolder() => _folderRecovery ??=
_readSelectedFolder().whenComplete(() => _folderRecovery = null);
Future<void> _readSelectedFolder() async {
final payload = await PlatformBridge.readSetupFolderSelection();
if (payload == null || !mounted) return;
final result = jsonDecode(payload) as Map<String, dynamic>;
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<void> _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<SetupScreen> {
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<SetupScreen> {
}
Future<void> _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<SetupScreen> {
}
}
if (!mounted) return;
setState(() => _storagePermissionGranted = allGranted);
if (!allGranted && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -167,7 +261,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
} catch (e) {
debugPrint('Permission error: $e');
} finally {
setState(() => _isLoading = false);
if (mounted) setState(() => _isLoading = false);
}
}
@@ -196,11 +290,15 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
}
Future<void> _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<SetupScreen> {
}
} 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<SetupScreen> {
} 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<SetupScreen> {
}
Future<void> _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<String, dynamic>? 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<SetupScreen> {
? 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<SetupScreen> {
),
);
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<SetupScreen> {
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<SetupScreen> {
}
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<SetupScreen> {
}
Future<String> _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<SetupScreen> {
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<SetupScreen> {
);
}
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<SetupScreen> {
);
}
} finally {
setState(() => _isLoading = false);
if (mounted) setState(() => _isLoading = false);
}
}
@@ -494,20 +616,31 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
}
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<void> _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<SetupScreen> {
case 0:
return _storagePermissionGranted;
case 1:
return _notificationPermissionGranted;
return _notificationPermissionGranted ||
_notificationPermissionSkipped;
case 2:
return _selectedDirectory != null;
}
@@ -539,6 +673,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
@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<SetupScreen> {
),
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<SetupScreen> {
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<SetupScreen> {
),
),
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<SetupScreen> {
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,
+25 -2
View File
@@ -320,6 +320,8 @@ class PlatformBridge {
StreamController<void>.broadcast();
static final StreamController<void> _verificationNotificationEvents =
StreamController<void>.broadcast();
static final StreamController<void> _setupFolderSelectionEvents =
StreamController<void>.broadcast();
static final StreamController<List<String>>
_iosBackgroundDownloadExpirationEvents =
StreamController<List<String>>.broadcast();
@@ -345,6 +347,11 @@ class PlatformBridge {
return _verificationNotificationEvents.stream;
}
static Stream<void> setupFolderSelectionEvents() {
_ensureBackendEventHandler();
return _setupFolderSelectionEvents.stream;
}
static Future<String?> consumeVerificationNotification() =>
_channel.invokeMethod<String>('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<Map<String, dynamic>?> pickSafTree() async {
final result = await _channel.invokeMethod('pickSafTree');
static Future<Map<String, dynamic>?> pickSafTree({
bool forSetup = false,
}) async {
final result = await _channel.invokeMethod(
'pickSafTree',
forSetup ? {'for_setup': true} : null,
);
return _decodeNullableMapResult(result, 'pickSafTree');
}
static Future<String?> readSetupFolderSelection() =>
_channel.invokeMethod<String>('readSetupFolderSelection');
static Future<void> acknowledgeSetupFolderSelection(String payload) =>
_channel.invokeMethod<void>('acknowledgeSetupFolderSelection', {
'payload': payload,
});
static Future<bool> safExists(String uri) async {
final result = await _channel.invokeMethod('safExists', {'uri': uri});
return result as bool;
+88
View File
@@ -40,9 +40,97 @@ class _Settings extends SettingsNotifier {
@override
AppSettings build() => const AppSettings();
@override
Future<void> 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(
+270
View File
@@ -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<void> 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<String?> 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<String?>();
pickerCalls++;
expect(call.arguments, {'for_setup': true});
final draft =
jsonDecode(prefs.getString('setup_progress_v1')!)
as Map<String, dynamic>;
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<String, dynamic>;
expect(draft['treeUri'], _treeUri);
}
retainedSelection = null;
return null;
default:
return null;
}
});
});
tearDown(() {
for (final channel in [_device, _permissions, _backend]) {
messenger.setMockMethodCallHandler(channel, null);
}
});
Future<void> 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);
});
}