mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 23:11:45 +02:00
fix(setup): recover folder permissions after Android restarts
This commit is contained in:
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()
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user