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