diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 301ba0a0..31c707cf 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -21,6 +21,9 @@ android:label="SpotiFLAC Mobile" android:name="${applicationName}" android:icon="@mipmap/ic_launcher" + android:allowBackup="false" + android:fullBackupContent="@xml/backup_rules_legacy" + android:dataExtractionRules="@xml/backup_rules" android:usesCleartextTraffic="false" android:networkSecurityConfig="@xml/network_security_config" android:enableOnBackInvokedCallback="true" 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 2e2c46b1..af79e6dc 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -1980,6 +1980,48 @@ class MainActivity: FlutterFragmentActivity() { } } + /** + * Creates an install-scoped marker in noBackupFilesDir. A platform restore + * can bring SharedPreferences back after reinstall, but this marker is never + * backed up. Package timestamps distinguish that case from the first app + * update after this marker was introduced, so existing users are not sent + * through onboarding again. + */ + private fun ensureInstallMarker(): Map { + val marker = File(noBackupFilesDir, "installation_state_v1") + val markerExisted = marker.isFile + val packageInfo = @Suppress("DEPRECATION") + packageManager.getPackageInfo(packageName, 0) + val installTimestampDelta = kotlin.math.abs( + packageInfo.lastUpdateTime - packageInfo.firstInstallTime + ) + val looksLikeFreshPackageInstall = installTimestampDelta <= 10_000L + + var markerCreated = markerExisted + if (!markerExisted) { + markerCreated = try { + marker.parentFile?.mkdirs() + marker.writeText( + "created_at=${System.currentTimeMillis()}\n" + + "version_code=${BuildConfig.VERSION_CODE}\n" + ) + true + } catch (e: Exception) { + android.util.Log.w( + "SpotiFLAC", + "Failed to create installation marker: ${e.message}" + ) + false + } + } + + return mapOf( + "marker_existed" to markerExisted, + "marker_created" to markerCreated, + "fresh_package_install" to looksLikeFreshPackageInstall, + ) + } + override fun onCreate(savedInstanceState: Bundle?) { // Ensure the shared audio_service engine exists before the activity // delegate looks it up by cached id (see getCachedEngineId above). @@ -2189,6 +2231,12 @@ class MainActivity: FlutterFragmentActivity() { scope.launch { try { when (call.method) { + "ensureInstallMarker" -> { + val installState = withContext(Dispatchers.IO) { + ensureInstallMarker() + } + result.success(installState) + } "exitApp" -> { flutterBackCallback?.isEnabled = false if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { diff --git a/android/app/src/main/res/xml/backup_rules.xml b/android/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 00000000..0d19ed83 --- /dev/null +++ b/android/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/xml/backup_rules_legacy.xml b/android/app/src/main/res/xml/backup_rules_legacy.xml new file mode 100644 index 00000000..3666dd36 --- /dev/null +++ b/android/app/src/main/res/xml/backup_rules_legacy.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/lib/app.dart b/lib/app.dart index 6b7421ef..42afb9e8 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -11,22 +11,29 @@ import 'package:spotiflac_android/services/app_navigation_service.dart'; import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart'; import 'package:spotiflac_android/l10n/app_localizations.dart'; +String initialLocationForAppState({ + required bool isFirstLaunch, + required bool hasCompletedTutorial, +}) { + if (isFirstLaunch) return '/setup'; + if (!hasCompletedTutorial) return '/tutorial'; + return '/'; +} + final _routerProvider = Provider((ref) { - final isFirstLaunch = ref.watch( - settingsProvider.select((s) => s.isFirstLaunch), - ); - final hasCompletedTutorial = ref.watch( - settingsProvider.select((s) => s.hasCompletedTutorial), + final routingSettings = ref.watch( + settingsProvider.select( + (settings) => ( + isFirstLaunch: settings.isFirstLaunch, + hasCompletedTutorial: settings.hasCompletedTutorial, + ), + ), ); - String initialLocation; - if (isFirstLaunch) { - initialLocation = '/setup'; - } else if (!hasCompletedTutorial) { - initialLocation = '/tutorial'; - } else { - initialLocation = '/'; - } + final initialLocation = initialLocationForAppState( + isFirstLaunch: routingSettings.isFirstLaunch, + hasCompletedTutorial: routingSettings.hasCompletedTutorial, + ); return GoRouter( navigatorKey: AppNavigationService.rootNavigatorKey, diff --git a/lib/main.dart b/lib/main.dart index 49c54764..8a7a3a95 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/app.dart'; +import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; @@ -16,6 +17,7 @@ import 'package:spotiflac_android/services/notification_service.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/share_intent_service.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; +import 'package:spotiflac_android/services/app_state_database.dart'; import 'package:spotiflac_android/utils/local_library_scan_prefs.dart'; import 'package:spotiflac_android/utils/logger.dart'; @@ -39,8 +41,12 @@ void main() { }; final prefs = await SharedPreferences.getInstance(); + await _prepareAndroidInstallationState(prefs); final bootstrapSettings = loadBootstrapSettings(prefs); final bootstrapTheme = loadBootstrapThemeSettings(prefs); + final initialSafAccessLost = await _detectInitialSafAccessLoss( + bootstrapSettings, + ); final runtimeProfile = await _resolveRuntimeProfile(prefs); _configureImageCache(runtimeProfile); @@ -54,6 +60,9 @@ void main() { runtimeProfile.enableBackdropBlur, ), initialSettingsProvider.overrideWithValue(bootstrapSettings), + initialSafAccessLostProvider.overrideWithValue( + initialSafAccessLost, + ), initialThemeSettingsProvider.overrideWithValue(bootstrapTheme), ], child: _EagerInitialization( @@ -72,6 +81,53 @@ void main() { const _runtimeProfileTierKey = 'runtime_profile_tier_v1'; +Future _prepareAndroidInstallationState(SharedPreferences prefs) async { + if (!Platform.isAndroid) return; + + try { + final hasPersistedSettings = hasPersistedAppSettings(prefs); + final installState = await PlatformBridge.ensureInstallMarker(); + final shouldReset = shouldResetRestoredInstallation( + hasPersistedSettings: hasPersistedSettings, + installState: installState, + ); + if (!shouldReset) return; + + _log.w( + 'Android restored state into a fresh installation; resetting ' + 'installation-bound settings', + ); + await resetRestoredInstallationSettings(prefs); + await prefs.remove(_runtimeProfileTierKey); + await prefs.remove(localLibraryLastScannedAtKey); + await AppStateDatabase.instance.clearPendingQueueAfterInstallationRestore(); + } catch (e) { + // Startup SAF validation remains the second line of defense if an OEM or + // bridge implementation prevents install-marker inspection. + _log.w('Failed to inspect restored installation state: $e'); + } +} + +Future _detectInitialSafAccessLoss(AppSettings settings) async { + if (!Platform.isAndroid || + settings.isFirstLaunch || + settings.storageMode != 'saf' || + settings.downloadTreeUri.isEmpty) { + return false; + } + + try { + return !await PlatformBridge.validateSafTreeAccess( + settings.downloadTreeUri, + ); + } catch (e) { + // A transient bridge failure must not trap the user at launch. Download + // preflight validates strictly again before any write starts. + _log.w('Failed to validate SAF access during startup: $e'); + return false; + } +} + Future<_RuntimeProfile> _resolveRuntimeProfile(SharedPreferences prefs) async { final cachedTier = prefs.getString(_runtimeProfileTierKey); if (cachedTier != null) { diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 55264a0d..1b023a46 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -2126,15 +2126,13 @@ class DownloadQueueNotifier extends Notifier { } else { _log.d('Output directory: SAF (tree_uri=${settings.downloadTreeUri})'); try { - final testResult = await PlatformBridge.createSafFileFromPath( - treeUri: settings.downloadTreeUri, - relativeDir: '', - fileName: '.spotiflac_test', - mimeType: 'application/octet-stream', - srcPath: '', + final accessible = await PlatformBridge.validateSafTreeAccess( + settings.downloadTreeUri, ); - if (testResult != null) { - await PlatformBridge.safDelete(testResult); + if (!accessible) { + throw const FileSystemException( + 'Persisted SAF tree grant is missing or no longer writable', + ); } } catch (e) { _log.e('SAF permission validation failed: $e'); diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 8c598e14..029c71aa 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -26,6 +26,16 @@ final initialSettingsProvider = Provider( (ref) => const AppSettings(), ); +/// Set during bootstrap when the saved Android SAF tree no longer has a valid +/// persisted grant. MainShell consumes this once to block downloads until the +/// user explicitly repairs or changes the destination. +final initialSafAccessLostProvider = Provider((ref) => false); + +bool hasPersistedAppSettings(SharedPreferences prefs) { + final rawSettings = prefs.getString(_settingsKey); + return rawSettings != null && rawSettings.isNotEmpty; +} + AppSettings loadBootstrapSettings(SharedPreferences prefs) { final rawSettings = prefs.getString(_settingsKey); if (rawSettings == null || rawSettings.isEmpty) return const AppSettings(); @@ -38,6 +48,45 @@ AppSettings loadBootstrapSettings(SharedPreferences prefs) { } } +/// Removes values that are only valid for one OS installation while keeping +/// portable user preferences. This is used when Android restores app data into +/// a fresh package install despite backup being disabled (for example an OEM +/// device-to-device transfer). +AppSettings resetInstallationBoundSettings(AppSettings settings) { + return settings.copyWith( + downloadDirectory: '', + downloadDirectoryBookmark: '', + storageMode: 'app', + downloadTreeUri: '', + isFirstLaunch: true, + useAllFilesAccess: false, + localLibraryEnabled: false, + localLibraryPath: '', + localLibraryBookmark: '', + localLibraryAutoScan: 'off', + hasCompletedTutorial: false, + ); +} + +Future resetRestoredInstallationSettings(SharedPreferences prefs) async { + final rawSettings = prefs.getString(_settingsKey); + if (rawSettings == null || rawSettings.isEmpty) return; + + try { + final decoded = jsonDecode(rawSettings); + if (decoded is! Map) { + await prefs.remove(_settingsKey); + return; + } + final restored = AppSettings.fromJson(Map.from(decoded)); + final sanitized = resetInstallationBoundSettings(restored); + await prefs.setString(_settingsKey, jsonEncode(sanitized.toJson())); + } catch (e) { + _log.w('Failed to sanitize restored settings; resetting to defaults: $e'); + await prefs.remove(_settingsKey); + } +} + class SettingsNotifier extends Notifier { static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$'); static const Set _searchTabValues = { diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 1964c1ed..f33c3715 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -41,7 +41,7 @@ class MainShell extends ConsumerStatefulWidget { } class _MainShellState extends ConsumerState - with SingleTickerProviderStateMixin { + with SingleTickerProviderStateMixin, WidgetsBindingObserver { int _currentIndex = 0; // Preserves the PageView element (and its kept-alive tabs) when the body // structure swaps between rail and bottom-bar layouts on rotation. @@ -50,6 +50,8 @@ class _MainShellState extends ConsumerState late final AnimationController _tabJumpTransitionController; bool _hasCheckedUpdate = false; bool _hasCheckedAppAnnouncement = false; + bool _initialSafRepairComplete = false; + bool _safRepairDialogVisible = false; StreamSubscription? _shareSubscription; DateTime? _lastBackPress; final GlobalKey _homeTabNavigatorKey = @@ -87,6 +89,7 @@ class _MainShellState extends ConsumerState @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _homePreviewStopObserver = _PreviewStopNavigatorObserver( () => ref.read(previewPlayerProvider.notifier).stop(), ); @@ -107,8 +110,13 @@ class _MainShellState extends ConsumerState showRepoTab: false, ); WidgetsBinding.instance.addPostFrameCallback((_) async { + await _repairSafAccessIfNeeded( + knownLost: ref.read(initialSafAccessLostProvider), + ); + _initialSafRepairComplete = true; + if (!mounted) return; _setupShareListener(); - _checkSafMigration(); + await _checkSafMigration(); final updateDialogShown = await _checkForUpdates(); if (!updateDialogShown) { await _checkAppAnnouncement(); @@ -116,6 +124,129 @@ class _MainShellState extends ConsumerState }); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed && _initialSafRepairComplete) { + unawaited(_repairSafAccessIfNeeded()); + } + } + + Future _repairSafAccessIfNeeded({bool knownLost = false}) async { + if (!Platform.isAndroid || _safRepairDialogVisible) return; + + var accessLost = knownLost; + if (!accessLost) { + final settings = ref.read(settingsProvider); + if (settings.storageMode != 'saf' || settings.downloadTreeUri.isEmpty) { + return; + } + accessLost = !await PlatformBridge.isSafTreeAccessible( + settings.downloadTreeUri, + ); + } + if (!accessLost || !mounted) return; + + _safRepairDialogVisible = true; + try { + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + var isPickingFolder = false; + return StatefulBuilder( + builder: (context, setDialogState) { + return PopScope( + canPop: false, + child: AlertDialog( + icon: Icon( + Icons.folder_off_outlined, + color: Theme.of(context).colorScheme.error, + ), + title: Text(context.l10n.downloadFolderAccessLostTitle), + content: Text(context.l10n.downloadFolderAccessLostSubtitle), + actions: [ + TextButton( + onPressed: isPickingFolder + ? null + : () { + final notifier = ref.read( + settingsProvider.notifier, + ); + notifier.setStorageMode('app'); + notifier.setDownloadTreeUri(''); + notifier.setDownloadDirectory(''); + Navigator.of(dialogContext).pop(); + }, + child: Text(context.l10n.storageModeAppFolder), + ), + FilledButton( + onPressed: isPickingFolder + ? null + : () async { + setDialogState(() => isPickingFolder = true); + try { + final result = + await PlatformBridge.pickSafTree(); + if (result == null) return; + final treeUri = + result['tree_uri'] as String? ?? ''; + final displayName = + result['display_name'] as String? ?? ''; + if (treeUri.isEmpty) return; + + final notifier = ref.read( + settingsProvider.notifier, + ); + notifier.setStorageMode('saf'); + notifier.setDownloadTreeUri( + treeUri, + displayName: displayName.isNotEmpty + ? displayName + : treeUri, + ); + if (dialogContext.mounted) { + Navigator.of(dialogContext).pop(); + } + } catch (e) { + _log.w( + 'Failed to repair SAF access from startup: $e', + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.snackbarCannotOpenFile( + e.toString(), + ), + ), + ), + ); + } + } finally { + if (dialogContext.mounted) { + setDialogState(() => isPickingFolder = false); + } + } + }, + child: isPickingFolder + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(context.l10n.downloadFolderReselect), + ), + ], + ), + ); + }, + ); + }, + ); + } finally { + _safRepairDialogVisible = false; + } + } + void _setupShareListener() { final pendingUrl = ShareIntentService().consumePendingUrl(); if (pendingUrl != null) { @@ -301,6 +432,7 @@ class _MainShellState extends ConsumerState @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _shareSubscription?.cancel(); _pageController.dispose(); _tabJumpTransitionController.dispose(); diff --git a/lib/services/app_state_database.dart b/lib/services/app_state_database.dart index 7cb23cd2..c809e68a 100644 --- a/lib/services/app_state_database.dart +++ b/lib/services/app_state_database.dart @@ -251,6 +251,16 @@ class AppStateDatabase { }); } + /// A pending queue is tied to the installation's output grants and worker + /// lifecycle. It must not resume after Android restores data into a newly + /// installed package. + Future clearPendingQueueAfterInstallationRestore() async { + await replacePendingDownloadQueueRows(const []); + final prefs = await _prefs; + await prefs.remove(_legacyQueueKey); + await prefs.setBool(_queueMigrationKey, true); + } + Future applyPendingDownloadQueueChanges({ required List> upserts, required List deletedIds, diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 2ebc1ab1..0b109ff9 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -31,6 +31,41 @@ class IosPickedDirectory { const IosPickedDirectory({required this.path, required this.bookmark}); } +class InstallationState { + final bool markerExisted; + final bool markerCreated; + final bool freshPackageInstall; + + const InstallationState({ + required this.markerExisted, + required this.markerCreated, + required this.freshPackageInstall, + }); + + const InstallationState.unsupported() + : markerExisted = true, + markerCreated = true, + freshPackageInstall = false; + + factory InstallationState.fromMap(Map map) { + return InstallationState( + markerExisted: map['marker_existed'] == true, + markerCreated: map['marker_created'] == true, + freshPackageInstall: map['fresh_package_install'] == true, + ); + } +} + +bool shouldResetRestoredInstallation({ + required bool hasPersistedSettings, + required InstallationState installState, +}) { + return hasPersistedSettings && + !installState.markerExisted && + installState.markerCreated && + installState.freshPackageInstall; +} + class _BridgeCacheEntry { final Map value; final DateTime expiresAt; @@ -137,6 +172,12 @@ class PlatformBridge { return _decodeRequiredMapResult(result, method); } + static Future ensureInstallMarker() async { + if (!Platform.isAndroid) return const InstallationState.unsupported(); + final result = await _invokeMap('ensureInstallMarker'); + return InstallationState.fromMap(result); + } + static Future> _cachedInvoke( String cacheKey, Map cache, @@ -578,16 +619,23 @@ class PlatformBridge { /// so a bridge problem never raises a false "access lost" alarm. static Future isSafTreeAccessible(String treeUri) async { try { - final result = await _channel.invokeMethod('isSafTreeAccessible', { - 'tree_uri': treeUri, - }); - return result as bool? ?? true; + return await validateSafTreeAccess(treeUri); } catch (e) { _log.w('Failed to check SAF tree accessibility: $e'); return true; } } + /// Strict SAF validation for startup and download preflight. Unlike + /// [isSafTreeAccessible], channel failures are surfaced to the caller so a + /// write cannot proceed on an unverified destination. + static Future validateSafTreeAccess(String treeUri) async { + final result = await _channel.invokeMethod('isSafTreeAccessible', { + 'tree_uri': treeUri, + }); + return result as bool? ?? false; + } + static Future safDelete(String uri) async { final result = await _channel.invokeMethod('safDelete', {'uri': uri}); return result as bool; diff --git a/test/installation_restore_test.dart b/test/installation_restore_test.dart new file mode 100644 index 00000000..a7883e72 --- /dev/null +++ b/test/installation_restore_test.dart @@ -0,0 +1,252 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/app.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const backendChannel = MethodChannel('com.zarz.spotiflac/backend'); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(backendChannel, null); + }); + + group('restored installation detection', () { + test('fresh package plus persisted settings requires a reset', () { + const installState = InstallationState( + markerExisted: false, + markerCreated: true, + freshPackageInstall: true, + ); + + expect( + shouldResetRestoredInstallation( + hasPersistedSettings: true, + installState: installState, + ), + isTrue, + ); + expect( + shouldResetRestoredInstallation( + hasPersistedSettings: false, + installState: installState, + ), + isFalse, + ); + }); + + test('normal upgrade without the new marker preserves settings', () { + const installState = InstallationState( + markerExisted: false, + markerCreated: true, + freshPackageInstall: false, + ); + + expect( + shouldResetRestoredInstallation( + hasPersistedSettings: true, + installState: installState, + ), + isFalse, + ); + }); + + test('marker creation failure never triggers a repeated reset', () { + const installState = InstallationState( + markerExisted: false, + markerCreated: false, + freshPackageInstall: true, + ); + + expect( + shouldResetRestoredInstallation( + hasPersistedSettings: true, + installState: installState, + ), + isFalse, + ); + }); + + test('subsequent launch with a marker preserves settings', () { + const installState = InstallationState( + markerExisted: true, + markerCreated: true, + freshPackageInstall: false, + ); + + expect( + shouldResetRestoredInstallation( + hasPersistedSettings: true, + installState: installState, + ), + isFalse, + ); + }); + }); + + group('restored settings sanitization', () { + const restored = AppSettings( + defaultService: 'extension.lossless', + audioQuality: 'HI_RES_LOSSLESS', + locale: 'id', + downloadDirectory: '/storage/emulated/0/Music/Old', + downloadDirectoryBookmark: 'old-bookmark', + storageMode: 'saf', + downloadTreeUri: 'content://tree/old', + isFirstLaunch: false, + useAllFilesAccess: true, + localLibraryEnabled: true, + localLibraryPath: 'content://tree/library', + localLibraryBookmark: 'library-bookmark', + localLibraryAutoScan: 'daily', + hasCompletedTutorial: true, + ); + + test('resets installation-bound fields and keeps portable preferences', () { + final sanitized = resetInstallationBoundSettings(restored); + + expect(sanitized.isFirstLaunch, isTrue); + expect(sanitized.hasCompletedTutorial, isFalse); + expect(sanitized.storageMode, 'app'); + expect(sanitized.downloadTreeUri, isEmpty); + expect(sanitized.downloadDirectory, isEmpty); + expect(sanitized.downloadDirectoryBookmark, isEmpty); + expect(sanitized.useAllFilesAccess, isFalse); + expect(sanitized.localLibraryEnabled, isFalse); + expect(sanitized.localLibraryPath, isEmpty); + expect(sanitized.localLibraryBookmark, isEmpty); + expect(sanitized.localLibraryAutoScan, 'off'); + expect(sanitized.defaultService, restored.defaultService); + expect(sanitized.audioQuality, restored.audioQuality); + expect(sanitized.locale, restored.locale); + }); + + test('persists the sanitized state before bootstrap routing', () async { + SharedPreferences.setMockInitialValues({ + 'app_settings': jsonEncode(restored.toJson()), + }); + final prefs = await SharedPreferences.getInstance(); + + expect(hasPersistedAppSettings(prefs), isTrue); + await resetRestoredInstallationSettings(prefs); + final bootstrap = loadBootstrapSettings(prefs); + + expect(bootstrap.isFirstLaunch, isTrue); + expect(bootstrap.storageMode, 'app'); + expect(bootstrap.downloadTreeUri, isEmpty); + expect(bootstrap.defaultService, restored.defaultService); + }); + }); + + group('bootstrap routing', () { + test('restored-install reset routes through setup and tutorial', () { + final sanitized = resetInstallationBoundSettings( + const AppSettings( + isFirstLaunch: false, + hasCompletedTutorial: true, + storageMode: 'saf', + downloadTreeUri: 'content://tree/old', + ), + ); + + expect( + initialLocationForAppState( + isFirstLaunch: sanitized.isFirstLaunch, + hasCompletedTutorial: sanitized.hasCompletedTutorial, + ), + '/setup', + ); + expect( + initialLocationForAppState( + isFirstLaunch: false, + hasCompletedTutorial: false, + ), + '/tutorial', + ); + expect( + initialLocationForAppState( + isFirstLaunch: false, + hasCompletedTutorial: true, + ), + '/', + ); + }); + }); + + group('strict SAF validation', () { + test('returns the native persisted-grant result', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(backendChannel, (call) async { + expect(call.method, 'isSafTreeAccessible'); + expect( + (call.arguments as Map)['tree_uri'], + 'content://tree/missing', + ); + return false; + }); + + expect( + await PlatformBridge.validateSafTreeAccess('content://tree/missing'), + isFalse, + ); + }); + + test('surfaces bridge errors instead of allowing an unverified write', () { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(backendChannel, (call) async { + throw PlatformException(code: 'bridge_failure'); + }); + + expect( + PlatformBridge.validateSafTreeAccess('content://tree/missing'), + throwsA(isA()), + ); + }); + }); + + test('Android backup policy excludes every app-data domain', () { + final manifest = File( + 'android/app/src/main/AndroidManifest.xml', + ).readAsStringSync(); + final currentRules = File( + 'android/app/src/main/res/xml/backup_rules.xml', + ).readAsStringSync(); + final legacyRules = File( + 'android/app/src/main/res/xml/backup_rules_legacy.xml', + ).readAsStringSync(); + + expect(manifest, contains('android:allowBackup="false"')); + expect( + manifest, + contains('android:dataExtractionRules="@xml/backup_rules"'), + ); + expect( + manifest, + contains('android:fullBackupContent="@xml/backup_rules_legacy"'), + ); + + for (final domain in [ + 'root', + 'file', + 'database', + 'sharedpref', + 'external', + 'device_root', + 'device_file', + 'device_database', + 'device_sharedpref', + ]) { + expect(currentRules, contains('domain="$domain" path="."')); + expect(legacyRules, contains('domain="$domain" path="."')); + } + expect(currentRules, contains('')); + expect(currentRules, contains('')); + }); +}