refactor: debug logs
This commit is contained in:
@@ -17,6 +17,7 @@ import '../error/api_error_interceptor.dart';
|
||||
import 'sse_parser.dart';
|
||||
import 'stream_recovery_service.dart';
|
||||
import 'persistent_streaming_service.dart';
|
||||
import '../utils/debug_logger.dart';
|
||||
|
||||
class ApiService {
|
||||
final Dio _dio;
|
||||
@@ -98,19 +99,23 @@ class ApiService {
|
||||
if (options.data != null) {
|
||||
if (options.data is Map) {
|
||||
final dataMap = options.data as Map<String, dynamic>;
|
||||
debugPrint('Data type: Map');
|
||||
debugPrint('Data keys: ${dataMap.keys.toList()}');
|
||||
debugPrint(
|
||||
DebugLogger.log('Data type: Map');
|
||||
DebugLogger.log('Data keys: ${dataMap.keys.toList()}');
|
||||
DebugLogger.log(
|
||||
'Has background_tasks: ${dataMap.containsKey('background_tasks')}',
|
||||
);
|
||||
debugPrint(
|
||||
DebugLogger.log(
|
||||
'Has session_id: ${dataMap.containsKey('session_id')}',
|
||||
);
|
||||
debugPrint('Has id: ${dataMap.containsKey('id')}');
|
||||
debugPrint('Full data: ${jsonEncode(dataMap)}');
|
||||
DebugLogger.log('Has id: ${dataMap.containsKey('id')}');
|
||||
DebugLogger.log(
|
||||
'Data structure logged (full data suppressed)',
|
||||
);
|
||||
} else {
|
||||
debugPrint('Data type: ${options.data.runtimeType}');
|
||||
debugPrint('Data: ${options.data}');
|
||||
DebugLogger.log('Data type: ${options.data.runtimeType}');
|
||||
DebugLogger.log(
|
||||
'Data structure logged (full data suppressed)',
|
||||
);
|
||||
}
|
||||
}
|
||||
debugPrint('===== END SSE REQUEST DEBUG =====');
|
||||
@@ -120,17 +125,8 @@ class ApiService {
|
||||
),
|
||||
);
|
||||
|
||||
// 5. Standard logging interceptor
|
||||
_dio.interceptors.add(
|
||||
LogInterceptor(
|
||||
requestBody: true,
|
||||
responseBody: false, // Don't log response bodies to reduce noise
|
||||
requestHeader: true,
|
||||
responseHeader: false,
|
||||
error: true,
|
||||
logPrint: (obj) => debugPrint('API: $obj'),
|
||||
),
|
||||
);
|
||||
// LogInterceptor removed - was exposing sensitive data and creating verbose logs
|
||||
// We now use custom interceptors with secure logging via DebugLogger
|
||||
}
|
||||
|
||||
// Initialize validation interceptor asynchronously
|
||||
@@ -247,14 +243,13 @@ class ApiService {
|
||||
// User info
|
||||
Future<User> getCurrentUser() async {
|
||||
final response = await _dio.get('/api/v1/auths/');
|
||||
debugPrint('DEBUG: /api/v1/auths/ response: ${jsonEncode(response.data)}');
|
||||
DebugLogger.log('User info retrieved successfully');
|
||||
return User.fromJson(response.data);
|
||||
}
|
||||
|
||||
// Models
|
||||
Future<List<Model>> getModels() async {
|
||||
final response = await _dio.get('/api/models');
|
||||
debugPrint('DEBUG: /api/models raw response: ${jsonEncode(response.data)}');
|
||||
|
||||
// Handle different response formats
|
||||
List<dynamic> models;
|
||||
@@ -265,11 +260,11 @@ class ApiService {
|
||||
// Response is a direct array
|
||||
models = response.data as List;
|
||||
} else {
|
||||
debugPrint('ERROR: Unexpected models response format');
|
||||
DebugLogger.error('Unexpected models response format');
|
||||
return [];
|
||||
}
|
||||
|
||||
debugPrint('DEBUG: Found ${models.length} models');
|
||||
DebugLogger.log('Found ${models.length} models');
|
||||
return models.map((m) => Model.fromJson(m)).toList();
|
||||
}
|
||||
|
||||
@@ -279,7 +274,7 @@ class ApiService {
|
||||
debugPrint('DEBUG: Fetching default model from user settings');
|
||||
final response = await _dio.get('/api/v1/users/user/settings');
|
||||
|
||||
debugPrint('DEBUG: User settings response: ${jsonEncode(response.data)}');
|
||||
DebugLogger.log('User settings retrieved successfully');
|
||||
|
||||
final settings = response.data as Map<String, dynamic>;
|
||||
|
||||
@@ -290,20 +285,20 @@ class ApiService {
|
||||
if (models != null && models.isNotEmpty) {
|
||||
// Return the first model in the user's preferred models list
|
||||
final defaultModel = models.first.toString();
|
||||
debugPrint(
|
||||
'DEBUG: Found default model from user settings: $defaultModel',
|
||||
DebugLogger.log(
|
||||
'Found default model from user settings: $defaultModel',
|
||||
);
|
||||
return defaultModel;
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('DEBUG: No default model found in user settings');
|
||||
DebugLogger.log('No default model found in user settings');
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Error fetching default model from user settings: $e');
|
||||
DebugLogger.error('Error fetching default model from user settings', e);
|
||||
// Fall back to trying the old endpoint
|
||||
try {
|
||||
debugPrint('DEBUG: Falling back to configs/models endpoint');
|
||||
DebugLogger.log('Falling back to configs/models endpoint');
|
||||
final response = await _dio.get('/api/v1/configs/models');
|
||||
final config = response.data as Map<String, dynamic>;
|
||||
|
||||
@@ -313,11 +308,11 @@ class ApiService {
|
||||
config['default_model'] as String?;
|
||||
|
||||
if (defaultModel != null && defaultModel.isNotEmpty) {
|
||||
debugPrint('DEBUG: Found default model from fallback: $defaultModel');
|
||||
DebugLogger.log('Found default model from fallback: $defaultModel');
|
||||
return defaultModel;
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
debugPrint('DEBUG: Fallback also failed: $fallbackError');
|
||||
DebugLogger.error('Fallback also failed', fallbackError);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -572,10 +567,10 @@ class ApiService {
|
||||
}
|
||||
|
||||
Future<Conversation> getConversation(String id) async {
|
||||
debugPrint('DEBUG: Fetching individual chat: $id');
|
||||
DebugLogger.log('Fetching individual chat: $id');
|
||||
final response = await _dio.get('/api/v1/chats/$id');
|
||||
|
||||
debugPrint('DEBUG: Chat response: ${response.data}');
|
||||
DebugLogger.log('Chat response received successfully');
|
||||
|
||||
// Parse OpenWebUI ChatResponse format
|
||||
final chatData = response.data as Map<String, dynamic>;
|
||||
@@ -820,10 +815,10 @@ class ApiService {
|
||||
|
||||
final response = await _dio.post('/api/v1/chats/new', data: chatData);
|
||||
|
||||
debugPrint(
|
||||
'DEBUG: Create conversation response status: ${response.statusCode}',
|
||||
DebugLogger.log(
|
||||
'Create conversation response status: ${response.statusCode}',
|
||||
);
|
||||
debugPrint('DEBUG: Create conversation response data: ${response.data}');
|
||||
DebugLogger.log('Create conversation response received successfully');
|
||||
|
||||
// Parse the response
|
||||
final responseData = response.data as Map<String, dynamic>;
|
||||
@@ -914,12 +909,9 @@ class ApiService {
|
||||
debugPrint('DEBUG: Updating chat with OpenWebUI format data using POST');
|
||||
|
||||
// OpenWebUI uses POST not PUT for updating chats
|
||||
final response = await _dio.post(
|
||||
'/api/v1/chats/$conversationId',
|
||||
data: chatData,
|
||||
);
|
||||
await _dio.post('/api/v1/chats/$conversationId', data: chatData);
|
||||
|
||||
debugPrint('DEBUG: Update conversation response: ${response.data}');
|
||||
DebugLogger.log('Update conversation response received successfully');
|
||||
}
|
||||
|
||||
Future<void> updateConversation(
|
||||
@@ -1012,15 +1004,15 @@ class ApiService {
|
||||
try {
|
||||
debugPrint('DEBUG: Fetching folders from /api/v1/folders/');
|
||||
final response = await _dio.get('/api/v1/folders/');
|
||||
debugPrint('DEBUG: Folders response status: ${response.statusCode}');
|
||||
debugPrint('DEBUG: Folders response data: ${response.data}');
|
||||
DebugLogger.log('Folders response status: ${response.statusCode}');
|
||||
DebugLogger.log('Folders response received successfully');
|
||||
|
||||
final data = response.data;
|
||||
if (data is List) {
|
||||
debugPrint('DEBUG: Found ${data.length} folders');
|
||||
return data.cast<Map<String, dynamic>>();
|
||||
} else {
|
||||
debugPrint('DEBUG: Response data is not a list: ${data.runtimeType}');
|
||||
DebugLogger.log('Response data is not a list: ${data.runtimeType}');
|
||||
return [];
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -1379,17 +1371,17 @@ class ApiService {
|
||||
data: {'queries': queries},
|
||||
);
|
||||
|
||||
debugPrint('DEBUG: Web search response status: ${response.statusCode}');
|
||||
debugPrint(
|
||||
'DEBUG: Web search response type: ${response.data.runtimeType}',
|
||||
);
|
||||
debugPrint('DEBUG: Web search response data: ${response.data}');
|
||||
DebugLogger.log('Web search response status: ${response.statusCode}');
|
||||
DebugLogger.log('Web search response type: ${response.data.runtimeType}');
|
||||
DebugLogger.log('Web search response received successfully');
|
||||
|
||||
return response.data as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Web search API error: $e');
|
||||
if (e is DioException) {
|
||||
debugPrint('DEBUG: Web search error response: ${e.response?.data}');
|
||||
DebugLogger.error(
|
||||
'Web search error response available (truncated for security)',
|
||||
);
|
||||
debugPrint('DEBUG: Web search error status: ${e.response?.statusCode}');
|
||||
}
|
||||
rethrow;
|
||||
@@ -1406,7 +1398,7 @@ class ApiService {
|
||||
|
||||
if (response.statusCode == 200 && response.data != null) {
|
||||
final modelData = response.data as Map<String, dynamic>;
|
||||
debugPrint('DEBUG: Model details for $modelId: $modelData');
|
||||
DebugLogger.log('Model details for $modelId retrieved successfully');
|
||||
return modelData;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -1430,7 +1422,7 @@ class ApiService {
|
||||
);
|
||||
|
||||
if (response.statusCode == 200 && response.data != null) {
|
||||
debugPrint('DEBUG: Raw title response: ${response.data}');
|
||||
DebugLogger.log('Raw title response received successfully');
|
||||
|
||||
// Parse the complex response structure
|
||||
String? extractedTitle;
|
||||
@@ -1604,7 +1596,7 @@ class ApiService {
|
||||
debugPrint(
|
||||
'DEBUG: Collection query response type: ${response.data.runtimeType}',
|
||||
);
|
||||
debugPrint('DEBUG: Collection query response data: ${response.data}');
|
||||
DebugLogger.log('Collection query response received successfully');
|
||||
|
||||
if (response.data is List) {
|
||||
return response.data as List<dynamic>;
|
||||
@@ -1644,7 +1636,7 @@ class ApiService {
|
||||
debugPrint(
|
||||
'DEBUG: Retrieval config response status: ${response.statusCode}',
|
||||
);
|
||||
debugPrint('DEBUG: Retrieval config response data: ${response.data}');
|
||||
DebugLogger.log('Retrieval config response received successfully');
|
||||
|
||||
return response.data as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
@@ -1730,7 +1722,7 @@ class ApiService {
|
||||
debugPrint(
|
||||
'DEBUG: Transcription response status: ${response.statusCode}',
|
||||
);
|
||||
debugPrint('DEBUG: Transcription response data: $data');
|
||||
DebugLogger.log('Transcription response received successfully');
|
||||
if (data is String) return data;
|
||||
if (data is Map<String, dynamic>) {
|
||||
final text = data['text'] ?? data['transcription'] ?? data['result'];
|
||||
@@ -1760,7 +1752,9 @@ class ApiService {
|
||||
debugPrint(
|
||||
'DEBUG: Transcription retry status: ${retryResponse.statusCode}',
|
||||
);
|
||||
debugPrint('DEBUG: Transcription retry data: $rdata');
|
||||
DebugLogger.log(
|
||||
'Transcription retry response received successfully',
|
||||
);
|
||||
if (rdata is String) return rdata;
|
||||
if (rdata is Map<String, dynamic>) {
|
||||
final text =
|
||||
@@ -2603,7 +2597,7 @@ class ApiService {
|
||||
),
|
||||
);
|
||||
|
||||
debugPrint('DEBUG: Sending SSE request with data: ${jsonEncode(data)}');
|
||||
DebugLogger.log('Sending SSE request with data structure logged');
|
||||
|
||||
final response = await streamDio.post(
|
||||
'/api/chat/completions',
|
||||
@@ -2678,8 +2672,8 @@ class ApiService {
|
||||
|
||||
// Log what we got if we couldn't extract content
|
||||
if (!streamController.isClosed) {
|
||||
debugPrint('DEBUG: JSON response structure: ${json.keys}');
|
||||
debugPrint('DEBUG: Full JSON response: $json');
|
||||
DebugLogger.log('JSON response structure: ${json.keys}');
|
||||
DebugLogger.log('JSON response received (full data suppressed)');
|
||||
|
||||
// Check if it's a task-based response
|
||||
if (json is Map && json.containsKey('task_id')) {
|
||||
@@ -3020,8 +3014,8 @@ class ApiService {
|
||||
debugPrint('DEBUG: Uploading to /api/v1/files/');
|
||||
final response = await _dio.post('/api/v1/files/', data: formData);
|
||||
|
||||
debugPrint('DEBUG: Upload response status: ${response.statusCode}');
|
||||
debugPrint('DEBUG: Upload response data: ${response.data}');
|
||||
DebugLogger.log('Upload response status: ${response.statusCode}');
|
||||
DebugLogger.log('Upload response received successfully');
|
||||
|
||||
if (response.data is Map && response.data['id'] != null) {
|
||||
final fileId = response.data['id'] as String;
|
||||
@@ -3065,13 +3059,13 @@ class ApiService {
|
||||
debugPrint('Testing endpoint: $endpoint');
|
||||
final response = await _dio.get(endpoint);
|
||||
debugPrint('✅ $endpoint - Status: ${response.statusCode}');
|
||||
debugPrint(' Response type: ${response.data.runtimeType}');
|
||||
DebugLogger.log(' Response type: ${response.data.runtimeType}');
|
||||
if (response.data is List) {
|
||||
debugPrint(' Array length: ${(response.data as List).length}');
|
||||
DebugLogger.log(' Array length: ${(response.data as List).length}');
|
||||
} else if (response.data is Map) {
|
||||
debugPrint(' Object keys: ${(response.data as Map).keys}');
|
||||
DebugLogger.log(' Object keys: ${(response.data as Map).keys}');
|
||||
}
|
||||
debugPrint(
|
||||
DebugLogger.log(
|
||||
' Sample data: ${response.data.toString().substring(0, 200)}...',
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,93 +1,105 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../utils/debug_logger.dart';
|
||||
|
||||
/// Handles background streaming continuation for iOS and Android
|
||||
///
|
||||
///
|
||||
/// On iOS: Uses background tasks to keep streams alive for ~30 seconds
|
||||
/// On Android: Uses foreground service notifications
|
||||
class BackgroundStreamingHandler {
|
||||
static const MethodChannel _channel = MethodChannel('conduit/background_streaming');
|
||||
|
||||
static const MethodChannel _channel = MethodChannel(
|
||||
'conduit/background_streaming',
|
||||
);
|
||||
|
||||
static BackgroundStreamingHandler? _instance;
|
||||
static BackgroundStreamingHandler get instance => _instance ??= BackgroundStreamingHandler._();
|
||||
|
||||
static BackgroundStreamingHandler get instance =>
|
||||
_instance ??= BackgroundStreamingHandler._();
|
||||
|
||||
BackgroundStreamingHandler._() {
|
||||
_setupMethodCallHandler();
|
||||
}
|
||||
|
||||
|
||||
final Set<String> _activeStreamIds = <String>{};
|
||||
final Map<String, StreamState> _streamStates = <String, StreamState>{};
|
||||
|
||||
|
||||
// Callbacks for platform-specific events
|
||||
void Function(List<String> streamIds)? onStreamsSuspending;
|
||||
void Function()? onBackgroundTaskExpiring;
|
||||
bool Function()? shouldContinueInBackground;
|
||||
|
||||
|
||||
void _setupMethodCallHandler() {
|
||||
_channel.setMethodCallHandler((call) async {
|
||||
switch (call.method) {
|
||||
case 'checkStreams':
|
||||
return _activeStreamIds.length;
|
||||
|
||||
|
||||
case 'streamsSuspending':
|
||||
final Map<String, dynamic> args = call.arguments as Map<String, dynamic>;
|
||||
final List<String> streamIds = (args['streamIds'] as List).cast<String>();
|
||||
final Map<String, dynamic> args =
|
||||
call.arguments as Map<String, dynamic>;
|
||||
final List<String> streamIds = (args['streamIds'] as List)
|
||||
.cast<String>();
|
||||
final String reason = args['reason'] as String;
|
||||
|
||||
debugPrint('Background: Streams suspending - $streamIds (reason: $reason)');
|
||||
|
||||
DebugLogger.stream(
|
||||
'Background: Streams suspending - $streamIds (reason: $reason)',
|
||||
);
|
||||
onStreamsSuspending?.call(streamIds);
|
||||
|
||||
|
||||
// Save stream states for recovery
|
||||
await _saveStreamStatesForRecovery(streamIds, reason);
|
||||
break;
|
||||
|
||||
|
||||
case 'backgroundTaskExpiring':
|
||||
debugPrint('Background: Background task expiring');
|
||||
DebugLogger.stream('Background: Background task expiring');
|
||||
onBackgroundTaskExpiring?.call();
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/// Start background execution for given stream IDs
|
||||
Future<void> startBackgroundExecution(List<String> streamIds) async {
|
||||
if (!Platform.isIOS && !Platform.isAndroid) return;
|
||||
|
||||
|
||||
_activeStreamIds.addAll(streamIds);
|
||||
|
||||
|
||||
try {
|
||||
await _channel.invokeMethod('startBackgroundExecution', {
|
||||
'streamIds': streamIds,
|
||||
});
|
||||
|
||||
debugPrint('Background: Started background execution for ${streamIds.length} streams');
|
||||
|
||||
DebugLogger.stream(
|
||||
'Background: Started background execution for ${streamIds.length} streams',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Background: Failed to start background execution: $e');
|
||||
DebugLogger.error('Background: Failed to start background execution', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Stop background execution for given stream IDs
|
||||
Future<void> stopBackgroundExecution(List<String> streamIds) async {
|
||||
if (!Platform.isIOS && !Platform.isAndroid) return;
|
||||
|
||||
|
||||
_activeStreamIds.removeAll(streamIds);
|
||||
streamIds.forEach(_streamStates.remove);
|
||||
|
||||
|
||||
try {
|
||||
await _channel.invokeMethod('stopBackgroundExecution', {
|
||||
'streamIds': streamIds,
|
||||
});
|
||||
|
||||
debugPrint('Background: Stopped background execution for ${streamIds.length} streams');
|
||||
|
||||
DebugLogger.stream(
|
||||
'Background: Stopped background execution for ${streamIds.length} streams',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Background: Failed to stop background execution: $e');
|
||||
DebugLogger.error('Background: Failed to stop background execution', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Register a stream with its current state
|
||||
void registerStream(String streamId, {
|
||||
void registerStream(
|
||||
String streamId, {
|
||||
required String conversationId,
|
||||
required String messageId,
|
||||
String? sessionId,
|
||||
@@ -103,58 +115,61 @@ class BackgroundStreamingHandler {
|
||||
lastContent: lastContent ?? '',
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
|
||||
|
||||
_activeStreamIds.add(streamId);
|
||||
}
|
||||
|
||||
|
||||
/// Update stream state with new chunk
|
||||
void updateStreamState(String streamId, {
|
||||
void updateStreamState(
|
||||
String streamId, {
|
||||
int? chunkSequence,
|
||||
String? content,
|
||||
String? appendedContent,
|
||||
}) {
|
||||
final state = _streamStates[streamId];
|
||||
if (state == null) return;
|
||||
|
||||
|
||||
_streamStates[streamId] = state.copyWith(
|
||||
lastChunkSequence: chunkSequence ?? state.lastChunkSequence,
|
||||
lastContent: appendedContent != null
|
||||
lastContent: appendedContent != null
|
||||
? (state.lastContent + appendedContent)
|
||||
: (content ?? state.lastContent),
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// Unregister a stream when it completes
|
||||
void unregisterStream(String streamId) {
|
||||
_activeStreamIds.remove(streamId);
|
||||
_streamStates.remove(streamId);
|
||||
}
|
||||
|
||||
|
||||
/// Get current stream state for recovery
|
||||
StreamState? getStreamState(String streamId) {
|
||||
return _streamStates[streamId];
|
||||
}
|
||||
|
||||
|
||||
/// Keep alive the background task (iOS only)
|
||||
Future<void> keepAlive() async {
|
||||
if (!Platform.isIOS) return;
|
||||
|
||||
|
||||
try {
|
||||
await _channel.invokeMethod('keepAlive');
|
||||
} catch (e) {
|
||||
debugPrint('Background: Failed to keep alive: $e');
|
||||
DebugLogger.error('Background: Failed to keep alive', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Recover stream states from previous app session
|
||||
Future<List<StreamState>> recoverStreamStates() async {
|
||||
if (!Platform.isIOS && !Platform.isAndroid) return [];
|
||||
|
||||
|
||||
try {
|
||||
final List<dynamic>? states = await _channel.invokeMethod('recoverStreamStates');
|
||||
final List<dynamic>? states = await _channel.invokeMethod(
|
||||
'recoverStreamStates',
|
||||
);
|
||||
if (states == null) return [];
|
||||
|
||||
|
||||
final recovered = <StreamState>[];
|
||||
for (final stateData in states) {
|
||||
final map = stateData as Map<String, dynamic>;
|
||||
@@ -164,39 +179,44 @@ class BackgroundStreamingHandler {
|
||||
_streamStates[state.streamId] = state;
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('Background: Recovered ${recovered.length} stream states');
|
||||
|
||||
DebugLogger.stream(
|
||||
'Background: Recovered ${recovered.length} stream states',
|
||||
);
|
||||
return recovered;
|
||||
} catch (e) {
|
||||
debugPrint('Background: Failed to recover stream states: $e');
|
||||
DebugLogger.error('Background: Failed to recover stream states', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Save stream states for recovery after app restart
|
||||
Future<void> _saveStreamStatesForRecovery(List<String> streamIds, String reason) async {
|
||||
Future<void> _saveStreamStatesForRecovery(
|
||||
List<String> streamIds,
|
||||
String reason,
|
||||
) async {
|
||||
final statesToSave = streamIds
|
||||
.map((id) => _streamStates[id])
|
||||
.where((state) => state != null)
|
||||
.map((state) => state!.toMap())
|
||||
.toList();
|
||||
|
||||
|
||||
try {
|
||||
await _channel.invokeMethod('saveStreamStates', {
|
||||
'states': statesToSave,
|
||||
'reason': reason,
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Background: Failed to save stream states: $e');
|
||||
DebugLogger.error('Background: Failed to save stream states', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Check if any streams are currently active
|
||||
bool get hasActiveStreams => _activeStreamIds.isNotEmpty;
|
||||
|
||||
|
||||
/// Get list of active stream IDs
|
||||
List<String> get activeStreamIds => _activeStreamIds.toList();
|
||||
|
||||
|
||||
/// Clear all stream data (usually on app termination)
|
||||
void clearAll() {
|
||||
_activeStreamIds.clear();
|
||||
@@ -213,7 +233,7 @@ class StreamState {
|
||||
final int lastChunkSequence;
|
||||
final String lastContent;
|
||||
final DateTime timestamp;
|
||||
|
||||
|
||||
const StreamState({
|
||||
required this.streamId,
|
||||
required this.conversationId,
|
||||
@@ -223,7 +243,7 @@ class StreamState {
|
||||
required this.lastContent,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
|
||||
StreamState copyWith({
|
||||
String? streamId,
|
||||
String? conversationId,
|
||||
@@ -243,7 +263,7 @@ class StreamState {
|
||||
timestamp: timestamp ?? this.timestamp,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'streamId': streamId,
|
||||
@@ -255,7 +275,7 @@ class StreamState {
|
||||
'timestamp': timestamp.millisecondsSinceEpoch,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
static StreamState? fromMap(Map<String, dynamic> map) {
|
||||
try {
|
||||
return StreamState(
|
||||
@@ -270,20 +290,20 @@ class StreamState {
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to parse StreamState from map: $e');
|
||||
DebugLogger.error('Failed to parse StreamState from map', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Check if this state is stale (older than threshold)
|
||||
bool isStale({Duration threshold = const Duration(minutes: 5)}) {
|
||||
return DateTime.now().difference(timestamp) > threshold;
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'StreamState(streamId: $streamId, conversationId: $conversationId, '
|
||||
'messageId: $messageId, sequence: $lastChunkSequence, '
|
||||
'contentLength: ${lastContent.length}, timestamp: $timestamp)';
|
||||
'messageId: $messageId, sequence: $lastChunkSequence, '
|
||||
'contentLength: ${lastContent.length}, timestamp: $timestamp)';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../utils/debug_logger.dart';
|
||||
|
||||
/// Navigation state data model
|
||||
class NavigationState {
|
||||
@@ -58,9 +59,9 @@ class NavigationStateService {
|
||||
try {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
await _loadNavigationState();
|
||||
debugPrint('DEBUG: NavigationStateService initialized');
|
||||
DebugLogger.navigation('NavigationStateService initialized');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to initialize NavigationStateService: $e');
|
||||
DebugLogger.error('Failed to initialize NavigationStateService', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,9 +96,9 @@ class NavigationStateService {
|
||||
|
||||
await _saveNavigationState();
|
||||
|
||||
debugPrint('DEBUG: Navigation state pushed - ${state.routeName}');
|
||||
DebugLogger.navigation('Navigation state pushed - ${state.routeName}');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to push navigation state: $e');
|
||||
DebugLogger.error('Failed to push navigation state', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,10 +115,12 @@ class NavigationStateService {
|
||||
|
||||
await _saveNavigationState();
|
||||
|
||||
debugPrint('DEBUG: Navigation state popped - ${poppedState.routeName}');
|
||||
DebugLogger.navigation(
|
||||
'Navigation state popped - ${poppedState.routeName}',
|
||||
);
|
||||
return poppedState;
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to pop navigation state: $e');
|
||||
DebugLogger.error('Failed to pop navigation state', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -153,9 +156,9 @@ class NavigationStateService {
|
||||
_stateNotifier.value = updatedState;
|
||||
await _saveNavigationState();
|
||||
|
||||
debugPrint('DEBUG: Navigation state updated');
|
||||
DebugLogger.navigation('Navigation state updated');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to update navigation state: $e');
|
||||
DebugLogger.error('Failed to update navigation state', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,9 +170,9 @@ class NavigationStateService {
|
||||
_navigationStack.add(_currentState!);
|
||||
}
|
||||
await _saveNavigationState();
|
||||
debugPrint('DEBUG: Navigation stack cleared');
|
||||
DebugLogger.navigation('Navigation stack cleared');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to clear navigation stack: $e');
|
||||
DebugLogger.error('Failed to clear navigation stack', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,11 +185,11 @@ class NavigationStateService {
|
||||
_stateNotifier.value = _currentState;
|
||||
|
||||
await _saveNavigationState();
|
||||
debugPrint(
|
||||
'DEBUG: Navigation stack replaced with ${newStack.length} states',
|
||||
DebugLogger.navigation(
|
||||
'Navigation stack replaced with ${newStack.length} states',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to replace navigation stack: $e');
|
||||
DebugLogger.error('Failed to replace navigation stack', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,9 +222,9 @@ class NavigationStateService {
|
||||
await replaceStack([deepLinkState]);
|
||||
}
|
||||
|
||||
debugPrint('DEBUG: Deep link handled - $routeName');
|
||||
DebugLogger.navigation('Deep link handled - $routeName');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to handle deep link: $e');
|
||||
DebugLogger.error('Failed to handle deep link', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,8 +278,8 @@ class NavigationStateService {
|
||||
|
||||
if (_currentState != null) {
|
||||
// Attempt to restore to the last known state
|
||||
debugPrint(
|
||||
'DEBUG: Restoring navigation to ${_currentState!.routeName}',
|
||||
DebugLogger.navigation(
|
||||
'Restoring navigation to ${_currentState!.routeName}',
|
||||
);
|
||||
|
||||
// This would need to be implemented based on your routing setup
|
||||
@@ -287,7 +290,7 @@ class NavigationStateService {
|
||||
// );
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to restore navigation state: $e');
|
||||
DebugLogger.error('Failed to restore navigation state', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,9 +305,9 @@ class NavigationStateService {
|
||||
await _prefs?.remove(_currentStateKey);
|
||||
await _prefs?.remove(_deepLinkStateKey);
|
||||
|
||||
debugPrint('DEBUG: All navigation state cleared');
|
||||
DebugLogger.navigation('All navigation state cleared');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to clear navigation state: $e');
|
||||
DebugLogger.error('Failed to clear navigation state', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,7 +332,7 @@ class NavigationStateService {
|
||||
await _prefs!.remove(_currentStateKey);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to save navigation state: $e');
|
||||
DebugLogger.error('Failed to save navigation state', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,11 +362,11 @@ class NavigationStateService {
|
||||
_stateNotifier.value = _currentState;
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'DEBUG: Navigation state loaded - ${_navigationStack.length} states',
|
||||
DebugLogger.navigation(
|
||||
'Navigation state loaded - ${_navigationStack.length} states',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to load navigation state: $e');
|
||||
DebugLogger.error('Failed to load navigation state', e);
|
||||
// Clear corrupted state
|
||||
await clearAll();
|
||||
}
|
||||
@@ -376,7 +379,7 @@ class NavigationStateService {
|
||||
try {
|
||||
await _prefs!.setString(_deepLinkStateKey, jsonEncode(state.toJson()));
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to save deep link state: $e');
|
||||
DebugLogger.error('Failed to save deep link state', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,11 @@ import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'background_streaming_handler.dart';
|
||||
import 'connectivity_service.dart';
|
||||
import '../utils/debug_logger.dart';
|
||||
|
||||
class PersistentStreamingService with WidgetsBindingObserver {
|
||||
static final PersistentStreamingService _instance = PersistentStreamingService._internal();
|
||||
static final PersistentStreamingService _instance =
|
||||
PersistentStreamingService._internal();
|
||||
factory PersistentStreamingService() => _instance;
|
||||
PersistentStreamingService._internal() {
|
||||
_initialize();
|
||||
@@ -17,25 +19,25 @@ class PersistentStreamingService with WidgetsBindingObserver {
|
||||
final Map<String, StreamController> _streamControllers = {};
|
||||
final Map<String, Function> _streamRecoveryCallbacks = {};
|
||||
final Map<String, Map<String, dynamic>> _streamMetadata = {};
|
||||
|
||||
|
||||
// App lifecycle state
|
||||
// AppLifecycleState? _lastLifecycleState; // Removed as it's unused
|
||||
bool _isInBackground = false;
|
||||
Timer? _backgroundTimer;
|
||||
Timer? _heartbeatTimer;
|
||||
|
||||
|
||||
// Background streaming handler
|
||||
late final BackgroundStreamingHandler _backgroundHandler;
|
||||
|
||||
|
||||
// Connectivity monitoring
|
||||
StreamSubscription<bool>? _connectivitySubscription;
|
||||
bool _hasConnectivity = true;
|
||||
|
||||
|
||||
// Recovery state
|
||||
final Map<String, int> _retryAttempts = {};
|
||||
static const int _maxRetryAttempts = 3;
|
||||
static const Duration _retryDelay = Duration(seconds: 2);
|
||||
|
||||
|
||||
void _initialize() {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_backgroundHandler = BackgroundStreamingHandler.instance;
|
||||
@@ -43,48 +45,56 @@ class PersistentStreamingService with WidgetsBindingObserver {
|
||||
_setupConnectivityMonitoring();
|
||||
_startHeartbeat();
|
||||
}
|
||||
|
||||
|
||||
void _setupBackgroundHandlerCallbacks() {
|
||||
_backgroundHandler.onStreamsSuspending = (streamIds) {
|
||||
debugPrint('PersistentStreaming: Streams suspending - $streamIds');
|
||||
DebugLogger.stream(
|
||||
'PersistentStreaming: Streams suspending - $streamIds',
|
||||
);
|
||||
// Mark streams as suspended but don't close them yet
|
||||
for (final streamId in streamIds) {
|
||||
_markStreamAsSuspended(streamId);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
_backgroundHandler.onBackgroundTaskExpiring = () {
|
||||
debugPrint('PersistentStreaming: Background task expiring');
|
||||
DebugLogger.stream('PersistentStreaming: Background task expiring');
|
||||
// Save states and prepare for recovery
|
||||
_saveStreamStatesForRecovery();
|
||||
};
|
||||
|
||||
|
||||
_backgroundHandler.shouldContinueInBackground = () {
|
||||
return _activeStreams.isNotEmpty;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
void _setupConnectivityMonitoring() {
|
||||
// Create a connectivity service instance - this would normally be injected
|
||||
// For now, create a temporary instance just for monitoring
|
||||
final connectivityService = ConnectivityService(Dio());
|
||||
|
||||
_connectivitySubscription = connectivityService.isConnected.listen((connected) {
|
||||
|
||||
_connectivitySubscription = connectivityService.isConnected.listen((
|
||||
connected,
|
||||
) {
|
||||
final wasConnected = _hasConnectivity;
|
||||
_hasConnectivity = connected;
|
||||
|
||||
|
||||
if (!wasConnected && connected) {
|
||||
// Connectivity restored - try to recover streams
|
||||
debugPrint('PersistentStreaming: Connectivity restored, recovering streams');
|
||||
DebugLogger.stream(
|
||||
'PersistentStreaming: Connectivity restored, recovering streams',
|
||||
);
|
||||
_recoverActiveStreams();
|
||||
} else if (wasConnected && !connected) {
|
||||
// Connectivity lost - mark streams as suspended
|
||||
debugPrint('PersistentStreaming: Connectivity lost, suspending streams');
|
||||
DebugLogger.stream(
|
||||
'PersistentStreaming: Connectivity lost, suspending streams',
|
||||
);
|
||||
_suspendAllStreams();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
void _startHeartbeat() {
|
||||
_heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (_) {
|
||||
if (_activeStreams.isNotEmpty && _isInBackground) {
|
||||
@@ -92,11 +102,11 @@ class PersistentStreamingService with WidgetsBindingObserver {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
// _lastLifecycleState = state; // Removed as it's unused
|
||||
|
||||
|
||||
switch (state) {
|
||||
case AppLifecycleState.paused:
|
||||
case AppLifecycleState.inactive:
|
||||
@@ -112,47 +122,49 @@ class PersistentStreamingService with WidgetsBindingObserver {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void _onAppBackground() {
|
||||
debugPrint('PersistentStreamingService: App went to background');
|
||||
DebugLogger.stream('PersistentStreamingService: App went to background');
|
||||
_isInBackground = true;
|
||||
|
||||
|
||||
// Enable wake lock to prevent device sleep during streaming
|
||||
if (_activeStreams.isNotEmpty) {
|
||||
_enableWakeLock();
|
||||
_startBackgroundExecution();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void _onAppForeground() {
|
||||
debugPrint('PersistentStreamingService: App returned to foreground');
|
||||
DebugLogger.stream(
|
||||
'PersistentStreamingService: App returned to foreground',
|
||||
);
|
||||
_isInBackground = false;
|
||||
|
||||
|
||||
// Cancel background timer
|
||||
_backgroundTimer?.cancel();
|
||||
_backgroundTimer = null;
|
||||
|
||||
|
||||
// Disable wake lock if no active streams
|
||||
if (_activeStreams.isEmpty) {
|
||||
_disableWakeLock();
|
||||
}
|
||||
|
||||
|
||||
// Check and recover any interrupted streams
|
||||
_recoverActiveStreams();
|
||||
}
|
||||
|
||||
|
||||
void _onAppDetached() {
|
||||
debugPrint('PersistentStreamingService: App detached');
|
||||
|
||||
DebugLogger.stream('PersistentStreamingService: App detached');
|
||||
|
||||
// Save stream states for recovery
|
||||
_saveStreamStatesForRecovery();
|
||||
|
||||
|
||||
// Clean up
|
||||
_backgroundTimer?.cancel();
|
||||
_heartbeatTimer?.cancel();
|
||||
_disableWakeLock();
|
||||
}
|
||||
|
||||
|
||||
// Register a stream for persistent handling
|
||||
String registerStream({
|
||||
required StreamSubscription subscription,
|
||||
@@ -161,17 +173,17 @@ class PersistentStreamingService with WidgetsBindingObserver {
|
||||
Map<String, dynamic>? metadata,
|
||||
}) {
|
||||
final streamId = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
|
||||
|
||||
_activeStreams[streamId] = subscription;
|
||||
_streamControllers[streamId] = controller;
|
||||
if (recoveryCallback != null) {
|
||||
_streamRecoveryCallbacks[streamId] = recoveryCallback;
|
||||
}
|
||||
|
||||
|
||||
// Store metadata for recovery
|
||||
if (metadata != null) {
|
||||
_streamMetadata[streamId] = metadata;
|
||||
|
||||
|
||||
// Register with background handler
|
||||
_backgroundHandler.registerStream(
|
||||
streamId,
|
||||
@@ -182,22 +194,24 @@ class PersistentStreamingService with WidgetsBindingObserver {
|
||||
lastContent: metadata['lastContent'],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Enable wake lock when streaming starts
|
||||
if (_activeStreams.length == 1) {
|
||||
_enableWakeLock();
|
||||
}
|
||||
|
||||
|
||||
// Start background execution if app is backgrounded
|
||||
if (_isInBackground) {
|
||||
_startBackgroundExecution();
|
||||
}
|
||||
|
||||
debugPrint('PersistentStreamingService: Registered stream $streamId');
|
||||
|
||||
|
||||
DebugLogger.stream(
|
||||
'PersistentStreamingService: Registered stream $streamId',
|
||||
);
|
||||
|
||||
return streamId;
|
||||
}
|
||||
|
||||
|
||||
// Unregister a stream
|
||||
void unregisterStream(String streamId) {
|
||||
_activeStreams.remove(streamId);
|
||||
@@ -205,31 +219,35 @@ class PersistentStreamingService with WidgetsBindingObserver {
|
||||
_streamRecoveryCallbacks.remove(streamId);
|
||||
_streamMetadata.remove(streamId);
|
||||
_retryAttempts.remove(streamId);
|
||||
|
||||
|
||||
// Unregister from background handler
|
||||
_backgroundHandler.unregisterStream(streamId);
|
||||
|
||||
|
||||
// Stop background execution if no more streams
|
||||
if (_activeStreams.isEmpty) {
|
||||
_backgroundHandler.stopBackgroundExecution([streamId]);
|
||||
_disableWakeLock();
|
||||
}
|
||||
|
||||
debugPrint('PersistentStreamingService: Unregistered stream $streamId');
|
||||
|
||||
DebugLogger.stream(
|
||||
'PersistentStreamingService: Unregistered stream $streamId',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Check if a stream is still active
|
||||
bool isStreamActive(String streamId) {
|
||||
return _activeStreams.containsKey(streamId);
|
||||
}
|
||||
|
||||
|
||||
// Recover interrupted streams
|
||||
Future<void> _recoverActiveStreams() async {
|
||||
if (!_hasConnectivity) {
|
||||
debugPrint('PersistentStreaming: No connectivity, skipping recovery');
|
||||
DebugLogger.stream(
|
||||
'PersistentStreaming: No connectivity, skipping recovery',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// First, try to recover from background handler saved states
|
||||
final savedStates = await _backgroundHandler.recoverStreamStates();
|
||||
for (final state in savedStates) {
|
||||
@@ -237,12 +255,12 @@ class PersistentStreamingService with WidgetsBindingObserver {
|
||||
await _recoverStreamFromState(state);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Then check active streams for recovery
|
||||
for (final entry in _streamRecoveryCallbacks.entries) {
|
||||
final streamId = entry.key;
|
||||
final recoveryCallback = entry.value;
|
||||
|
||||
|
||||
// Check if stream was interrupted or needs recovery
|
||||
final subscription = _activeStreams[streamId];
|
||||
if (subscription == null || _needsRecovery(streamId)) {
|
||||
@@ -250,69 +268,84 @@ class PersistentStreamingService with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Future<void> _recoverStreamFromState(StreamState state) async {
|
||||
final recoveryCallback = _streamRecoveryCallbacks[state.streamId];
|
||||
if (recoveryCallback != null) {
|
||||
debugPrint('PersistentStreaming: Recovering stream from saved state: ${state.streamId}');
|
||||
DebugLogger.stream(
|
||||
'PersistentStreaming: Recovering stream from saved state: ${state.streamId}',
|
||||
);
|
||||
await _attemptStreamRecovery(state.streamId, recoveryCallback);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _attemptStreamRecovery(String streamId, Function recoveryCallback) async {
|
||||
|
||||
Future<void> _attemptStreamRecovery(
|
||||
String streamId,
|
||||
Function recoveryCallback,
|
||||
) async {
|
||||
final attempts = _retryAttempts[streamId] ?? 0;
|
||||
if (attempts >= _maxRetryAttempts) {
|
||||
debugPrint('PersistentStreaming: Max retry attempts reached for stream $streamId');
|
||||
DebugLogger.warning(
|
||||
'PersistentStreaming: Max retry attempts reached for stream $streamId',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('PersistentStreaming: Recovering stream $streamId (attempt ${attempts + 1})');
|
||||
|
||||
|
||||
DebugLogger.stream(
|
||||
'PersistentStreaming: Recovering stream $streamId (attempt ${attempts + 1})',
|
||||
);
|
||||
|
||||
try {
|
||||
_retryAttempts[streamId] = attempts + 1;
|
||||
|
||||
|
||||
// Add exponential backoff delay
|
||||
if (attempts > 0) {
|
||||
final delay = _retryDelay * (1 << (attempts - 1)); // 2s, 4s, 8s...
|
||||
await Future.delayed(delay);
|
||||
}
|
||||
|
||||
|
||||
// Call recovery callback to restart the stream
|
||||
await recoveryCallback();
|
||||
|
||||
|
||||
// Reset retry count on success
|
||||
_retryAttempts.remove(streamId);
|
||||
} catch (e) {
|
||||
debugPrint('PersistentStreaming: Failed to recover stream $streamId: $e');
|
||||
|
||||
DebugLogger.error(
|
||||
'PersistentStreaming: Failed to recover stream $streamId',
|
||||
e,
|
||||
);
|
||||
|
||||
// Schedule next retry if under limit
|
||||
if (_retryAttempts[streamId]! < _maxRetryAttempts) {
|
||||
Timer(_retryDelay, () => _attemptStreamRecovery(streamId, recoveryCallback));
|
||||
Timer(
|
||||
_retryDelay,
|
||||
() => _attemptStreamRecovery(streamId, recoveryCallback),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
bool _needsRecovery(String streamId) {
|
||||
final metadata = _streamMetadata[streamId];
|
||||
if (metadata == null) return false;
|
||||
|
||||
|
||||
// Check if stream has been inactive for too long
|
||||
final lastUpdate = metadata['lastUpdate'] as DateTime?;
|
||||
if (lastUpdate != null) {
|
||||
final timeSinceUpdate = DateTime.now().difference(lastUpdate);
|
||||
return timeSinceUpdate > const Duration(minutes: 1);
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Platform-specific background execution
|
||||
void _startBackgroundExecution() {
|
||||
if (_activeStreams.isNotEmpty) {
|
||||
_backgroundHandler.startBackgroundExecution(_activeStreams.keys.toList());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void _markStreamAsSuspended(String streamId) {
|
||||
final metadata = _streamMetadata[streamId];
|
||||
if (metadata != null) {
|
||||
@@ -320,20 +353,23 @@ class PersistentStreamingService with WidgetsBindingObserver {
|
||||
metadata['suspendedAt'] = DateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void _suspendAllStreams() {
|
||||
for (final streamId in _activeStreams.keys) {
|
||||
_markStreamAsSuspended(streamId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void _saveStreamStatesForRecovery() {
|
||||
// The background handler will handle the actual saving
|
||||
debugPrint('PersistentStreaming: Saving ${_activeStreams.length} stream states for recovery');
|
||||
DebugLogger.stream(
|
||||
'PersistentStreaming: Saving ${_activeStreams.length} stream states for recovery',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Update stream metadata when chunks are received
|
||||
void updateStreamProgress(String streamId, {
|
||||
void updateStreamProgress(
|
||||
String streamId, {
|
||||
int? chunkSequence,
|
||||
String? content,
|
||||
String? appendedContent,
|
||||
@@ -345,54 +381,62 @@ class PersistentStreamingService with WidgetsBindingObserver {
|
||||
content: content,
|
||||
appendedContent: appendedContent,
|
||||
);
|
||||
|
||||
|
||||
// Update local metadata
|
||||
final metadata = _streamMetadata[streamId];
|
||||
if (metadata != null) {
|
||||
metadata['lastUpdate'] = DateTime.now();
|
||||
metadata['lastChunkSequence'] = chunkSequence ?? metadata['lastChunkSequence'];
|
||||
metadata['lastChunkSequence'] =
|
||||
chunkSequence ?? metadata['lastChunkSequence'];
|
||||
if (appendedContent != null) {
|
||||
metadata['lastContent'] = (metadata['lastContent'] ?? '') + appendedContent;
|
||||
metadata['lastContent'] =
|
||||
(metadata['lastContent'] ?? '') + appendedContent;
|
||||
} else if (content != null) {
|
||||
metadata['lastContent'] = content;
|
||||
}
|
||||
metadata['suspended'] = false; // Mark as active
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Wake lock management
|
||||
void _enableWakeLock() async {
|
||||
try {
|
||||
await WakelockPlus.enable();
|
||||
debugPrint('PersistentStreamingService: Wake lock enabled');
|
||||
DebugLogger.stream('PersistentStreamingService: Wake lock enabled');
|
||||
} catch (e) {
|
||||
debugPrint('PersistentStreamingService: Failed to enable wake lock: $e');
|
||||
DebugLogger.error(
|
||||
'PersistentStreamingService: Failed to enable wake lock',
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void _disableWakeLock() async {
|
||||
try {
|
||||
await WakelockPlus.disable();
|
||||
debugPrint('PersistentStreamingService: Wake lock disabled');
|
||||
DebugLogger.stream('PersistentStreamingService: Wake lock disabled');
|
||||
} catch (e) {
|
||||
debugPrint('PersistentStreamingService: Failed to disable wake lock: $e');
|
||||
DebugLogger.error(
|
||||
'PersistentStreamingService: Failed to disable wake lock',
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Get active stream count
|
||||
int get activeStreamCount => _activeStreams.length;
|
||||
|
||||
|
||||
// Get stream metadata
|
||||
Map<String, dynamic>? getStreamMetadata(String streamId) {
|
||||
return _streamMetadata[streamId];
|
||||
}
|
||||
|
||||
|
||||
// Check if stream is suspended
|
||||
bool isStreamSuspended(String streamId) {
|
||||
final metadata = _streamMetadata[streamId];
|
||||
return metadata?['suspended'] == true;
|
||||
}
|
||||
|
||||
|
||||
// Force recovery of a specific stream
|
||||
Future<void> forceRecoverStream(String streamId) async {
|
||||
final recoveryCallback = _streamRecoveryCallbacks[streamId];
|
||||
@@ -401,7 +445,7 @@ class PersistentStreamingService with WidgetsBindingObserver {
|
||||
await _attemptStreamRecovery(streamId, recoveryCallback);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Cleanup
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
@@ -409,18 +453,18 @@ class PersistentStreamingService with WidgetsBindingObserver {
|
||||
_heartbeatTimer?.cancel();
|
||||
_connectivitySubscription?.cancel();
|
||||
_disableWakeLock();
|
||||
|
||||
|
||||
// Stop all background execution
|
||||
if (_activeStreams.isNotEmpty) {
|
||||
_backgroundHandler.stopBackgroundExecution(_activeStreams.keys.toList());
|
||||
}
|
||||
|
||||
|
||||
// Cancel all active streams
|
||||
for (final subscription in _activeStreams.values) {
|
||||
subscription.cancel();
|
||||
}
|
||||
_activeStreams.clear();
|
||||
|
||||
|
||||
// Close all controllers
|
||||
for (final controller in _streamControllers.values) {
|
||||
if (!controller.isClosed) {
|
||||
@@ -428,13 +472,13 @@ class PersistentStreamingService with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
_streamControllers.clear();
|
||||
|
||||
|
||||
// Clear all metadata
|
||||
_streamMetadata.clear();
|
||||
_streamRecoveryCallbacks.clear();
|
||||
_retryAttempts.clear();
|
||||
|
||||
|
||||
// Clear background handler
|
||||
_backgroundHandler.clearAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import '../utils/debug_logger.dart';
|
||||
|
||||
/// Enhanced secure credential storage with platform-specific optimizations
|
||||
class SecureCredentialStorage {
|
||||
@@ -73,9 +73,9 @@ class SecureCredentialStorage {
|
||||
);
|
||||
}
|
||||
|
||||
debugPrint('DEBUG: Credentials saved and verified securely');
|
||||
DebugLogger.storage('Credentials saved and verified securely');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to save credentials: $e');
|
||||
DebugLogger.error('Failed to save credentials', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,7 @@ class SecureCredentialStorage {
|
||||
final decoded = jsonDecode(jsonString);
|
||||
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
debugPrint('Warning: Invalid credentials format');
|
||||
DebugLogger.warning('Invalid credentials format');
|
||||
await deleteSavedCredentials();
|
||||
return null;
|
||||
}
|
||||
@@ -103,8 +103,8 @@ class SecureCredentialStorage {
|
||||
final currentDeviceId = await _getDeviceFingerprint();
|
||||
|
||||
if (savedDeviceId != currentDeviceId) {
|
||||
debugPrint(
|
||||
'Info: Device fingerprint changed, but allowing credential access for better UX',
|
||||
DebugLogger.info(
|
||||
'Device fingerprint changed, but allowing credential access for better UX',
|
||||
);
|
||||
// Don't clear credentials immediately - allow the user to continue
|
||||
// They can re-login if needed, which will update the fingerprint
|
||||
@@ -115,8 +115,8 @@ class SecureCredentialStorage {
|
||||
if (!decoded.containsKey('serverId') ||
|
||||
!decoded.containsKey('username') ||
|
||||
!decoded.containsKey('password')) {
|
||||
debugPrint(
|
||||
'Warning: Invalid saved credentials format - missing required fields',
|
||||
DebugLogger.warning(
|
||||
'Invalid saved credentials format - missing required fields',
|
||||
);
|
||||
await deleteSavedCredentials();
|
||||
return null;
|
||||
@@ -132,12 +132,12 @@ class SecureCredentialStorage {
|
||||
|
||||
// Warn if credentials are very old (but don't delete them)
|
||||
if (daysSinceCreated > 90) {
|
||||
debugPrint(
|
||||
'Info: Saved credentials are $daysSinceCreated days old',
|
||||
DebugLogger.info(
|
||||
'Saved credentials are $daysSinceCreated days old',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Warning: Could not parse savedAt timestamp: $e');
|
||||
DebugLogger.warning('Could not parse savedAt timestamp: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ class SecureCredentialStorage {
|
||||
'savedAt': decoded['savedAt']?.toString() ?? '',
|
||||
};
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to retrieve credentials: $e');
|
||||
DebugLogger.error('Failed to retrieve credentials', e);
|
||||
// Don't delete credentials on retrieval errors - they might be recoverable
|
||||
return null;
|
||||
}
|
||||
@@ -158,9 +158,9 @@ class SecureCredentialStorage {
|
||||
Future<void> deleteSavedCredentials() async {
|
||||
try {
|
||||
await _secureStorage.delete(key: _credentialsKey);
|
||||
debugPrint('DEBUG: Credentials deleted');
|
||||
DebugLogger.storage('Credentials deleted');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to delete credentials: $e');
|
||||
DebugLogger.error('Failed to delete credentials', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ class SecureCredentialStorage {
|
||||
final encryptedToken = await _encryptData(token);
|
||||
await _secureStorage.write(key: _authTokenKey, value: encryptedToken);
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to save auth token: $e');
|
||||
DebugLogger.error('Failed to save auth token', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -183,7 +183,7 @@ class SecureCredentialStorage {
|
||||
|
||||
return await _decryptData(encryptedToken);
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to retrieve auth token: $e');
|
||||
DebugLogger.error('Failed to retrieve auth token', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -193,7 +193,7 @@ class SecureCredentialStorage {
|
||||
try {
|
||||
await _secureStorage.delete(key: _authTokenKey);
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to delete auth token: $e');
|
||||
DebugLogger.error('Failed to delete auth token', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ class SecureCredentialStorage {
|
||||
value: encryptedConfigs,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to save server configs: $e');
|
||||
DebugLogger.error('Failed to save server configs', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -221,7 +221,7 @@ class SecureCredentialStorage {
|
||||
|
||||
return await _decryptData(encryptedConfigs);
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to retrieve server configs: $e');
|
||||
DebugLogger.error('Failed to retrieve server configs', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -239,7 +239,7 @@ class SecureCredentialStorage {
|
||||
|
||||
return result == testValue;
|
||||
} catch (e) {
|
||||
debugPrint('WARNING: Secure storage not available: $e');
|
||||
DebugLogger.warning('Secure storage not available: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -248,9 +248,9 @@ class SecureCredentialStorage {
|
||||
Future<void> clearAll() async {
|
||||
try {
|
||||
await _secureStorage.deleteAll();
|
||||
debugPrint('DEBUG: All secure data cleared');
|
||||
DebugLogger.storage('All secure data cleared');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to clear secure data: $e');
|
||||
DebugLogger.error('Failed to clear secure data', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ class SecureCredentialStorage {
|
||||
// In a more advanced implementation, you could add an additional layer of AES encryption
|
||||
return data;
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to encrypt data: $e');
|
||||
DebugLogger.error('Failed to encrypt data', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -273,7 +273,7 @@ class SecureCredentialStorage {
|
||||
// This matches the encryption method above
|
||||
return encryptedData;
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to decrypt data: $e');
|
||||
DebugLogger.error('Failed to decrypt data', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -297,7 +297,7 @@ class SecureCredentialStorage {
|
||||
|
||||
return digest.toString();
|
||||
} catch (e) {
|
||||
debugPrint('WARNING: Failed to generate device fingerprint: $e');
|
||||
DebugLogger.warning('Failed to generate device fingerprint: $e');
|
||||
// Return a consistent fallback fingerprint
|
||||
return 'stable_fallback_device_id';
|
||||
}
|
||||
@@ -315,11 +315,11 @@ class SecureCredentialStorage {
|
||||
username: oldCredentials['username'] ?? '',
|
||||
password: oldCredentials['password'] ?? '',
|
||||
);
|
||||
debugPrint(
|
||||
'DEBUG: Successfully migrated credentials to new secure format',
|
||||
DebugLogger.storage(
|
||||
'Successfully migrated credentials to new secure format',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to migrate credentials: $e');
|
||||
DebugLogger.error('Failed to migrate credentials', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user