2025-08-16 20:27:44 +05:30
|
|
|
import 'dart:async';
|
|
|
|
|
import 'dart:io';
|
|
|
|
|
import 'package:flutter/services.dart';
|
2025-08-20 22:15:26 +05:30
|
|
|
import '../utils/debug_logger.dart';
|
2025-08-16 20:27:44 +05:30
|
|
|
|
|
|
|
|
/// Handles background streaming continuation for iOS and Android
|
2025-08-20 22:15:26 +05:30
|
|
|
///
|
2025-08-16 20:27:44 +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',
|
|
|
|
|
);
|
|
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
static BackgroundStreamingHandler? _instance;
|
2025-08-20 22:15:26 +05:30
|
|
|
static BackgroundStreamingHandler get instance =>
|
|
|
|
|
_instance ??= BackgroundStreamingHandler._();
|
|
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
BackgroundStreamingHandler._() {
|
|
|
|
|
_setupMethodCallHandler();
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
final Set<String> _activeStreamIds = <String>{};
|
|
|
|
|
final Map<String, StreamState> _streamStates = <String, StreamState>{};
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +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
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
void _setupMethodCallHandler() {
|
|
|
|
|
_channel.setMethodCallHandler((call) async {
|
|
|
|
|
switch (call.method) {
|
|
|
|
|
case 'checkStreams':
|
|
|
|
|
return _activeStreamIds.length;
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +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>();
|
2025-08-16 20:27:44 +05:30
|
|
|
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
|
|
|
);
|
2025-08-16 20:27:44 +05:30
|
|
|
onStreamsSuspending?.call(streamIds);
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Save stream states for recovery
|
2025-10-05 23:16:44 +05:30
|
|
|
await saveStreamStatesForRecovery(streamIds, reason);
|
2025-08-16 20:27:44 +05:30
|
|
|
break;
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
case 'backgroundTaskExpiring':
|
2025-09-25 22:36:42 +05:30
|
|
|
DebugLogger.stream('task-expiring', scope: 'background');
|
2025-08-16 20:27:44 +05:30
|
|
|
onBackgroundTaskExpiring?.call();
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
/// Start background execution for given stream IDs
|
2025-10-09 16:18:14 +05:30
|
|
|
Future<void> startBackgroundExecution(
|
|
|
|
|
List<String> streamIds, {
|
|
|
|
|
bool requiresMicrophone = false,
|
|
|
|
|
}) async {
|
2025-08-16 20:27:44 +05:30
|
|
|
if (!Platform.isIOS && !Platform.isAndroid) return;
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
_activeStreamIds.addAll(streamIds);
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
try {
|
|
|
|
|
await _channel.invokeMethod('startBackgroundExecution', {
|
|
|
|
|
'streamIds': streamIds,
|
2025-10-09 16:18:14 +05:30
|
|
|
'requiresMicrophone': requiresMicrophone,
|
2025-08-16 20:27:44 +05:30
|
|
|
});
|
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
|
|
|
);
|
2025-08-16 20:27:44 +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-16 20:27:44 +05:30
|
|
|
}
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +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
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
_activeStreamIds.removeAll(streamIds);
|
|
|
|
|
streamIds.forEach(_streamStates.remove);
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +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
|
|
|
);
|
2025-08-16 20:27:44 +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-16 20:27:44 +05:30
|
|
|
}
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
/// Register a stream with its current state
|
2025-08-20 22:15:26 +05:30
|
|
|
void registerStream(
|
|
|
|
|
String streamId, {
|
2025-08-16 20:27:44 +05:30
|
|
|
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
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
_activeStreamIds.add(streamId);
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
/// Update stream state with new chunk
|
2025-08-20 22:15:26 +05:30
|
|
|
void updateStreamState(
|
|
|
|
|
String streamId, {
|
2025-08-16 20:27:44 +05:30
|
|
|
int? chunkSequence,
|
|
|
|
|
String? content,
|
|
|
|
|
String? appendedContent,
|
|
|
|
|
}) {
|
|
|
|
|
final state = _streamStates[streamId];
|
|
|
|
|
if (state == null) return;
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
_streamStates[streamId] = state.copyWith(
|
|
|
|
|
lastChunkSequence: chunkSequence ?? state.lastChunkSequence,
|
2025-08-20 22:15:26 +05:30
|
|
|
lastContent: appendedContent != null
|
2025-08-16 20:27:44 +05:30
|
|
|
? (state.lastContent + appendedContent)
|
|
|
|
|
: (content ?? state.lastContent),
|
|
|
|
|
timestamp: DateTime.now(),
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +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
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
/// Get current stream state for recovery
|
|
|
|
|
StreamState? getStreamState(String streamId) {
|
|
|
|
|
return _streamStates[streamId];
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +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
|
|
|
|
2025-08-16 20:27:44 +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-16 20:27:44 +05:30
|
|
|
}
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +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
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
try {
|
2025-08-20 22:15:26 +05:30
|
|
|
final List<dynamic>? states = await _channel.invokeMethod(
|
|
|
|
|
'recoverStreamStates',
|
|
|
|
|
);
|
2025-08-16 20:27:44 +05:30
|
|
|
if (states == null) return [];
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
final recovered = <StreamState>[];
|
|
|
|
|
for (final stateData in states) {
|
2025-10-05 23:16:44 +05:30
|
|
|
// Platform channels return Map<Object?, Object?>, need to convert
|
|
|
|
|
final map = Map<String, dynamic>.from(stateData as Map);
|
2025-08-16 20:27:44 +05:30
|
|
|
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
|
|
|
);
|
2025-08-16 20:27:44 +05:30
|
|
|
return recovered;
|
|
|
|
|
} catch (e) {
|
2025-09-25 22:36:42 +05:30
|
|
|
DebugLogger.error('recover-failed', scope: 'background', error: e);
|
2025-08-16 20:27:44 +05:30
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
/// Save stream states for recovery after app restart
|
2025-10-05 23:16:44 +05:30
|
|
|
Future<void> saveStreamStatesForRecovery(
|
2025-08-20 22:15:26 +05:30
|
|
|
List<String> streamIds,
|
|
|
|
|
String reason,
|
|
|
|
|
) async {
|
2025-10-05 23:16:44 +05:30
|
|
|
DebugLogger.stream(
|
|
|
|
|
'saveStreamStatesForRecovery called',
|
|
|
|
|
scope: 'background',
|
2025-10-09 01:49:56 +05:30
|
|
|
data: {
|
|
|
|
|
'streamIds': streamIds,
|
|
|
|
|
'reason': reason,
|
|
|
|
|
'statesCount': _streamStates.length,
|
|
|
|
|
},
|
2025-10-05 23:16:44 +05:30
|
|
|
);
|
|
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
final statesToSave = streamIds
|
|
|
|
|
.map((id) => _streamStates[id])
|
|
|
|
|
.where((state) => state != null)
|
|
|
|
|
.map((state) => state!.toMap())
|
|
|
|
|
.toList();
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-10-05 23:16:44 +05:30
|
|
|
DebugLogger.stream(
|
|
|
|
|
'statesToSave prepared',
|
|
|
|
|
scope: 'background',
|
|
|
|
|
data: {'count': statesToSave.length},
|
|
|
|
|
);
|
|
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
try {
|
|
|
|
|
await _channel.invokeMethod('saveStreamStates', {
|
|
|
|
|
'states': statesToSave,
|
|
|
|
|
'reason': reason,
|
|
|
|
|
});
|
2025-10-05 23:16:44 +05:30
|
|
|
DebugLogger.stream(
|
|
|
|
|
'save-states-success',
|
|
|
|
|
scope: 'background',
|
|
|
|
|
data: {'count': statesToSave.length, 'reason': reason},
|
|
|
|
|
);
|
2025-08-16 20:27:44 +05:30
|
|
|
} 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-16 20:27:44 +05:30
|
|
|
}
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
/// Check if any streams are currently active
|
|
|
|
|
bool get hasActiveStreams => _activeStreamIds.isNotEmpty;
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
/// Get list of active stream IDs
|
|
|
|
|
List<String> get activeStreamIds => _activeStreamIds.toList();
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +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
|
|
|
|
2025-08-16 20:27:44 +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
|
|
|
|
2025-08-16 20:27:44 +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
|
|
|
|
2025-08-16 20:27:44 +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
|
|
|
|
2025-08-16 20:27:44 +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);
|
2025-08-16 20:27:44 +05:30
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +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
|
|
|
|
2025-08-16 20:27:44 +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-16 20:27:44 +05:30
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
}
|