2025-08-16 20:27:44 +05:30
|
|
|
import 'dart:async';
|
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
import 'package:wakelock_plus/wakelock_plus.dart';
|
|
|
|
|
import 'background_streaming_handler.dart';
|
|
|
|
|
import 'connectivity_service.dart';
|
2025-08-20 22:15:26 +05:30
|
|
|
import '../utils/debug_logger.dart';
|
2025-08-16 20:27:44 +05:30
|
|
|
|
|
|
|
|
class PersistentStreamingService with WidgetsBindingObserver {
|
2025-08-20 22:15:26 +05:30
|
|
|
static final PersistentStreamingService _instance =
|
|
|
|
|
PersistentStreamingService._internal();
|
2025-08-16 20:27:44 +05:30
|
|
|
factory PersistentStreamingService() => _instance;
|
|
|
|
|
PersistentStreamingService._internal() {
|
|
|
|
|
_initialize();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Active streams registry
|
|
|
|
|
final Map<String, StreamSubscription> _activeStreams = {};
|
|
|
|
|
final Map<String, StreamController> _streamControllers = {};
|
|
|
|
|
final Map<String, Function> _streamRecoveryCallbacks = {};
|
|
|
|
|
final Map<String, Map<String, dynamic>> _streamMetadata = {};
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// App lifecycle state
|
|
|
|
|
// AppLifecycleState? _lastLifecycleState; // Removed as it's unused
|
|
|
|
|
bool _isInBackground = false;
|
|
|
|
|
Timer? _backgroundTimer;
|
|
|
|
|
Timer? _heartbeatTimer;
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Background streaming handler
|
|
|
|
|
late final BackgroundStreamingHandler _backgroundHandler;
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Connectivity monitoring
|
|
|
|
|
StreamSubscription<bool>? _connectivitySubscription;
|
2025-09-23 00:58:58 +05:30
|
|
|
ConnectivityService? _connectivityService;
|
2025-08-16 20:27:44 +05:30
|
|
|
bool _hasConnectivity = true;
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Recovery state
|
|
|
|
|
final Map<String, int> _retryAttempts = {};
|
|
|
|
|
static const int _maxRetryAttempts = 3;
|
|
|
|
|
static const Duration _retryDelay = Duration(seconds: 2);
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
void _initialize() {
|
|
|
|
|
WidgetsBinding.instance.addObserver(this);
|
|
|
|
|
_backgroundHandler = BackgroundStreamingHandler.instance;
|
|
|
|
|
_setupBackgroundHandlerCallbacks();
|
|
|
|
|
_startHeartbeat();
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
void _setupBackgroundHandlerCallbacks() {
|
|
|
|
|
_backgroundHandler.onStreamsSuspending = (streamIds) {
|
2025-08-20 22:15:26 +05:30
|
|
|
DebugLogger.stream(
|
|
|
|
|
'PersistentStreaming: Streams suspending - $streamIds',
|
|
|
|
|
);
|
2025-08-16 20:27:44 +05:30
|
|
|
// Mark streams as suspended but don't close them yet
|
|
|
|
|
for (final streamId in streamIds) {
|
|
|
|
|
_markStreamAsSuspended(streamId);
|
|
|
|
|
}
|
|
|
|
|
};
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
_backgroundHandler.onBackgroundTaskExpiring = () {
|
2025-08-20 22:15:26 +05:30
|
|
|
DebugLogger.stream('PersistentStreaming: Background task expiring');
|
2025-08-16 20:27:44 +05:30
|
|
|
// Save states and prepare for recovery
|
|
|
|
|
_saveStreamStatesForRecovery();
|
|
|
|
|
};
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-10-11 16:17:35 +05:30
|
|
|
_backgroundHandler
|
|
|
|
|
.onBackgroundTaskExtended = (streamIds, estimatedSeconds) {
|
2025-10-11 13:53:30 +05:30
|
|
|
DebugLogger.stream(
|
|
|
|
|
'PersistentStreaming: Background task extended for $estimatedSeconds seconds',
|
|
|
|
|
);
|
|
|
|
|
// BGTaskScheduler has given us more time - streams can continue
|
|
|
|
|
for (final streamId in streamIds) {
|
|
|
|
|
final metadata = _streamMetadata[streamId];
|
|
|
|
|
if (metadata != null) {
|
|
|
|
|
metadata['bgTaskExtended'] = true;
|
|
|
|
|
metadata['bgTaskExtendedAt'] = DateTime.now();
|
|
|
|
|
metadata['bgTaskEstimatedTime'] = estimatedSeconds;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
_backgroundHandler.onBackgroundKeepAlive = () {
|
|
|
|
|
DebugLogger.stream('PersistentStreaming: Background keep-alive signal');
|
|
|
|
|
// BGTaskScheduler is keeping us alive - we can continue streaming
|
|
|
|
|
_heartbeatTimer?.cancel();
|
|
|
|
|
_startHeartbeat(); // Restart heartbeat timer
|
|
|
|
|
};
|
|
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
_backgroundHandler.shouldContinueInBackground = () {
|
|
|
|
|
return _activeStreams.isNotEmpty;
|
|
|
|
|
};
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-09-23 00:58:58 +05:30
|
|
|
void attachConnectivityService(ConnectivityService service) {
|
|
|
|
|
if (identical(_connectivityService, service)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_connectivitySubscription?.cancel();
|
|
|
|
|
_connectivityService = service;
|
2025-10-09 15:05:34 +05:30
|
|
|
_connectivitySubscription = service.statusStream
|
|
|
|
|
.map((status) => status == ConnectivityStatus.online)
|
|
|
|
|
.listen(_handleConnectivityChange);
|
2025-09-23 00:58:58 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _handleConnectivityChange(bool connected) {
|
|
|
|
|
final wasConnected = _hasConnectivity;
|
|
|
|
|
_hasConnectivity = connected;
|
|
|
|
|
|
|
|
|
|
if (!wasConnected && connected) {
|
|
|
|
|
DebugLogger.stream(
|
|
|
|
|
'PersistentStreaming: Connectivity restored, recovering streams',
|
|
|
|
|
);
|
|
|
|
|
_recoverActiveStreams();
|
|
|
|
|
} else if (wasConnected && !connected) {
|
|
|
|
|
DebugLogger.stream(
|
|
|
|
|
'PersistentStreaming: Connectivity lost, suspending streams',
|
|
|
|
|
);
|
|
|
|
|
_suspendAllStreams();
|
|
|
|
|
}
|
2025-08-16 20:27:44 +05:30
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
void _startHeartbeat() {
|
|
|
|
|
_heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (_) {
|
|
|
|
|
if (_activeStreams.isNotEmpty && _isInBackground) {
|
|
|
|
|
_backgroundHandler.keepAlive();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
@override
|
|
|
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
|
|
|
// _lastLifecycleState = state; // Removed as it's unused
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
switch (state) {
|
|
|
|
|
case AppLifecycleState.paused:
|
|
|
|
|
case AppLifecycleState.inactive:
|
|
|
|
|
_onAppBackground();
|
|
|
|
|
break;
|
|
|
|
|
case AppLifecycleState.resumed:
|
|
|
|
|
_onAppForeground();
|
|
|
|
|
break;
|
|
|
|
|
case AppLifecycleState.detached:
|
|
|
|
|
case AppLifecycleState.hidden:
|
|
|
|
|
// Handle app termination
|
|
|
|
|
_onAppDetached();
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
void _onAppBackground() {
|
2025-08-20 22:15:26 +05:30
|
|
|
DebugLogger.stream('PersistentStreamingService: App went to background');
|
2025-08-16 20:27:44 +05:30
|
|
|
_isInBackground = true;
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Enable wake lock to prevent device sleep during streaming
|
|
|
|
|
if (_activeStreams.isNotEmpty) {
|
|
|
|
|
_enableWakeLock();
|
|
|
|
|
_startBackgroundExecution();
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
void _onAppForeground() {
|
2025-08-20 22:15:26 +05:30
|
|
|
DebugLogger.stream(
|
|
|
|
|
'PersistentStreamingService: App returned to foreground',
|
|
|
|
|
);
|
2025-08-16 20:27:44 +05:30
|
|
|
_isInBackground = false;
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Cancel background timer
|
|
|
|
|
_backgroundTimer?.cancel();
|
|
|
|
|
_backgroundTimer = null;
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Disable wake lock if no active streams
|
|
|
|
|
if (_activeStreams.isEmpty) {
|
|
|
|
|
_disableWakeLock();
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-10-05 23:16:44 +05:30
|
|
|
// Close any controllers for streams that were suspended in background
|
|
|
|
|
// This allows the onComplete handlers to fire now that we're in foreground
|
|
|
|
|
final suspendedStreams = _streamMetadata.entries
|
|
|
|
|
.where((e) => e.value['suspended'] == true)
|
|
|
|
|
.map((e) => e.key)
|
|
|
|
|
.toList();
|
|
|
|
|
|
|
|
|
|
for (final streamId in suspendedStreams) {
|
|
|
|
|
final controller = _streamControllers[streamId];
|
|
|
|
|
if (controller != null && !controller.isClosed) {
|
|
|
|
|
DebugLogger.stream(
|
|
|
|
|
'PersistentStreamingService: Closing suspended stream $streamId controller in foreground',
|
|
|
|
|
);
|
|
|
|
|
controller.close();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Check and recover any interrupted streams
|
|
|
|
|
_recoverActiveStreams();
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
void _onAppDetached() {
|
2025-08-20 22:15:26 +05:30
|
|
|
DebugLogger.stream('PersistentStreamingService: App detached');
|
|
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Save stream states for recovery
|
|
|
|
|
_saveStreamStatesForRecovery();
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Clean up
|
|
|
|
|
_backgroundTimer?.cancel();
|
|
|
|
|
_heartbeatTimer?.cancel();
|
|
|
|
|
_disableWakeLock();
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Register a stream for persistent handling
|
|
|
|
|
String registerStream({
|
|
|
|
|
required StreamSubscription subscription,
|
|
|
|
|
required StreamController controller,
|
|
|
|
|
Function? recoveryCallback,
|
|
|
|
|
Map<String, dynamic>? metadata,
|
|
|
|
|
}) {
|
|
|
|
|
final streamId = DateTime.now().millisecondsSinceEpoch.toString();
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
_activeStreams[streamId] = subscription;
|
|
|
|
|
_streamControllers[streamId] = controller;
|
|
|
|
|
if (recoveryCallback != null) {
|
|
|
|
|
_streamRecoveryCallbacks[streamId] = recoveryCallback;
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Store metadata for recovery
|
|
|
|
|
if (metadata != null) {
|
|
|
|
|
_streamMetadata[streamId] = metadata;
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Register with background handler
|
|
|
|
|
_backgroundHandler.registerStream(
|
|
|
|
|
streamId,
|
|
|
|
|
conversationId: metadata['conversationId'] ?? '',
|
|
|
|
|
messageId: metadata['messageId'] ?? '',
|
|
|
|
|
sessionId: metadata['sessionId'],
|
|
|
|
|
lastChunkSequence: metadata['lastChunkSequence'],
|
|
|
|
|
lastContent: metadata['lastContent'],
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Enable wake lock when streaming starts
|
|
|
|
|
if (_activeStreams.length == 1) {
|
|
|
|
|
_enableWakeLock();
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Start background execution if app is backgrounded
|
|
|
|
|
if (_isInBackground) {
|
|
|
|
|
_startBackgroundExecution();
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
|
|
|
|
DebugLogger.stream(
|
|
|
|
|
'PersistentStreamingService: Registered stream $streamId',
|
|
|
|
|
);
|
|
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
return streamId;
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Unregister a stream
|
2025-10-05 23:16:44 +05:30
|
|
|
void unregisterStream(String streamId, {bool saveForRecovery = false}) {
|
|
|
|
|
// If app is in background and stream is unregistering, it might be due to
|
|
|
|
|
// network interruption - save state for recovery instead of just dropping it
|
2025-10-09 01:49:56 +05:30
|
|
|
if (_isInBackground &&
|
|
|
|
|
!saveForRecovery &&
|
|
|
|
|
_streamMetadata.containsKey(streamId)) {
|
2025-10-05 23:16:44 +05:30
|
|
|
DebugLogger.stream(
|
|
|
|
|
'PersistentStreamingService: Stream $streamId interrupted in background, saving for recovery',
|
|
|
|
|
);
|
|
|
|
|
// Don't unregister yet - keep it for recovery
|
|
|
|
|
_markStreamAsSuspended(streamId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
_activeStreams.remove(streamId);
|
|
|
|
|
_streamControllers.remove(streamId);
|
|
|
|
|
_streamRecoveryCallbacks.remove(streamId);
|
|
|
|
|
_streamMetadata.remove(streamId);
|
|
|
|
|
_retryAttempts.remove(streamId);
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Unregister from background handler
|
|
|
|
|
_backgroundHandler.unregisterStream(streamId);
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Stop background execution if no more streams
|
|
|
|
|
if (_activeStreams.isEmpty) {
|
|
|
|
|
_backgroundHandler.stopBackgroundExecution([streamId]);
|
|
|
|
|
_disableWakeLock();
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
|
|
|
|
DebugLogger.stream(
|
|
|
|
|
'PersistentStreamingService: Unregistered stream $streamId',
|
|
|
|
|
);
|
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 a stream is still active
|
|
|
|
|
bool isStreamActive(String streamId) {
|
|
|
|
|
return _activeStreams.containsKey(streamId);
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Recover interrupted streams
|
|
|
|
|
Future<void> _recoverActiveStreams() async {
|
|
|
|
|
if (!_hasConnectivity) {
|
2025-08-20 22:15:26 +05:30
|
|
|
DebugLogger.stream(
|
|
|
|
|
'PersistentStreaming: No connectivity, skipping recovery',
|
|
|
|
|
);
|
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
|
|
|
// First, try to recover from background handler saved states
|
|
|
|
|
final savedStates = await _backgroundHandler.recoverStreamStates();
|
|
|
|
|
for (final state in savedStates) {
|
|
|
|
|
if (!state.isStale()) {
|
|
|
|
|
await _recoverStreamFromState(state);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Then check active streams for recovery
|
|
|
|
|
for (final entry in _streamRecoveryCallbacks.entries) {
|
|
|
|
|
final streamId = entry.key;
|
|
|
|
|
final recoveryCallback = entry.value;
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Check if stream was interrupted or needs recovery
|
|
|
|
|
final subscription = _activeStreams[streamId];
|
|
|
|
|
if (subscription == null || _needsRecovery(streamId)) {
|
|
|
|
|
await _attemptStreamRecovery(streamId, recoveryCallback);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
Future<void> _recoverStreamFromState(StreamState state) async {
|
|
|
|
|
final recoveryCallback = _streamRecoveryCallbacks[state.streamId];
|
|
|
|
|
if (recoveryCallback != null) {
|
2025-08-20 22:15:26 +05:30
|
|
|
DebugLogger.stream(
|
|
|
|
|
'PersistentStreaming: Recovering stream from saved state: ${state.streamId}',
|
|
|
|
|
);
|
2025-08-16 20:27:44 +05:30
|
|
|
await _attemptStreamRecovery(state.streamId, recoveryCallback);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
|
|
|
|
Future<void> _attemptStreamRecovery(
|
|
|
|
|
String streamId,
|
|
|
|
|
Function recoveryCallback,
|
|
|
|
|
) async {
|
2025-08-16 20:27:44 +05:30
|
|
|
final attempts = _retryAttempts[streamId] ?? 0;
|
|
|
|
|
if (attempts >= _maxRetryAttempts) {
|
2025-08-20 22:15:26 +05:30
|
|
|
DebugLogger.warning(
|
|
|
|
|
'PersistentStreaming: Max retry attempts reached for stream $streamId',
|
|
|
|
|
);
|
2025-08-16 20:27:44 +05:30
|
|
|
return;
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
|
|
|
|
DebugLogger.stream(
|
|
|
|
|
'PersistentStreaming: Recovering stream $streamId (attempt ${attempts + 1})',
|
|
|
|
|
);
|
|
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
try {
|
|
|
|
|
_retryAttempts[streamId] = attempts + 1;
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Add exponential backoff delay
|
|
|
|
|
if (attempts > 0) {
|
|
|
|
|
final delay = _retryDelay * (1 << (attempts - 1)); // 2s, 4s, 8s...
|
|
|
|
|
await Future.delayed(delay);
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Call recovery callback to restart the stream
|
|
|
|
|
await recoveryCallback();
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Reset retry count on success
|
|
|
|
|
_retryAttempts.remove(streamId);
|
|
|
|
|
} catch (e) {
|
2025-08-20 22:15:26 +05:30
|
|
|
DebugLogger.error(
|
2025-09-25 22:36:42 +05:30
|
|
|
'recover-failed',
|
|
|
|
|
scope: 'streaming/persistent',
|
|
|
|
|
error: e,
|
|
|
|
|
data: {'streamId': streamId},
|
2025-08-20 22:15:26 +05:30
|
|
|
);
|
|
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Schedule next retry if under limit
|
|
|
|
|
if (_retryAttempts[streamId]! < _maxRetryAttempts) {
|
2025-08-20 22:15:26 +05:30
|
|
|
Timer(
|
|
|
|
|
_retryDelay,
|
|
|
|
|
() => _attemptStreamRecovery(streamId, recoveryCallback),
|
|
|
|
|
);
|
2025-08-16 20:27:44 +05:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
bool _needsRecovery(String streamId) {
|
|
|
|
|
final metadata = _streamMetadata[streamId];
|
|
|
|
|
if (metadata == null) return false;
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Check if stream has been inactive for too long
|
|
|
|
|
final lastUpdate = metadata['lastUpdate'] as DateTime?;
|
|
|
|
|
if (lastUpdate != null) {
|
|
|
|
|
final timeSinceUpdate = DateTime.now().difference(lastUpdate);
|
2025-09-07 23:17:26 +05:30
|
|
|
// Align with app-side watchdogs: be less aggressive than UI guard
|
|
|
|
|
// but still attempt recovery before server timeouts become likely.
|
|
|
|
|
return timeSinceUpdate > const Duration(minutes: 2);
|
2025-08-16 20:27:44 +05:30
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
return false;
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Platform-specific background execution
|
|
|
|
|
void _startBackgroundExecution() {
|
|
|
|
|
if (_activeStreams.isNotEmpty) {
|
|
|
|
|
_backgroundHandler.startBackgroundExecution(_activeStreams.keys.toList());
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
void _markStreamAsSuspended(String streamId) {
|
|
|
|
|
final metadata = _streamMetadata[streamId];
|
|
|
|
|
if (metadata != null) {
|
|
|
|
|
metadata['suspended'] = true;
|
|
|
|
|
metadata['suspendedAt'] = DateTime.now();
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
void _suspendAllStreams() {
|
|
|
|
|
for (final streamId in _activeStreams.keys) {
|
|
|
|
|
_markStreamAsSuspended(streamId);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
void _saveStreamStatesForRecovery() {
|
2025-10-05 23:16:44 +05:30
|
|
|
if (_activeStreams.isEmpty) {
|
|
|
|
|
DebugLogger.stream(
|
|
|
|
|
'PersistentStreaming: No active streams to save for recovery',
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-20 22:15:26 +05:30
|
|
|
DebugLogger.stream(
|
|
|
|
|
'PersistentStreaming: Saving ${_activeStreams.length} stream states for recovery',
|
|
|
|
|
);
|
2025-10-05 23:16:44 +05:30
|
|
|
|
|
|
|
|
// Actually save the stream states through the background handler
|
|
|
|
|
final streamIds = _activeStreams.keys.toList();
|
|
|
|
|
_backgroundHandler.saveStreamStatesForRecovery(streamIds, 'app_detached');
|
2025-08-16 20:27:44 +05:30
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Update stream metadata when chunks are received
|
2025-08-20 22:15:26 +05:30
|
|
|
void updateStreamProgress(
|
|
|
|
|
String streamId, {
|
2025-08-16 20:27:44 +05:30
|
|
|
int? chunkSequence,
|
|
|
|
|
String? content,
|
|
|
|
|
String? appendedContent,
|
|
|
|
|
}) {
|
|
|
|
|
// Update background handler state
|
|
|
|
|
_backgroundHandler.updateStreamState(
|
|
|
|
|
streamId,
|
|
|
|
|
chunkSequence: chunkSequence,
|
|
|
|
|
content: content,
|
|
|
|
|
appendedContent: appendedContent,
|
|
|
|
|
);
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Update local metadata
|
|
|
|
|
final metadata = _streamMetadata[streamId];
|
|
|
|
|
if (metadata != null) {
|
|
|
|
|
metadata['lastUpdate'] = DateTime.now();
|
2025-08-20 22:15:26 +05:30
|
|
|
metadata['lastChunkSequence'] =
|
|
|
|
|
chunkSequence ?? metadata['lastChunkSequence'];
|
2025-08-16 20:27:44 +05:30
|
|
|
if (appendedContent != null) {
|
2025-08-20 22:15:26 +05:30
|
|
|
metadata['lastContent'] =
|
|
|
|
|
(metadata['lastContent'] ?? '') + appendedContent;
|
2025-08-16 20:27:44 +05:30
|
|
|
} else if (content != null) {
|
|
|
|
|
metadata['lastContent'] = content;
|
|
|
|
|
}
|
|
|
|
|
metadata['suspended'] = false; // Mark as active
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Wake lock management
|
|
|
|
|
void _enableWakeLock() async {
|
|
|
|
|
try {
|
|
|
|
|
await WakelockPlus.enable();
|
2025-09-25 22:36:42 +05:30
|
|
|
DebugLogger.stream('wake-lock-enabled', scope: 'streaming/persistent');
|
2025-08-16 20:27:44 +05:30
|
|
|
} catch (e) {
|
2025-08-20 22:15:26 +05:30
|
|
|
DebugLogger.error(
|
2025-09-25 22:36:42 +05:30
|
|
|
'wake-lock-enable-failed',
|
|
|
|
|
scope: 'streaming/persistent',
|
|
|
|
|
error: e,
|
2025-08-20 22:15:26 +05:30
|
|
|
);
|
2025-08-16 20:27:44 +05:30
|
|
|
}
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
void _disableWakeLock() async {
|
|
|
|
|
try {
|
|
|
|
|
await WakelockPlus.disable();
|
2025-09-25 22:36:42 +05:30
|
|
|
DebugLogger.stream('wake-lock-disabled', scope: 'streaming/persistent');
|
2025-08-16 20:27:44 +05:30
|
|
|
} catch (e) {
|
2025-08-20 22:15:26 +05:30
|
|
|
DebugLogger.error(
|
2025-09-25 22:36:42 +05:30
|
|
|
'wake-lock-disable-failed',
|
|
|
|
|
scope: 'streaming/persistent',
|
|
|
|
|
error: e,
|
2025-08-20 22:15:26 +05:30
|
|
|
);
|
2025-08-16 20:27:44 +05:30
|
|
|
}
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Get active stream count
|
|
|
|
|
int get activeStreamCount => _activeStreams.length;
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-10-05 23:16:44 +05:30
|
|
|
// Check if app is in background
|
|
|
|
|
bool get isInBackground => _isInBackground;
|
|
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Get stream metadata
|
|
|
|
|
Map<String, dynamic>? getStreamMetadata(String streamId) {
|
|
|
|
|
return _streamMetadata[streamId];
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Check if stream is suspended
|
|
|
|
|
bool isStreamSuspended(String streamId) {
|
|
|
|
|
final metadata = _streamMetadata[streamId];
|
|
|
|
|
return metadata?['suspended'] == true;
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Force recovery of a specific stream
|
|
|
|
|
Future<void> forceRecoverStream(String streamId) async {
|
|
|
|
|
final recoveryCallback = _streamRecoveryCallbacks[streamId];
|
|
|
|
|
if (recoveryCallback != null) {
|
|
|
|
|
_retryAttempts.remove(streamId); // Reset retry count
|
|
|
|
|
await _attemptStreamRecovery(streamId, recoveryCallback);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Cleanup
|
|
|
|
|
void dispose() {
|
|
|
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
|
|
|
_backgroundTimer?.cancel();
|
|
|
|
|
_heartbeatTimer?.cancel();
|
|
|
|
|
_connectivitySubscription?.cancel();
|
|
|
|
|
_disableWakeLock();
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Stop all background execution
|
|
|
|
|
if (_activeStreams.isNotEmpty) {
|
|
|
|
|
_backgroundHandler.stopBackgroundExecution(_activeStreams.keys.toList());
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Cancel all active streams
|
|
|
|
|
for (final subscription in _activeStreams.values) {
|
|
|
|
|
subscription.cancel();
|
|
|
|
|
}
|
|
|
|
|
_activeStreams.clear();
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Close all controllers
|
|
|
|
|
for (final controller in _streamControllers.values) {
|
|
|
|
|
if (!controller.isClosed) {
|
|
|
|
|
controller.close();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_streamControllers.clear();
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Clear all metadata
|
|
|
|
|
_streamMetadata.clear();
|
|
|
|
|
_streamRecoveryCallbacks.clear();
|
|
|
|
|
_retryAttempts.clear();
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Clear background handler
|
|
|
|
|
_backgroundHandler.clearAll();
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
}
|