feat(cache): Add lightweight in-memory cache with TTL and LRU eviction

This commit is contained in:
cogwheel0
2025-11-22 21:53:14 +05:30
parent 8ed75f8f14
commit c4a36bb51c
14 changed files with 1298 additions and 242 deletions

View File

@@ -5,9 +5,11 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
// Types are used through app_providers.dart
import '../providers/app_providers.dart';
import '../models/user.dart';
import '../services/optimized_storage_service.dart';
import 'token_validator.dart';
import 'auth_cache_manager.dart';
import '../utils/debug_logger.dart';
import '../utils/user_avatar_utils.dart';
part 'auth_state_manager.g.dart';
@@ -97,12 +99,63 @@ class AuthStateManager extends _$AuthStateManager {
state.asData?.value ?? const AuthState(status: AuthStatus.initial);
void _set(AuthState next, {bool cache = false}) {
final storage = ref.read(optimizedStorageServiceProvider);
if (next.user != null && next.isAuthenticated) {
// Persist user and avatar asynchronously without blocking state update
unawaited(_persistUserWithAvatar(next, storage));
} else if (!next.isAuthenticated) {
unawaited(storage.saveLocalUser(null).onError((error, stack) {
DebugLogger.error(
'Failed to clear local user on logout',
scope: 'auth/persistence',
error: error,
stackTrace: stack,
);
}));
unawaited(storage.saveLocalUserAvatar(null).onError((error, stack) {
DebugLogger.error(
'Failed to clear local user avatar on logout',
scope: 'auth/persistence',
error: error,
stackTrace: stack,
);
}));
}
state = AsyncValue.data(next);
if (cache) {
_cacheManager.cacheAuthState(next);
}
}
Future<void> _persistUserWithAvatar(
AuthState authState,
OptimizedStorageService storage,
) async {
try {
final api = ref.read(apiServiceProvider);
final user = authState.user!;
final resolvedAvatar = resolveUserProfileImageUrl(
api,
deriveUserProfileImage(user),
);
final userWithAvatar =
resolvedAvatar != null && resolvedAvatar != user.profileImage
? user.copyWith(profileImage: resolvedAvatar)
: user;
await storage.saveLocalUser(userWithAvatar);
if (resolvedAvatar != null) {
await storage.saveLocalUserAvatar(resolvedAvatar);
}
} catch (error, stack) {
DebugLogger.error(
'Failed to persist user with avatar',
scope: 'auth/persistence',
error: error,
stackTrace: stack,
);
}
}
void _update(
AuthState Function(AuthState current) transform, {
bool cache = false,
@@ -143,6 +196,25 @@ class AuthStateManager extends _$AuthStateManager {
cache: true,
);
try {
final cachedUser = await storage.getLocalUser();
if (cachedUser != null) {
// Restore cached avatar as well
final cachedAvatar = await storage.getLocalUserAvatar();
final userWithAvatar =
cachedAvatar != null &&
cachedAvatar.isNotEmpty &&
cachedUser.profileImage != cachedAvatar
? cachedUser.copyWith(profileImage: cachedAvatar)
: cachedUser;
_update(
(current) => current.copyWith(user: userWithAvatar),
cache: true,
);
DebugLogger.auth('Restored user from cache');
}
} catch (_) {}
// Update API service with token and kick off dependent background work
_updateApiServiceToken(token);
_preloadDefaultModel();
@@ -706,11 +778,11 @@ class AuthStateManager extends _$AuthStateManager {
// Clear active server to force return to server connection page
await storage.setActiveServerId(null);
// Invalidate all auth-related providers to clear cached data
ref.invalidate(activeServerProvider);
ref.invalidate(serverConfigsProvider);
// Clear auth cache manager
_cacheManager.clearAuthCache();
@@ -725,7 +797,9 @@ class AuthStateManager extends _$AuthStateManager {
),
);
DebugLogger.auth('Logout complete - all data cleared including server configs and custom headers');
DebugLogger.auth(
'Logout complete - all data cleared including server configs and custom headers',
);
} catch (e, stack) {
DebugLogger.error(
'logout-failed',

View File

@@ -35,16 +35,27 @@ class BackendConfig {
}
Map<String, dynamic> toJson() {
return <String, dynamic>{'enable_websocket': enableWebsocket};
return <String, dynamic>{
'enable_websocket': enableWebsocket,
};
}
static BackendConfig fromJson(Map<String, dynamic> json) {
bool? enableWebsocket;
final features = json['features'];
if (features is Map<String, dynamic>) {
final value = features['enable_websocket'];
if (value is bool) {
enableWebsocket = value;
// Try canonical format first
final value = json['enable_websocket'];
if (value is bool) {
enableWebsocket = value;
}
// Fallback to nested format for backwards compatibility
if (enableWebsocket == null) {
final features = json['features'];
if (features is Map<String, dynamic>) {
final nestedValue = features['enable_websocket'];
if (nestedValue is bool) {
enableWebsocket = nestedValue;
}
}
}

View File

@@ -20,6 +20,18 @@ sealed class Model with _$Model {
}) = _Model;
factory Model.fromJson(Map<String, dynamic> json) {
final cachedIsMultimodal = switch (json['isMultimodal']) {
final bool value => value,
_ => json['is_multimodal'] is bool ? json['is_multimodal'] as bool : null,
};
final cachedSupportsStreaming = switch (json['supportsStreaming']) {
final bool value => value,
_ =>
json['supports_streaming'] is bool
? json['supports_streaming'] as bool
: null,
};
// Handle different response formats from OpenWebUI
// Extract architecture info for capabilities
@@ -29,8 +41,9 @@ sealed class Model with _$Model {
// Determine if multimodal based on architecture
final isMultimodal =
modality?.contains('image') == true ||
inputModalities?.contains('image') == true;
cachedIsMultimodal ??
(modality?.contains('image') == true ||
inputModalities?.contains('image') == true);
// Extract supported parameters robustly (top-level or nested under provider keys)
List? supportedParams =
@@ -63,7 +76,8 @@ sealed class Model with _$Model {
}
// Determine streaming support from supported parameters if known
final supportsStreaming = supportedParams?.contains('stream') ?? true;
final supportsStreaming =
cachedSupportsStreaming ?? supportedParams?.contains('stream') ?? true;
// Convert supported parameters to List<String> if present
final supportedParamsList = supportedParams
@@ -154,4 +168,22 @@ sealed class Model with _$Model {
toolIds: toolIds,
);
}
Map<String, dynamic> toJson() {
final data = <String, dynamic>{
'id': id,
'name': name,
'description': description,
'isMultimodal': isMultimodal,
'supportsStreaming': supportsStreaming,
'supportsRAG': supportsRAG,
'supported_parameters': supportedParameters,
'capabilities': capabilities,
'metadata': metadata,
'architecture': capabilities?['architecture'],
'toolIds': toolIds,
};
data.removeWhere((_, value) => value == null);
return data;
}
}

View File

@@ -0,0 +1,23 @@
class SocketTransportAvailability {
const SocketTransportAvailability({
required this.allowPolling,
required this.allowWebsocketOnly,
});
final bool allowPolling;
final bool allowWebsocketOnly;
Map<String, dynamic> toJson() {
return {
'allowPolling': allowPolling,
'allowWebsocketOnly': allowWebsocketOnly,
};
}
factory SocketTransportAvailability.fromJson(Map<String, dynamic> json) {
return SocketTransportAvailability(
allowPolling: json['allowPolling'] == true,
allowWebsocketOnly: json['allowWebsocketOnly'] == true,
);
}
}

View File

@@ -23,4 +23,14 @@ sealed class Tool with _$Tool {
meta: json['meta'] as Map<String, dynamic>?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'description': description,
'user_id': userId,
'meta': meta,
};
}
}

View File

@@ -30,4 +30,16 @@ sealed class User with _$User {
isActive: json['is_active'] as bool? ?? json['isActive'] as bool? ?? true,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'username': username,
'email': email,
'name': name,
'profile_image_url': profileImage,
'role': role,
'is_active': isActive,
};
}
}

View File

@@ -15,6 +15,13 @@ final class HiveStoreKeys {
// Cache entries
static const String localConversations = 'local_conversations';
static const String localUser = 'local_user';
static const String localUserAvatar = 'local_user_avatar';
static const String localBackendConfig = 'local_backend_config';
static const String localTransportOptions = 'local_transport_options';
static const String localTools = 'local_tools';
static const String localDefaultModel = 'local_default_model';
static const String localModels = 'local_models';
static const String localFolders = 'local_folders';
static const String attachmentQueueEntries = 'attachment_queue_entries';
static const String taskQueue = 'outbound_task_queue_v1';

View File

@@ -3,9 +3,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../persistence/persistence_providers.dart';
import '../services/api_service.dart';
import '../auth/auth_state_manager.dart';
import '../../features/auth/providers/unified_auth_providers.dart';
@@ -30,38 +28,13 @@ import '../services/worker_manager.dart';
import '../../shared/theme/tweakcn_themes.dart';
import '../../shared/theme/app_theme.dart';
import '../../features/tools/providers/tools_providers.dart';
import '../models/socket_transport_availability.dart';
import 'storage_providers.dart';
export 'storage_providers.dart';
part 'app_providers.g.dart';
// Storage providers
final secureStorageProvider = Provider<FlutterSecureStorage>((ref) {
// Single, shared instance with explicit platform options
return const FlutterSecureStorage(
aOptions: AndroidOptions(
encryptedSharedPreferences: true,
sharedPreferencesName: 'conduit_secure_prefs',
preferencesKeyPrefix: 'conduit_',
// Avoid auto-wipe on transient errors; we handle errors in code
resetOnError: false,
),
iOptions: IOSOptions(
accountName: 'conduit_secure_storage',
synchronizable: false,
),
);
});
// Optimized storage service provider
final optimizedStorageServiceProvider = Provider<OptimizedStorageService>((
ref,
) {
return OptimizedStorageService(
secureStorage: ref.watch(secureStorageProvider),
boxes: ref.watch(hiveBoxesProvider),
workerManager: ref.watch(workerManagerProvider),
);
});
// Theme provider
@Riverpod(keepAlive: true)
class AppThemeMode extends _$AppThemeMode {
@@ -175,7 +148,37 @@ final serverConnectionStateProvider = Provider<bool>((ref) {
);
});
final backendConfigProvider = FutureProvider<BackendConfig?>((ref) async {
@Riverpod(keepAlive: true)
class BackendConfigNotifier extends _$BackendConfigNotifier {
late final OptimizedStorageService _storage;
@override
Future<BackendConfig?> build() async {
_storage = ref.watch(optimizedStorageServiceProvider);
final cached = await _storage.getLocalBackendConfig();
unawaited(_refreshBackendConfig());
return cached;
}
Future<void> refresh() => _refreshBackendConfig();
Future<void> _refreshBackendConfig() async {
final fresh = await _loadBackendConfig(ref);
if (fresh == null || !ref.mounted) {
return;
}
state = AsyncData(fresh);
await _storage.saveLocalBackendConfig(fresh);
// Persist resolved transport options based on backend config
if (!ref.mounted) return;
final options = _resolveTransportAvailability(fresh);
await _storage.saveLocalTransportOptions(options);
}
}
Future<BackendConfig?> _loadBackendConfig(Ref ref) async {
final api = ref.watch(apiServiceProvider);
if (api == null) {
return null;
@@ -205,21 +208,22 @@ final backendConfigProvider = FutureProvider<BackendConfig?>((ref) async {
} catch (_) {
return null;
}
});
class SocketTransportAvailability {
const SocketTransportAvailability({
required this.allowPolling,
required this.allowWebsocketOnly,
});
final bool allowPolling;
final bool allowWebsocketOnly;
}
/// Provides resolved socket transport options based on backend configuration.
///
/// This is a synchronous provider that:
/// - Returns cached transport options when backend config is not yet loaded
/// - Derives transport options from backend config once available
/// - Does NOT perform side effects (persistence is handled by BackendConfigNotifier)
///
/// The persistence of resolved options happens asynchronously when the
/// backend config is refreshed, ensuring the sync provider remains pure.
final socketTransportOptionsProvider = Provider<SocketTransportAvailability>((
ref,
) {
final storage = ref.watch(optimizedStorageServiceProvider);
// Watch async backend config for proper invalidation
final backendConfigAsync = ref.watch(backendConfigProvider);
final config = backendConfigAsync.maybeWhen(
data: (value) => value,
@@ -227,30 +231,16 @@ final socketTransportOptionsProvider = Provider<SocketTransportAvailability>((
);
if (config == null) {
return const SocketTransportAvailability(
allowPolling: true,
allowWebsocketOnly: true,
);
// Return cached value or defaults when config not available
return storage.getLocalTransportOptionsSync() ??
const SocketTransportAvailability(
allowPolling: true,
allowWebsocketOnly: true,
);
}
if (config.websocketOnly) {
return const SocketTransportAvailability(
allowPolling: false,
allowWebsocketOnly: true,
);
}
if (config.pollingOnly) {
return const SocketTransportAvailability(
allowPolling: true,
allowWebsocketOnly: false,
);
}
return const SocketTransportAvailability(
allowPolling: true,
allowWebsocketOnly: true,
);
// Determine transport availability from backend config
return _resolveTransportAvailability(config);
});
// API Service provider with unified auth integration
@@ -551,52 +541,146 @@ final refreshAuthStateProvider = Provider<void>((ref) {
// Model providers
@Riverpod(keepAlive: true)
Future<List<Model>> models(Ref ref) async {
// Reviewer mode returns mock models
final reviewerMode = ref.watch(reviewerModeProvider);
if (reviewerMode) {
return [
const Model(
id: 'demo/gemma-2-mini',
name: 'Gemma 2 Mini (Demo)',
description: 'Demo model for reviewer mode',
isMultimodal: true,
supportsStreaming: true,
supportedParameters: ['max_tokens', 'stream'],
),
const Model(
id: 'demo/llama-3-8b',
name: 'Llama 3 8B (Demo)',
description: 'Fast text model for demo',
isMultimodal: false,
supportsStreaming: true,
supportedParameters: ['max_tokens', 'stream'],
),
];
}
final api = ref.watch(apiServiceProvider);
if (api == null) return [];
try {
DebugLogger.log('fetch-start', scope: 'models');
final models = await api.getModels();
DebugLogger.log(
'fetch-ok',
scope: 'models',
data: {'count': models.length},
);
return models;
} catch (e) {
DebugLogger.error('fetch-failed', scope: 'models', error: e);
// If models endpoint returns 403, this should now clear auth token
// and redirect user to login since it's marked as a core endpoint
if (e.toString().contains('403')) {
DebugLogger.warning('endpoint-403', scope: 'models');
class Models extends _$Models {
@override
Future<List<Model>> build() async {
// Reviewer mode returns mock models
if (ref.watch(reviewerModeProvider)) {
return _demoModels();
}
return [];
if (!ref.watch(isAuthenticatedProvider2)) {
DebugLogger.log('skip-unauthed', scope: 'models');
_persistModelsAsync(const <Model>[]);
return const [];
}
final storage = ref.watch(optimizedStorageServiceProvider);
try {
final cached = await storage.getLocalModels();
if (cached.isNotEmpty) {
DebugLogger.log(
'cache-restored',
scope: 'models/cache',
data: {'count': cached.length},
);
Future.microtask(() async {
try {
await refresh();
} catch (error, stackTrace) {
DebugLogger.error(
'warm-refresh-failed',
scope: 'models/cache',
error: error,
stackTrace: stackTrace,
);
}
});
return cached;
}
DebugLogger.log('cache-empty', scope: 'models/cache');
} catch (error, stackTrace) {
DebugLogger.error(
'cache-load-failed',
scope: 'models/cache',
error: error,
stackTrace: stackTrace,
);
}
final api = ref.watch(apiServiceProvider);
if (api == null) {
DebugLogger.warning('api-missing', scope: 'models');
_persistModelsAsync(const <Model>[]);
return const [];
}
final fresh = await _load(api);
return fresh;
}
Future<void> refresh() async {
if (ref.read(reviewerModeProvider)) {
state = AsyncData<List<Model>>(_demoModels());
return;
}
if (!ref.read(isAuthenticatedProvider2)) {
state = const AsyncData<List<Model>>(<Model>[]);
_persistModelsAsync(const <Model>[]);
return;
}
final api = ref.read(apiServiceProvider);
if (api == null) {
state = const AsyncData<List<Model>>(<Model>[]);
_persistModelsAsync(const <Model>[]);
return;
}
final result = await AsyncValue.guard(() => _load(api));
if (!ref.mounted) return;
state = result;
}
Future<List<Model>> _load(ApiService api) async {
try {
DebugLogger.log('fetch-start', scope: 'models');
final models = await api.getModels();
DebugLogger.log(
'fetch-ok',
scope: 'models',
data: {'count': models.length},
);
_persistModelsAsync(models);
return models;
} catch (e, stackTrace) {
DebugLogger.error(
'fetch-failed',
scope: 'models',
error: e,
stackTrace: stackTrace,
);
// If models endpoint returns 403, this should now clear auth token
// and redirect user to login since it's marked as a core endpoint
if (e.toString().contains('403')) {
DebugLogger.warning('endpoint-403', scope: 'models');
}
return const [];
}
}
void _persistModelsAsync(List<Model> models) {
final storage = ref.read(optimizedStorageServiceProvider);
unawaited(
storage.saveLocalModels(models).onError((error, stack) {
DebugLogger.error(
'Failed to persist models to cache',
scope: 'models/cache',
error: error,
stackTrace: stack,
);
}),
);
}
List<Model> _demoModels() => const [
Model(
id: 'demo/gemma-2-mini',
name: 'Gemma 2 Mini (Demo)',
description: 'Demo model for reviewer mode',
isMultimodal: true,
supportsStreaming: true,
supportedParameters: ['max_tokens', 'stream'],
),
Model(
id: 'demo/llama-3-8b',
name: 'Llama 3 8B (Demo)',
description: 'Fast text model for demo',
isMultimodal: false,
supportsStreaming: true,
supportedParameters: ['max_tokens', 'stream'],
),
];
}
@Riverpod(keepAlive: true)
@@ -1243,6 +1327,7 @@ Future<Model?> defaultModel(Ref ref) async {
// Initialize the settings watcher (side-effect only)
ref.read(_settingsWatcherProvider);
final storage = ref.read(optimizedStorageServiceProvider);
// Read settings without subscribing to rebuilds to avoid watch/await hazards
final reviewerMode = ref.read(reviewerModeProvider);
if (reviewerMode) {
@@ -1287,6 +1372,20 @@ Future<Model?> defaultModel(Ref ref) async {
DebugLogger.log('api-available', scope: 'models/default');
try {
// Warm restore from cached resolved default model
try {
final cached = await storage.getLocalDefaultModel();
if (cached != null && !ref.read(isManualModelSelectionProvider)) {
ref.read(selectedModelProvider.notifier).set(cached);
DebugLogger.log(
'cached-default',
scope: 'models/default',
data: {'name': cached.name},
);
return cached;
}
} catch (_) {}
// Respect manual selection if present
if (ref.read(isManualModelSelectionProvider)) {
final current = ref.read(selectedModelProvider);
@@ -1298,18 +1397,46 @@ Future<Model?> defaultModel(Ref ref) async {
final storedDefaultId = await SettingsService.getDefaultModel();
if (storedDefaultId != null && storedDefaultId.isNotEmpty) {
if (!ref.read(isManualModelSelectionProvider)) {
final placeholder = Model(
id: storedDefaultId,
name: storedDefaultId,
supportsStreaming: true,
);
ref.read(selectedModelProvider.notifier).set(placeholder);
final cachedMatch = await selectCachedModel(storage, storedDefaultId);
if (cachedMatch != null) {
ref.read(selectedModelProvider.notifier).set(cachedMatch);
unawaited(
storage.saveLocalDefaultModel(cachedMatch).onError((
error,
stack,
) {
DebugLogger.error(
'Failed to save default model to cache',
scope: 'models/default',
error: error,
stackTrace: stack,
);
}),
);
DebugLogger.log(
'cache-select',
scope: 'models/default',
data: {'name': cachedMatch.name, 'source': 'cache'},
);
} else {
DebugLogger.log(
'cache-skip-missing',
scope: 'models/default',
data: {'id': storedDefaultId},
);
}
}
// Reconcile against real models in background
Future.microtask(() async {
try {
if (!ref.mounted) return;
final models = await ref.read(modelsProvider.future);
List<Model> models;
final modelsAsync = ref.read(modelsProvider);
if (modelsAsync.hasValue) {
models = modelsAsync.value ?? const <Model>[];
} else {
models = await ref.read(modelsProvider.future);
}
if (!ref.mounted) return;
Model? resolved;
@@ -1326,6 +1453,16 @@ Future<Model?> defaultModel(Ref ref) async {
if (!ref.mounted) return;
if (resolved != null && !ref.read(isManualModelSelectionProvider)) {
ref.read(selectedModelProvider.notifier).set(resolved);
unawaited(
storage.saveLocalDefaultModel(resolved).onError((error, stack) {
DebugLogger.error(
'Failed to save default model to cache',
scope: 'models/default',
error: error,
stackTrace: stack,
);
}),
);
DebugLogger.log(
'reconcile',
scope: 'models/default',
@@ -1355,6 +1492,16 @@ Future<Model?> defaultModel(Ref ref) async {
supportsStreaming: true,
);
ref.read(selectedModelProvider.notifier).set(placeholder);
unawaited(
storage.saveLocalDefaultModel(placeholder).onError((error, stack) {
DebugLogger.error(
'Failed to save placeholder model to cache',
scope: 'models/default',
error: error,
stackTrace: stack,
);
}),
);
}
// Reconcile against real models in background
Future.microtask(() async {
@@ -1377,6 +1524,16 @@ Future<Model?> defaultModel(Ref ref) async {
if (!ref.mounted) return;
if (resolved != null && !ref.read(isManualModelSelectionProvider)) {
ref.read(selectedModelProvider.notifier).set(resolved);
unawaited(
storage.saveLocalDefaultModel(resolved).onError((error, stack) {
DebugLogger.error(
'Failed to save default model to cache',
scope: 'models/default',
error: error,
stackTrace: stack,
);
}),
);
DebugLogger.log(
'reconcile',
scope: 'models/default',
@@ -1410,6 +1567,16 @@ Future<Model?> defaultModel(Ref ref) async {
final selectedModel = models.first;
if (!ref.read(isManualModelSelectionProvider)) {
ref.read(selectedModelProvider.notifier).set(selectedModel);
unawaited(
storage.saveLocalDefaultModel(selectedModel).onError((error, stack) {
DebugLogger.error(
'Failed to save default model to cache',
scope: 'models/default',
error: error,
stackTrace: stack,
);
}),
);
DebugLogger.log(
'fallback-selected',
scope: 'models/default',
@@ -2158,3 +2325,66 @@ Future<List<Map<String, dynamic>>> imageModels(Ref ref) async {
return [];
}
}
/// Helper function to select cached model based on settings and available models.
/// Used by both chat page and defaultModel provider to ensure consistent behavior.
/// Returns a cached model if available, otherwise returns null.
Future<Model?> selectCachedModel(
OptimizedStorageService storage,
String? desiredModelId,
) async {
try {
final cachedModels = await storage.getLocalModels();
if (cachedModels.isEmpty) return null;
Model? match;
if (desiredModelId != null && desiredModelId.isNotEmpty) {
try {
match = cachedModels.firstWhere(
(model) =>
model.id == desiredModelId ||
model.name.trim() == desiredModelId.trim(),
);
} catch (_) {
match = null;
}
}
return match ?? cachedModels.first;
} catch (error, stackTrace) {
DebugLogger.error(
'cache-select-failed',
scope: 'models/cache',
error: error,
stackTrace: stackTrace,
);
return null;
}
}
/// Resolves socket transport availability from backend configuration.
///
/// Used by both the sync [socketTransportOptionsProvider] and the
/// [BackendConfigNotifier] to ensure consistent resolution logic.
SocketTransportAvailability _resolveTransportAvailability(
BackendConfig config,
) {
if (config.websocketOnly) {
return const SocketTransportAvailability(
allowPolling: false,
allowWebsocketOnly: true,
);
}
if (config.pollingOnly) {
return const SocketTransportAvailability(
allowPolling: true,
allowWebsocketOnly: false,
);
}
return const SocketTransportAvailability(
allowPolling: true,
allowWebsocketOnly: true,
);
}

View File

@@ -0,0 +1,35 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../persistence/persistence_providers.dart';
import '../services/optimized_storage_service.dart';
import '../services/worker_manager.dart';
/// Provides a shared [FlutterSecureStorage] instance with platform-specific
/// configuration.
final secureStorageProvider = Provider<FlutterSecureStorage>((ref) {
return const FlutterSecureStorage(
aOptions: AndroidOptions(
encryptedSharedPreferences: true,
sharedPreferencesName: 'conduit_secure_prefs',
preferencesKeyPrefix: 'conduit_',
// Avoid auto-wipe on transient errors; handled at call sites instead.
resetOnError: false,
),
iOptions: IOSOptions(
accountName: 'conduit_secure_storage',
synchronizable: false,
),
);
});
/// Optimized storage service backed by Hive plus secure storage.
final optimizedStorageServiceProvider = Provider<OptimizedStorageService>((
ref,
) {
return OptimizedStorageService(
secureStorage: ref.watch(secureStorageProvider),
boxes: ref.watch(hiveBoxesProvider),
workerManager: ref.watch(workerManagerProvider),
);
});

View File

@@ -0,0 +1,121 @@
/// Lightweight in-memory cache with TTL enforcement and LRU eviction.
///
/// Centralizes cache handling so services can avoid duplicating map and
/// timestamp bookkeeping. Entries expire after [defaultTtl] and the cache is
/// trimmed to [maxEntries] using least-recently-used eviction.
class CacheManager {
CacheManager({
Duration defaultTtl = const Duration(minutes: 5),
int maxEntries = 64,
}) : _defaultTtl = defaultTtl,
_maxEntries = maxEntries;
final Duration _defaultTtl;
final int _maxEntries;
final Map<String, _CacheRecord> _entries = {};
/// Reads a cached value and returns whether the lookup was a hit.
({bool hit, T? value}) lookup<T>(String key) {
final record = _getRecord(key);
if (record == null) return (hit: false, value: null);
return (hit: true, value: record.value as T?);
}
/// Stores [value] with an optional [ttl] override.
void write<T>(String key, T? value, {Duration? ttl}) {
final now = DateTime.now();
_entries[key] = _CacheRecord(
value: value,
ttl: ttl ?? _defaultTtl,
createdAt: now,
lastAccessed: now,
);
_enforceLimits(now);
}
/// Removes a single cached entry.
void invalidate(String key) {
_entries.remove(key);
}
/// Removes entries that match [predicate].
void invalidateMatching(bool Function(String key) predicate) {
_entries.removeWhere((key, _) => predicate(key));
}
/// Clears all cached entries.
void clear() {
_entries.clear();
}
/// Current cache statistics for debugging and health checks.
Map<String, dynamic> stats() {
final now = DateTime.now();
return {
'size': _entries.length,
'maxEntries': _maxEntries,
'defaultTtlSeconds': _defaultTtl.inSeconds,
'entries': _entries.map((key, record) {
final age = now.difference(record.createdAt);
final idle = now.difference(record.lastAccessed);
return MapEntry(key, {
'ageSeconds': age.inSeconds,
'idleSeconds': idle.inSeconds,
'ttlSeconds': record.ttl.inSeconds,
});
}),
};
}
_CacheRecord? _getRecord(String key) {
final record = _entries[key];
if (record == null) return null;
final now = DateTime.now();
if (record.isExpired(now)) {
_entries.remove(key);
return null;
}
record.touch(now);
return record;
}
void _enforceLimits(DateTime now) {
_removeExpired(now);
if (_entries.length <= _maxEntries) return;
final oldestFirst = _entries.entries.toList()
..sort((a, b) => a.value.lastAccessed.compareTo(b.value.lastAccessed));
final overflow = oldestFirst.length - _maxEntries;
for (var i = 0; i < overflow; i++) {
_entries.remove(oldestFirst[i].key);
}
}
void _removeExpired(DateTime now) {
_entries.removeWhere((_, record) => record.isExpired(now));
}
}
class _CacheRecord {
_CacheRecord({
required this.value,
required this.ttl,
required this.createdAt,
required this.lastAccessed,
});
final Object? value;
final Duration ttl;
final DateTime createdAt;
DateTime lastAccessed;
bool isExpired(DateTime now) {
return now.difference(createdAt) > ttl;
}
void touch(DateTime now) {
lastAccessed = now;
}
}

View File

@@ -3,12 +3,18 @@ import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:hive_ce/hive.dart';
import '../models/backend_config.dart';
import '../models/conversation.dart';
import '../models/folder.dart';
import '../models/model.dart';
import '../models/server_config.dart';
import '../models/user.dart';
import '../models/tool.dart';
import '../models/socket_transport_availability.dart';
import '../persistence/hive_boxes.dart';
import '../persistence/persistence_keys.dart';
import '../utils/debug_logger.dart';
import 'cache_manager.dart';
import 'secure_credential_storage.dart';
import 'worker_manager.dart';
@@ -34,6 +40,7 @@ class OptimizedStorageService {
final Box<dynamic> _metadataBox;
final SecureCredentialStorage _secureCredentialStorage;
final WorkerManager _workerManager;
final CacheManager _cacheManager = CacheManager(maxEntries: 64);
static const String _authTokenKey = 'auth_token_v3';
static const String _activeServerIdKey = PreferenceKeys.activeServerId;
@@ -41,22 +48,25 @@ class OptimizedStorageService {
static const String _themePaletteKey = PreferenceKeys.themePalette;
static const String _localeCodeKey = PreferenceKeys.localeCode;
static const String _localConversationsKey = HiveStoreKeys.localConversations;
static const String _localUserKey = HiveStoreKeys.localUser;
static const String _localUserAvatarKey = HiveStoreKeys.localUserAvatar;
static const String _localBackendConfigKey = HiveStoreKeys.localBackendConfig;
static const String _localTransportOptionsKey =
HiveStoreKeys.localTransportOptions;
static const String _localToolsKey = HiveStoreKeys.localTools;
static const String _localDefaultModelKey = HiveStoreKeys.localDefaultModel;
static const String _localModelsKey = HiveStoreKeys.localModels;
static const String _localFoldersKey = HiveStoreKeys.localFolders;
static const String _onboardingSeenKey = PreferenceKeys.onboardingSeen;
static const String _reviewerModeKey = PreferenceKeys.reviewerMode;
final Map<String, dynamic> _cache = {};
final Map<String, DateTime> _cacheTimestamps = {};
static const Duration _cacheTimeout = Duration(minutes: 5);
// ---------------------------------------------------------------------------
// Auth token APIs (secure storage + in-memory cache)
// ---------------------------------------------------------------------------
Future<void> saveAuthToken(String token) async {
try {
await _secureCredentialStorage.saveAuthToken(token);
_cache[_authTokenKey] = token;
_cacheTimestamps[_authTokenKey] = DateTime.now();
_cacheManager.write(_authTokenKey, token);
DebugLogger.log(
'Auth token saved and cached',
scope: 'storage/optimized',
@@ -71,19 +81,17 @@ class OptimizedStorageService {
}
Future<String?> getAuthToken() async {
if (_isCacheValid(_authTokenKey)) {
final cached = _cache[_authTokenKey] as String?;
if (cached != null) {
DebugLogger.log('Using cached auth token', scope: 'storage/optimized');
return cached;
}
final (hit: hasCachedToken, value: cachedToken) = _cacheManager
.lookup<String>(_authTokenKey);
if (hasCachedToken) {
DebugLogger.log('Using cached auth token', scope: 'storage/optimized');
return cachedToken;
}
try {
final token = await _secureCredentialStorage.getAuthToken();
if (token != null) {
_cache[_authTokenKey] = token;
_cacheTimestamps[_authTokenKey] = DateTime.now();
_cacheManager.write(_authTokenKey, token);
}
return token;
} catch (error) {
@@ -98,8 +106,7 @@ class OptimizedStorageService {
Future<void> deleteAuthToken() async {
try {
await _secureCredentialStorage.deleteAuthToken();
_cache.remove(_authTokenKey);
_cacheTimestamps.remove(_authTokenKey);
_cacheManager.invalidate(_authTokenKey);
DebugLogger.log(
'Auth token deleted and cache cleared',
scope: 'storage/optimized',
@@ -129,8 +136,7 @@ class OptimizedStorageService {
password: password,
);
_cache['has_credentials'] = true;
_cacheTimestamps['has_credentials'] = DateTime.now();
_cacheManager.write('has_credentials', true);
DebugLogger.log(
'Credentials saved via optimized storage',
@@ -148,8 +154,7 @@ class OptimizedStorageService {
Future<Map<String, String>?> getSavedCredentials() async {
try {
final credentials = await _secureCredentialStorage.getSavedCredentials();
_cache['has_credentials'] = credentials != null;
_cacheTimestamps['has_credentials'] = DateTime.now();
_cacheManager.write('has_credentials', credentials != null);
return credentials;
} catch (error) {
DebugLogger.log(
@@ -163,8 +168,7 @@ class OptimizedStorageService {
Future<void> deleteSavedCredentials() async {
try {
await _secureCredentialStorage.deleteSavedCredentials();
_cache.remove('has_credentials');
_cacheTimestamps.remove('has_credentials');
_cacheManager.invalidate('has_credentials');
DebugLogger.log(
'Credentials deleted via optimized storage',
scope: 'storage/optimized',
@@ -180,8 +184,10 @@ class OptimizedStorageService {
}
Future<bool> hasCredentials() async {
if (_isCacheValid('has_credentials')) {
return _cache['has_credentials'] == true;
final (hit: hasCachedValue, value: hasCredentials) = _cacheManager
.lookup<bool>('has_credentials');
if (hasCachedValue) {
return hasCredentials == true;
}
final credentials = await getSavedCredentials();
return credentials != null;
@@ -194,8 +200,7 @@ class OptimizedStorageService {
try {
final jsonString = jsonEncode(configs.map((c) => c.toJson()).toList());
await _secureCredentialStorage.saveServerConfigs(jsonString);
_cache['server_config_count'] = configs.length;
_cacheTimestamps['server_config_count'] = DateTime.now();
_cacheManager.write('server_config_count', configs.length);
DebugLogger.log(
'Server configs saved (${configs.length} entries)',
scope: 'storage/optimized',
@@ -213,8 +218,7 @@ class OptimizedStorageService {
try {
final jsonString = await _secureCredentialStorage.getServerConfigs();
if (jsonString == null || jsonString.isEmpty) {
_cache['server_config_count'] = 0;
_cacheTimestamps['server_config_count'] = DateTime.now();
_cacheManager.write('server_config_count', 0);
return const [];
}
@@ -222,8 +226,7 @@ class OptimizedStorageService {
final configs = decoded
.map((item) => ServerConfig.fromJson(item))
.toList();
_cache['server_config_count'] = configs.length;
_cacheTimestamps['server_config_count'] = DateTime.now();
_cacheManager.write('server_config_count', configs.length);
return configs;
} catch (error) {
DebugLogger.log(
@@ -240,17 +243,18 @@ class OptimizedStorageService {
} else {
await _preferencesBox.delete(_activeServerIdKey);
}
_cache[_activeServerIdKey] = serverId;
_cacheTimestamps[_activeServerIdKey] = DateTime.now();
_cacheManager.write(_activeServerIdKey, serverId);
}
Future<String?> getActiveServerId() async {
if (_isCacheValid(_activeServerIdKey)) {
return _cache[_activeServerIdKey] as String?;
final (hit: hasCachedId, value: cachedId) = _cacheManager.lookup<String>(
_activeServerIdKey,
);
if (hasCachedId) {
return cachedId;
}
final serverId = _preferencesBox.get(_activeServerIdKey) as String?;
_cache[_activeServerIdKey] = serverId;
_cacheTimestamps[_activeServerIdKey] = DateTime.now();
_cacheManager.write(_activeServerIdKey, serverId);
return serverId;
}
@@ -392,6 +396,378 @@ class OptimizedStorageService {
}
}
Future<User?> getLocalUser() async {
try {
final stored = _cachesBox.get(_localUserKey);
if (stored == null) return null;
if (stored is String) {
final decoded = jsonDecode(stored);
if (decoded is Map<String, dynamic>) {
return User.fromJson(decoded);
}
} else if (stored is Map<String, dynamic>) {
return User.fromJson(stored);
}
} catch (error, stack) {
DebugLogger.error(
'Failed to retrieve local user',
scope: 'storage/optimized',
error: error,
stackTrace: stack,
);
}
return null;
}
Future<void> saveLocalUser(User? user) async {
try {
if (user == null) {
await _cachesBox.delete(_localUserKey);
await _cachesBox.delete(_localUserAvatarKey);
return;
}
final serialized = jsonEncode(user.toJson());
await _cachesBox.put(_localUserKey, serialized);
DebugLogger.log('Saved local user profile', scope: 'storage/optimized');
} catch (error, stack) {
DebugLogger.error(
'Failed to save local user',
scope: 'storage/optimized',
error: error,
stackTrace: stack,
);
}
}
Future<String?> getLocalUserAvatar() async {
try {
final stored = _cachesBox.get(_localUserAvatarKey);
if (stored is String && stored.isNotEmpty) {
return stored;
}
} catch (error, stack) {
DebugLogger.error(
'Failed to retrieve local user avatar',
scope: 'storage/optimized',
error: error,
stackTrace: stack,
);
}
return null;
}
Future<void> saveLocalUserAvatar(String? avatarUrl) async {
try {
if (avatarUrl == null || avatarUrl.isEmpty) {
await _cachesBox.delete(_localUserAvatarKey);
return;
}
await _cachesBox.put(_localUserAvatarKey, avatarUrl);
} catch (error, stack) {
DebugLogger.error(
'Failed to save local user avatar',
scope: 'storage/optimized',
error: error,
stackTrace: stack,
);
}
}
Future<BackendConfig?> getLocalBackendConfig() async {
try {
final stored = _cachesBox.get(_localBackendConfigKey);
if (stored == null) return null;
final activeServerId = await getActiveServerId();
final (payload, ownerServerId) = _unwrapServerScoped(stored);
if (!_matchesActiveServer(activeServerId, ownerServerId)) {
return null;
}
if (payload is String) {
final decoded = jsonDecode(payload);
if (decoded is Map<String, dynamic>) {
return BackendConfig.fromJson(decoded);
}
} else if (payload is Map) {
return BackendConfig.fromJson(Map<String, dynamic>.from(payload));
}
} catch (error, stack) {
DebugLogger.error(
'Failed to retrieve local backend config',
scope: 'storage/optimized',
error: error,
stackTrace: stack,
);
}
return null;
}
Future<void> saveLocalBackendConfig(BackendConfig? config) async {
try {
if (config == null) {
await _cachesBox.delete(_localBackendConfigKey);
return;
}
final serialized = jsonEncode(config.toJson());
await _cachesBox.put(
_localBackendConfigKey,
_wrapServerScoped(serialized),
);
} catch (error, stack) {
DebugLogger.error(
'Failed to save local backend config',
scope: 'storage/optimized',
error: error,
stackTrace: stack,
);
}
}
Future<SocketTransportAvailability?> getLocalTransportOptions() async {
try {
final stored = _cachesBox.get(_localTransportOptionsKey);
if (stored == null) return null;
final activeServerId = await getActiveServerId();
final (payload, ownerServerId) = _unwrapServerScoped(stored);
if (!_matchesActiveServer(activeServerId, ownerServerId)) {
return null;
}
if (payload is String) {
final decoded = jsonDecode(payload);
if (decoded is Map<String, dynamic>) {
return _transportFromJson(decoded);
}
} else if (payload is Map) {
return _transportFromJson(Map<String, dynamic>.from(payload));
}
} catch (error, stack) {
DebugLogger.error(
'Failed to retrieve local transport options',
scope: 'storage/optimized',
error: error,
stackTrace: stack,
);
}
return null;
}
Future<void> saveLocalTransportOptions(
SocketTransportAvailability? options,
) async {
try {
if (options == null) {
await _cachesBox.delete(_localTransportOptionsKey);
return;
}
final json = {
'allowPolling': options.allowPolling,
'allowWebsocketOnly': options.allowWebsocketOnly,
};
await _cachesBox.put(
_localTransportOptionsKey,
_wrapServerScoped(jsonEncode(json)),
);
} catch (error, stack) {
DebugLogger.error(
'Failed to save local transport options',
scope: 'storage/optimized',
error: error,
stackTrace: stack,
);
}
}
SocketTransportAvailability? getLocalTransportOptionsSync() {
try {
final stored = _cachesBox.get(_localTransportOptionsKey);
if (stored == null) return null;
final (payload, ownerServerId) = _unwrapServerScoped(stored);
if (!_matchesActiveServer(_readActiveServerIdSync(), ownerServerId)) {
return null;
}
if (payload is String) {
final decoded = jsonDecode(payload);
if (decoded is Map<String, dynamic>) {
return _transportFromJson(decoded);
}
} else if (payload is Map) {
return _transportFromJson(Map<String, dynamic>.from(payload));
}
} catch (error, stack) {
DebugLogger.error(
'Failed to retrieve local transport options sync',
scope: 'storage/optimized',
error: error,
stackTrace: stack,
);
}
return null;
}
Future<List<Model>> getLocalModels() async {
try {
final stored = _cachesBox.get(_localModelsKey);
if (stored == null) {
return const [];
}
final activeServerId = await getActiveServerId();
final (payload, ownerServerId) = _unwrapServerScoped(stored);
if (!_matchesActiveServer(activeServerId, ownerServerId)) {
return const [];
}
if (payload == null) return const [];
final parsed = await _workerManager
.schedule<Map<String, dynamic>, List<Map<String, dynamic>>>(
_decodeStoredJsonListWorker,
{'stored': payload},
debugLabel: 'decode_local_models',
);
return parsed.map(Model.fromJson).toList(growable: false);
} catch (error, stack) {
DebugLogger.error(
'Failed to retrieve local models',
scope: 'storage/optimized',
error: error,
stackTrace: stack,
);
return const [];
}
}
Future<void> saveLocalModels(List<Model> models) async {
try {
final jsonReady = models.map((model) => model.toJson()).toList();
final serialized = await _workerManager
.schedule<Map<String, dynamic>, String>(_encodeJsonListWorker, {
'items': jsonReady,
}, debugLabel: 'encode_local_models');
await _cachesBox.put(_localModelsKey, _wrapServerScoped(serialized));
DebugLogger.log(
'Saved ${models.length} local models',
scope: 'storage/optimized',
);
} catch (error, stack) {
DebugLogger.error(
'Failed to save local models',
scope: 'storage/optimized',
error: error,
stackTrace: stack,
);
}
}
Future<List<Tool>> getLocalTools() async {
try {
final stored = _cachesBox.get(_localToolsKey);
if (stored == null) return const [];
final activeServerId = await getActiveServerId();
final (payload, ownerServerId) = _unwrapServerScoped(stored);
if (!_matchesActiveServer(activeServerId, ownerServerId)) {
return const [];
}
if (payload == null) return const [];
final parsed = await _workerManager
.schedule<Map<String, dynamic>, List<Map<String, dynamic>>>(
_decodeStoredJsonListWorker,
{'stored': payload},
debugLabel: 'decode_local_tools',
);
return parsed.map(Tool.fromJson).toList(growable: false);
} catch (error, stack) {
DebugLogger.error(
'Failed to retrieve local tools',
scope: 'storage/optimized',
error: error,
stackTrace: stack,
);
return const [];
}
}
Future<void> saveLocalTools(List<Tool> tools) async {
try {
final jsonReady = tools.map((tool) => tool.toJson()).toList();
final serialized = await _workerManager
.schedule<Map<String, dynamic>, String>(_encodeJsonListWorker, {
'items': jsonReady,
}, debugLabel: 'encode_local_tools');
await _cachesBox.put(_localToolsKey, _wrapServerScoped(serialized));
DebugLogger.log(
'Saved ${tools.length} local tools',
scope: 'storage/optimized',
);
} catch (error, stack) {
DebugLogger.error(
'Failed to save local tools',
scope: 'storage/optimized',
error: error,
stackTrace: stack,
);
}
}
Future<Model?> getLocalDefaultModel() async {
try {
final stored = _cachesBox.get(_localDefaultModelKey);
if (stored == null) return null;
final activeServerId = await getActiveServerId();
final (payload, ownerServerId) = _unwrapServerScoped(stored);
if (!_matchesActiveServer(activeServerId, ownerServerId)) {
return null;
}
Model? parsed;
if (payload is String) {
final decoded = jsonDecode(payload);
if (decoded is Map<String, dynamic>) {
parsed = Model.fromJson(decoded);
}
} else if (payload is Map) {
parsed = Model.fromJson(Map<String, dynamic>.from(payload));
}
if (parsed == null) return null;
final parsedModel = parsed;
final cachedModels = await getLocalModels();
final hasMatch = cachedModels.any(
(model) =>
model.id == parsedModel.id ||
model.name.trim() == parsedModel.name.trim(),
);
if (cachedModels.isNotEmpty && !hasMatch) {
return null;
}
return parsedModel;
} catch (error, stack) {
DebugLogger.error(
'Failed to retrieve local default model',
scope: 'storage/optimized',
error: error,
stackTrace: stack,
);
}
return null;
}
Future<void> saveLocalDefaultModel(Model? model) async {
try {
if (model == null) {
await _cachesBox.delete(_localDefaultModelKey);
return;
}
final serialized = jsonEncode(model.toJson());
await _cachesBox.put(
_localDefaultModelKey,
_wrapServerScoped(serialized),
);
} catch (error, stack) {
DebugLogger.error(
'Failed to save local default model',
scope: 'storage/optimized',
error: error,
stackTrace: stack,
);
}
}
// ---------------------------------------------------------------------------
// Batch operations
// ---------------------------------------------------------------------------
@@ -402,18 +778,19 @@ class OptimizedStorageService {
deleteAuthToken(),
deleteSavedCredentials(),
_preferencesBox.delete(_activeServerIdKey),
_cachesBox.delete(_localUserKey),
_cachesBox.delete(_localUserAvatarKey),
_cachesBox.delete(_localBackendConfigKey),
_cachesBox.delete(_localTransportOptionsKey),
_cachesBox.delete(_localToolsKey),
_cachesBox.delete(_localDefaultModelKey),
_cachesBox.delete(_localModelsKey),
// Clear server configurations (which include custom headers)
_secureCredentialStorage.clearAll(),
]);
_cache.removeWhere(
(key, _) =>
key.contains('auth') ||
key.contains('credentials') ||
key.contains('server'),
);
_cacheTimestamps.removeWhere(
(key, _) =>
_cacheManager.invalidateMatching(
(key) =>
key.contains('auth') ||
key.contains('credentials') ||
key.contains('server'),
@@ -434,8 +811,7 @@ class OptimizedStorageService {
_attachmentQueueBox.clear(),
]);
_cache.clear();
_cacheTimestamps.clear();
_cacheManager.clear();
// Preserve migration metadata
final migrationVersion =
@@ -462,22 +838,53 @@ class OptimizedStorageService {
}
// ---------------------------------------------------------------------------
// Cache helpers
// Server scoping helpers
// ---------------------------------------------------------------------------
bool _isCacheValid(String key) {
final timestamp = _cacheTimestamps[key];
if (timestamp == null) {
return false;
(Object?, String?) _unwrapServerScoped(Object? stored) {
if (stored is Map && stored.containsKey('data')) {
final serverId = stored['serverId'];
return (stored['data'], serverId is String ? serverId : null);
}
return DateTime.now().difference(timestamp) < _cacheTimeout;
return (stored, null);
}
Map<String, Object?> _wrapServerScoped(Object data) {
return {'data': data, 'serverId': _readActiveServerIdSync()};
}
bool _matchesActiveServer(String? activeServerId, String? ownerServerId) {
if (ownerServerId == null || ownerServerId.isEmpty) {
return activeServerId == null;
}
return activeServerId == ownerServerId;
}
String? _readActiveServerIdSync() {
final (hit: hasCachedId, value: cachedId) = _cacheManager.lookup<String>(
_activeServerIdKey,
);
if (hasCachedId) {
return cachedId;
}
return _preferencesBox.get(_activeServerIdKey) as String?;
}
// ---------------------------------------------------------------------------
// Cache helpers
// ---------------------------------------------------------------------------
void clearCache() {
_cache.clear();
_cacheTimestamps.clear();
_cacheManager.clear();
DebugLogger.log('Storage cache cleared', scope: 'storage/optimized');
}
SocketTransportAvailability? _transportFromJson(Map<String, dynamic> json) {
try {
return SocketTransportAvailability.fromJson(json);
} catch (_) {
return null;
}
}
// ---------------------------------------------------------------------------
// Legacy migration hooks (no-op)
// ---------------------------------------------------------------------------
@@ -500,13 +907,7 @@ class OptimizedStorageService {
}
Map<String, dynamic> getStorageStats() {
return {
'cacheSize': _cache.length,
'cachedKeys': _cache.keys.toList(),
'lastAccess': _cacheTimestamps.entries
.map((entry) => '${entry.key}: ${entry.value}')
.toList(),
};
return _cacheManager.stats();
}
}

View File

@@ -1,7 +1,10 @@
import 'dart:developer' as developer;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:hive_ce/hive.dart';
import '../persistence/hive_bootstrap.dart';
import '../persistence/hive_boxes.dart';
import '../persistence/persistence_keys.dart';
import 'animation_service.dart';
@@ -126,47 +129,7 @@ class SettingsService {
/// Load all settings
static Future<AppSettings> loadSettings() {
final box = _preferencesBox();
return Future.value(
AppSettings(
reduceMotion: (box.get(_reduceMotionKey) as bool?) ?? false,
animationSpeed:
(box.get(_animationSpeedKey) as num?)?.toDouble() ?? 1.0,
hapticFeedback: (box.get(_hapticFeedbackKey) as bool?) ?? true,
highContrast: (box.get(_highContrastKey) as bool?) ?? false,
largeText: (box.get(_largeTextKey) as bool?) ?? false,
darkMode: (box.get(_darkModeKey) as bool?) ?? true,
defaultModel: box.get(_defaultModelKey) as String?,
voiceLocaleId: box.get(_voiceLocaleKey) as String?,
voiceHoldToTalk: (box.get(_voiceHoldToTalkKey) as bool?) ?? false,
voiceAutoSendFinal: (box.get(_voiceAutoSendKey) as bool?) ?? false,
socketTransportMode:
box.get(_socketTransportModeKey, defaultValue: 'ws') as String,
quickPills: List<String>.from(
(box.get(_quickPillsKey) as List<dynamic>?) ?? const <String>[],
),
sendOnEnter: (box.get(_sendOnEnterKey) as bool?) ?? false,
ttsVoice: box.get(PreferenceKeys.ttsVoice) as String?,
ttsSpeechRate:
(box.get(PreferenceKeys.ttsSpeechRate) as num?)?.toDouble() ?? 0.5,
ttsPitch: (box.get(PreferenceKeys.ttsPitch) as num?)?.toDouble() ?? 1.0,
ttsVolume:
(box.get(PreferenceKeys.ttsVolume) as num?)?.toDouble() ?? 1.0,
ttsEngine: _parseTtsEngine(
box.get(PreferenceKeys.ttsEngine) as String?,
),
ttsServerVoiceId: box.get(PreferenceKeys.ttsServerVoiceId) as String?,
ttsServerVoiceName:
box.get(PreferenceKeys.ttsServerVoiceName) as String?,
sttPreference: _parseSttPreference(
box.get(PreferenceKeys.voiceSttPreference) as String?,
),
voiceSilenceDuration:
(box.get(_voiceSilenceDurationKey) as int? ?? 2000).clamp(
300,
3000,
),
),
);
return Future.value(_loadSettingsSync(box));
}
/// Save all settings
@@ -379,6 +342,40 @@ class SettingsService {
// Ensure reasonable bounds
return baseScale.clamp(0.8, 3.0);
}
static AppSettings _loadSettingsSync(Box<dynamic> box) {
return AppSettings(
reduceMotion: (box.get(_reduceMotionKey) as bool?) ?? false,
animationSpeed: (box.get(_animationSpeedKey) as num?)?.toDouble() ?? 1.0,
hapticFeedback: (box.get(_hapticFeedbackKey) as bool?) ?? true,
highContrast: (box.get(_highContrastKey) as bool?) ?? false,
largeText: (box.get(_largeTextKey) as bool?) ?? false,
darkMode: (box.get(_darkModeKey) as bool?) ?? true,
defaultModel: box.get(_defaultModelKey) as String?,
voiceLocaleId: box.get(_voiceLocaleKey) as String?,
voiceHoldToTalk: (box.get(_voiceHoldToTalkKey) as bool?) ?? false,
voiceAutoSendFinal: (box.get(_voiceAutoSendKey) as bool?) ?? false,
socketTransportMode:
box.get(_socketTransportModeKey, defaultValue: 'ws') as String,
quickPills: List<String>.from(
(box.get(_quickPillsKey) as List<dynamic>?) ?? const <String>[],
),
sendOnEnter: (box.get(_sendOnEnterKey) as bool?) ?? false,
ttsVoice: box.get(PreferenceKeys.ttsVoice) as String?,
ttsSpeechRate:
(box.get(PreferenceKeys.ttsSpeechRate) as num?)?.toDouble() ?? 0.5,
ttsPitch: (box.get(PreferenceKeys.ttsPitch) as num?)?.toDouble() ?? 1.0,
ttsVolume: (box.get(PreferenceKeys.ttsVolume) as num?)?.toDouble() ?? 1.0,
ttsEngine: _parseTtsEngine(box.get(PreferenceKeys.ttsEngine) as String?),
ttsServerVoiceId: box.get(PreferenceKeys.ttsServerVoiceId) as String?,
ttsServerVoiceName: box.get(PreferenceKeys.ttsServerVoiceName) as String?,
sttPreference: _parseSttPreference(
box.get(PreferenceKeys.voiceSttPreference) as String?,
),
voiceSilenceDuration: (box.get(_voiceSilenceDurationKey) as int? ?? 2000)
.clamp(300, 3000),
);
}
}
/// Sentinel class to detect when defaultModel parameter is not provided
@@ -562,23 +559,36 @@ bool _listEquals(List<String> a, List<String> b) {
/// Provider for app settings
@Riverpod(keepAlive: true)
class AppSettingsNotifier extends _$AppSettingsNotifier {
bool _initialized = false;
Future<void>? _pendingLoad;
@override
AppSettings build() {
if (!_initialized) {
_initialized = true;
Future.microtask(_loadSettings);
if (Hive.isBoxOpen(HiveBoxNames.preferences)) {
final box = Hive.box<dynamic>(HiveBoxNames.preferences);
return SettingsService._loadSettingsSync(box);
}
_pendingLoad ??= _hydrateFromHive();
return const AppSettings();
}
Future<void> _loadSettings() async {
final settings = await SettingsService.loadSettings();
if (!ref.mounted) {
return;
Future<void> _hydrateFromHive() async {
try {
await HiveBootstrap.instance.ensureInitialized();
if (!ref.mounted) return;
final box = Hive.box<dynamic>(HiveBoxNames.preferences);
state = SettingsService._loadSettingsSync(box);
} catch (error, stackTrace) {
developer.log(
'Failed to hydrate settings',
name: 'AppSettingsNotifier',
level: 1000,
error: error,
stackTrace: stackTrace,
);
} finally {
_pendingLoad = null;
}
state = settings;
}
Future<void> setReduceMotion(bool value) async {

View File

@@ -18,7 +18,6 @@ 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';
import '../widgets/assistant_message_widget.dart' as assistant;
@@ -83,6 +82,42 @@ class _ChatPageState extends ConsumerState<ChatPage> {
return fileSize <= (maxSizeMB * 1024 * 1024);
}
Future<Model?> _trySelectCachedModel() async {
final existing = ref.read(selectedModelProvider);
if (existing != null) {
return existing;
}
try {
final storage = ref.read(optimizedStorageServiceProvider);
// Prefer the stored default model ID/name if available
final settingsDesired = ref.read(appSettingsProvider).defaultModel;
final storedDesired = await SettingsService.getDefaultModel().catchError(
(_) => null,
);
final desiredId = settingsDesired ?? storedDesired;
final match = await selectCachedModel(storage, desiredId);
if (match != null) {
ref.read(selectedModelProvider.notifier).set(match);
DebugLogger.log(
'cache-select',
scope: 'chat/model',
data: {'name': match.name, 'source': 'cache'},
);
}
return match;
} catch (error, stackTrace) {
DebugLogger.error(
'cache-select-failed',
scope: 'chat/model',
error: error,
stackTrace: stackTrace,
);
return null;
}
}
void startNewChat() {
// Clear current conversation
ref.read(chatMessagesProvider.notifier).clearMessages();
@@ -110,6 +145,17 @@ class _ChatPageState extends ConsumerState<ChatPage> {
return;
}
// Fast path: try cached models + stored default before waiting on providers
final cached = await _trySelectCachedModel();
if (cached != null) {
// Still continue to reconcile against remote models below
DebugLogger.log(
'cache-hit',
scope: 'chat/model',
data: {'name': cached.name},
);
}
DebugLogger.log('auto-select-start', scope: 'chat/model');
try {

View File

@@ -1,15 +1,59 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:conduit/core/models/tool.dart';
import 'package:conduit/core/providers/storage_providers.dart';
import 'package:conduit/core/services/tools_service.dart';
part 'tools_providers.g.dart';
@Riverpod(keepAlive: true)
Future<List<Tool>> toolsList(Ref ref) async {
final toolsService = ref.watch(toolsServiceProvider);
if (toolsService == null) return [];
return await toolsService.getTools();
class ToolsList extends _$ToolsList {
@override
Future<List<Tool>> build() async {
final storage = ref.watch(optimizedStorageServiceProvider);
final toolsService = ref.watch(toolsServiceProvider);
final cached = await storage.getLocalTools();
if (cached.isNotEmpty) {
_scheduleWarmRefresh(toolsService);
return cached;
}
if (toolsService == null) {
return const [];
}
return _fetchAndPersist(toolsService);
}
Future<void> refresh() async {
final toolsService = ref.read(toolsServiceProvider);
if (toolsService == null) {
return;
}
final result = await AsyncValue.guard(() => _fetchAndPersist(toolsService));
if (!ref.mounted) return;
state = result;
}
void _scheduleWarmRefresh(ToolsService? service) {
if (service == null) {
return;
}
Future.microtask(() async {
await refresh();
});
}
Future<List<Tool>> _fetchAndPersist(ToolsService service) async {
final tools = await service.getTools();
final storage = ref.read(optimizedStorageServiceProvider);
await storage.saveLocalTools(tools);
return tools;
}
}
@Riverpod(keepAlive: true)