fix(android): sanitize restored installation state

This commit is contained in:
zarzet
2026-07-23 10:39:31 +07:00
parent 480a96bd0e
commit c9114fef01
12 changed files with 673 additions and 27 deletions
+3
View File
@@ -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"
@@ -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<String, Any> {
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) {
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<!--
SpotiFLAC Mobile provides an explicit, schema-aware Backup & Restore
feature. Platform backup must not copy device-bound SAF grants, paths,
queues, extension credentials, or signed sessions to another install.
-->
<cloud-backup>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
<exclude domain="device_root" path="." />
<exclude domain="device_file" path="." />
<exclude domain="device_database" path="." />
<exclude domain="device_sharedpref" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
<exclude domain="device_root" path="." />
<exclude domain="device_file" path="." />
<exclude domain="device_database" path="." />
<exclude domain="device_sharedpref" path="." />
</device-transfer>
</data-extraction-rules>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<!-- Android 11 and lower use this legacy rule format. -->
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
<exclude domain="device_root" path="." />
<exclude domain="device_file" path="." />
<exclude domain="device_database" path="." />
<exclude domain="device_sharedpref" path="." />
</full-backup-content>
+20 -13
View File
@@ -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<GoRouter>((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,
+56
View File
@@ -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<void> _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<bool> _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) {
+6 -8
View File
@@ -2126,15 +2126,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} 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');
+49
View File
@@ -26,6 +26,16 @@ final initialSettingsProvider = Provider<AppSettings>(
(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<bool>((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<void> 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<String, dynamic>.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<AppSettings> {
static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$');
static const Set<String> _searchTabValues = {
+134 -2
View File
@@ -41,7 +41,7 @@ class MainShell extends ConsumerStatefulWidget {
}
class _MainShellState extends ConsumerState<MainShell>
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<MainShell>
late final AnimationController _tabJumpTransitionController;
bool _hasCheckedUpdate = false;
bool _hasCheckedAppAnnouncement = false;
bool _initialSafRepairComplete = false;
bool _safRepairDialogVisible = false;
StreamSubscription<String>? _shareSubscription;
DateTime? _lastBackPress;
final GlobalKey<NavigatorState> _homeTabNavigatorKey =
@@ -87,6 +89,7 @@ class _MainShellState extends ConsumerState<MainShell>
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_homePreviewStopObserver = _PreviewStopNavigatorObserver(
() => ref.read(previewPlayerProvider.notifier).stop(),
);
@@ -107,8 +110,13 @@ class _MainShellState extends ConsumerState<MainShell>
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<MainShell>
});
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed && _initialSafRepairComplete) {
unawaited(_repairSafAccessIfNeeded());
}
}
Future<void> _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<void>(
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<MainShell>
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_shareSubscription?.cancel();
_pageController.dispose();
_tabJumpTransitionController.dispose();
+10
View File
@@ -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<void> clearPendingQueueAfterInstallationRestore() async {
await replacePendingDownloadQueueRows(const []);
final prefs = await _prefs;
await prefs.remove(_legacyQueueKey);
await prefs.setBool(_queueMigrationKey, true);
}
Future<void> applyPendingDownloadQueueChanges({
required List<Map<String, dynamic>> upserts,
required List<String> deletedIds,
+52 -4
View File
@@ -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<String, dynamic> 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<String, dynamic> value;
final DateTime expiresAt;
@@ -137,6 +172,12 @@ class PlatformBridge {
return _decodeRequiredMapResult(result, method);
}
static Future<InstallationState> ensureInstallMarker() async {
if (!Platform.isAndroid) return const InstallationState.unsupported();
final result = await _invokeMap('ensureInstallMarker');
return InstallationState.fromMap(result);
}
static Future<Map<String, dynamic>> _cachedInvoke(
String cacheKey,
Map<String, _BridgeCacheEntry> cache,
@@ -578,16 +619,23 @@ class PlatformBridge {
/// so a bridge problem never raises a false "access lost" alarm.
static Future<bool> 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<bool> validateSafTreeAccess(String treeUri) async {
final result = await _channel.invokeMethod('isSafTreeAccessible', {
'tree_uri': treeUri,
});
return result as bool? ?? false;
}
static Future<bool> safDelete(String uri) async {
final result = await _channel.invokeMethod('safDelete', {'uri': uri});
return result as bool;
+252
View File
@@ -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<PlatformException>()),
);
});
});
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('<cloud-backup>'));
expect(currentRules, contains('<device-transfer>'));
});
}