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