mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-28 23:08:59 +02:00
fix(android): sanitize restored installation state
This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>'));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user