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
|
|
|
|
2025-12-20 22:10:28 +05:30
|
|
|
/// Handles background streaming continuation for iOS and Android.
|
2025-08-20 22:15:26 +05:30
|
|
|
///
|
2025-12-20 22:10:28 +05:30
|
|
|
/// This service keeps the app alive when streaming content in the background,
|
|
|
|
|
/// ensuring that chat responses, voice calls, and socket connections continue
|
|
|
|
|
/// even when the app is not in the foreground.
|
|
|
|
|
///
|
|
|
|
|
/// ## Platform Implementations
|
|
|
|
|
///
|
|
|
|
|
/// ### iOS
|
|
|
|
|
/// - Uses `beginBackgroundTask` for ~30 seconds of execution
|
|
|
|
|
/// - Uses `BGProcessingTask` for extended time (~1-3 minutes when granted)
|
|
|
|
|
/// - **Limitation**: iOS may not grant extended time; streams may be interrupted
|
|
|
|
|
/// - Audio mode (`UIBackgroundModes: audio`) provides reliable background for voice calls
|
|
|
|
|
///
|
|
|
|
|
/// ### Android
|
|
|
|
|
/// - Uses foreground service with notification (reliable, can run for hours)
|
|
|
|
|
/// - Acquires wake lock to prevent CPU sleep during active streaming
|
|
|
|
|
/// - **Android 14+**: dataSync services limited to 6 hours (we stop at 5h with warning)
|
|
|
|
|
///
|
|
|
|
|
/// ## Usage
|
|
|
|
|
///
|
|
|
|
|
/// For most streaming operations, only [startBackgroundExecution] and
|
|
|
|
|
/// [stopBackgroundExecution] are needed:
|
|
|
|
|
///
|
|
|
|
|
/// ```dart
|
|
|
|
|
/// // When streaming starts
|
|
|
|
|
/// await BackgroundStreamingHandler.instance.startBackgroundExecution(['stream-123']);
|
|
|
|
|
///
|
|
|
|
|
/// // When streaming completes
|
|
|
|
|
/// await BackgroundStreamingHandler.instance.stopBackgroundExecution(['stream-123']);
|
|
|
|
|
/// ```
|
|
|
|
|
///
|
|
|
|
|
/// For extended background sessions (e.g., voice calls), call [keepAlive] periodically:
|
|
|
|
|
///
|
|
|
|
|
/// ```dart
|
|
|
|
|
/// Timer.periodic(Duration(minutes: 5), (_) {
|
|
|
|
|
/// BackgroundStreamingHandler.instance.keepAlive();
|
|
|
|
|
/// });
|
|
|
|
|
/// ```
|
2025-08-16 20:27:44 +05:30
|
|
|
class BackgroundStreamingHandler {
|
2025-08-20 22:15:26 +05:30
|
|
|
static const MethodChannel _channel = MethodChannel(
|
|
|
|
|
'conduit/background_streaming',
|
|
|
|
|
);
|
|
|
|
|
|
2025-12-20 18:21:38 +05:30
|
|
|
/// Stream ID used for socket keepalive - not counted as an "active stream"
|
|
|
|
|
/// since it's a background task, not user-visible streaming.
|
|
|
|
|
static const String socketKeepaliveId = 'socket-keepalive';
|
|
|
|
|
|
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>{};
|
2025-12-20 22:10:28 +05:30
|
|
|
final Set<String> _microphoneStreamIds = <String>{};
|
|
|
|
|
bool _initialized = false;
|
|
|
|
|
|
|
|
|
|
/// Initialize the background streaming handler with callbacks.
|
|
|
|
|
///
|
|
|
|
|
/// This should be called once during app startup to register error and
|
|
|
|
|
/// event callbacks.
|
|
|
|
|
Future<void> initialize({
|
|
|
|
|
void Function(String error, String errorType, List<String> streamIds)?
|
|
|
|
|
serviceFailedCallback,
|
|
|
|
|
void Function(int remainingMinutes)? timeLimitApproachingCallback,
|
|
|
|
|
void Function()? microphonePermissionFallbackCallback,
|
|
|
|
|
void Function(List<String> streamIds)? streamsSuspendingCallback,
|
|
|
|
|
void Function()? backgroundTaskExpiringCallback,
|
|
|
|
|
void Function(List<String> streamIds, int estimatedSeconds)?
|
|
|
|
|
backgroundTaskExtendedCallback,
|
|
|
|
|
void Function()? backgroundKeepAliveCallback,
|
|
|
|
|
}) async {
|
|
|
|
|
if (_initialized) {
|
|
|
|
|
DebugLogger.stream('already-initialized', scope: 'background');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
_initialized = true;
|
|
|
|
|
|
|
|
|
|
// Register callbacks
|
|
|
|
|
onServiceFailed = serviceFailedCallback;
|
|
|
|
|
onBackgroundTimeLimitApproaching = timeLimitApproachingCallback;
|
|
|
|
|
onMicrophonePermissionFallback = microphonePermissionFallbackCallback;
|
|
|
|
|
onStreamsSuspending = streamsSuspendingCallback;
|
|
|
|
|
onBackgroundTaskExpiring = backgroundTaskExpiringCallback;
|
|
|
|
|
onBackgroundTaskExtended = backgroundTaskExtendedCallback;
|
|
|
|
|
onBackgroundKeepAlive = backgroundKeepAliveCallback;
|
|
|
|
|
|
|
|
|
|
DebugLogger.stream('initialized', scope: 'background');
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-12-20 18:21:38 +05:30
|
|
|
/// Returns count of actual content streams (excludes socket keepalive).
|
|
|
|
|
int get _userVisibleStreamCount =>
|
|
|
|
|
_activeStreamIds.where((id) => id != socketKeepaliveId).length;
|
|
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
// Callbacks for platform-specific events
|
|
|
|
|
void Function(List<String> streamIds)? onStreamsSuspending;
|
|
|
|
|
void Function()? onBackgroundTaskExpiring;
|
2025-10-11 16:17:35 +05:30
|
|
|
void Function(List<String> streamIds, int estimatedSeconds)?
|
2025-10-31 23:20:04 +05:30
|
|
|
onBackgroundTaskExtended;
|
2025-10-11 13:53:30 +05:30
|
|
|
void Function()? onBackgroundKeepAlive;
|
2025-08-16 20:27:44 +05:30
|
|
|
bool Function()? shouldContinueInBackground;
|
2025-10-28 13:59:17 +05:30
|
|
|
void Function(String error, String errorType, List<String> streamIds)?
|
2025-10-31 23:20:04 +05:30
|
|
|
onServiceFailed;
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-12-20 18:21:38 +05:30
|
|
|
/// Called when Android 14's foreground service time limit is reached.
|
|
|
|
|
/// The service stops after 5 hours (buffer before Android's 6-hour limit).
|
|
|
|
|
/// [remainingMinutes] will be 0 when this is called.
|
|
|
|
|
void Function(int remainingMinutes)? onBackgroundTimeLimitApproaching;
|
|
|
|
|
|
|
|
|
|
/// Called when microphone permission was requested but not granted,
|
|
|
|
|
/// causing fallback to dataSync-only foreground service type.
|
|
|
|
|
void Function()? onMicrophonePermissionFallback;
|
|
|
|
|
|
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);
|
|
|
|
|
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-10-11 13:53:30 +05:30
|
|
|
|
|
|
|
|
case 'backgroundTaskExtended':
|
|
|
|
|
final Map<String, dynamic> args =
|
|
|
|
|
call.arguments as Map<String, dynamic>;
|
|
|
|
|
final List<String> streamIds = (args['streamIds'] as List)
|
|
|
|
|
.cast<String>();
|
|
|
|
|
final int estimatedTime = args['estimatedTime'] as int;
|
|
|
|
|
|
|
|
|
|
DebugLogger.stream(
|
|
|
|
|
'task-extended',
|
|
|
|
|
scope: 'background',
|
|
|
|
|
data: {'count': streamIds.length, 'time': estimatedTime},
|
|
|
|
|
);
|
|
|
|
|
onBackgroundTaskExtended?.call(streamIds, estimatedTime);
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case 'backgroundKeepAlive':
|
|
|
|
|
DebugLogger.stream('keepalive-signal', scope: 'background');
|
|
|
|
|
onBackgroundKeepAlive?.call();
|
|
|
|
|
break;
|
2025-10-28 13:59:17 +05:30
|
|
|
|
|
|
|
|
case 'serviceFailed':
|
|
|
|
|
final Map<String, dynamic> args =
|
|
|
|
|
call.arguments as Map<String, dynamic>;
|
|
|
|
|
final String error = args['error'] as String? ?? 'Unknown error';
|
|
|
|
|
final String errorType = args['errorType'] as String? ?? 'Exception';
|
|
|
|
|
final List<String> streamIds =
|
|
|
|
|
(args['streamIds'] as List?)?.cast<String>() ?? [];
|
|
|
|
|
|
|
|
|
|
DebugLogger.error(
|
|
|
|
|
'service-failed',
|
|
|
|
|
scope: 'background',
|
|
|
|
|
error: error,
|
|
|
|
|
data: {'type': errorType, 'streams': streamIds.length},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Notify callback about service failure
|
|
|
|
|
onServiceFailed?.call(error, errorType, streamIds);
|
|
|
|
|
|
|
|
|
|
// Clean up failed streams
|
|
|
|
|
for (final streamId in streamIds) {
|
|
|
|
|
_activeStreamIds.remove(streamId);
|
|
|
|
|
}
|
|
|
|
|
break;
|
2025-12-20 18:21:38 +05:30
|
|
|
|
|
|
|
|
case 'timeLimitApproaching':
|
|
|
|
|
final Map<String, dynamic> args =
|
|
|
|
|
call.arguments as Map<String, dynamic>;
|
|
|
|
|
final int remainingMinutes = args['remainingMinutes'] as int? ?? -1;
|
|
|
|
|
|
|
|
|
|
DebugLogger.stream(
|
|
|
|
|
'time-limit-approaching',
|
|
|
|
|
scope: 'background',
|
|
|
|
|
data: {'remainingMinutes': remainingMinutes},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
onBackgroundTimeLimitApproaching?.call(remainingMinutes);
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case 'microphonePermissionFallback':
|
2025-12-20 22:10:28 +05:30
|
|
|
DebugLogger.stream('mic-permission-fallback', scope: 'background');
|
2025-12-20 18:21:38 +05:30
|
|
|
|
|
|
|
|
onMicrophonePermissionFallback?.call();
|
|
|
|
|
break;
|
2025-08-16 20:27:44 +05:30
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
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
|
|
|
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
|
|
|
|
2025-12-20 22:10:28 +05:30
|
|
|
// Only add to active streams after successful platform call
|
|
|
|
|
_activeStreamIds.addAll(streamIds);
|
|
|
|
|
|
|
|
|
|
// Track which streams require microphone for reconciliation
|
|
|
|
|
if (requiresMicrophone) {
|
|
|
|
|
_microphoneStreamIds.addAll(streamIds);
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-20 22:15:26 +05:30
|
|
|
DebugLogger.stream(
|
2025-09-25 22:36:42 +05:30
|
|
|
'start',
|
|
|
|
|
scope: 'background',
|
2025-12-20 22:10:28 +05:30
|
|
|
data: {'count': streamIds.length, 'mic': requiresMicrophone},
|
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-12-20 22:10:28 +05:30
|
|
|
// Re-throw so callers know the background execution failed
|
|
|
|
|
rethrow;
|
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
|
|
|
try {
|
|
|
|
|
await _channel.invokeMethod('stopBackgroundExecution', {
|
|
|
|
|
'streamIds': streamIds,
|
|
|
|
|
});
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-12-20 22:10:28 +05:30
|
|
|
// Only remove from tracking after successful platform call
|
|
|
|
|
// to maintain state consistency between Flutter and native layers
|
|
|
|
|
_activeStreamIds.removeAll(streamIds);
|
|
|
|
|
_microphoneStreamIds.removeAll(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-12-20 22:10:28 +05:30
|
|
|
// Still remove from local tracking on error - the platform may have
|
|
|
|
|
// already stopped, and keeping stale state causes issues
|
|
|
|
|
_activeStreamIds.removeAll(streamIds);
|
|
|
|
|
_microphoneStreamIds.removeAll(streamIds);
|
|
|
|
|
|
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-10-10 19:59:17 +05:30
|
|
|
/// Keep alive the background task
|
|
|
|
|
///
|
|
|
|
|
/// On iOS: Refreshes background task to prevent early termination
|
|
|
|
|
/// On Android: Refreshes wake lock to keep service running
|
2025-12-20 22:10:28 +05:30
|
|
|
///
|
|
|
|
|
/// Returns true if keep-alive succeeded, false otherwise.
|
|
|
|
|
Future<bool> keepAlive() async {
|
|
|
|
|
if (!Platform.isIOS && !Platform.isAndroid) return true;
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-12-20 18:21:38 +05:30
|
|
|
// Skip keep-alive if no active streams - this ensures Android's count
|
|
|
|
|
// stays synchronized with Flutter's actual state
|
2025-12-20 22:10:28 +05:30
|
|
|
if (_activeStreamIds.isEmpty) return true;
|
2025-12-20 18:21:38 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
try {
|
2025-12-20 18:21:38 +05:30
|
|
|
await _channel.invokeMethod('keepAlive', {
|
|
|
|
|
// Pass user-visible stream count (excludes socket-keepalive)
|
|
|
|
|
// for accurate logging, but service still runs for any background task
|
|
|
|
|
'streamCount': _userVisibleStreamCount,
|
|
|
|
|
});
|
2025-10-10 19:59:17 +05:30
|
|
|
DebugLogger.stream('keepalive-success', scope: 'background');
|
2025-12-20 22:10:28 +05:30
|
|
|
return true;
|
2025-08-16 20:27:44 +05:30
|
|
|
} catch (e) {
|
2025-09-25 22:36:42 +05:30
|
|
|
DebugLogger.error('keepalive-failed', scope: 'background', error: e);
|
2025-12-20 22:10:28 +05:30
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Check if background app refresh is enabled (iOS only).
|
|
|
|
|
///
|
|
|
|
|
/// Returns true on Android or if iOS background refresh is available.
|
|
|
|
|
/// Returns false if iOS background refresh is disabled by user.
|
|
|
|
|
Future<bool> checkBackgroundRefreshStatus() async {
|
|
|
|
|
if (!Platform.isIOS) return true;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
final bool? status = await _channel.invokeMethod<bool>(
|
|
|
|
|
'checkBackgroundRefreshStatus',
|
|
|
|
|
);
|
|
|
|
|
return status ?? true;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
DebugLogger.error(
|
|
|
|
|
'check-background-refresh-failed',
|
|
|
|
|
scope: 'background',
|
|
|
|
|
error: e,
|
|
|
|
|
);
|
|
|
|
|
return true; // Assume available on error to not block functionality
|
2025-08-16 20:27:44 +05:30
|
|
|
}
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-12-20 18:21:38 +05:30
|
|
|
/// Check if notification permission is granted (Android 13+ only).
|
|
|
|
|
///
|
|
|
|
|
/// Returns true on iOS, Android < 13, or if permission is granted.
|
|
|
|
|
/// Returns false if Android 13+ and permission is not granted.
|
|
|
|
|
Future<bool> checkNotificationPermission() async {
|
|
|
|
|
if (!Platform.isAndroid) return true;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
final bool? hasPermission = await _channel.invokeMethod<bool>(
|
|
|
|
|
'checkNotificationPermission',
|
|
|
|
|
);
|
|
|
|
|
return hasPermission ?? true;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
DebugLogger.error(
|
|
|
|
|
'check-notification-permission-failed',
|
|
|
|
|
scope: 'background',
|
|
|
|
|
error: e,
|
|
|
|
|
);
|
|
|
|
|
return true; // Assume granted on error to not block functionality
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-20 22:10:28 +05:30
|
|
|
/// Check if any streams are currently active
|
|
|
|
|
bool get hasActiveStreams => _activeStreamIds.isNotEmpty;
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-12-20 22:10:28 +05:30
|
|
|
/// Get list of active stream IDs
|
|
|
|
|
List<String> get activeStreamIds => _activeStreamIds.toList();
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-12-20 22:10:28 +05:30
|
|
|
/// Notify the native layer that an external component (e.g., speech_to_text
|
|
|
|
|
/// plugin) is managing the audio session.
|
|
|
|
|
///
|
|
|
|
|
/// On iOS, this prevents VoiceBackgroundAudioManager from conflicting with
|
|
|
|
|
/// the speech_to_text plugin's audio session management.
|
|
|
|
|
/// On Android, this is a no-op as audio session management is different.
|
|
|
|
|
Future<void> setExternalAudioSessionOwner(bool isExternal) async {
|
|
|
|
|
if (!Platform.isIOS) return;
|
2025-10-05 23:16:44 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
try {
|
2025-12-20 22:10:28 +05:30
|
|
|
await _channel.invokeMethod('setExternalAudioSessionOwner', {
|
|
|
|
|
'isExternal': isExternal,
|
2025-08-16 20:27:44 +05:30
|
|
|
});
|
2025-10-05 23:16:44 +05:30
|
|
|
DebugLogger.stream(
|
2025-12-20 22:10:28 +05:30
|
|
|
isExternal
|
|
|
|
|
? 'external-audio-owner-set'
|
|
|
|
|
: 'external-audio-owner-cleared',
|
2025-10-05 23:16:44 +05:30
|
|
|
scope: 'background',
|
|
|
|
|
);
|
2025-08-16 20:27:44 +05:30
|
|
|
} catch (e) {
|
2025-09-25 22:36:42 +05:30
|
|
|
DebugLogger.error(
|
2025-12-20 22:10:28 +05:30
|
|
|
'set-external-audio-owner-failed',
|
2025-09-25 22:36:42 +05:30
|
|
|
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
|
|
|
/// Clear all stream data (usually on app termination)
|
|
|
|
|
void clearAll() {
|
|
|
|
|
_activeStreamIds.clear();
|
2025-12-20 22:10:28 +05:30
|
|
|
_microphoneStreamIds.clear();
|
2025-08-16 20:27:44 +05:30
|
|
|
}
|
|
|
|
|
|
2025-12-20 22:10:28 +05:30
|
|
|
/// Reconcile Flutter state with native platform state.
|
|
|
|
|
///
|
|
|
|
|
/// This should be called on app resume to detect and fix state drift
|
|
|
|
|
/// caused by native service crashes or other edge cases. Returns true
|
|
|
|
|
/// if reconciliation was needed and performed.
|
|
|
|
|
Future<bool> reconcileState() async {
|
|
|
|
|
if (!Platform.isIOS && !Platform.isAndroid) return false;
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-08-16 20:27:44 +05:30
|
|
|
try {
|
2025-12-20 22:10:28 +05:30
|
|
|
final int? nativeCount = await _channel.invokeMethod<int>(
|
|
|
|
|
'getActiveStreamCount',
|
2025-08-16 20:27:44 +05:30
|
|
|
);
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-12-20 22:10:28 +05:30
|
|
|
if (nativeCount == null) return false;
|
|
|
|
|
|
|
|
|
|
// If native has streams but Flutter doesn't, the native service is orphaned
|
|
|
|
|
if (nativeCount > 0 && _activeStreamIds.isEmpty) {
|
|
|
|
|
DebugLogger.warning(
|
|
|
|
|
'reconcile-orphaned-service',
|
|
|
|
|
scope: 'background',
|
|
|
|
|
data: {'nativeCount': nativeCount},
|
|
|
|
|
);
|
|
|
|
|
// Stop the orphaned native service
|
|
|
|
|
await _channel.invokeMethod('stopAllBackgroundExecution');
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If Flutter has streams but native doesn't, restart the service
|
|
|
|
|
if (_activeStreamIds.isNotEmpty && nativeCount == 0) {
|
|
|
|
|
// Preserve microphone requirement from tracked streams
|
|
|
|
|
final requiresMicrophone = _microphoneStreamIds.isNotEmpty;
|
|
|
|
|
DebugLogger.warning(
|
|
|
|
|
'reconcile-restart-service',
|
|
|
|
|
scope: 'background',
|
|
|
|
|
data: {
|
|
|
|
|
'flutterCount': _activeStreamIds.length,
|
|
|
|
|
'requiresMic': requiresMicrophone,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
// Restart background execution for active streams with preserved capabilities
|
|
|
|
|
await _channel.invokeMethod('startBackgroundExecution', {
|
|
|
|
|
'streamIds': _activeStreamIds.toList(),
|
|
|
|
|
'requiresMicrophone': requiresMicrophone,
|
|
|
|
|
});
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
|
2025-12-20 22:10:28 +05:30
|
|
|
return false;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
DebugLogger.error('reconcile-failed', scope: 'background', error: e);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2025-08-16 20:27:44 +05:30
|
|
|
}
|
2025-08-20 22:15:26 +05:30
|
|
|
}
|