improvements
parent
8aa40f7b8a
commit
79b6d4fa2b
@ -0,0 +1 @@
|
||||
|
||||
@ -0,0 +1,202 @@
|
||||
import 'dart:developer';
|
||||
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||
import 'package:flutter_callkit_incoming/entities/entities.dart';
|
||||
import 'package:test_sa/modules/cx_module/chat/services/call_manager.dart';
|
||||
|
||||
/// Service to handle incoming call notifications across all app states
|
||||
/// Integrates with existing Firebase notification system
|
||||
class CallNotificationService {
|
||||
static final CallNotificationService _instance = CallNotificationService._internal();
|
||||
factory CallNotificationService() => _instance;
|
||||
CallNotificationService._internal();
|
||||
|
||||
// Track processed call IDs to prevent duplicates
|
||||
final Set<String> _processedCallIds = {};
|
||||
|
||||
// Store pending call data for restoration after app wake
|
||||
Map<String, dynamic>? _pendingCallData;
|
||||
|
||||
/// Handle incoming call notification from push (FCM/Huawei) or SignalR
|
||||
/// This is the SINGLE entry point for all incoming calls regardless of app state
|
||||
Future<void> handleIncomingCallNotification({
|
||||
required Map<String, dynamic> notificationData,
|
||||
required String source, // 'push' or 'signalr'
|
||||
}) async {
|
||||
try {
|
||||
log('═══════════════════════════════════════════', name: 'CallNotificationService');
|
||||
log('📞 [INCOMING CALL] Notification received from: $source', name: 'CallNotificationService');
|
||||
log('📞 [INCOMING CALL] Data: $notificationData', name: 'CallNotificationService');
|
||||
|
||||
// Extract call information
|
||||
final notificationType = notificationData['notificationType'] as String?;
|
||||
final transactionType = notificationData['transactionType'] as String?;
|
||||
|
||||
// Verify this is a call notification
|
||||
if (notificationType != 'incoming_call' && transactionType != 'call') {
|
||||
log('⚠️ [INCOMING CALL] Not a call notification, ignoring', name: 'CallNotificationService');
|
||||
return;
|
||||
}
|
||||
|
||||
final callId = notificationData['callId'] as String? ??
|
||||
notificationData['sessionId'] as String?;
|
||||
final callerId = notificationData['callerId'] as String? ??
|
||||
notificationData['sourceUserId'] as String?;
|
||||
final callerName = notificationData['callerName'] as String? ??
|
||||
notificationData['userName'] as String? ??
|
||||
'Unknown Caller';
|
||||
final isVideoCall = notificationData['isVideoCall'] as bool? ??
|
||||
(notificationData['callType'] == 'video');
|
||||
final moduleId = notificationData['moduleId'] as String?;
|
||||
final referenceId = notificationData['referenceId'] as String?;
|
||||
final conversationId = notificationData['conversationId'] as String?;
|
||||
|
||||
if (callId == null || callerId == null) {
|
||||
log('❌ [INCOMING CALL] Missing required data (callId or callerId)', name: 'CallNotificationService');
|
||||
return;
|
||||
}
|
||||
|
||||
// CRITICAL: Prevent duplicate processing
|
||||
if (_processedCallIds.contains(callId)) {
|
||||
log('⚠️ [INCOMING CALL] Already processed call $callId, ignoring duplicate', name: 'CallNotificationService');
|
||||
return;
|
||||
}
|
||||
_processedCallIds.add(callId);
|
||||
|
||||
// Clean up old processed IDs (keep last 50)
|
||||
if (_processedCallIds.length > 50) {
|
||||
final oldIds = _processedCallIds.take(_processedCallIds.length - 50).toList();
|
||||
_processedCallIds.removeAll(oldIds);
|
||||
}
|
||||
|
||||
log('✅ [INCOMING CALL] Valid call notification', name: 'CallNotificationService');
|
||||
log(' Call ID: $callId', name: 'CallNotificationService');
|
||||
log(' Caller: $callerName ($callerId)', name: 'CallNotificationService');
|
||||
log(' Video: $isVideoCall', name: 'CallNotificationService');
|
||||
|
||||
// Store pending call data
|
||||
_pendingCallData = {
|
||||
'callId': callId,
|
||||
'callerId': callerId,
|
||||
'callerName': callerName,
|
||||
'isVideoCall': isVideoCall,
|
||||
'moduleId': moduleId,
|
||||
'referenceId': referenceId,
|
||||
'conversationId': conversationId,
|
||||
'timestamp': DateTime.now().toIso8601String(),
|
||||
};
|
||||
|
||||
// Show CallKit/ConnectionService immediately
|
||||
await _showNativeIncomingCallUI(
|
||||
callId: callId,
|
||||
callerName: callerName,
|
||||
callerNumber: callerId,
|
||||
isVideo: isVideoCall,
|
||||
extra: _pendingCallData!,
|
||||
);
|
||||
log('✅ [INCOMING CALL] CallKit UI displayed', name: 'CallNotificationService');
|
||||
|
||||
// Use CallManager to handle the incoming call
|
||||
log('📞 [INCOMING CALL] Delegating to CallManager...', name: 'CallNotificationService');
|
||||
await CallManager().handleIncomingCallNotification(
|
||||
callId: callId,
|
||||
callerId: callerId,
|
||||
callerName: callerName,
|
||||
isVideoCall: isVideoCall,
|
||||
extraData: _pendingCallData,
|
||||
);
|
||||
|
||||
log('✅ [INCOMING CALL] Call handled successfully', name: 'CallNotificationService');
|
||||
log('═══════════════════════════════════════════', name: 'CallNotificationService');
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
log('❌ [INCOMING CALL] Error handling notification: $e',
|
||||
name: 'CallNotificationService', error: e, stackTrace: stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show native incoming call UI using CallKit/ConnectionService
|
||||
Future<void> _showNativeIncomingCallUI({
|
||||
required String callId,
|
||||
required String callerName,
|
||||
required String callerNumber,
|
||||
required bool isVideo,
|
||||
required Map<String, dynamic> extra,
|
||||
}) async {
|
||||
try {
|
||||
log('📱 [CALLKIT] Showing native incoming call UI...', name: 'CallNotificationService');
|
||||
log(' Call ID: $callId', name: 'CallNotificationService');
|
||||
log(' Caller: $callerName', name: 'CallNotificationService');
|
||||
log(' Video: $isVideo', name: 'CallNotificationService');
|
||||
|
||||
final params = CallKitParams(
|
||||
id: callId,
|
||||
nameCaller: callerName,
|
||||
appName: 'Atoms SA',
|
||||
handle: callerNumber,
|
||||
type: isVideo ? 1 : 0, // 0 = audio, 1 = video
|
||||
duration: 30000, // 30 seconds timeout
|
||||
extra: extra,
|
||||
headers: <String, dynamic>{'platform': 'flutter'},
|
||||
android: AndroidParams(
|
||||
isCustomNotification: true,
|
||||
isShowLogo: false,
|
||||
ringtonePath: 'system_ringtone_default',
|
||||
backgroundColor: '#0955fa',
|
||||
backgroundUrl: '',
|
||||
actionColor: '#4CAF50',
|
||||
incomingCallNotificationChannelName: 'Incoming Calls',
|
||||
missedCallNotificationChannelName: 'Missed Calls',
|
||||
),
|
||||
ios: IOSParams(
|
||||
iconName: 'CallKitLogo',
|
||||
handleType: 'generic',
|
||||
supportsVideo: true,
|
||||
maximumCallGroups: 2,
|
||||
maximumCallsPerCallGroup: 1,
|
||||
audioSessionMode: 'videoChat',
|
||||
audioSessionActive: true,
|
||||
audioSessionPreferredSampleRate: 44100.0,
|
||||
audioSessionPreferredIOBufferDuration: 0.005,
|
||||
supportsDTMF: true,
|
||||
supportsHolding: true,
|
||||
supportsGrouping: false,
|
||||
supportsUngrouping: false,
|
||||
ringtonePath: 'system_ringtone_default',
|
||||
),
|
||||
);
|
||||
|
||||
await FlutterCallkitIncoming.showCallkitIncoming(params);
|
||||
log('✅ [CALLKIT] Native UI displayed successfully', name: 'CallNotificationService');
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
log('❌ [CALLKIT] Error showing native UI: $e',
|
||||
name: 'CallNotificationService', error: e, stackTrace: stackTrace);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get pending call data (used when app wakes up)
|
||||
Map<String, dynamic>? get pendingCallData => _pendingCallData;
|
||||
|
||||
/// Clear pending call data
|
||||
void clearPendingCallData() {
|
||||
_pendingCallData = null;
|
||||
}
|
||||
|
||||
/// Mark a call as processed (for external use)
|
||||
void markCallAsProcessed(String callId) {
|
||||
_processedCallIds.add(callId);
|
||||
}
|
||||
|
||||
/// Check if a call has been processed
|
||||
bool isCallProcessed(String callId) {
|
||||
return _processedCallIds.contains(callId);
|
||||
}
|
||||
|
||||
/// Reset the service (for testing or logout)
|
||||
void reset() {
|
||||
_processedCallIds.clear();
|
||||
_pendingCallData = null;
|
||||
log('🔄 [RESET] CallNotificationService reset', name: 'CallNotificationService');
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,277 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:signalr_netcore/hub_connection.dart';
|
||||
import 'package:signalr_netcore/signalr_client.dart';
|
||||
import 'package:test_sa/controllers/api_routes/urls.dart';
|
||||
|
||||
/// Singleton SignalR service that manages the single HubConnection
|
||||
/// Both ChatProvider and CallManager use this service
|
||||
class SignalRService {
|
||||
static final SignalRService _instance = SignalRService._internal();
|
||||
factory SignalRService() => _instance;
|
||||
SignalRService._internal();
|
||||
|
||||
// Single HubConnection instance
|
||||
HubConnection? _hubConnection;
|
||||
|
||||
// Connection state
|
||||
bool _isInitializing = false;
|
||||
|
||||
// Authentication data
|
||||
String? _userId;
|
||||
String? _authToken;
|
||||
String? _currentConversationId;
|
||||
|
||||
// Event handler registrations
|
||||
final Map<String, List<Function(List<Object?>?)>> _eventHandlers = {};
|
||||
|
||||
/// Get the current HubConnection
|
||||
HubConnection? get hubConnection => _hubConnection;
|
||||
|
||||
/// Check if connected
|
||||
bool get isConnected => _hubConnection?.state == HubConnectionState.Connected;
|
||||
|
||||
/// Get current connection state
|
||||
HubConnectionState? get connectionState => _hubConnection?.state;
|
||||
|
||||
/// Initialize SignalR connection with authentication
|
||||
Future<bool> initialize({
|
||||
required String userId,
|
||||
required String authToken,
|
||||
String? conversationId,
|
||||
}) async {
|
||||
try {
|
||||
log('═══════════════════════════════════════════', name: 'SignalRService');
|
||||
log('🔌 [SIGNALR] Initializing SignalR connection...', name: 'SignalRService');
|
||||
log(' User ID: $userId', name: 'SignalRService');
|
||||
log(' Conversation ID: ${conversationId ?? "none"}', name: 'SignalRService');
|
||||
|
||||
// Prevent multiple simultaneous initializations
|
||||
if (_isInitializing) {
|
||||
log('⏳ [SIGNALR] Already initializing, waiting...', name: 'SignalRService');
|
||||
// Wait for current initialization to complete
|
||||
final timeout = DateTime.now().add(const Duration(seconds: 5));
|
||||
while (_isInitializing && DateTime.now().isBefore(timeout)) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
return isConnected;
|
||||
}
|
||||
|
||||
// If already connected with same credentials, reuse connection
|
||||
if (isConnected && _userId == userId && _authToken == authToken) {
|
||||
log('✅ [SIGNALR] Already connected with same credentials, reusing connection', name: 'SignalRService');
|
||||
|
||||
// Join new conversation if provided and different
|
||||
if (conversationId != null && conversationId != _currentConversationId) {
|
||||
await _joinConversation(conversationId);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
_isInitializing = true;
|
||||
|
||||
// Store credentials
|
||||
_userId = userId;
|
||||
_authToken = authToken;
|
||||
_currentConversationId = conversationId;
|
||||
|
||||
// Dispose existing connection if any
|
||||
await _disposeConnection();
|
||||
|
||||
// Create new connection
|
||||
final httpOp = HttpConnectionOptions(
|
||||
skipNegotiation: false,
|
||||
logMessageContent: kDebugMode,
|
||||
);
|
||||
|
||||
_hubConnection = HubConnectionBuilder()
|
||||
.withUrl(
|
||||
"${URLs.chatHubUrlChat}?UserId=$userId&source=Desktop&access_token=$authToken",
|
||||
options: httpOp,
|
||||
)
|
||||
.withAutomaticReconnect(retryDelays: <int>[2000, 5000, 10000, 20000])
|
||||
.build();
|
||||
|
||||
// Setup reconnection handlers
|
||||
_setupReconnectionHandlers();
|
||||
|
||||
// Start connection
|
||||
await _hubConnection!.start();
|
||||
|
||||
log('✅ [SIGNALR] Connection established', name: 'SignalRService');
|
||||
log(' Connection ID: ${_hubConnection!.connectionId}', name: 'SignalRService');
|
||||
|
||||
// Join conversation if provided
|
||||
if (conversationId != null) {
|
||||
await _joinConversation(conversationId);
|
||||
}
|
||||
|
||||
// Re-register all event handlers
|
||||
_reregisterAllHandlers();
|
||||
|
||||
_isInitializing = false;
|
||||
log('═══════════════════════════════════════════', name: 'SignalRService');
|
||||
|
||||
return true;
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
log('❌ [SIGNALR] Error initializing connection: $e',
|
||||
name: 'SignalRService', error: e, stackTrace: stackTrace);
|
||||
_isInitializing = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Setup reconnection handlers
|
||||
void _setupReconnectionHandlers() {
|
||||
if (_hubConnection == null) return;
|
||||
|
||||
_hubConnection!.onclose(({Exception? error}) {
|
||||
log('🔴 [SIGNALR] Connection closed: $error', name: 'SignalRService');
|
||||
});
|
||||
|
||||
_hubConnection!.onreconnecting(({Exception? error}) {
|
||||
log('🟡 [SIGNALR] Reconnecting: $error', name: 'SignalRService');
|
||||
});
|
||||
|
||||
_hubConnection!.onreconnected(({String? connectionId}) async {
|
||||
log('🟢 [SIGNALR] Reconnected: $connectionId', name: 'SignalRService');
|
||||
|
||||
// Rejoin conversation if we had one
|
||||
if (_currentConversationId != null) {
|
||||
await _joinConversation(_currentConversationId!);
|
||||
}
|
||||
|
||||
// Re-register all event handlers
|
||||
_reregisterAllHandlers();
|
||||
});
|
||||
}
|
||||
|
||||
/// Join a conversation
|
||||
Future<void> _joinConversation(String conversationId) async {
|
||||
try {
|
||||
if (_hubConnection?.state != HubConnectionState.Connected) {
|
||||
log('⚠️ [SIGNALR] Cannot join conversation - not connected', name: 'SignalRService');
|
||||
return;
|
||||
}
|
||||
|
||||
await _hubConnection!.invoke("JoinConversation", args: [conversationId]);
|
||||
_currentConversationId = conversationId;
|
||||
log('✅ [SIGNALR] Joined conversation: $conversationId', name: 'SignalRService');
|
||||
} catch (e) {
|
||||
log('❌ [SIGNALR] Error joining conversation: $e', name: 'SignalRService');
|
||||
}
|
||||
}
|
||||
|
||||
/// Register an event handler
|
||||
void on(String eventName, Function(List<Object?>?) handler) {
|
||||
log('🔧 [SIGNALR] Registering handler for: $eventName', name: 'SignalRService');
|
||||
|
||||
// Store handler for re-registration after reconnect
|
||||
if (!_eventHandlers.containsKey(eventName)) {
|
||||
_eventHandlers[eventName] = [];
|
||||
}
|
||||
_eventHandlers[eventName]!.add(handler);
|
||||
|
||||
// Register with SignalR if connected
|
||||
if (_hubConnection != null) {
|
||||
_hubConnection!.on(eventName, handler);
|
||||
}
|
||||
}
|
||||
|
||||
/// Unregister an event handler
|
||||
void off(String eventName, [Function(List<Object?>?)? handler]) {
|
||||
log('🔧 [SIGNALR] Unregistering handler for: $eventName', name: 'SignalRService');
|
||||
|
||||
if (handler != null) {
|
||||
_eventHandlers[eventName]?.remove(handler);
|
||||
if (_eventHandlers[eventName]?.isEmpty ?? false) {
|
||||
_eventHandlers.remove(eventName);
|
||||
}
|
||||
} else {
|
||||
_eventHandlers.remove(eventName);
|
||||
}
|
||||
|
||||
// Unregister from SignalR if connected
|
||||
if (_hubConnection != null) {
|
||||
_hubConnection!.off(eventName, method: handler);
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-register all event handlers (after reconnection)
|
||||
void _reregisterAllHandlers() {
|
||||
if (_hubConnection == null) return;
|
||||
|
||||
log('🔄 [SIGNALR] Re-registering ${_eventHandlers.length} event handlers...', name: 'SignalRService');
|
||||
|
||||
for (final entry in _eventHandlers.entries) {
|
||||
final eventName = entry.key;
|
||||
final handlers = entry.value;
|
||||
|
||||
for (final handler in handlers) {
|
||||
_hubConnection!.on(eventName, handler);
|
||||
}
|
||||
}
|
||||
|
||||
log('✅ [SIGNALR] All event handlers re-registered', name: 'SignalRService');
|
||||
}
|
||||
|
||||
/// Invoke a SignalR method
|
||||
Future<Object?> invoke(String methodName, {List<Object>? args}) async {
|
||||
if (_hubConnection?.state != HubConnectionState.Connected) {
|
||||
throw Exception('SignalR not connected. Current state: ${_hubConnection?.state}');
|
||||
}
|
||||
|
||||
log('📤 [SIGNALR] Invoking: $methodName', name: 'SignalRService');
|
||||
return await _hubConnection!.invoke(methodName, args: args);
|
||||
}
|
||||
|
||||
/// Ensure connection is ready (connect if needed)
|
||||
Future<bool> ensureConnected() async {
|
||||
if (isConnected) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_userId != null && _authToken != null) {
|
||||
return await initialize(
|
||||
userId: _userId!,
|
||||
authToken: _authToken!,
|
||||
conversationId: _currentConversationId,
|
||||
);
|
||||
}
|
||||
|
||||
log('❌ [SIGNALR] Cannot reconnect - no credentials stored', name: 'SignalRService');
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Dispose connection
|
||||
Future<void> _disposeConnection() async {
|
||||
try {
|
||||
if (_hubConnection != null) {
|
||||
await _hubConnection!.stop();
|
||||
_hubConnection = null;
|
||||
log('✅ [SIGNALR] Connection disposed', name: 'SignalRService');
|
||||
}
|
||||
} catch (e) {
|
||||
log('⚠️ [SIGNALR] Error disposing connection: $e', name: 'SignalRService');
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset service (for logout)
|
||||
Future<void> reset() async {
|
||||
log('🔄 [SIGNALR] Resetting service...', name: 'SignalRService');
|
||||
|
||||
await _disposeConnection();
|
||||
|
||||
_eventHandlers.clear();
|
||||
_userId = null;
|
||||
_authToken = null;
|
||||
_currentConversationId = null;
|
||||
_isInitializing = false;
|
||||
|
||||
log('✅ [SIGNALR] Service reset complete', name: 'SignalRService');
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue