From 5d4843c3685f5374a368fff68c372d93a2989eb3 Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Mon, 24 Nov 2025 16:38:26 +0530 Subject: [PATCH 1/2] refactor(auth): Replace custom cache with shared CacheManager --- lib/core/auth/auth_cache_manager.dart | 207 ++++++------------ .../services/optimized_storage_service.dart | 20 +- 2 files changed, 84 insertions(+), 143 deletions(-) diff --git a/lib/core/auth/auth_cache_manager.dart b/lib/core/auth/auth_cache_manager.dart index 2be9fa8..0d81bc0 100644 --- a/lib/core/auth/auth_cache_manager.dart +++ b/lib/core/auth/auth_cache_manager.dart @@ -1,198 +1,131 @@ -import 'auth_state_manager.dart'; +import '../services/cache_manager.dart'; import '../utils/debug_logger.dart'; +import 'auth_state_manager.dart'; -/// Comprehensive caching manager for auth-related operations -/// Reduces redundant operations and improves app performance +/// Comprehensive caching manager for auth-related operations. +/// +/// Delegates to the shared [CacheManager] to keep TTL and eviction behavior +/// consistent across the app. class AuthCacheManager { static final AuthCacheManager _instance = AuthCacheManager._internal(); factory AuthCacheManager() => _instance; AuthCacheManager._internal(); - // Cache for various auth-related operations - final Map _cache = {}; - final Map _cacheTimestamps = {}; + static const Duration _shortCache = Duration(minutes: 2); + static const Duration _mediumCache = Duration(minutes: 5); + static const Duration _longCache = Duration(minutes: 15); - // Cache timeouts for different types of data - static const Duration _shortCache = Duration( - minutes: 2, - ); // For frequently changing data - static const Duration _mediumCache = Duration( - minutes: 5, - ); // For moderately stable data - static const Duration _longCache = Duration(minutes: 15); // For stable data - - // Cache keys static const String _userDataKey = 'user_data'; static const String _serverConnectionKey = 'server_connection'; static const String _credentialsExistKey = 'credentials_exist'; static const String _serverConfigsKey = 'server_configs'; + static const String _authStatusKey = 'auth_status'; + + final CacheManager _cache = CacheManager( + defaultTtl: _mediumCache, + maxEntries: 32, + ); - /// Cache user data with medium timeout void cacheUserData(dynamic userData) { - _cache[_userDataKey] = userData; - _cacheTimestamps[_userDataKey] = DateTime.now(); + _cache.write(_userDataKey, userData, ttl: _mediumCache); DebugLogger.storage('User data cached'); } - /// Get cached user data dynamic getCachedUserData() { - if (_isCacheValid(_userDataKey, _mediumCache)) { + final (hit: hit, value: user) = _cache.lookup(_userDataKey); + if (hit) { DebugLogger.storage('Using cached user data'); - return _cache[_userDataKey]; } - return null; + return user; } - /// Cache server connection status with short timeout void cacheServerConnection(bool isConnected) { - _cache[_serverConnectionKey] = isConnected; - _cacheTimestamps[_serverConnectionKey] = DateTime.now(); + _cache.write(_serverConnectionKey, isConnected, ttl: _shortCache); } - /// Get cached server connection status bool? getCachedServerConnection() { - if (_isCacheValid(_serverConnectionKey, _shortCache)) { - return _cache[_serverConnectionKey] as bool?; - } - return null; + final (hit: hit, value: connection) = _cache.lookup( + _serverConnectionKey, + ); + return hit ? connection : null; } - /// Cache credentials existence with medium timeout void cacheCredentialsExist(bool exist) { - _cache[_credentialsExistKey] = exist; - _cacheTimestamps[_credentialsExistKey] = DateTime.now(); + _cache.write(_credentialsExistKey, exist, ttl: _mediumCache); } - /// Get cached credentials existence bool? getCachedCredentialsExist() { - if (_isCacheValid(_credentialsExistKey, _mediumCache)) { - return _cache[_credentialsExistKey] as bool?; - } - return null; + final (hit: hit, value: hasCreds) = _cache.lookup( + _credentialsExistKey, + ); + return hit ? hasCreds : null; } - /// Cache server configurations with long timeout void cacheServerConfigs(List configs) { - _cache[_serverConfigsKey] = configs; - _cacheTimestamps[_serverConfigsKey] = DateTime.now(); + _cache.write>(_serverConfigsKey, configs, ttl: _longCache); } - /// Get cached server configurations List? getCachedServerConfigs() { - if (_isCacheValid(_serverConfigsKey, _longCache)) { - return _cache[_serverConfigsKey] as List?; - } - return null; + final (hit: hit, value: configs) = _cache.lookup>( + _serverConfigsKey, + ); + return hit ? configs : null; } - /// Check if cache entry is valid - bool _isCacheValid(String key, Duration timeout) { - final timestamp = _cacheTimestamps[key]; - if (timestamp == null) return false; - - return DateTime.now().difference(timestamp) < timeout; - } - - /// Clear specific cache entry void clearCacheEntry(String key) { - _cache.remove(key); - _cacheTimestamps.remove(key); + _cache.invalidate(key); DebugLogger.storage('Cache entry cleared: $key'); } - /// Clear all auth-related cache including server configs void clearAuthCache() { _cache.clear(); - _cacheTimestamps.clear(); - DebugLogger.storage('All auth cache cleared (including server configs and custom headers)'); - } - - /// Clear expired cache entries - void cleanExpiredCache() { - final now = DateTime.now(); - final expiredKeys = []; - - for (final entry in _cacheTimestamps.entries) { - // Use the longest timeout for cleanup to be conservative - if (now.difference(entry.value) > _longCache) { - expiredKeys.add(entry.key); - } - } - - for (final key in expiredKeys) { - _cache.remove(key); - _cacheTimestamps.remove(key); - } - - if (expiredKeys.isNotEmpty) { - DebugLogger.storage( - 'Cleaned ${expiredKeys.length} expired cache entries', - ); - } - } - - /// Get cache statistics for monitoring - Map getCacheStats() { - final now = DateTime.now(); - final stats = {}; - - stats['totalEntries'] = _cache.length; - stats['entries'] = >{}; - - for (final key in _cache.keys) { - final timestamp = _cacheTimestamps[key]; - if (timestamp != null) { - stats['entries'][key] = { - 'age': now.difference(timestamp).inSeconds, - 'hasData': _cache[key] != null, - }; - } - } - - return stats; - } - - /// Optimize cache by removing least recently used entries if cache gets too large - void optimizeCache() { - const maxCacheSize = 20; // Reasonable limit for auth cache - - if (_cache.length <= maxCacheSize) return; - - // Sort by timestamp (oldest first) - final sortedEntries = _cacheTimestamps.entries.toList() - ..sort((a, b) => a.value.compareTo(b.value)); - - // Remove oldest entries - final entriesToRemove = sortedEntries.length - maxCacheSize; - for (int i = 0; i < entriesToRemove; i++) { - final key = sortedEntries[i].key; - _cache.remove(key); - _cacheTimestamps.remove(key); - } - DebugLogger.storage( - 'Cache optimized, removed $entriesToRemove old entries', + 'All auth cache cleared (including server configs and custom headers)', ); } - /// Cache state from AuthState for quick access + void cleanExpiredCache() { + final stats = _cache.stats(); + final entries = stats['entries']; + if (entries is! Map) return; + + var expiredCount = 0; + entries.forEach((key, value) { + if (value is! Map) return; + final ageSeconds = value['ageSeconds']; + final ttlSeconds = value['ttlSeconds']; + if (ageSeconds is num && ttlSeconds is num && ageSeconds > ttlSeconds) { + _cache.invalidate(key); + expiredCount++; + } + }); + + if (expiredCount > 0) { + DebugLogger.storage('Cleaned $expiredCount expired auth cache entries'); + } + } + + Map getCacheStats() => _cache.stats(); + + void optimizeCache() { + // CacheManager enforces maxEntries using LRU; no extra work needed. + } + void cacheAuthState(AuthState authState) { if (authState.user != null) { cacheUserData(authState.user); } - - // Don't cache loading or error states if (authState.status == AuthStatus.authenticated) { - _cache['auth_status'] = authState.status; - _cacheTimestamps['auth_status'] = DateTime.now(); + _cache.write( + _authStatusKey, + authState.status, + ttl: _shortCache, + ); } } - /// Get cached auth status AuthStatus? getCachedAuthStatus() { - if (_isCacheValid('auth_status', _shortCache)) { - return _cache['auth_status'] as AuthStatus?; - } - return null; + final (hit: hit, value: status) = _cache.lookup(_authStatusKey); + return hit ? status : null; } } diff --git a/lib/core/services/optimized_storage_service.dart b/lib/core/services/optimized_storage_service.dart index 000026f..4e989e6 100644 --- a/lib/core/services/optimized_storage_service.dart +++ b/lib/core/services/optimized_storage_service.dart @@ -59,6 +59,10 @@ class OptimizedStorageService { static const String _localFoldersKey = HiveStoreKeys.localFolders; static const String _onboardingSeenKey = PreferenceKeys.onboardingSeen; static const String _reviewerModeKey = PreferenceKeys.reviewerMode; + // Longer TTLs to reduce secure storage churn for OpenWebUI sessions. + static const Duration _authTokenTtl = Duration(hours: 12); + static const Duration _serverIdTtl = Duration(days: 7); + static const Duration _credentialsFlagTtl = Duration(hours: 12); // --------------------------------------------------------------------------- // Auth token APIs (secure storage + in-memory cache) @@ -66,7 +70,7 @@ class OptimizedStorageService { Future saveAuthToken(String token) async { try { await _secureCredentialStorage.saveAuthToken(token); - _cacheManager.write(_authTokenKey, token); + _cacheManager.write(_authTokenKey, token, ttl: _authTokenTtl); DebugLogger.log( 'Auth token saved and cached', scope: 'storage/optimized', @@ -91,7 +95,7 @@ class OptimizedStorageService { try { final token = await _secureCredentialStorage.getAuthToken(); if (token != null) { - _cacheManager.write(_authTokenKey, token); + _cacheManager.write(_authTokenKey, token, ttl: _authTokenTtl); } return token; } catch (error) { @@ -136,7 +140,7 @@ class OptimizedStorageService { password: password, ); - _cacheManager.write('has_credentials', true); + _cacheManager.write('has_credentials', true, ttl: _credentialsFlagTtl); DebugLogger.log( 'Credentials saved via optimized storage', @@ -154,7 +158,11 @@ class OptimizedStorageService { Future?> getSavedCredentials() async { try { final credentials = await _secureCredentialStorage.getSavedCredentials(); - _cacheManager.write('has_credentials', credentials != null); + _cacheManager.write( + 'has_credentials', + credentials != null, + ttl: _credentialsFlagTtl, + ); return credentials; } catch (error) { DebugLogger.log( @@ -243,7 +251,7 @@ class OptimizedStorageService { } else { await _preferencesBox.delete(_activeServerIdKey); } - _cacheManager.write(_activeServerIdKey, serverId); + _cacheManager.write(_activeServerIdKey, serverId, ttl: _serverIdTtl); } Future getActiveServerId() async { @@ -254,7 +262,7 @@ class OptimizedStorageService { return cachedId; } final serverId = _preferencesBox.get(_activeServerIdKey) as String?; - _cacheManager.write(_activeServerIdKey, serverId); + _cacheManager.write(_activeServerIdKey, serverId, ttl: _serverIdTtl); return serverId; } From 8f390b0d2d98751a56efeb62ab8594615827f1d1 Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Mon, 24 Nov 2025 17:43:05 +0530 Subject: [PATCH 2/2] feat(auth): Improve user fetching with caching and background refresh --- lib/core/models/backend_config.dart | 146 ++++++++++++++++-- lib/core/providers/app_providers.dart | 77 ++++++++- lib/features/chat/views/chat_page.dart | 4 +- .../navigation/widgets/chats_drawer.dart | 4 +- .../onboarding/views/onboarding_sheet.dart | 4 +- 5 files changed, 213 insertions(+), 22 deletions(-) diff --git a/lib/core/models/backend_config.dart b/lib/core/models/backend_config.dart index c52db4a..68cd398 100644 --- a/lib/core/models/backend_config.dart +++ b/lib/core/models/backend_config.dart @@ -3,15 +3,55 @@ import 'package:flutter/foundation.dart'; /// Subset of the backend `/api/config` response the app cares about. @immutable class BackendConfig { - const BackendConfig({this.enableWebsocket}); + const BackendConfig({ + this.enableWebsocket, + this.enableAudioInput, + this.enableAudioOutput, + this.sttProvider, + this.ttsProvider, + this.ttsVoice, + this.defaultSttLocale, + this.audioSampleRate, + this.audioFrameSize, + this.vadEnabled, + }); /// Mirrors `features.enable_websocket` from OpenWebUI. final bool? enableWebsocket; + final bool? enableAudioInput; + final bool? enableAudioOutput; + final String? sttProvider; + final String? ttsProvider; + final String? ttsVoice; + final String? defaultSttLocale; + final int? audioSampleRate; + final int? audioFrameSize; + final bool? vadEnabled; /// Returns a copy with updated fields. - BackendConfig copyWith({bool? enableWebsocket}) { + BackendConfig copyWith({ + bool? enableWebsocket, + bool? enableAudioInput, + bool? enableAudioOutput, + String? sttProvider, + String? ttsProvider, + String? ttsVoice, + String? defaultSttLocale, + int? audioSampleRate, + int? audioFrameSize, + bool? vadEnabled, + }) { return BackendConfig( enableWebsocket: enableWebsocket ?? this.enableWebsocket, + enableAudioInput: enableAudioInput ?? this.enableAudioInput, + enableAudioOutput: enableAudioOutput ?? this.enableAudioOutput, + sttProvider: sttProvider ?? this.sttProvider, + ttsProvider: ttsProvider ?? this.ttsProvider, + ttsVoice: ttsVoice ?? this.ttsVoice, + defaultSttLocale: defaultSttLocale ?? this.defaultSttLocale, + audioSampleRate: audioSampleRate ?? this.audioSampleRate, + audioFrameSize: audioFrameSize ?? this.audioFrameSize, + vadEnabled: vadEnabled ?? this.vadEnabled, ); } @@ -37,28 +77,114 @@ class BackendConfig { Map toJson() { return { 'enable_websocket': enableWebsocket, + 'enable_audio_input': enableAudioInput, + 'enable_audio_output': enableAudioOutput, + 'stt_provider': sttProvider, + 'tts_provider': ttsProvider, + 'tts_voice': ttsVoice, + 'default_stt_locale': defaultSttLocale, + 'audio_sample_rate': audioSampleRate, + 'audio_frame_size': audioFrameSize, + 'vad_enabled': vadEnabled, }; } static BackendConfig fromJson(Map json) { bool? enableWebsocket; + bool? enableAudioInput; + bool? enableAudioOutput; + String? sttProvider; + String? ttsProvider; + String? ttsVoice; + String? defaultSttLocale; + int? audioSampleRate; + int? audioFrameSize; + bool? vadEnabled; // Try canonical format first final value = json['enable_websocket']; if (value is bool) { enableWebsocket = value; } + final audioIn = json['enable_audio_input']; + if (audioIn is bool) enableAudioInput = audioIn; + final audioOut = json['enable_audio_output']; + if (audioOut is bool) enableAudioOutput = audioOut; + + final stt = json['stt_provider']; + if (stt is String) sttProvider = stt; + final tts = json['tts_provider']; + if (tts is String) ttsProvider = tts; + final ttsVoiceValue = json['tts_voice']; + if (ttsVoiceValue is String) ttsVoice = ttsVoiceValue; + + final defaultLocale = json['default_stt_locale']; + if (defaultLocale is String) defaultSttLocale = defaultLocale; + + final sampleRate = json['audio_sample_rate']; + if (sampleRate is int) audioSampleRate = sampleRate; + final frameSize = json['audio_frame_size']; + if (frameSize is int) audioFrameSize = frameSize; + + final vad = json['vad_enabled']; + if (vad is bool) vadEnabled = vad; + // Fallback to nested format for backwards compatibility - if (enableWebsocket == null) { - final features = json['features']; - if (features is Map) { - final nestedValue = features['enable_websocket']; - if (nestedValue is bool) { - enableWebsocket = nestedValue; - } + final features = json['features']; + if (features is Map) { + final nestedValue = features['enable_websocket']; + if (nestedValue is bool && enableWebsocket == null) { + enableWebsocket = nestedValue; + } + final nestedAudioIn = features['enable_audio_input']; + if (nestedAudioIn is bool && enableAudioInput == null) { + enableAudioInput = nestedAudioIn; + } + final nestedAudioOut = features['enable_audio_output']; + if (nestedAudioOut is bool && enableAudioOutput == null) { + enableAudioOutput = nestedAudioOut; + } + final nestedStt = features['stt_provider']; + if (nestedStt is String && sttProvider == null) { + sttProvider = nestedStt; + } + final nestedTts = features['tts_provider']; + if (nestedTts is String && ttsProvider == null) { + ttsProvider = nestedTts; + } + final nestedVoice = features['tts_voice']; + if (nestedVoice is String && ttsVoice == null) { + ttsVoice = nestedVoice; + } + final nestedLocale = features['default_stt_locale']; + if (nestedLocale is String && defaultSttLocale == null) { + defaultSttLocale = nestedLocale; + } + final nestedSample = features['audio_sample_rate']; + if (nestedSample is int && audioSampleRate == null) { + audioSampleRate = nestedSample; + } + final nestedFrame = features['audio_frame_size']; + if (nestedFrame is int && audioFrameSize == null) { + audioFrameSize = nestedFrame; + } + final nestedVad = features['vad_enabled']; + if (nestedVad is bool && vadEnabled == null) { + vadEnabled = nestedVad; } } - return BackendConfig(enableWebsocket: enableWebsocket); + return BackendConfig( + enableWebsocket: enableWebsocket, + enableAudioInput: enableAudioInput, + enableAudioOutput: enableAudioOutput, + sttProvider: sttProvider, + ttsProvider: ttsProvider, + ttsVoice: ttsVoice, + defaultSttLocale: defaultSttLocale, + audioSampleRate: audioSampleRate, + audioFrameSize: audioFrameSize, + vadEnabled: vadEnabled, + ); } } diff --git a/lib/core/providers/app_providers.dart b/lib/core/providers/app_providers.dart index ca8765b..aebf9b4 100644 --- a/lib/core/providers/app_providers.dart +++ b/lib/core/providers/app_providers.dart @@ -521,17 +521,88 @@ final apiTokenUpdaterProvider = Provider((ref) { @Riverpod(keepAlive: true) Future currentUser(Ref ref) async { final api = ref.read(apiServiceProvider); - final isAuthenticated = ref.watch(isAuthenticatedProvider2); + final authState = ref.watch(authStateManagerProvider); + final isAuthenticated = authState.maybeWhen( + data: (state) => state.isAuthenticated, + orElse: () => false, + ); if (api == null || !isAuthenticated) return null; + // Fast path: use user already in auth state. + final authUser = authState.maybeWhen( + data: (state) => state.user, + orElse: () => null, + ); + if (authUser != null) return authUser; + + // Next: try cached user from storage, then refresh in the background. + final storage = ref.read(optimizedStorageServiceProvider); + final cachedUser = await _getCachedUserWithAvatar(storage); + if (cachedUser != null) { + final lastRefresh = ref.read(_lastUserRefreshProvider); + final now = DateTime.now(); + final shouldRefresh = + lastRefresh == null || + now.difference(lastRefresh) > const Duration(minutes: 5); + + if (shouldRefresh) { + Future.microtask(() async { + final fresh = await _refreshCurrentUser(ref); + if (fresh != null && ref.mounted) { + ref.read(_lastUserRefreshProvider.notifier).set(now); + ref.invalidate(currentUserProvider); + } + }); + } + return cachedUser; + } + + // Fallback: fetch fresh. + final fresh = await _refreshCurrentUser(ref); + if (fresh != null) { + ref.read(_lastUserRefreshProvider.notifier).set(DateTime.now()); + } + return fresh; +} + +Future _getCachedUserWithAvatar(OptimizedStorageService storage) async { + final cachedUser = await storage.getLocalUser(); + if (cachedUser == null) return null; + final cachedAvatar = await storage.getLocalUserAvatar(); + if (cachedAvatar == null || + cachedAvatar.isEmpty || + cachedUser.profileImage == cachedAvatar) { + return cachedUser; + } + return cachedUser.copyWith(profileImage: cachedAvatar); +} + +Future _refreshCurrentUser(Ref ref) async { + final api = ref.read(apiServiceProvider); + if (api == null) return null; + try { - return await api.getCurrentUser(); - } catch (e) { + final user = await api.getCurrentUser(); + final storage = ref.read(optimizedStorageServiceProvider); + await storage.saveLocalUser(user); + if (user.profileImage != null && user.profileImage!.isNotEmpty) { + await storage.saveLocalUserAvatar(user.profileImage); + } + return user; + } catch (_) { return null; } } +@Riverpod(keepAlive: true) +class _LastUserRefresh extends _$LastUserRefresh { + @override + DateTime? build() => null; + + void set(DateTime? timestamp) => state = timestamp; +} + // Helper provider to force refresh auth state - now using unified system final refreshAuthStateProvider = Provider((ref) { // This provider can be invalidated to force refresh the unified auth system diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index 749aa44..0a39f9b 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -16,7 +16,6 @@ import '../providers/chat_providers.dart'; import '../../../core/utils/debug_logger.dart'; import '../../../core/utils/user_display_name.dart'; import '../../../core/utils/model_icon_utils.dart'; -import '../../auth/providers/unified_auth_providers.dart'; import '../../../core/utils/android_assistant_handler.dart'; import '../widgets/modern_chat_input.dart'; import '../widgets/user_message_bubble.dart'; @@ -1128,8 +1127,7 @@ class _ChatPageState extends ConsumerState { data: (user) => user, orElse: () => null, ); - final authUser = ref.watch(currentUserProvider2); - final user = userFromProfile ?? authUser; + final user = userFromProfile; String? greetingName; if (user != null) { final derived = deriveUserDisplayName(user, fallback: '').trim(); diff --git a/lib/features/navigation/widgets/chats_drawer.dart b/lib/features/navigation/widgets/chats_drawer.dart index 18cdebd..4a911e2 100644 --- a/lib/features/navigation/widgets/chats_drawer.dart +++ b/lib/features/navigation/widgets/chats_drawer.dart @@ -17,7 +17,6 @@ import '../../../shared/widgets/themed_dialogs.dart'; import 'package:conduit/l10n/app_localizations.dart'; import '../../../core/utils/user_display_name.dart'; import '../../../core/utils/model_icon_utils.dart'; -import '../../auth/providers/unified_auth_providers.dart'; import '../../../core/utils/user_avatar_utils.dart'; import '../../../shared/utils/conversation_context_menu.dart'; import '../../../shared/widgets/user_avatar.dart'; @@ -1483,8 +1482,7 @@ class _ChatsDrawerState extends ConsumerState { data: (u) => u, orElse: () => null, ); - final authUser = ref.watch(currentUserProvider2); - final user = userFromProfile ?? authUser; + final user = userFromProfile; final api = ref.watch(apiServiceProvider); String initialFor(String name) { diff --git a/lib/features/onboarding/views/onboarding_sheet.dart b/lib/features/onboarding/views/onboarding_sheet.dart index 433280f..64559f1 100644 --- a/lib/features/onboarding/views/onboarding_sheet.dart +++ b/lib/features/onboarding/views/onboarding_sheet.dart @@ -8,7 +8,6 @@ import '../../../core/providers/app_providers.dart'; import '../../../core/utils/user_display_name.dart'; import '../../../shared/theme/theme_extensions.dart'; import '../../../shared/widgets/sheet_handle.dart'; -import '../../auth/providers/unified_auth_providers.dart'; class OnboardingSheet extends ConsumerStatefulWidget { const OnboardingSheet({super.key}); @@ -73,8 +72,7 @@ class _OnboardingSheetState extends ConsumerState { data: (user) => user, orElse: () => null, ); - final authUser = ref.watch(currentUserProvider2); - final user = userFromProfile ?? authUser; + final user = userFromProfile; final greetingName = deriveUserDisplayName(user); final pages = _buildPages(l10n, greetingName); final pageCount = pages.length;