Files
iiEsaywebUIapp/lib/core/services/background_streaming_handler.dart

351 lines
9.9 KiB
Dart
Raw Normal View History

import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart';
2025-08-20 22:15:26 +05:30
import '../utils/debug_logger.dart';
/// Handles background streaming continuation for iOS and Android
2025-08-20 22:15:26 +05:30
///
/// On iOS: Uses background tasks to keep streams alive for ~30 seconds
/// On Android: Uses foreground service notifications
class BackgroundStreamingHandler {
2025-08-20 22:15:26 +05:30
static const MethodChannel _channel = MethodChannel(
'conduit/background_streaming',
);
static BackgroundStreamingHandler? _instance;
2025-08-20 22:15:26 +05:30
static BackgroundStreamingHandler get instance =>
_instance ??= BackgroundStreamingHandler._();
BackgroundStreamingHandler._() {
_setupMethodCallHandler();
}
2025-08-20 22:15:26 +05:30
final Set<String> _activeStreamIds = <String>{};
final Map<String, StreamState> _streamStates = <String, StreamState>{};
2025-08-20 22:15:26 +05:30
// Callbacks for platform-specific events
void Function(List<String> streamIds)? onStreamsSuspending;
void Function()? onBackgroundTaskExpiring;
bool Function()? shouldContinueInBackground;
2025-08-20 22:15:26 +05:30
void _setupMethodCallHandler() {
_channel.setMethodCallHandler((call) async {
switch (call.method) {
case 'checkStreams':
return _activeStreamIds.length;
2025-08-20 22:15:26 +05:30
case 'streamsSuspending':
2025-08-20 22:15:26 +05:30
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;
2025-08-20 22:15:26 +05:30
DebugLogger.stream(
2025-09-25 22:36:42 +05:30
'suspending',
scope: 'background',
data: {'count': streamIds.length, 'reason': reason},
2025-08-20 22:15:26 +05:30
);
onStreamsSuspending?.call(streamIds);
2025-08-20 22:15:26 +05:30
// Save stream states for recovery
await saveStreamStatesForRecovery(streamIds, reason);
break;
2025-08-20 22:15:26 +05:30
case 'backgroundTaskExpiring':
2025-09-25 22:36:42 +05:30
DebugLogger.stream('task-expiring', scope: 'background');
onBackgroundTaskExpiring?.call();
break;
}
});
}
2025-08-20 22:15:26 +05:30
/// Start background execution for given stream IDs
Future<void> startBackgroundExecution(List<String> streamIds) async {
if (!Platform.isIOS && !Platform.isAndroid) return;
2025-08-20 22:15:26 +05:30
_activeStreamIds.addAll(streamIds);
2025-08-20 22:15:26 +05:30
try {
await _channel.invokeMethod('startBackgroundExecution', {
'streamIds': streamIds,
});
2025-08-20 22:15:26 +05:30
DebugLogger.stream(
2025-09-25 22:36:42 +05:30
'start',
scope: 'background',
data: {'count': streamIds.length},
2025-08-20 22:15:26 +05:30
);
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error(
'start-failed',
scope: 'background',
error: e,
data: {'count': streamIds.length},
);
}
}
2025-08-20 22:15:26 +05:30
/// Stop background execution for given stream IDs
Future<void> stopBackgroundExecution(List<String> streamIds) async {
if (!Platform.isIOS && !Platform.isAndroid) return;
2025-08-20 22:15:26 +05:30
_activeStreamIds.removeAll(streamIds);
streamIds.forEach(_streamStates.remove);
2025-08-20 22:15:26 +05:30
try {
await _channel.invokeMethod('stopBackgroundExecution', {
'streamIds': streamIds,
});
2025-08-20 22:15:26 +05:30
DebugLogger.stream(
2025-09-25 22:36:42 +05:30
'stop',
scope: 'background',
data: {'count': streamIds.length},
2025-08-20 22:15:26 +05:30
);
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error(
'stop-failed',
scope: 'background',
error: e,
data: {'count': streamIds.length},
);
}
}
2025-08-20 22:15:26 +05:30
/// Register a stream with its current state
2025-08-20 22:15:26 +05:30
void registerStream(
String streamId, {
required String conversationId,
required String messageId,
String? sessionId,
int? lastChunkSequence,
String? lastContent,
}) {
_streamStates[streamId] = StreamState(
streamId: streamId,
conversationId: conversationId,
messageId: messageId,
sessionId: sessionId,
lastChunkSequence: lastChunkSequence ?? 0,
lastContent: lastContent ?? '',
timestamp: DateTime.now(),
);
2025-08-20 22:15:26 +05:30
_activeStreamIds.add(streamId);
}
2025-08-20 22:15:26 +05:30
/// Update stream state with new chunk
2025-08-20 22:15:26 +05:30
void updateStreamState(
String streamId, {
int? chunkSequence,
String? content,
String? appendedContent,
}) {
final state = _streamStates[streamId];
if (state == null) return;
2025-08-20 22:15:26 +05:30
_streamStates[streamId] = state.copyWith(
lastChunkSequence: chunkSequence ?? state.lastChunkSequence,
2025-08-20 22:15:26 +05:30
lastContent: appendedContent != null
? (state.lastContent + appendedContent)
: (content ?? state.lastContent),
timestamp: DateTime.now(),
);
}
2025-08-20 22:15:26 +05:30
/// Unregister a stream when it completes
void unregisterStream(String streamId) {
_activeStreamIds.remove(streamId);
_streamStates.remove(streamId);
}
2025-08-20 22:15:26 +05:30
/// Get current stream state for recovery
StreamState? getStreamState(String streamId) {
return _streamStates[streamId];
}
2025-08-20 22:15:26 +05:30
/// Keep alive the background task (iOS only)
Future<void> keepAlive() async {
if (!Platform.isIOS) return;
2025-08-20 22:15:26 +05:30
try {
await _channel.invokeMethod('keepAlive');
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error('keepalive-failed', scope: 'background', error: e);
}
}
2025-08-20 22:15:26 +05:30
/// Recover stream states from previous app session
Future<List<StreamState>> recoverStreamStates() async {
if (!Platform.isIOS && !Platform.isAndroid) return [];
2025-08-20 22:15:26 +05:30
try {
2025-08-20 22:15:26 +05:30
final List<dynamic>? states = await _channel.invokeMethod(
'recoverStreamStates',
);
if (states == null) return [];
2025-08-20 22:15:26 +05:30
final recovered = <StreamState>[];
for (final stateData in states) {
// Platform channels return Map<Object?, Object?>, need to convert
final map = Map<String, dynamic>.from(stateData as Map);
final state = StreamState.fromMap(map);
if (state != null) {
recovered.add(state);
_streamStates[state.streamId] = state;
}
}
2025-08-20 22:15:26 +05:30
DebugLogger.stream(
2025-09-25 22:36:42 +05:30
'recovered',
scope: 'background',
data: {'count': recovered.length},
2025-08-20 22:15:26 +05:30
);
return recovered;
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error('recover-failed', scope: 'background', error: e);
return [];
}
}
2025-08-20 22:15:26 +05:30
/// Save stream states for recovery after app restart
Future<void> saveStreamStatesForRecovery(
2025-08-20 22:15:26 +05:30
List<String> streamIds,
String reason,
) async {
DebugLogger.stream(
'saveStreamStatesForRecovery called',
scope: 'background',
data: {'streamIds': streamIds, 'reason': reason, 'statesCount': _streamStates.length},
);
final statesToSave = streamIds
.map((id) => _streamStates[id])
.where((state) => state != null)
.map((state) => state!.toMap())
.toList();
2025-08-20 22:15:26 +05:30
DebugLogger.stream(
'statesToSave prepared',
scope: 'background',
data: {'count': statesToSave.length},
);
try {
await _channel.invokeMethod('saveStreamStates', {
'states': statesToSave,
'reason': reason,
});
DebugLogger.stream(
'save-states-success',
scope: 'background',
data: {'count': statesToSave.length, 'reason': reason},
);
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error(
'save-states-failed',
scope: 'background',
error: e,
data: {'count': streamIds.length, 'reason': reason},
);
}
}
2025-08-20 22:15:26 +05:30
/// Check if any streams are currently active
bool get hasActiveStreams => _activeStreamIds.isNotEmpty;
2025-08-20 22:15:26 +05:30
/// Get list of active stream IDs
List<String> get activeStreamIds => _activeStreamIds.toList();
2025-08-20 22:15:26 +05:30
/// Clear all stream data (usually on app termination)
void clearAll() {
_activeStreamIds.clear();
_streamStates.clear();
}
}
/// Represents the state of a streaming request
class StreamState {
final String streamId;
final String conversationId;
final String messageId;
final String? sessionId;
final int lastChunkSequence;
final String lastContent;
final DateTime timestamp;
2025-08-20 22:15:26 +05:30
const StreamState({
required this.streamId,
required this.conversationId,
required this.messageId,
this.sessionId,
required this.lastChunkSequence,
required this.lastContent,
required this.timestamp,
});
2025-08-20 22:15:26 +05:30
StreamState copyWith({
String? streamId,
String? conversationId,
String? messageId,
String? sessionId,
int? lastChunkSequence,
String? lastContent,
DateTime? timestamp,
}) {
return StreamState(
streamId: streamId ?? this.streamId,
conversationId: conversationId ?? this.conversationId,
messageId: messageId ?? this.messageId,
sessionId: sessionId ?? this.sessionId,
lastChunkSequence: lastChunkSequence ?? this.lastChunkSequence,
lastContent: lastContent ?? this.lastContent,
timestamp: timestamp ?? this.timestamp,
);
}
2025-08-20 22:15:26 +05:30
Map<String, dynamic> toMap() {
return {
'streamId': streamId,
'conversationId': conversationId,
'messageId': messageId,
'sessionId': sessionId,
'lastChunkSequence': lastChunkSequence,
'lastContent': lastContent,
'timestamp': timestamp.millisecondsSinceEpoch,
};
}
2025-08-20 22:15:26 +05:30
static StreamState? fromMap(Map<String, dynamic> map) {
try {
return StreamState(
streamId: map['streamId'] as String,
conversationId: map['conversationId'] as String,
messageId: map['messageId'] as String,
sessionId: map['sessionId'] as String?,
lastChunkSequence: map['lastChunkSequence'] as int? ?? 0,
lastContent: map['lastContent'] as String? ?? '',
timestamp: DateTime.fromMillisecondsSinceEpoch(
map['timestamp'] as int? ?? DateTime.now().millisecondsSinceEpoch,
),
);
} catch (e) {
2025-09-25 22:36:42 +05:30
DebugLogger.error('parse-failed', scope: 'background', error: e);
return null;
}
}
2025-08-20 22:15:26 +05:30
/// Check if this state is stale (older than threshold)
bool isStale({Duration threshold = const Duration(minutes: 5)}) {
return DateTime.now().difference(timestamp) > threshold;
}
2025-08-20 22:15:26 +05:30
@override
String toString() {
return 'StreamState(streamId: $streamId, conversationId: $conversationId, '
2025-08-20 22:15:26 +05:30
'messageId: $messageId, sequence: $lastChunkSequence, '
'contentLength: ${lastContent.length}, timestamp: $timestamp)';
}
2025-08-20 22:15:26 +05:30
}