import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/artist_utils.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/logger.dart'; const _settingsKey = 'app_settings'; const _settingsCorruptBackupKey = 'app_settings_corrupt_backup'; const _migrationVersionKey = 'settings_migration_version'; const _currentMigrationVersion = 11; const _spotifyClientSecretKey = 'spotify_client_secret'; const _retiredBuiltInProviderIds = {'deezer', 'qobuz', 'tidal', 'youtube'}; final _log = AppLogger('SettingsProvider'); class SettingsNotifier extends Notifier { static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$'); static const Set _searchTabValues = { 'all', 'track', 'artist', 'album', 'playlist', }; static const Set _extensionVerificationBrowserModeValues = { 'external_first', 'in_app_first', }; final Future _prefs = SharedPreferences.getInstance(); final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); bool _isSavingSettings = false; bool _saveQueued = false; String? _pendingSettingsJson; @override AppSettings build() { _loadSettings(); return const AppSettings(); } Future _loadSettings() async { final prefs = await _prefs; final rawSettings = prefs.getString(_settingsKey); if (rawSettings != null) { AppSettings? loaded; try { final decoded = jsonDecode(rawSettings); if (decoded is! Map) { throw const FormatException('settings root must be a JSON object'); } loaded = AppSettings.fromJson(Map.from(decoded)); } catch (e, stack) { _log.e('Failed to load settings, resetting to defaults: $e', e, stack); try { await prefs.setString(_settingsCorruptBackupKey, rawSettings); await prefs.remove(_settingsKey); } catch (backupError) { _log.w('Failed to backup corrupt settings: $backupError'); } } if (loaded != null) { final sanitizedDownloadFallbackExtensionIds = _sanitizeDownloadFallbackExtensionIds( loaded.downloadFallbackExtensionIds, ); final sanitizedDefaultSearchTab = _normalizeDefaultSearchTab( loaded.defaultSearchTab, ); state = loaded.copyWith( useExtensionProviders: true, downloadFallbackExtensionIds: sanitizedDownloadFallbackExtensionIds, clearDownloadFallbackExtensionIds: loaded.downloadFallbackExtensionIds != null && sanitizedDownloadFallbackExtensionIds == null, defaultSearchTab: sanitizedDefaultSearchTab, defaultService: loaded.defaultService, searchProvider: loaded.searchProvider, extensionVerificationBrowserMode: _normalizeExtensionVerificationBrowserMode( loaded.extensionVerificationBrowserMode, ), ); await _runMigrations(prefs); await _normalizeIosDownloadDirectoryIfNeeded(); await _normalizeSongLinkRegionIfNeeded(); } } await _cleanupRetiredSpotifySettings(); LogBuffer.loggingEnabled = state.enableLogging; _syncLyricsSettingsToBackend(); _syncNetworkCompatibilitySettingsToBackend(); _syncExtensionFallbackSettingsToBackend(); } void _syncLyricsSettingsToBackend() { unawaited(syncLyricsSettingsToBackend()); } Future syncLyricsSettingsToBackend() async { if (!PlatformBridge.supportsCoreBackend) return; try { await PlatformBridge.setLyricsProviders(state.lyricsProviders); } catch (e) { _log.w('Failed to sync lyrics providers to backend: $e'); } try { await PlatformBridge.setLyricsFetchOptions({ 'include_translation_netease': state.lyricsIncludeTranslationNetease, 'include_romanization_netease': state.lyricsIncludeRomanizationNetease, 'multi_person_word_by_word': state.lyricsMultiPersonWordByWord, 'apple_elrc_word_sync': state.lyricsAppleElrcWordSync, 'musixmatch_language': state.musixmatchLanguage, }); } catch (e) { _log.w('Failed to sync lyrics fetch options to backend: $e'); } } void _syncNetworkCompatibilitySettingsToBackend() { if (!PlatformBridge.supportsCoreBackend) return; final compatibilityMode = state.networkCompatibilityMode; PlatformBridge.setNetworkCompatibilityOptions( allowHttp: compatibilityMode, insecureTls: compatibilityMode, ).catchError((Object e) { _log.w('Failed to sync network compatibility options to backend: $e'); }); PlatformBridge.setAllowPrivateNetwork(state.allowLocalNetwork).catchError(( Object e, ) { _log.w('Failed to sync allow local network option to backend: $e'); }); } void _syncExtensionFallbackSettingsToBackend() { if (!PlatformBridge.supportsCoreBackend) return; PlatformBridge.setDownloadFallbackExtensionIds( state.downloadFallbackExtensionIds, ).catchError((Object e) { _log.w('Failed to sync extension fallback settings to backend: $e'); }); } Future _runMigrations(SharedPreferences prefs) async { final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0; if (lastMigration < _currentMigrationVersion) { if (state.downloadTreeUri.isNotEmpty && state.storageMode != 'saf') { state = state.copyWith(storageMode: 'saf'); } // Migration 2: existing users who already completed setup should skip tutorial if (!state.isFirstLaunch && !state.hasCompletedTutorial) { state = state.copyWith(hasCompletedTutorial: true); } if (state.lyricsProviders.contains('spotify_api')) { final updatedProviders = state.lyricsProviders .where((provider) => provider != 'spotify_api') .toList(); state = state.copyWith( lyricsProviders: updatedProviders.isEmpty ? const ['lrclib', 'apple_music'] : updatedProviders, ); } state = state.copyWith(lastSeenVersion: AppInfo.version); // Migration 7/11: retired built-in services are now reconciled after // extensions load so manifest-declared replacements can adopt old prefs. if (!state.useExtensionProviders) { state = state.copyWith(useExtensionProviders: true); } await prefs.setInt(_migrationVersionKey, _currentMigrationVersion); await _saveSettings(); } } Future _saveSettings() async { _pendingSettingsJson = jsonEncode(state.toJson()); if (_isSavingSettings) { _saveQueued = true; return; } _isSavingSettings = true; try { final prefs = await _prefs; do { final jsonToWrite = _pendingSettingsJson; _saveQueued = false; if (jsonToWrite != null) { await prefs.setString(_settingsKey, jsonToWrite); } } while (_saveQueued); } catch (e) { _log.e('Failed to save settings: $e'); } finally { _isSavingSettings = false; } } /// Restores settings from a backup payload (the map produced by /// [AppSettings.toJson]). Device-specific storage location fields /// (download directory and SAF tree URI) are intentionally preserved from the /// current device, because a SAF tree URI from another phone is not valid /// here and would break downloads. Future restoreFromBackup(Map json) async { final current = state; AppSettings restored; try { restored = AppSettings.fromJson(Map.from(json)); } catch (e, stack) { _log.e('Failed to parse settings from backup: $e', e, stack); rethrow; } state = restored.copyWith( // Always keep extension providers enabled (matches _loadSettings). useExtensionProviders: true, // Preserve this device's storage location; the backup's values point at // the original device and would not resolve here. downloadDirectory: current.downloadDirectory, downloadDirectoryBookmark: current.downloadDirectoryBookmark, storageMode: current.storageMode, downloadTreeUri: current.downloadTreeUri, ); await _saveSettings(); LogBuffer.loggingEnabled = state.enableLogging; _syncLyricsSettingsToBackend(); _syncNetworkCompatibilitySettingsToBackend(); _syncExtensionFallbackSettingsToBackend(); } Future _normalizeIosDownloadDirectoryIfNeeded() async { if (!Platform.isIOS) return; // A security-scoped bookmark is the source of truth for folders picked // from Files: its stored path may legitimately point outside the app // container, and the download flow re-resolves the real path from the // bookmark. "Fixing" the path here would also drop the bookmark and // silently revert the user's chosen folder. if (state.downloadDirectoryBookmark.isNotEmpty) return; final currentDir = state.downloadDirectory.trim(); if (currentDir.isEmpty) return; final normalizedDir = await validateOrFixIosPath(currentDir); if (normalizedDir == currentDir) return; _log.i('Normalized iOS download directory: $currentDir -> $normalizedDir'); state = state.copyWith( downloadDirectory: normalizedDir, downloadDirectoryBookmark: '', ); await _saveSettings(); } String _normalizeSongLinkRegion(String region) { final normalized = region.trim().toUpperCase(); if (_isoRegionPattern.hasMatch(normalized)) return normalized; return 'US'; } String _normalizeDefaultSearchTab(String value) { final normalized = value.trim().toLowerCase(); if (_searchTabValues.contains(normalized)) return normalized; return 'all'; } String _normalizeExtensionVerificationBrowserMode(String value) { final normalized = value.trim().toLowerCase(); if (_extensionVerificationBrowserModeValues.contains(normalized)) { return normalized; } return 'in_app_first'; } String? _sanitizeRetiredBuiltInProviderId(String? providerId) { final normalized = providerId?.trim().toLowerCase(); if (normalized == null || normalized.isEmpty) return providerId; return _retiredBuiltInProviderIds.contains(normalized) ? null : providerId; } Future _normalizeSongLinkRegionIfNeeded() async { final normalized = _normalizeSongLinkRegion(state.songLinkRegion); if (normalized == state.songLinkRegion) return; state = state.copyWith(songLinkRegion: normalized); await _saveSettings(); } List? _sanitizeDownloadFallbackExtensionIds(List? ids) { if (ids == null) { return null; } final result = []; for (final id in ids) { final normalized = id.trim(); if (normalized.isEmpty || result.contains(normalized)) { continue; } result.add(normalized); } return result; } Future _cleanupRetiredSpotifySettings() async { final storedSecret = await _secureStorage.read( key: _spotifyClientSecretKey, ); if (storedSecret != null && storedSecret.isNotEmpty) { await _secureStorage.delete(key: _spotifyClientSecretKey); } } void setDefaultService(String service) { state = state.copyWith( defaultService: _sanitizeRetiredBuiltInProviderId(service) ?? '', ); _saveSettings(); } void setAudioQuality(String quality) { state = state.copyWith(audioQuality: quality); _saveSettings(); } void setFilenameFormat(String format) { state = state.copyWith(filenameFormat: format); _saveSettings(); } void setSingleFilenameFormat(String format) { state = state.copyWith(singleFilenameFormat: format); _saveSettings(); } void setDownloadDirectory(String directory, {String? iosBookmark}) { state = state.copyWith( downloadDirectory: directory, downloadDirectoryBookmark: iosBookmark ?? '', ); _saveSettings(); } void setStorageMode(String mode) { final normalized = mode == 'saf' ? 'saf' : 'app'; state = state.copyWith(storageMode: normalized); _saveSettings(); } void setDownloadTreeUri(String uri, {String? displayName}) { final nextDisplay = displayName ?? state.downloadDirectory; state = state.copyWith( downloadTreeUri: uri, storageMode: uri.isNotEmpty ? 'saf' : state.storageMode, downloadDirectory: nextDisplay, downloadDirectoryBookmark: uri.isNotEmpty ? '' : state.downloadDirectoryBookmark, ); _saveSettings(); } void setAutoFallback(bool enabled) { state = state.copyWith(autoFallback: enabled); _saveSettings(); } void setEmbedLyrics(bool enabled) { state = state.copyWith(embedLyrics: enabled); _saveSettings(); } void setEmbedReplayGain(bool enabled) { state = state.copyWith(embedReplayGain: enabled); _saveSettings(); } void setEmbedMetadata(bool enabled) { state = state.copyWith(embedMetadata: enabled); _saveSettings(); } void setArtistTagMode(String mode) { if (mode == artistTagModeJoined || mode == artistTagModeSplitVorbis) { state = state.copyWith(artistTagMode: mode); _saveSettings(); } } void setLyricsMode(String mode) { if (mode == 'embed' || mode == 'external' || mode == 'both') { state = state.copyWith(lyricsMode: mode); _saveSettings(); } } void setLyricsProviders(List providers) { state = state.copyWith(lyricsProviders: providers); _saveSettings(); _syncLyricsSettingsToBackend(); } void setLyricsIncludeTranslationNetease(bool enabled) { state = state.copyWith(lyricsIncludeTranslationNetease: enabled); _saveSettings(); _syncLyricsSettingsToBackend(); } void setLyricsIncludeRomanizationNetease(bool enabled) { state = state.copyWith(lyricsIncludeRomanizationNetease: enabled); _saveSettings(); _syncLyricsSettingsToBackend(); } void setLyricsMultiPersonWordByWord(bool enabled) { state = state.copyWith(lyricsMultiPersonWordByWord: enabled); _saveSettings(); _syncLyricsSettingsToBackend(); } void setLyricsAppleElrcWordSync(bool enabled) { state = state.copyWith(lyricsAppleElrcWordSync: enabled); _saveSettings(); _syncLyricsSettingsToBackend(); } void setMusixmatchLanguage(String languageCode) { state = state.copyWith( musixmatchLanguage: languageCode.trim().toLowerCase(), ); _saveSettings(); _syncLyricsSettingsToBackend(); } void setMaxQualityCover(bool enabled) { state = state.copyWith(maxQualityCover: enabled); _saveSettings(); } void setFirstLaunchComplete() { state = state.copyWith(isFirstLaunch: false); _saveSettings(); } void setCheckForUpdates(bool enabled) { state = state.copyWith(checkForUpdates: enabled); _saveSettings(); } void setUpdateChannel(String channel) { state = state.copyWith(updateChannel: channel); _saveSettings(); } void setHasSearchedBefore() { if (!state.hasSearchedBefore) { state = state.copyWith(hasSearchedBefore: true); _saveSettings(); } } void setFolderOrganization(String organization) { state = state.copyWith(folderOrganization: organization); _saveSettings(); } void setCreatePlaylistFolder(bool enabled) { state = state.copyWith(createPlaylistFolder: enabled); _saveSettings(); } void setUseAlbumArtistForFolders(bool enabled) { state = state.copyWith(useAlbumArtistForFolders: enabled); _saveSettings(); } void setUsePrimaryArtistOnly(bool enabled) { state = state.copyWith(usePrimaryArtistOnly: enabled); _saveSettings(); } void setFilterContributingArtistsInAlbumArtist(bool enabled) { state = state.copyWith(filterContributingArtistsInAlbumArtist: enabled); _saveSettings(); } void setHistoryViewMode(String mode) { state = state.copyWith(historyViewMode: mode); _saveSettings(); } void setHistoryFilterMode(String mode) { state = state.copyWith(historyFilterMode: mode); _saveSettings(); } void setAskQualityBeforeDownload(bool enabled) { state = state.copyWith(askQualityBeforeDownload: enabled); _saveSettings(); } void setSearchProvider(String? provider) { final sanitized = _sanitizeRetiredBuiltInProviderId(provider); if (sanitized == null || sanitized.isEmpty) { state = state.copyWith(clearSearchProvider: true); } else { state = state.copyWith(searchProvider: sanitized); } _saveSettings(); } void setDefaultSearchTab(String tab) { state = state.copyWith(defaultSearchTab: _normalizeDefaultSearchTab(tab)); _saveSettings(); } void setHomeFeedProvider(String? provider) { if (provider == null || provider.isEmpty) { state = state.copyWith(clearHomeFeedProvider: true); } else { state = state.copyWith(homeFeedProvider: provider); } _saveSettings(); } void setEnableLogging(bool enabled) { state = state.copyWith(enableLogging: enabled); _saveSettings(); LogBuffer.loggingEnabled = enabled; } void setUseExtensionProviders(bool enabled) { state = state.copyWith(useExtensionProviders: true); _saveSettings(); } void setDownloadFallbackExtensionIds(List? extensionIds) { final sanitized = _sanitizeDownloadFallbackExtensionIds(extensionIds); state = state.copyWith( downloadFallbackExtensionIds: sanitized, clearDownloadFallbackExtensionIds: extensionIds == null && state.downloadFallbackExtensionIds != null, ); _saveSettings(); _syncExtensionFallbackSettingsToBackend(); } void setSeparateSingles(bool enabled) { state = state.copyWith(separateSingles: enabled); _saveSettings(); } void setAlbumFolderStructure(String structure) { state = state.copyWith(albumFolderStructure: structure); _saveSettings(); } void setShowExtensionStore(bool enabled) { state = state.copyWith(showExtensionStore: enabled); _saveSettings(); } void setExtensionVerificationBrowserMode(String mode) { state = state.copyWith( extensionVerificationBrowserMode: _normalizeExtensionVerificationBrowserMode(mode), ); _saveSettings(); } void setLocale(String locale) { state = state.copyWith(locale: locale); _saveSettings(); } void setTidalHighFormat(String format) { state = state.copyWith(tidalHighFormat: format); _saveSettings(); } void setUseAllFilesAccess(bool enabled) { state = state.copyWith(useAllFilesAccess: enabled); _saveSettings(); } void setAutoExportFailedDownloads(bool enabled) { state = state.copyWith(autoExportFailedDownloads: enabled); _saveSettings(); } void setDownloadNetworkMode(String mode) { state = state.copyWith(downloadNetworkMode: mode); _saveSettings(); } void setConcurrentDownloads(int count) { state = state.copyWith(concurrentDownloads: count.clamp(1, 3)); _saveSettings(); } void setNetworkCompatibilityMode(bool enabled) { state = state.copyWith(networkCompatibilityMode: enabled); _saveSettings(); _syncNetworkCompatibilitySettingsToBackend(); } void setAllowLocalNetwork(bool enabled) { state = state.copyWith(allowLocalNetwork: enabled); _saveSettings(); _syncNetworkCompatibilitySettingsToBackend(); } void setSongLinkRegion(String region) { final normalized = _normalizeSongLinkRegion(region); state = state.copyWith(songLinkRegion: normalized); _saveSettings(); } void setNativeDownloadWorkerEnabled(bool enabled) { state = state.copyWith(nativeDownloadWorkerEnabled: enabled); _saveSettings(); } void setLocalLibraryEnabled(bool enabled) { state = state.copyWith(localLibraryEnabled: enabled); _saveSettings(); } void setLocalLibraryPath(String path) { state = state.copyWith(localLibraryPath: path); _saveSettings(); } void setLocalLibraryBookmark(String bookmark) { state = state.copyWith(localLibraryBookmark: bookmark); _saveSettings(); } void setLocalLibraryPathAndBookmark(String path, String bookmark) { state = state.copyWith( localLibraryPath: path, localLibraryBookmark: bookmark, ); _saveSettings(); } void setLocalLibraryShowDuplicates(bool show) { state = state.copyWith(localLibraryShowDuplicates: show); _saveSettings(); } void setLocalLibraryAutoScan(String mode) { state = state.copyWith(localLibraryAutoScan: mode); _saveSettings(); } void setTutorialComplete() { state = state.copyWith(hasCompletedTutorial: true); _saveSettings(); } void setDeduplicateDownloads(bool enabled) { state = state.copyWith(deduplicateDownloads: enabled); _saveSettings(); } void setSaveDownloadHistory(bool enabled) { state = state.copyWith(saveDownloadHistory: enabled); _saveSettings(); } void setPlayerMode(String mode) { final normalized = mode == 'internal' ? 'internal' : 'external'; state = state.copyWith(playerMode: normalized); _saveSettings(); } } final settingsProvider = NotifierProvider( SettingsNotifier.new, );