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', }; 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, ); await _runMigrations(prefs); await _normalizeIosDownloadDirectoryIfNeeded(); await _normalizeSongLinkRegionIfNeeded(); } } await _cleanupRetiredSpotifySettings(); LogBuffer.loggingEnabled = state.enableLogging; _syncLyricsSettingsToBackend(); _syncNetworkCompatibilitySettingsToBackend(); _syncExtensionFallbackSettingsToBackend(); } void _syncLyricsSettingsToBackend() { if (!PlatformBridge.supportsCoreBackend) return; PlatformBridge.setLyricsProviders(state.lyricsProviders).catchError(( Object e, ) { _log.w('Failed to sync lyrics providers to backend: $e'); }); 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, }).catchError((Object 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'); }); } 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; } } Future _normalizeIosDownloadDirectoryIfNeeded() async { if (!Platform.isIOS) 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? _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 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 setNetworkCompatibilityMode(bool enabled) { state = state.copyWith(networkCompatibilityMode: 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(); } } final settingsProvider = NotifierProvider( SettingsNotifier.new, );