|
|
|
|
|
import 'dart:async';
|
|
|
|
|
|
import 'dart:convert';
|
|
|
|
|
|
import 'dart:developer';
|
|
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
|
|
|
|
import 'package:provider/provider.dart';
|
|
|
|
|
|
import 'package:test_sa/main.dart';
|
|
|
|
|
|
import 'package:test_sa/modules/cx_module/chat/call/audio_call_page.dart';
|
|
|
|
|
|
import 'package:test_sa/modules/cx_module/chat/call/video_call_page.dart';
|
|
|
|
|
|
import 'package:test_sa/modules/cx_module/chat/call/services/webrtc_service.dart';
|
|
|
|
|
|
import 'package:test_sa/modules/cx_module/chat/call/services/callkit_service.dart';
|
|
|
|
|
|
import 'package:test_sa/modules/cx_module/chat/call/call_error_handler.dart';
|
|
|
|
|
|
import 'package:test_sa/modules/cx_module/chat/model/call_session.dart';
|
|
|
|
|
|
import 'package:test_sa/modules/cx_module/chat/services/signalr_service.dart';
|
|
|
|
|
|
import 'package:test_sa/modules/cx_module/chat/chat_provider.dart';
|
|
|
|
|
|
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
|
|
|
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
|
|
import 'package:test_sa/core/di/service_locator.dart';
|
|
|
|
|
|
|
|
|
|
|
|
class CallManager extends ChangeNotifier {
|
|
|
|
|
|
static final CallManager _instance = CallManager._internal();
|
|
|
|
|
|
factory CallManager() => _instance;
|
|
|
|
|
|
CallManager._internal() {
|
|
|
|
|
|
log('🏗️ [CallManager] Singleton instance created (hashCode: $hashCode)', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Services - Retrieved from DI
|
|
|
|
|
|
SignalRService get _signalRService => getIt<SignalRService>();
|
|
|
|
|
|
final CallKitService _callKitService = CallKitService();
|
|
|
|
|
|
WebRTCService? _webrtcService;
|
|
|
|
|
|
|
|
|
|
|
|
// Call state
|
|
|
|
|
|
CallSession? _currentCall;
|
|
|
|
|
|
CallStatus _callStatus = CallStatus.idle;
|
|
|
|
|
|
Duration _callDuration = Duration.zero;
|
|
|
|
|
|
Timer? _callDurationTimer;
|
|
|
|
|
|
Timer? _callTimeoutTimer;
|
|
|
|
|
|
|
|
|
|
|
|
// Call controls
|
|
|
|
|
|
bool _isMuted = false;
|
|
|
|
|
|
bool _isSpeakerOn = false;
|
|
|
|
|
|
bool _isCameraOn = true;
|
|
|
|
|
|
bool _isPeerMuted = false;
|
|
|
|
|
|
bool _isPeerCameraOn = true;
|
|
|
|
|
|
|
|
|
|
|
|
// Initialization state
|
|
|
|
|
|
bool _callKitInitialized = false;
|
|
|
|
|
|
bool _handlersRegistered = false;
|
|
|
|
|
|
|
|
|
|
|
|
// Module context (for SignalR invocations)
|
|
|
|
|
|
String? _userId;
|
|
|
|
|
|
String? _authToken;
|
|
|
|
|
|
String? _moduleId;
|
|
|
|
|
|
String? _referenceId;
|
|
|
|
|
|
String? _conversationId;
|
|
|
|
|
|
String? _myEmployeeNumber;
|
|
|
|
|
|
|
|
|
|
|
|
// CRITICAL FIX: Static credential cache for background calls
|
|
|
|
|
|
// This allows incoming calls to work even when app is in background
|
|
|
|
|
|
static String? _cachedUserId;
|
|
|
|
|
|
static String? _cachedAuthToken;
|
|
|
|
|
|
static String? _cachedEmployeeNumber;
|
|
|
|
|
|
|
|
|
|
|
|
// Getters
|
|
|
|
|
|
CallSession? get currentCall => _currentCall;
|
|
|
|
|
|
CallStatus get callStatus => _callStatus;
|
|
|
|
|
|
Duration get callDuration => _callDuration;
|
|
|
|
|
|
bool get isMuted => _isMuted;
|
|
|
|
|
|
bool get isSpeakerOn => _isSpeakerOn;
|
|
|
|
|
|
bool get isCameraOn => _isCameraOn;
|
|
|
|
|
|
bool get isPeerMuted => _isPeerMuted;
|
|
|
|
|
|
bool get isPeerCameraOn => _isPeerCameraOn;
|
|
|
|
|
|
WebRTCService? get webrtcService => _webrtcService;
|
|
|
|
|
|
bool get isCallInProgress => _callStatus != CallStatus.idle;
|
|
|
|
|
|
|
|
|
|
|
|
/// Initialize CallManager with user credentials
|
|
|
|
|
|
Future<void> initialize({
|
|
|
|
|
|
required String userId,
|
|
|
|
|
|
required String authToken,
|
|
|
|
|
|
String? conversationId,
|
|
|
|
|
|
String? moduleId,
|
|
|
|
|
|
String? referenceId,
|
|
|
|
|
|
String? employeeNumber,
|
|
|
|
|
|
}) async {
|
|
|
|
|
|
try {
|
|
|
|
|
|
log('🔧 [CallManager] Initializing...', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// CRITICAL FIX: Cache credentials for background call handling
|
|
|
|
|
|
_cachedUserId = userId;
|
|
|
|
|
|
_cachedAuthToken = authToken;
|
|
|
|
|
|
_cachedEmployeeNumber = employeeNumber;
|
|
|
|
|
|
|
|
|
|
|
|
// CRITICAL FIX: Don't re-initialize if already initialized with same USER credentials
|
|
|
|
|
|
// conversationId can change (different chats) - that's OK, just update it
|
|
|
|
|
|
// Only re-initialize if USER changes (userId, authToken, employeeNumber)
|
|
|
|
|
|
// Compare employee numbers case-insitively (backend sometimes changes case)
|
|
|
|
|
|
if (_userId == userId &&
|
|
|
|
|
|
_authToken == authToken &&
|
|
|
|
|
|
_myEmployeeNumber?.toLowerCase() == employeeNumber?.toLowerCase() &&
|
|
|
|
|
|
_handlersRegistered) {
|
|
|
|
|
|
|
|
|
|
|
|
// User is the same, just update conversation context without reconnecting
|
|
|
|
|
|
final conversationChanged = _conversationId != conversationId;
|
|
|
|
|
|
final moduleChanged = _moduleId != moduleId;
|
|
|
|
|
|
final referenceChanged = _referenceId != referenceId;
|
|
|
|
|
|
|
|
|
|
|
|
if (conversationChanged || moduleChanged || referenceChanged) {
|
|
|
|
|
|
log('🔄 [CallManager] Updating context (no reconnection needed)', name: 'CallManager');
|
|
|
|
|
|
log(' Conversation: $_conversationId -> $conversationId', name: 'CallManager');
|
|
|
|
|
|
log(' Module: $_moduleId -> $moduleId', name: 'CallManager');
|
|
|
|
|
|
log(' Reference: $_referenceId -> $referenceId', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// Update context
|
|
|
|
|
|
_conversationId = conversationId;
|
|
|
|
|
|
_moduleId = moduleId;
|
|
|
|
|
|
_referenceId = referenceId;
|
|
|
|
|
|
|
|
|
|
|
|
// Update SignalR conversation only if needed
|
|
|
|
|
|
if (conversationChanged && conversationId != null) {
|
|
|
|
|
|
await _signalRService.initialize(
|
|
|
|
|
|
userId: userId,
|
|
|
|
|
|
authToken: authToken,
|
|
|
|
|
|
conversationId: conversationId,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
log('✅ [CallManager] Already initialized with same credentials - skipping', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Update employee number if case changed (keep latest version)
|
|
|
|
|
|
if (_myEmployeeNumber != employeeNumber) {
|
|
|
|
|
|
log('🔄 [CallManager] Updating employee number case: $_myEmployeeNumber -> $employeeNumber', name: 'CallManager');
|
|
|
|
|
|
_myEmployeeNumber = employeeNumber;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
log(' User ID: $userId', name: 'CallManager');
|
|
|
|
|
|
log(' Employee Number: $employeeNumber', name: 'CallManager');
|
|
|
|
|
|
log(' Conversation ID: $conversationId', name: 'CallManager');
|
|
|
|
|
|
log(' SignalR state: ${_signalRService.connectionState}', name: 'CallManager');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
log('🔄 [CallManager] User changed - full re-initialization', name: 'CallManager');
|
|
|
|
|
|
log(' Previous User: $_userId -> New: $userId', name: 'CallManager');
|
|
|
|
|
|
log(' Previous Employee: $_myEmployeeNumber -> New: $employeeNumber', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// Store module context
|
|
|
|
|
|
_userId = userId;
|
|
|
|
|
|
_authToken = authToken;
|
|
|
|
|
|
_moduleId = moduleId;
|
|
|
|
|
|
_referenceId = referenceId;
|
|
|
|
|
|
_conversationId = conversationId;
|
|
|
|
|
|
_myEmployeeNumber = employeeNumber;
|
|
|
|
|
|
|
|
|
|
|
|
// Initialize SignalR connection
|
|
|
|
|
|
final connected = await _signalRService.initialize(
|
|
|
|
|
|
userId: userId,
|
|
|
|
|
|
authToken: authToken,
|
|
|
|
|
|
conversationId: conversationId,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if (!connected) {
|
|
|
|
|
|
throw Exception('Failed to initialize SignalR connection');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Initialize CallKit
|
|
|
|
|
|
if (!_callKitInitialized) {
|
|
|
|
|
|
await _initializeCallKit();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Register call event handlers
|
|
|
|
|
|
if (!_handlersRegistered) {
|
|
|
|
|
|
_registerCallHandlers();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
log('✅ [CallManager] Initialized successfully', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
} catch (e, stackTrace) {
|
|
|
|
|
|
log('❌ [CallManager] Initialization error: $e',
|
|
|
|
|
|
name: 'CallManager', error: e, stackTrace: stackTrace);
|
|
|
|
|
|
rethrow;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Initialize CallKit service
|
|
|
|
|
|
Future<void> _initializeCallKit() async {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await _callKitService.initialize();
|
|
|
|
|
|
|
|
|
|
|
|
// Setup CallKit callbacks
|
|
|
|
|
|
_callKitService.onCallAccepted = _handleCallKitAccepted;
|
|
|
|
|
|
_callKitService.onCallDeclined = _handleCallKitDeclined;
|
|
|
|
|
|
_callKitService.onCallEnded = _handleCallKitEnded;
|
|
|
|
|
|
_callKitService.onCallTimeout = _handleCallKitTimeout;
|
|
|
|
|
|
|
|
|
|
|
|
_callKitInitialized = true;
|
|
|
|
|
|
|
|
|
|
|
|
} catch (e, stackTrace) {
|
|
|
|
|
|
log('❌ [CallKit] Initialization error: $e',
|
|
|
|
|
|
name: 'CallManager', error: e, stackTrace: stackTrace);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Register SignalR call event handlers
|
|
|
|
|
|
void _registerCallHandlers() {
|
|
|
|
|
|
_signalRService.on('OnIncomingCallAsync', _handleIncomingCall);
|
|
|
|
|
|
_signalRService.on('OnCallAcceptedAsync', _handleCallAccepted);
|
|
|
|
|
|
_signalRService.on('OnCallDeclinedAsync', _handleCallDeclined);
|
|
|
|
|
|
_signalRService.on('OnHangUpAsync', _handleHangUp);
|
|
|
|
|
|
_signalRService.on('OnOfferAsync', _handleOffer);
|
|
|
|
|
|
_signalRService.on('OnAnswerOfferAsync', _handleAnswer);
|
|
|
|
|
|
_signalRService.on('OnIceCandidateAsync', _handleIceCandidate);
|
|
|
|
|
|
_signalRService.on('OnAudioToggle', _handleAudioToggle);
|
|
|
|
|
|
_signalRService.on('OnCameraToggle', _handleCameraToggle);
|
|
|
|
|
|
_signalRService.on('OnUserOnlineAsync', _handleUserOnline);
|
|
|
|
|
|
_signalRService.on('OnUserOfflineAsync', _handleUserOffline);
|
|
|
|
|
|
_signalRService.on('OnCallHistoryUpdated', _handleCallHistoryUpdated);
|
|
|
|
|
|
_signalRService.on('OnError', _handleError);
|
|
|
|
|
|
|
|
|
|
|
|
_handlersRegistered = true;
|
|
|
|
|
|
log('✅ [CallManager] Event handlers registered (13 events)', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Start outgoing call
|
|
|
|
|
|
Future<void> startCall({
|
|
|
|
|
|
required String peerId,
|
|
|
|
|
|
required String peerName,
|
|
|
|
|
|
required CallType callType,
|
|
|
|
|
|
String? peerAvatar,
|
|
|
|
|
|
}) async {
|
|
|
|
|
|
try {
|
|
|
|
|
|
log('═══════════════════════════════════════════', name: 'CallManager');
|
|
|
|
|
|
log('📞 [OUTGOING] Starting ${callType.name} call to $peerName ($peerId)', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// CRITICAL: Verify CallManager is initialized
|
|
|
|
|
|
if (_userId == null || _authToken == null) {
|
|
|
|
|
|
log('❌ [OUTGOING] CallManager not initialized!', name: 'CallManager');
|
|
|
|
|
|
log(' _userId: ${_userId ?? "NULL"}', name: 'CallManager');
|
|
|
|
|
|
log(' _authToken: ${_authToken != null ? "SET" : "NULL"}', name: 'CallManager');
|
|
|
|
|
|
final context = navigatorKey.currentContext;
|
|
|
|
|
|
if (context != null) {
|
|
|
|
|
|
CallErrorHandler.showGenericError(context, 'Call system not ready. Please try again.');
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// CRITICAL: Verify handlers are registered
|
|
|
|
|
|
if (!_handlersRegistered) {
|
|
|
|
|
|
log('❌ [OUTGOING] Call handlers not registered!', name: 'CallManager');
|
|
|
|
|
|
log(' Attempting to register handlers now...', name: 'CallManager');
|
|
|
|
|
|
_registerCallHandlers();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
log('✅ [OUTGOING] CallManager initialized', name: 'CallManager');
|
|
|
|
|
|
log(' User ID: $_userId', name: 'CallManager');
|
|
|
|
|
|
log(' Employee Number: $_myEmployeeNumber', name: 'CallManager');
|
|
|
|
|
|
log(' Handlers registered: $_handlersRegistered', name: 'CallManager');
|
|
|
|
|
|
log(' SignalR state: ${_signalRService.connectionState}', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// Update status
|
|
|
|
|
|
_updateCallStatus(CallStatus.checkingPermissions);
|
|
|
|
|
|
log('🔍 [OUTGOING] Checking permissions...', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// Check permissions
|
|
|
|
|
|
if (!await _checkPermissions(callType)) {
|
|
|
|
|
|
log('❌ [OUTGOING] Permissions denied', name: 'CallManager');
|
|
|
|
|
|
_updateCallStatus(CallStatus.idle);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
log('✅ [OUTGOING] Permissions granted', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// CRITICAL: Ensure SignalR is connected before every invocation
|
|
|
|
|
|
_updateCallStatus(CallStatus.connecting);
|
|
|
|
|
|
log('🔌 [OUTGOING] Ensuring SignalR connection...', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
if (!await _signalRService.ensureConnected()) {
|
|
|
|
|
|
log('❌ [OUTGOING] SignalR connection failed', name: 'CallManager');
|
|
|
|
|
|
_updateCallStatus(CallStatus.idle);
|
|
|
|
|
|
final context = navigatorKey.currentContext;
|
|
|
|
|
|
if (context != null) {
|
|
|
|
|
|
CallErrorHandler.showSignalRNotConnected(context);
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
log('✅ [OUTGOING] SignalR connected - state: ${_signalRService.connectionState}', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// Generate call ID
|
|
|
|
|
|
final callId = const Uuid().v4();
|
|
|
|
|
|
log('🆔 [OUTGOING] Generated call ID: $callId', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// Create call session
|
|
|
|
|
|
_currentCall = CallSession(
|
|
|
|
|
|
callId: callId,
|
|
|
|
|
|
type: callType,
|
|
|
|
|
|
direction: CallDirection.outgoing,
|
|
|
|
|
|
peerId: peerId,
|
|
|
|
|
|
peerName: peerName,
|
|
|
|
|
|
peerAvatar: peerAvatar,
|
|
|
|
|
|
startTime: DateTime.now(),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Invoke CallUserAsync
|
|
|
|
|
|
log('📤 [OUTGOING] Invoking CallUserAsync...', name: 'CallManager');
|
|
|
|
|
|
log(' From: ${_myEmployeeNumber ?? ""}', name: 'CallManager');
|
|
|
|
|
|
log(' To: $peerId', name: 'CallManager');
|
|
|
|
|
|
log(' Video: ${callType == CallType.video}', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
await _signalRService.invoke(
|
|
|
|
|
|
'CallUserAsync',
|
|
|
|
|
|
args: [
|
|
|
|
|
|
_myEmployeeNumber ?? '',
|
|
|
|
|
|
peerId,
|
|
|
|
|
|
callType == CallType.video,
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
log('✅ [OUTGOING] CallUserAsync invoked successfully', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// CRITICAL FIX: Check if call was declined/cancelled while we were waiting
|
|
|
|
|
|
if (_currentCall?.callId != callId || _callStatus == CallStatus.idle) {
|
|
|
|
|
|
log('⚠️ [OUTGOING] Call was cancelled/declined during setup - aborting', name: 'CallManager');
|
|
|
|
|
|
log(' Current call ID: ${_currentCall?.callId}', name: 'CallManager');
|
|
|
|
|
|
log(' Expected call ID: $callId', name: 'CallManager');
|
|
|
|
|
|
log(' Current status: ${_callStatus.name}', name: 'CallManager');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_updateCallStatus(CallStatus.outgoingRinging);
|
|
|
|
|
|
|
|
|
|
|
|
// Initialize WebRTC
|
|
|
|
|
|
log('🔧 [OUTGOING] Initializing WebRTC...', name: 'CallManager');
|
|
|
|
|
|
await _initializeWebRTC();
|
|
|
|
|
|
log('✅ [OUTGOING] WebRTC initialized', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// CRITICAL FIX: Check again after WebRTC initialization
|
|
|
|
|
|
if (_currentCall?.callId != callId || _callStatus == CallStatus.idle) {
|
|
|
|
|
|
log('⚠️ [OUTGOING] Call was cancelled/declined after WebRTC init - aborting', name: 'CallManager');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Start timeout timer (60 seconds for outgoing calls)
|
|
|
|
|
|
_callTimeoutTimer = Timer(const Duration(seconds: 60), () {
|
|
|
|
|
|
if (_callStatus == CallStatus.outgoingRinging) {
|
|
|
|
|
|
log('⏱️ [OUTGOING] Call timeout - no answer after 60s', name: 'CallManager');
|
|
|
|
|
|
_cleanup();
|
|
|
|
|
|
final context = navigatorKey.currentContext;
|
|
|
|
|
|
if (context != null) {
|
|
|
|
|
|
CallErrorHandler.showGenericError(context, 'Call timeout - no answer');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
log('⏱️ [OUTGOING] Timeout timer started (60s)', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// Navigate to call screen
|
|
|
|
|
|
_navigateToCallScreen();
|
|
|
|
|
|
log('✅ [OUTGOING] Navigated to call screen', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
log('✅ [OUTGOING] Call started successfully', name: 'CallManager');
|
|
|
|
|
|
log('═══════════════════════════════════════════', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
} catch (e, stackTrace) {
|
|
|
|
|
|
log('❌ [OUTGOING] Error starting call: $e',
|
|
|
|
|
|
name: 'CallManager', error: e, stackTrace: stackTrace);
|
|
|
|
|
|
_cleanup();
|
|
|
|
|
|
|
|
|
|
|
|
final context = navigatorKey.currentContext;
|
|
|
|
|
|
if (context != null) {
|
|
|
|
|
|
CallErrorHandler.showGenericError(context, 'Failed to start call: $e');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Handle incoming call from notification
|
|
|
|
|
|
Future<void> handleIncomingCallNotification({
|
|
|
|
|
|
required String callId,
|
|
|
|
|
|
required String callerId,
|
|
|
|
|
|
required String callerName,
|
|
|
|
|
|
required bool isVideoCall,
|
|
|
|
|
|
Map<String, dynamic>? extraData,
|
|
|
|
|
|
}) async {
|
|
|
|
|
|
try {
|
|
|
|
|
|
log('═══════════════════════════════════════════', name: 'CallManager');
|
|
|
|
|
|
log('📞 [INCOMING] Handling incoming call notification', name: 'CallManager');
|
|
|
|
|
|
log(' Call ID: $callId', name: 'CallManager');
|
|
|
|
|
|
log(' Caller: $callerName ($callerId)', name: 'CallManager');
|
|
|
|
|
|
log(' Video: $isVideoCall', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// CRITICAL FIX: Auto-initialize CallManager if not already initialized
|
|
|
|
|
|
// This handles the case when app is in background and context is null
|
|
|
|
|
|
if (_userId == null || _authToken == null) {
|
|
|
|
|
|
log('⚠️ [INCOMING] CallManager not initialized - attempting auto-initialization...', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// CRITICAL FIX: Try cached credentials first (for background calls)
|
|
|
|
|
|
if (_cachedUserId != null && _cachedAuthToken != null && _cachedEmployeeNumber != null) {
|
|
|
|
|
|
log('🔧 [INCOMING] Using cached credentials (app in background)', name: 'CallManager');
|
|
|
|
|
|
log(' Cached User ID: $_cachedUserId', name: 'CallManager');
|
|
|
|
|
|
log(' Cached Employee Number: $_cachedEmployeeNumber', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
await initialize(
|
|
|
|
|
|
userId: _cachedUserId!,
|
|
|
|
|
|
authToken: _cachedAuthToken!,
|
|
|
|
|
|
conversationId: extraData?['conversationId'] as String?,
|
|
|
|
|
|
moduleId: extraData?['moduleId'] as String?,
|
|
|
|
|
|
referenceId: extraData?['referenceId'] as String?,
|
|
|
|
|
|
employeeNumber: _cachedEmployeeNumber,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
log('✅ [INCOMING] Auto-initialization successful (from cache)', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
// Try to get credentials from ChatProvider (app in foreground)
|
|
|
|
|
|
else if (navigatorKey.currentContext != null) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
final chatProvider = Provider.of<ChatProvider>(navigatorKey.currentContext!, listen: false);
|
|
|
|
|
|
final chatLoginResponse = chatProvider.chatLoginResponse;
|
|
|
|
|
|
final sender = chatProvider.sender; // Get sender for employee number
|
|
|
|
|
|
|
|
|
|
|
|
if (chatLoginResponse != null) {
|
|
|
|
|
|
log('🔧 [INCOMING] Found credentials in ChatProvider', name: 'CallManager');
|
|
|
|
|
|
log(' User ID: ${chatLoginResponse.userId}', name: 'CallManager');
|
|
|
|
|
|
log(' Employee Number: ${sender?.employeeNumber ?? "NOT AVAILABLE"}', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
await initialize(
|
|
|
|
|
|
userId: chatLoginResponse.userId.toString(),
|
|
|
|
|
|
authToken: chatLoginResponse.token ?? '',
|
|
|
|
|
|
conversationId: extraData?['conversationId'] as String?,
|
|
|
|
|
|
moduleId: extraData?['moduleId'] as String?,
|
|
|
|
|
|
referenceId: extraData?['referenceId'] as String?,
|
|
|
|
|
|
employeeNumber: sender?.employeeNumber, // Get from sender participant
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
log('✅ [INCOMING] Auto-initialization successful (from ChatProvider)', name: 'CallManager');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
log('❌ [INCOMING] No credentials available', name: 'CallManager');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
log('❌ [INCOMING] Auto-initialization failed: $e', name: 'CallManager');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
log('❌ [INCOMING] No cached credentials and no context available', name: 'CallManager');
|
|
|
|
|
|
log(' This means user never logged into chat in this session', name: 'CallManager');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Check if already in call
|
|
|
|
|
|
if (_callStatus != CallStatus.idle) {
|
|
|
|
|
|
log('⚠️ [INCOMING] Already in a call - declining with CALLER_BUSY', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// Ensure SignalR is connected before declining
|
|
|
|
|
|
if (!await _signalRService.ensureConnected()) {
|
|
|
|
|
|
log('❌ [INCOMING] Cannot decline - SignalR not connected', name: 'CallManager');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await _signalRService.invoke('CallDeclinedAsync', args: [
|
|
|
|
|
|
_myEmployeeNumber ?? '',
|
|
|
|
|
|
callerId,
|
|
|
|
|
|
'CALLER_BUSY',
|
|
|
|
|
|
]);
|
|
|
|
|
|
log('✅ [INCOMING] Declined with CALLER_BUSY', name: 'CallManager');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Ensure SignalR is connected
|
|
|
|
|
|
log('🔌 [INCOMING] Ensuring SignalR connection...', name: 'CallManager');
|
|
|
|
|
|
if (!await _signalRService.ensureConnected()) {
|
|
|
|
|
|
log('❌ [INCOMING] SignalR connection failed - cannot handle call', name: 'CallManager');
|
|
|
|
|
|
final context = navigatorKey.currentContext;
|
|
|
|
|
|
if (context != null) {
|
|
|
|
|
|
CallErrorHandler.showSignalRNotConnected(context);
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
log('✅ [INCOMING] SignalR connected', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// Create call session
|
|
|
|
|
|
_currentCall = CallSession(
|
|
|
|
|
|
callId: callId,
|
|
|
|
|
|
type: isVideoCall ? CallType.video : CallType.audio,
|
|
|
|
|
|
direction: CallDirection.incoming,
|
|
|
|
|
|
peerId: callerId,
|
|
|
|
|
|
peerName: callerName,
|
|
|
|
|
|
peerAvatar: null,
|
|
|
|
|
|
startTime: DateTime.now(),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
_updateCallStatus(CallStatus.incomingRinging);
|
|
|
|
|
|
log('✅ [INCOMING] Call session created', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// Initialize CallKit if not already initialized
|
|
|
|
|
|
if (!_callKitInitialized) {
|
|
|
|
|
|
await _initializeCallKit();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Show CallKit UI
|
|
|
|
|
|
log('📱 [INCOMING] Showing CallKit UI...', name: 'CallManager');
|
|
|
|
|
|
await _callKitService.showIncomingCall(
|
|
|
|
|
|
callId: callId,
|
|
|
|
|
|
callerName: callerName,
|
|
|
|
|
|
callerNumber: callerId,
|
|
|
|
|
|
isVideo: isVideoCall,
|
|
|
|
|
|
extra: extraData,
|
|
|
|
|
|
);
|
|
|
|
|
|
log('✅ [INCOMING] CallKit UI displayed', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// Start timeout timer (30 seconds for incoming calls)
|
|
|
|
|
|
_callTimeoutTimer = Timer(const Duration(seconds: 30), () {
|
|
|
|
|
|
if (_callStatus == CallStatus.incomingRinging) {
|
|
|
|
|
|
log('⏱️ [INCOMING] Call timeout - no answer after 30s', name: 'CallManager');
|
|
|
|
|
|
_handleCallTimeout();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
log('⏱️ [INCOMING] Timeout timer started (30s)', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
log('✅ [INCOMING] Incoming call handled successfully', name: 'CallManager');
|
|
|
|
|
|
log('═══════════════════════════════════════════', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
} catch (e, stackTrace) {
|
|
|
|
|
|
log('❌ [INCOMING] Error handling incoming call: $e',
|
|
|
|
|
|
name: 'CallManager', error: e, stackTrace: stackTrace);
|
|
|
|
|
|
_cleanup();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Handle call timeout (for incoming calls)
|
|
|
|
|
|
Future<void> _handleCallTimeout() async {
|
|
|
|
|
|
try {
|
|
|
|
|
|
log('⏱️ [TIMEOUT] Handling call timeout...', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
if (_currentCall == null) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Invoke CallMissedAsync
|
|
|
|
|
|
if (await _signalRService.ensureConnected()) {
|
|
|
|
|
|
await _signalRService.invoke('CallMissedAsync', args: [
|
|
|
|
|
|
_myEmployeeNumber ?? '',
|
|
|
|
|
|
_currentCall!.peerId,
|
|
|
|
|
|
]);
|
|
|
|
|
|
log('✅ [TIMEOUT] CallMissedAsync invoked', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_cleanup();
|
|
|
|
|
|
} catch (e, stackTrace) {
|
|
|
|
|
|
log('❌ [TIMEOUT] Error: $e', name: 'CallManager', error: e, stackTrace: stackTrace);
|
|
|
|
|
|
_cleanup();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Accept incoming call
|
|
|
|
|
|
Future<void> acceptCall() async {
|
|
|
|
|
|
try {
|
|
|
|
|
|
log('═══════════════════════════════════════════', name: 'CallManager');
|
|
|
|
|
|
log('✅ [ACCEPT] Accepting call...', name: 'CallManager');
|
|
|
|
|
|
log('🔍 [ACCEPT] CODE VERSION: 2026-07-19-v3-DEBUG', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
if (_currentCall == null || _callStatus != CallStatus.incomingRinging) {
|
|
|
|
|
|
log('⚠️ [ACCEPT] No incoming call to accept', name: 'CallManager');
|
|
|
|
|
|
log(' Current call: ${_currentCall?.callId}', name: 'CallManager');
|
|
|
|
|
|
log(' Current status: ${_callStatus.name}', name: 'CallManager');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
log('📊 [ACCEPT] Current call state:', name: 'CallManager');
|
|
|
|
|
|
log(' Call ID: ${_currentCall!.callId}', name: 'CallManager');
|
|
|
|
|
|
log(' Peer ID: ${_currentCall!.peerId}', name: 'CallManager');
|
|
|
|
|
|
log(' Peer Name: ${_currentCall!.peerName}', name: 'CallManager');
|
|
|
|
|
|
log(' Call Type: ${_currentCall!.type.name}', name: 'CallManager');
|
|
|
|
|
|
log(' Direction: ${_currentCall!.direction.name}', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// Check permissions
|
|
|
|
|
|
log('🔍 [ACCEPT] Checking permissions...', name: 'CallManager');
|
|
|
|
|
|
if (!await _checkPermissions(_currentCall!.type)) {
|
|
|
|
|
|
log('❌ [ACCEPT] Permissions denied', name: 'CallManager');
|
|
|
|
|
|
await declineCall('permission_denied');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
log('✅ [ACCEPT] Permissions granted', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// Ensure SignalR connected
|
|
|
|
|
|
log('🔌 [ACCEPT] Ensuring SignalR connection...', name: 'CallManager');
|
|
|
|
|
|
log(' Current SignalR state: ${_signalRService.connectionState}', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
if (!await _signalRService.ensureConnected()) {
|
|
|
|
|
|
log('❌ [ACCEPT] SignalR connection failed', name: 'CallManager');
|
|
|
|
|
|
log(' Final state: ${_signalRService.connectionState}', name: 'CallManager');
|
|
|
|
|
|
_cleanup();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
log('✅ [ACCEPT] SignalR connected', name: 'CallManager');
|
|
|
|
|
|
log(' SignalR state: ${_signalRService.connectionState}', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
_updateCallStatus(CallStatus.connecting);
|
|
|
|
|
|
|
|
|
|
|
|
// Cancel timeout timer
|
|
|
|
|
|
_callTimeoutTimer?.cancel();
|
|
|
|
|
|
log('✅ [ACCEPT] Timeout timer cancelled', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// Invoke AnswerCallAsync
|
|
|
|
|
|
log('📤 [ACCEPT] Invoking AnswerCallAsync...', name: 'CallManager');
|
|
|
|
|
|
log(' My Employee Number: ${_myEmployeeNumber ?? "NULL"}', name: 'CallManager');
|
|
|
|
|
|
log(' Peer ID: ${_currentCall!.peerId}', name: 'CallManager');
|
|
|
|
|
|
log(' Module ID: ${_moduleId ?? "0"}', name: 'CallManager');
|
|
|
|
|
|
log(' Reference ID: ${_referenceId ?? "0"}', name: 'CallManager');
|
|
|
|
|
|
log(' Conversation ID: ${_conversationId ?? ""}', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
await _signalRService.invoke('AnswerCallAsync', args: [
|
|
|
|
|
|
_myEmployeeNumber ?? '',
|
|
|
|
|
|
_currentCall!.peerId,
|
|
|
|
|
|
_moduleId ?? '0',
|
|
|
|
|
|
_referenceId ?? '0',
|
|
|
|
|
|
_conversationId ?? '',
|
|
|
|
|
|
]);
|
|
|
|
|
|
log('✅ [ACCEPT] AnswerCallAsync invoked successfully', name: 'CallManager');
|
|
|
|
|
|
log(' Waiting for backend to process...', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// Initialize WebRTC
|
|
|
|
|
|
log('🔧 [ACCEPT] Initializing WebRTC...', name: 'CallManager');
|
|
|
|
|
|
log(' Call type: ${_currentCall!.type.name}', name: 'CallManager');
|
|
|
|
|
|
await _initializeWebRTC();
|
|
|
|
|
|
log('✅ [ACCEPT] WebRTC initialized', name: 'CallManager');
|
|
|
|
|
|
log(' WebRTC service: ${_webrtcService != null ? "Created" : "NULL"}', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// Navigate to call screen
|
|
|
|
|
|
log('🧭 [ACCEPT] Navigating to call screen...', name: 'CallManager');
|
|
|
|
|
|
_navigateToCallScreen();
|
|
|
|
|
|
log('✅ [ACCEPT] Navigated to call screen', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
log('✅ [ACCEPT] Call accepted successfully', name: 'CallManager');
|
|
|
|
|
|
log('ℹ️ [ACCEPT] Now waiting for caller to send offer via OnOfferAsync...', name: 'CallManager');
|
|
|
|
|
|
log('ℹ️ [ACCEPT] Expected sequence:', name: 'CallManager');
|
|
|
|
|
|
log(' 1. Caller receives OnCallAcceptedAsync', name: 'CallManager');
|
|
|
|
|
|
log(' 2. Caller creates offer and sends OfferAsync', name: 'CallManager');
|
|
|
|
|
|
log(' 3. We receive OnOfferAsync', name: 'CallManager');
|
|
|
|
|
|
log(' 4. We create answer and send AnswerOfferAsync', name: 'CallManager');
|
|
|
|
|
|
log(' 5. Caller receives OnAnswerOfferAsync', name: 'CallManager');
|
|
|
|
|
|
log(' 6. ICE candidates exchange', name: 'CallManager');
|
|
|
|
|
|
log(' 7. Connection established', name: 'CallManager');
|
|
|
|
|
|
log('═══════════════════════════════════════════', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
} catch (e, stackTrace) {
|
|
|
|
|
|
log('❌ [ACCEPT] Error accepting call: $e',
|
|
|
|
|
|
name: 'CallManager', error: e, stackTrace: stackTrace);
|
|
|
|
|
|
_cleanup();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Decline incoming call
|
|
|
|
|
|
Future<void> declineCall(String reason) async {
|
|
|
|
|
|
try {
|
|
|
|
|
|
log('═══════════════════════════════════════════', name: 'CallManager');
|
|
|
|
|
|
log('❌ [DECLINE] Declining call - reason: $reason', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
if (_currentCall == null) {
|
|
|
|
|
|
log('⚠️ [DECLINE] No call to decline', name: 'CallManager');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Cancel timeout timer
|
|
|
|
|
|
_callTimeoutTimer?.cancel();
|
|
|
|
|
|
|
|
|
|
|
|
// Ensure SignalR connected
|
|
|
|
|
|
if (await _signalRService.ensureConnected()) {
|
|
|
|
|
|
log('📤 [DECLINE] Invoking CallDeclinedAsync...', name: 'CallManager');
|
|
|
|
|
|
await _signalRService.invoke('CallDeclinedAsync', args: [
|
|
|
|
|
|
_myEmployeeNumber ?? '',
|
|
|
|
|
|
_currentCall!.peerId,
|
|
|
|
|
|
reason,
|
|
|
|
|
|
]);
|
|
|
|
|
|
log('✅ [DECLINE] CallDeclinedAsync invoked', name: 'CallManager');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
log('⚠️ [DECLINE] SignalR not connected, skipping backend call', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_cleanup();
|
|
|
|
|
|
log('✅ [DECLINE] Call declined successfully', name: 'CallManager');
|
|
|
|
|
|
log('═══════════════════════════════════════════', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
} catch (e, stackTrace) {
|
|
|
|
|
|
log('❌ [DECLINE] Error declining call: $e',
|
|
|
|
|
|
name: 'CallManager', error: e, stackTrace: stackTrace);
|
|
|
|
|
|
_cleanup();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Hang up call
|
|
|
|
|
|
Future<void> hangUp() async {
|
|
|
|
|
|
try {
|
|
|
|
|
|
log('═══════════════════════════════════════════', name: 'CallManager');
|
|
|
|
|
|
log('📞 [HANGUP] Hanging up...', name: 'CallManager');
|
|
|
|
|
|
log(' Current call: ${_currentCall?.callId}', name: 'CallManager');
|
|
|
|
|
|
log(' Current status: ${_callStatus.name}', name: 'CallManager');
|
|
|
|
|
|
log(' SignalR state: ${_signalRService.connectionState}', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
if (_currentCall == null) {
|
|
|
|
|
|
log('⚠️ [HANGUP] No call to hang up', name: 'CallManager');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Store call info before cleanup
|
|
|
|
|
|
final peerId = _currentCall!.peerId;
|
|
|
|
|
|
final callId = _currentCall!.callId;
|
|
|
|
|
|
|
|
|
|
|
|
// CRITICAL FIX: Verify SignalR connection state
|
|
|
|
|
|
log('🔍 [HANGUP] Verifying SignalR connection...', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// Check if connected, if not try to reconnect
|
|
|
|
|
|
if (!_signalRService.isConnected) {
|
|
|
|
|
|
log('⚠️ [HANGUP] SignalR not connected, attempting to reconnect...', name: 'CallManager');
|
|
|
|
|
|
final reconnected = await _signalRService.ensureConnected();
|
|
|
|
|
|
|
|
|
|
|
|
if (!reconnected) {
|
|
|
|
|
|
log('❌ [HANGUP] SignalR reconnection failed', name: 'CallManager');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
log('✅ [HANGUP] SignalR reconnected successfully', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Try to invoke HangUpAsync if connected
|
|
|
|
|
|
if (_signalRService.isConnected) {
|
|
|
|
|
|
log('📤 [HANGUP] Invoking HangUpAsync...', name: 'CallManager');
|
|
|
|
|
|
log(' From: ${_myEmployeeNumber ?? ""}', name: 'CallManager');
|
|
|
|
|
|
log(' To: $peerId', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// CRITICAL FIX: HangUpAsync only takes 2 parameters (from, to)
|
|
|
|
|
|
// The old code was sending moduleId as 3rd parameter which caused:
|
|
|
|
|
|
// "Failed to invoke 'HangUpAsync' due to an error on the server"
|
|
|
|
|
|
await _signalRService.invoke('HangUpAsync', args: [
|
|
|
|
|
|
_myEmployeeNumber ?? '',
|
|
|
|
|
|
peerId,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
log('✅ [HANGUP] HangUpAsync invoked successfully', name: 'CallManager');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
log('⚠️ [HANGUP] SignalR not connected after retry, skipping backend call', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
log('❌ [HANGUP] Error invoking HangUpAsync: $e', name: 'CallManager');
|
|
|
|
|
|
// Continue with cleanup even if SignalR call fails
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// End CallKit call
|
|
|
|
|
|
log('📱 [HANGUP] Ending CallKit call...', name: 'CallManager');
|
|
|
|
|
|
try {
|
|
|
|
|
|
await _callKitService.endCall(callId);
|
|
|
|
|
|
log('✅ [HANGUP] CallKit call ended', name: 'CallManager');
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
log('⚠️ [HANGUP] Error ending CallKit: $e', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// CRITICAL FIX: Don't manually navigate - let the call pages handle navigation
|
|
|
|
|
|
// Call pages listen to status changes and will pop when status becomes idle
|
|
|
|
|
|
log('🧭 [HANGUP] Navigation will be handled by call page listeners', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// CRITICAL: Always cleanup to reset state for next call
|
|
|
|
|
|
log('🧹 [HANGUP] Starting cleanup...', name: 'CallManager');
|
|
|
|
|
|
_cleanup();
|
|
|
|
|
|
|
|
|
|
|
|
// CRITICAL FIX: Verify SignalR is still connected after cleanup
|
|
|
|
|
|
log('🔍 [HANGUP] Post-cleanup SignalR verification...', name: 'CallManager');
|
|
|
|
|
|
log(' SignalR state: ${_signalRService.connectionState}', name: 'CallManager');
|
|
|
|
|
|
log(' SignalR connected: ${_signalRService.isConnected}', name: 'CallManager');
|
|
|
|
|
|
log(' CallManager status: ${_callStatus.name}', name: 'CallManager');
|
|
|
|
|
|
log(' Current call: ${_currentCall?.callId ?? "null"}', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
if (!_signalRService.isConnected) {
|
|
|
|
|
|
log('⚠️ [HANGUP] SignalR disconnected after cleanup, attempting reconnect...', name: 'CallManager');
|
|
|
|
|
|
await _signalRService.ensureConnected();
|
|
|
|
|
|
log(' Reconnect result: ${_signalRService.isConnected}', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
log('✅ [HANGUP] Call ended successfully', name: 'CallManager');
|
|
|
|
|
|
log(' SignalR ready for next call: ${_signalRService.isConnected}', name: 'CallManager');
|
|
|
|
|
|
log('═══════════════════════════════════════════', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
} catch (e, stackTrace) {
|
|
|
|
|
|
log('❌ [HANGUP] Error hanging up: $e',
|
|
|
|
|
|
name: 'CallManager', error: e, stackTrace: stackTrace);
|
|
|
|
|
|
|
|
|
|
|
|
// CRITICAL FIX: Don't manually navigate in error case either
|
|
|
|
|
|
log('🧭 [HANGUP-ERROR] Navigation will be handled by call page listeners', name: 'CallManager');
|
|
|
|
|
|
_cleanup();
|
|
|
|
|
|
|
|
|
|
|
|
// Try to ensure SignalR is ready for next call
|
|
|
|
|
|
try {
|
|
|
|
|
|
await _signalRService.ensureConnected();
|
|
|
|
|
|
} catch (reconnectError) {
|
|
|
|
|
|
log('❌ [HANGUP] Failed to reconnect SignalR: $reconnectError', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Toggle mute
|
|
|
|
|
|
Future<void> toggleMute() async {
|
|
|
|
|
|
log('🎤 [CONTROL] Toggling mute: $_isMuted -> ${!_isMuted}', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
_isMuted = !_isMuted;
|
|
|
|
|
|
_webrtcService?.setMicrophoneMuted(_isMuted);
|
|
|
|
|
|
|
|
|
|
|
|
if (await _signalRService.ensureConnected()) {
|
|
|
|
|
|
await _signalRService.invoke('AudioToggle', args: [
|
|
|
|
|
|
_myEmployeeNumber ?? '',
|
|
|
|
|
|
_currentCall?.peerId ?? '',
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
log('✅ [CONTROL] Mute toggled: $_isMuted', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Toggle speaker
|
|
|
|
|
|
Future<void> toggleSpeaker() async {
|
|
|
|
|
|
log('🔊 [CONTROL] Toggling speaker: $_isSpeakerOn -> ${!_isSpeakerOn}', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
_isSpeakerOn = !_isSpeakerOn;
|
|
|
|
|
|
await _webrtcService?.setSpeakerphoneEnabled(_isSpeakerOn);
|
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
|
|
|
|
|
|
log('✅ [CONTROL] Speaker toggled: $_isSpeakerOn', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Toggle camera
|
|
|
|
|
|
Future<void> toggleCamera() async {
|
|
|
|
|
|
if (_currentCall?.type != CallType.video) return;
|
|
|
|
|
|
|
|
|
|
|
|
log('📹 [CONTROL] Toggling camera: $_isCameraOn -> ${!_isCameraOn}', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
_isCameraOn = !_isCameraOn;
|
|
|
|
|
|
_webrtcService?.setCameraEnabled(_isCameraOn);
|
|
|
|
|
|
|
|
|
|
|
|
if (await _signalRService.ensureConnected()) {
|
|
|
|
|
|
await _signalRService.invoke('CameraToggle', args: [
|
|
|
|
|
|
_myEmployeeNumber ?? '',
|
|
|
|
|
|
_currentCall?.peerId ?? '',
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
log('✅ [CONTROL] Camera toggled: $_isCameraOn', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Switch camera
|
|
|
|
|
|
Future<void> switchCamera() async {
|
|
|
|
|
|
if (_currentCall?.type != CallType.video || !_isCameraOn) return;
|
|
|
|
|
|
|
|
|
|
|
|
log('🔄 [CONTROL] Switching camera...', name: 'CallManager');
|
|
|
|
|
|
await _webrtcService?.switchCamera();
|
|
|
|
|
|
log('✅ [CONTROL] Camera switched', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== Private Methods ====================
|
|
|
|
|
|
|
|
|
|
|
|
Future<bool> _checkPermissions(CallType callType) async {
|
|
|
|
|
|
log('🔍 [PERMISSIONS] Checking permissions for ${callType.name} call...', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
final micStatus = await Permission.microphone.request();
|
|
|
|
|
|
if (!micStatus.isGranted) {
|
|
|
|
|
|
log('❌ [PERMISSIONS] Microphone permission denied', name: 'CallManager');
|
|
|
|
|
|
final context = navigatorKey.currentContext;
|
|
|
|
|
|
if (context != null) {
|
|
|
|
|
|
CallErrorHandler.showMicrophonePermissionDenied(context);
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
log('✅ [PERMISSIONS] Microphone permission granted', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
if (callType == CallType.video) {
|
|
|
|
|
|
final cameraStatus = await Permission.camera.request();
|
|
|
|
|
|
if (!cameraStatus.isGranted) {
|
|
|
|
|
|
log('❌ [PERMISSIONS] Camera permission denied', name: 'CallManager');
|
|
|
|
|
|
final context = navigatorKey.currentContext;
|
|
|
|
|
|
if (context != null) {
|
|
|
|
|
|
CallErrorHandler.showCameraPermissionDenied(context);
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
log('✅ [PERMISSIONS] Camera permission granted', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> _initializeWebRTC() async {
|
|
|
|
|
|
log('🔧 [WEBRTC] Initializing WebRTC...', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
_webrtcService = WebRTCService();
|
|
|
|
|
|
_setupWebRTCCallbacks();
|
|
|
|
|
|
|
|
|
|
|
|
if (_currentCall!.type == CallType.audio) {
|
|
|
|
|
|
log('🎵 [WEBRTC] Initializing for audio call', name: 'CallManager');
|
|
|
|
|
|
await _webrtcService!.initializeForAudioCall();
|
|
|
|
|
|
|
|
|
|
|
|
// CRITICAL FIX: Speaker is OFF by default in WebRTC service (earpiece mode)
|
|
|
|
|
|
// Update the CallManager state to reflect this
|
|
|
|
|
|
_isSpeakerOn = false;
|
|
|
|
|
|
log('✅ [WEBRTC] Speaker state synchronized: OFF (earpiece mode)', name: 'CallManager');
|
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
log('📹 [WEBRTC] Initializing for video call', name: 'CallManager');
|
|
|
|
|
|
await _webrtcService!.initializeForVideoCall();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
log('✅ [WEBRTC] WebRTC initialized', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _setupWebRTCCallbacks() {
|
|
|
|
|
|
log('🔧 [WEBRTC] Setting up callbacks...', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
_webrtcService!.onIceCandidate = (RTCIceCandidate candidate) {
|
|
|
|
|
|
log('🧊 [WEBRTC] ICE candidate generated', name: 'CallManager');
|
|
|
|
|
|
log(' Candidate: ${candidate.candidate?.substring(0, 50)}...', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
final candidateJson = jsonEncode({
|
|
|
|
|
|
'candidate': candidate.candidate,
|
|
|
|
|
|
'sdpMid': candidate.sdpMid,
|
|
|
|
|
|
'sdpMLineIndex': candidate.sdpMLineIndex,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
_signalRService.invoke('IceCandidateAsync', args: [
|
|
|
|
|
|
_currentCall!.peerId,
|
|
|
|
|
|
candidateJson,
|
|
|
|
|
|
_currentCall!.callId,
|
|
|
|
|
|
]);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
_webrtcService!.onRemoteStream = (MediaStream stream) {
|
|
|
|
|
|
log('📡 [WEBRTC] Remote stream received', name: 'CallManager');
|
|
|
|
|
|
log(' Audio tracks: ${stream.getAudioTracks().length}', name: 'CallManager');
|
|
|
|
|
|
log(' Video tracks: ${stream.getVideoTracks().length}', name: 'CallManager');
|
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
_webrtcService!.onIceConnectionStateChange = (RTCIceConnectionState state) {
|
|
|
|
|
|
log('🔗 [WEBRTC] ICE state: ${state.toString()}', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
if (state == RTCIceConnectionState.RTCIceConnectionStateConnected) {
|
|
|
|
|
|
log('✅ [WEBRTC] ICE connection established', name: 'CallManager');
|
|
|
|
|
|
_updateCallStatus(CallStatus.connected);
|
|
|
|
|
|
_startCallDurationTimer();
|
|
|
|
|
|
} else if (state == RTCIceConnectionState.RTCIceConnectionStateFailed) {
|
|
|
|
|
|
log('❌ [WEBRTC] ICE connection failed', name: 'CallManager');
|
|
|
|
|
|
_cleanup();
|
|
|
|
|
|
} else if (state == RTCIceConnectionState.RTCIceConnectionStateDisconnected) {
|
|
|
|
|
|
log('⚠️ [WEBRTC] ICE connection disconnected', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
log('✅ [WEBRTC] Callbacks set up', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _navigateToCallScreen() {
|
|
|
|
|
|
final context = navigatorKey.currentContext;
|
|
|
|
|
|
if (context == null || _currentCall == null) {
|
|
|
|
|
|
log('⚠️ [NAV] Cannot navigate - context or call is null', name: 'CallManager');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
log('🧭 [NAV] Navigating to ${_currentCall!.type.name} call screen', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
Navigator.of(context).push(
|
|
|
|
|
|
MaterialPageRoute(
|
|
|
|
|
|
builder: (context) => _currentCall!.type == CallType.audio
|
|
|
|
|
|
? const AudioCallPage()
|
|
|
|
|
|
: const VideoCallPage(),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _updateCallStatus(CallStatus newStatus) {
|
|
|
|
|
|
if (_callStatus == newStatus) return;
|
|
|
|
|
|
|
|
|
|
|
|
log('🔄 [STATUS] ${_callStatus.name} -> ${newStatus.name}', name: 'CallManager');
|
|
|
|
|
|
_callStatus = newStatus;
|
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _startCallDurationTimer() {
|
|
|
|
|
|
_callDurationTimer?.cancel();
|
|
|
|
|
|
_callDuration = Duration.zero;
|
|
|
|
|
|
|
|
|
|
|
|
log('⏱️ [TIMER] Starting call duration timer', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
_callDurationTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
|
|
|
|
|
_callDuration = Duration(seconds: _callDuration.inSeconds + 1);
|
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _cleanup() {
|
|
|
|
|
|
log('🧹 [CLEANUP] Starting cleanup...', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
_callTimeoutTimer?.cancel();
|
|
|
|
|
|
_callDurationTimer?.cancel();
|
|
|
|
|
|
|
|
|
|
|
|
_webrtcService?.dispose();
|
|
|
|
|
|
_webrtcService = null;
|
|
|
|
|
|
|
|
|
|
|
|
if (_currentCall != null) {
|
|
|
|
|
|
_callKitService.endCall(_currentCall!.callId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_currentCall = null;
|
|
|
|
|
|
_updateCallStatus(CallStatus.idle);
|
|
|
|
|
|
_callDuration = Duration.zero;
|
|
|
|
|
|
_isMuted = false;
|
|
|
|
|
|
_isSpeakerOn = false;
|
|
|
|
|
|
_isCameraOn = true;
|
|
|
|
|
|
_isPeerMuted = false;
|
|
|
|
|
|
_isPeerCameraOn = true;
|
|
|
|
|
|
|
|
|
|
|
|
log('✅ [CLEANUP] Cleanup complete', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Reset service (for logout or testing)
|
|
|
|
|
|
Future<void> reset() async {
|
|
|
|
|
|
log('🔄 [RESET] Resetting CallManager...', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
_cleanup();
|
|
|
|
|
|
|
|
|
|
|
|
_callKitInitialized = false;
|
|
|
|
|
|
_handlersRegistered = false;
|
|
|
|
|
|
_moduleId = null;
|
|
|
|
|
|
_referenceId = null;
|
|
|
|
|
|
_conversationId = null;
|
|
|
|
|
|
_myEmployeeNumber = null;
|
|
|
|
|
|
|
|
|
|
|
|
log('✅ [RESET] CallManager reset complete', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== SignalR Event Handlers ====================
|
|
|
|
|
|
|
|
|
|
|
|
void _handleIncomingCall(List<Object?>? args) {
|
|
|
|
|
|
log('═══════════════════════════════════════════', name: 'CallManager');
|
|
|
|
|
|
log('📞 [EVENT] ⚠️ OnIncomingCallAsync received', name: 'CallManager');
|
|
|
|
|
|
log(' Args count: ${args?.length ?? 0}', name: 'CallManager');
|
|
|
|
|
|
log(' Args: $args', name: 'CallManager');
|
|
|
|
|
|
log('═══════════════════════════════════════════', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// Note: Incoming calls are now handled by CallNotificationService
|
|
|
|
|
|
// This event is kept for chat screen integration
|
|
|
|
|
|
log('ℹ️ [EVENT] Incoming call handled by notification service', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _handleCallAccepted(List<Object?>? args) async {
|
|
|
|
|
|
log('═══════════════════════════════════════════', name: 'CallManager');
|
|
|
|
|
|
log('📞 [EVENT] ✅ OnCallAcceptedAsync received', name: 'CallManager');
|
|
|
|
|
|
log(' Args count: ${args?.length ?? 0}', name: 'CallManager');
|
|
|
|
|
|
log(' Args: $args', name: 'CallManager');
|
|
|
|
|
|
log(' Current status: ${_callStatus.name}', name: 'CallManager');
|
|
|
|
|
|
log(' Current call: ${_currentCall?.callId}', name: 'CallManager');
|
|
|
|
|
|
log(' Call direction: ${_currentCall?.direction.name}', name: 'CallManager');
|
|
|
|
|
|
log(' Peer: ${_currentCall?.peerId}', name: 'CallManager');
|
|
|
|
|
|
log('═══════════════════════════════════════════', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// CRITICAL: Only the CALLER should create an offer
|
|
|
|
|
|
// The RECEIVER should wait for OnOfferAsync
|
|
|
|
|
|
if (_currentCall?.direction != CallDirection.outgoing) {
|
|
|
|
|
|
log('⚠️ [EVENT] This is an INCOMING call - receiver should NOT create offer', name: 'CallManager');
|
|
|
|
|
|
log(' Current direction: ${_currentCall?.direction.name}', name: 'CallManager');
|
|
|
|
|
|
log(' ✅ Receiver will wait for OnOfferAsync from caller', name: 'CallManager');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// FIXED: Accept event in both connecting and outgoingRinging states
|
|
|
|
|
|
// Backend might send OnCallAcceptedAsync very quickly before status changes to outgoingRinging
|
|
|
|
|
|
if (_callStatus != CallStatus.connecting && _callStatus != CallStatus.outgoingRinging) {
|
|
|
|
|
|
log('⚠️ [EVENT] Not in valid state for creating offer (current: ${_callStatus.name})', name: 'CallManager');
|
|
|
|
|
|
log(' Expected: connecting OR outgoingRinging', name: 'CallManager');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// If we're still in connecting, update to outgoingRinging first
|
|
|
|
|
|
if (_callStatus == CallStatus.connecting) {
|
|
|
|
|
|
log('🔄 [EVENT] Status was connecting, updating to outgoingRinging first', name: 'CallManager');
|
|
|
|
|
|
_updateCallStatus(CallStatus.outgoingRinging);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_callTimeoutTimer?.cancel();
|
|
|
|
|
|
_updateCallStatus(CallStatus.connecting);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// Ensure WebRTC is initialized before creating offer
|
|
|
|
|
|
if (_webrtcService == null) {
|
|
|
|
|
|
log('⚠️ [EVENT] WebRTC not initialized yet, waiting...', name: 'CallManager');
|
|
|
|
|
|
// Wait a bit for WebRTC to initialize (it should be initializing in parallel)
|
|
|
|
|
|
await Future.delayed(const Duration(milliseconds: 500));
|
|
|
|
|
|
|
|
|
|
|
|
if (_webrtcService == null) {
|
|
|
|
|
|
log('❌ [EVENT] WebRTC still not initialized after wait', name: 'CallManager');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// CALLER SIDE: Create and send offer
|
|
|
|
|
|
log('🔧 [EVENT] [CALLER] Creating SDP offer...', name: 'CallManager');
|
|
|
|
|
|
final offer = await _webrtcService!.createOffer();
|
|
|
|
|
|
log('✅ [EVENT] [CALLER] SDP offer created (length: ${offer.sdp?.length ?? 0})', name: 'CallManager');
|
|
|
|
|
|
log(' Offer SDP type: ${offer.type}', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
log('📤 [EVENT] [CALLER] Sending offer via OfferAsync...', name: 'CallManager');
|
|
|
|
|
|
log(' To peer: ${_currentCall!.peerId}', name: 'CallManager');
|
|
|
|
|
|
log(' Call ID: ${_currentCall!.callId}', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
await _signalRService.invoke('OfferAsync', args: [
|
|
|
|
|
|
_currentCall!.peerId,
|
|
|
|
|
|
offer.sdp ?? '',
|
|
|
|
|
|
_currentCall!.callId,
|
|
|
|
|
|
]);
|
|
|
|
|
|
log('✅ [EVENT] [CALLER] Offer sent successfully via OfferAsync', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
} catch (e, stackTrace) {
|
|
|
|
|
|
log('❌ [EVENT] Error handling call accepted: $e',
|
|
|
|
|
|
name: 'CallManager', error: e, stackTrace: stackTrace);
|
|
|
|
|
|
_cleanup();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _handleCallDeclined(List<Object?>? args) {
|
|
|
|
|
|
log('═══════════════════════════════════════════', name: 'CallManager');
|
|
|
|
|
|
log('📞 [EVENT] ❌ OnCallDeclinedAsync received', name: 'CallManager');
|
|
|
|
|
|
log(' Args count: ${args?.length ?? 0}', name: 'CallManager');
|
|
|
|
|
|
log(' Args: $args', name: 'CallManager');
|
|
|
|
|
|
log('═══════════════════════════════════════════', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// Extract decline reason if available
|
|
|
|
|
|
String? reason;
|
|
|
|
|
|
if (args != null && args.isNotEmpty) {
|
|
|
|
|
|
reason = args[0]?.toString();
|
|
|
|
|
|
log(' Decline reason: $reason', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Show appropriate message
|
|
|
|
|
|
final context = navigatorKey.currentContext;
|
|
|
|
|
|
if (context != null && reason != null) {
|
|
|
|
|
|
if (reason == 'USER_BUSY') {
|
|
|
|
|
|
CallErrorHandler.showGenericError(context, 'The user is currently busy on another call');
|
|
|
|
|
|
} else if (reason == 'USER_OFFLINE') {
|
|
|
|
|
|
CallErrorHandler.showGenericError(context, 'The user is currently offline');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_cleanup();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _handleHangUp(List<Object?>? args) async {
|
|
|
|
|
|
log('═══════════════════════════════════════════', name: 'CallManager');
|
|
|
|
|
|
log('📞 [EVENT] 🔴 OnHangUpAsync received', name: 'CallManager');
|
|
|
|
|
|
log(' Args count: ${args?.length ?? 0}', name: 'CallManager');
|
|
|
|
|
|
log(' Args: $args', name: 'CallManager');
|
|
|
|
|
|
log(' Current call status: ${_callStatus.name}', name: 'CallManager');
|
|
|
|
|
|
log(' Current call ID: ${_currentCall?.callId}', name: 'CallManager');
|
|
|
|
|
|
log(' SignalR state: ${_signalRService.connectionState}', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// CRITICAL FIX: Don't manually navigate - call pages listen to status and auto-pop when idle
|
|
|
|
|
|
// Avoid double navigation which causes navigation errors
|
|
|
|
|
|
log('🧭 [EVENT-HANGUP] Skipping manual navigation - call page will auto-pop on status change', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// End CallKit call first
|
|
|
|
|
|
if (_currentCall != null) {
|
|
|
|
|
|
log('📱 [EVENT-HANGUP] Ending CallKit call...', name: 'CallManager');
|
|
|
|
|
|
try {
|
|
|
|
|
|
await _callKitService.endCall(_currentCall!.callId);
|
|
|
|
|
|
log('✅ [EVENT-HANGUP] CallKit call ended', name: 'CallManager');
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
log('⚠️ [EVENT-HANGUP] Error ending CallKit: $e', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Cleanup call resources - this will set status to idle, triggering call page navigation
|
|
|
|
|
|
log('🧹 [EVENT-HANGUP] Starting cleanup...', name: 'CallManager');
|
|
|
|
|
|
_cleanup();
|
|
|
|
|
|
|
|
|
|
|
|
// CRITICAL FIX: Verify SignalR is still connected after cleanup
|
|
|
|
|
|
log('🔍 [EVENT-HANGUP] Post-cleanup SignalR verification...', name: 'CallManager');
|
|
|
|
|
|
log(' SignalR state: ${_signalRService.connectionState}', name: 'CallManager');
|
|
|
|
|
|
log(' SignalR connected: ${_signalRService.isConnected}', name: 'CallManager');
|
|
|
|
|
|
log(' CallManager status: ${_callStatus.name}', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// CRITICAL: Ensure SignalR is ready for next call
|
|
|
|
|
|
if (!_signalRService.isConnected) {
|
|
|
|
|
|
log('⚠️ [EVENT-HANGUP] SignalR disconnected after cleanup, attempting reconnect...', name: 'CallManager');
|
|
|
|
|
|
try {
|
|
|
|
|
|
final reconnected = await _signalRService.ensureConnected();
|
|
|
|
|
|
log(' Reconnect result: $reconnected', name: 'CallManager');
|
|
|
|
|
|
log(' SignalR state after reconnect: ${_signalRService.connectionState}', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
if (!reconnected) {
|
|
|
|
|
|
log('❌ [EVENT-HANGUP] Failed to reconnect SignalR!', name: 'CallManager');
|
|
|
|
|
|
log(' This will prevent next call from working', name: 'CallManager');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
log('✅ [EVENT-HANGUP] SignalR reconnected successfully', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
log('❌ [EVENT-HANGUP] Error reconnecting SignalR: $e', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
log('✅ [EVENT-HANGUP] SignalR still connected after cleanup', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
log('✅ [EVENT-HANGUP] Hangup event processed', name: 'CallManager');
|
|
|
|
|
|
log(' Call page will auto-navigate when it detects idle status', name: 'CallManager');
|
|
|
|
|
|
log(' SignalR ready for next call: ${_signalRService.isConnected}', name: 'CallManager');
|
|
|
|
|
|
log('═══════════════════════════════════════════', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _handleOffer(List<Object?>? args) async {
|
|
|
|
|
|
log('═══════════════════════════════════════════', name: 'CallManager');
|
|
|
|
|
|
log('📞 [EVENT] 📥 OnOfferAsync received', name: 'CallManager');
|
|
|
|
|
|
log(' Args count: ${args?.length ?? 0}', name: 'CallManager');
|
|
|
|
|
|
log(' Current status: ${_callStatus.name}', name: 'CallManager');
|
|
|
|
|
|
log(' WebRTC initialized: ${_webrtcService != null}', name: 'CallManager');
|
|
|
|
|
|
log('═══════════════════════════════════════════', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
if (args == null || args.isEmpty) {
|
|
|
|
|
|
log('⚠️ [EVENT] No offer data received', name: 'CallManager');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
final offerSdp = args[0] as String?;
|
|
|
|
|
|
if (offerSdp == null || offerSdp.isEmpty) {
|
|
|
|
|
|
log('⚠️ [EVENT] Offer SDP is null or empty', name: 'CallManager');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
log(' Offer SDP length: ${offerSdp.length}', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
if (_webrtcService == null) {
|
|
|
|
|
|
log('⚠️ [EVENT] WebRTC service not initialized', name: 'CallManager');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
log('🔧 [EVENT] Creating SDP answer...', name: 'CallManager');
|
|
|
|
|
|
final answer = await _webrtcService!.createAnswer(offerSdp);
|
|
|
|
|
|
log('✅ [EVENT] SDP answer created (length: ${answer.sdp?.length ?? 0})', name: 'CallManager');
|
|
|
|
|
|
log(' Answer SDP type: ${answer.type}', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
log('📤 [EVENT] Sending answer via AnswerOfferAsync...', name: 'CallManager');
|
|
|
|
|
|
log(' To peer: ${_currentCall!.peerId}', name: 'CallManager');
|
|
|
|
|
|
log(' Call ID: ${_currentCall!.callId}', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
await _signalRService.invoke('AnswerOfferAsync', args: [
|
|
|
|
|
|
_currentCall!.peerId,
|
|
|
|
|
|
answer.sdp ?? '',
|
|
|
|
|
|
_currentCall!.callId,
|
|
|
|
|
|
]);
|
|
|
|
|
|
log('✅ [EVENT] Answer sent successfully via AnswerOfferAsync', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
} catch (e, stackTrace) {
|
|
|
|
|
|
log('❌ [EVENT] Error handling offer: $e',
|
|
|
|
|
|
name: 'CallManager', error: e, stackTrace: stackTrace);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _handleAnswer(List<Object?>? args) async {
|
|
|
|
|
|
log('═══════════════════════════════════════════', name: 'CallManager');
|
|
|
|
|
|
log('📞 [EVENT] 📥 OnAnswerOfferAsync received', name: 'CallManager');
|
|
|
|
|
|
log(' Args count: ${args?.length ?? 0}', name: 'CallManager');
|
|
|
|
|
|
log(' Current status: ${_callStatus.name}', name: 'CallManager');
|
|
|
|
|
|
log(' WebRTC initialized: ${_webrtcService != null}', name: 'CallManager');
|
|
|
|
|
|
log('═══════════════════════════════════════════', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
if (args == null || args.isEmpty) {
|
|
|
|
|
|
log('⚠️ [EVENT] No answer data received', name: 'CallManager');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
final answerSdp = args[0] as String?;
|
|
|
|
|
|
if (answerSdp == null || answerSdp.isEmpty) {
|
|
|
|
|
|
log('⚠️ [EVENT] Answer SDP is null or empty', name: 'CallManager');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
log(' Answer SDP length: ${answerSdp.length}', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
if (_webrtcService == null) {
|
|
|
|
|
|
log('⚠️ [EVENT] WebRTC service not initialized', name: 'CallManager');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
log('🔧 [EVENT] Setting remote answer...', name: 'CallManager');
|
|
|
|
|
|
await _webrtcService!.setRemoteAnswer(answerSdp);
|
|
|
|
|
|
log('✅ [EVENT] Remote answer set successfully', name: 'CallManager');
|
|
|
|
|
|
log(' Now waiting for ICE connection to establish...', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
} catch (e, stackTrace) {
|
|
|
|
|
|
log('❌ [EVENT] Error handling answer: $e',
|
|
|
|
|
|
name: 'CallManager', error: e, stackTrace: stackTrace);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _handleIceCandidate(List<Object?>? args) async {
|
|
|
|
|
|
log('📞 [EVENT] 🧊 OnIceCandidateAsync received', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
if (args == null || args.isEmpty) {
|
|
|
|
|
|
log('⚠️ [EVENT] No ICE candidate data received', name: 'CallManager');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
final candidateJson = args[0] as String?;
|
|
|
|
|
|
if (candidateJson == null) {
|
|
|
|
|
|
log('⚠️ [EVENT] Candidate JSON is null', name: 'CallManager');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (_webrtcService == null) {
|
|
|
|
|
|
log('⚠️ [EVENT] WebRTC service not initialized', name: 'CallManager');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
final candidateData = jsonDecode(candidateJson) as Map<String, dynamic>;
|
|
|
|
|
|
final candidate = RTCIceCandidate(
|
|
|
|
|
|
candidateData['candidate'] as String?,
|
|
|
|
|
|
candidateData['sdpMid'] as String?,
|
|
|
|
|
|
candidateData['sdpMLineIndex'] as int?,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
log('🧊 [EVENT] Adding remote ICE candidate...', name: 'CallManager');
|
|
|
|
|
|
log(' Candidate: ${candidate.candidate?.substring(0, 50)}...', name: 'CallManager');
|
|
|
|
|
|
await _webrtcService!.addIceCandidate(candidate);
|
|
|
|
|
|
log('✅ [EVENT] Remote ICE candidate added', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
} catch (e, stackTrace) {
|
|
|
|
|
|
log('❌ [EVENT] Error handling ICE candidate: $e',
|
|
|
|
|
|
name: 'CallManager', error: e, stackTrace: stackTrace);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _handleAudioToggle(List<Object?>? args) {
|
|
|
|
|
|
log('📞 [EVENT] 🎤 OnAudioToggle received', name: 'CallManager');
|
|
|
|
|
|
log(' Peer muted: $_isPeerMuted -> ${!_isPeerMuted}', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
_isPeerMuted = !_isPeerMuted;
|
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _handleCameraToggle(List<Object?>? args) {
|
|
|
|
|
|
log('📞 [EVENT] 📹 OnCameraToggle received', name: 'CallManager');
|
|
|
|
|
|
log(' Peer camera: $_isPeerCameraOn -> ${!_isPeerCameraOn}', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
_isPeerCameraOn = !_isPeerCameraOn;
|
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== CallKit Event Handlers ====================
|
|
|
|
|
|
|
|
|
|
|
|
void _handleCallKitAccepted(String callId) {
|
|
|
|
|
|
log('✅ [CALLKIT] Call accepted: $callId', name: 'CallManager');
|
|
|
|
|
|
if (_currentCall?.callId == callId) {
|
|
|
|
|
|
acceptCall();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
log('⚠️ [CALLKIT] Call ID mismatch: expected ${_currentCall?.callId}, got $callId', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _handleCallKitDeclined(String callId) {
|
|
|
|
|
|
log('❌ [CALLKIT] Call declined: $callId', name: 'CallManager');
|
|
|
|
|
|
if (_currentCall?.callId == callId) {
|
|
|
|
|
|
declineCall('user_declined');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
log('⚠️ [CALLKIT] Call ID mismatch: expected ${_currentCall?.callId}, got $callId', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _handleCallKitEnded(String callId) {
|
|
|
|
|
|
log('🔴 [CALLKIT] Call ended: $callId', name: 'CallManager');
|
|
|
|
|
|
if (_currentCall?.callId == callId) {
|
|
|
|
|
|
hangUp();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
log('⚠️ [CALLKIT] Call ID mismatch: expected ${_currentCall?.callId}, got $callId', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _handleCallKitTimeout(String callId) {
|
|
|
|
|
|
log('⏱️ [CALLKIT] Call timeout: $callId', name: 'CallManager');
|
|
|
|
|
|
if (_currentCall?.callId == callId) {
|
|
|
|
|
|
_handleCallTimeout();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
log('⚠️ [CALLKIT] Call ID mismatch: expected ${_currentCall?.callId}, got $callId', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== Additional SignalR Event Handlers ====================
|
|
|
|
|
|
|
|
|
|
|
|
void _handleUserOnline(List<Object?>? args) {
|
|
|
|
|
|
log('📞 [EVENT] 🟢 OnUserOnlineAsync received', name: 'CallManager');
|
|
|
|
|
|
log(' Args count: ${args?.length ?? 0}', name: 'CallManager');
|
|
|
|
|
|
if (args != null && args.isNotEmpty) {
|
|
|
|
|
|
log(' User online: ${args[0]}', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
log('═══════════════════════════════════════════', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// Notify listeners in case UI needs to update user status
|
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _handleUserOffline(List<Object?>? args) {
|
|
|
|
|
|
log('📞 [EVENT] 🔴 OnUserOfflineAsync received', name: 'CallManager');
|
|
|
|
|
|
log(' Args count: ${args?.length ?? 0}', name: 'CallManager');
|
|
|
|
|
|
if (args != null && args.isNotEmpty) {
|
|
|
|
|
|
log(' User offline: ${args[0]}', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
log('═══════════════════════════════════════════', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// Notify listeners in case UI needs to update user status
|
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _handleCallHistoryUpdated(List<Object?>? args) {
|
|
|
|
|
|
log('📞 [EVENT] 📋 OnCallHistoryUpdated received', name: 'CallManager');
|
|
|
|
|
|
log(' Args count: ${args?.length ?? 0}', name: 'CallManager');
|
|
|
|
|
|
if (args != null && args.isNotEmpty) {
|
|
|
|
|
|
log(' Call history update: ${args[0]}', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
log('═══════════════════════════════════════════', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// Forward to ChatProvider if needed (call history is typically managed there)
|
|
|
|
|
|
// This is just logging for now
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _handleError(List<Object?>? args) {
|
|
|
|
|
|
log('═══════════════════════════════════════════', name: 'CallManager');
|
|
|
|
|
|
log('📞 [EVENT] ❌ OnError received', name: 'CallManager');
|
|
|
|
|
|
log(' Args count: ${args?.length ?? 0}', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
if (args != null && args.isNotEmpty) {
|
|
|
|
|
|
final error = args[0]?.toString() ?? 'Unknown error';
|
|
|
|
|
|
log(' Error: $error', name: 'CallManager');
|
|
|
|
|
|
|
|
|
|
|
|
// Show error to user if we have context
|
|
|
|
|
|
final context = navigatorKey.currentContext;
|
|
|
|
|
|
if (context != null) {
|
|
|
|
|
|
CallErrorHandler.showGenericError(context, 'Call error: $error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
log('═══════════════════════════════════════════', name: 'CallManager');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|