monorepo/dialer/lib/domain/services/call_service.dart
AlexisDanlos aa8d32f28c
All checks were successful
/ mirror (push) Successful in 5s
/ build (push) Successful in 11m27s
/ build-stealth (push) Successful in 11m34s
change-default-sim (#60)
Co-authored-by: AlexisDanlos <91090088+AlexisDanlos@users.noreply.github.com>
Co-authored-by: stcb <21@stcb.cc>
Reviewed-on: #60
Co-authored-by: AlexisDanlos <alexis.danlos@epitech.eu>
Co-committed-by: AlexisDanlos <alexis.danlos@epitech.eu>
2025-07-08 07:31:38 +00:00

721 lines
27 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../presentation/features/call/call_page.dart';
import '../../presentation/features/call/incoming_call_page.dart'; // Import the new page
import 'contact_service.dart';
// Import for history update callback
import '../../presentation/features/history/history_page.dart';
class CallService {
static const MethodChannel _channel = MethodChannel('call_service');
static String? currentPhoneNumber;
static String? currentDisplayName;
static Uint8List? currentThumbnail;
static int? currentSimSlot; // Track which SIM slot is being used
static bool _isCallPageVisible = false;
static Map<String, dynamic>? _pendingCall;
static bool wasPhoneLocked = false;
static String? _activeCallNumber;
static bool _isNavigating = false;
final ContactService _contactService = ContactService();
final _callStateController = StreamController<String>.broadcast();
final _audioStateController =
StreamController<Map<String, dynamic>>.broadcast();
final _simStateController = StreamController<int?>.broadcast();
Map<String, dynamic>? _currentAudioState;
static final GlobalKey<NavigatorState> navigatorKey =
GlobalKey<NavigatorState>();
Stream<String> get callStateStream => _callStateController.stream;
Stream<Map<String, dynamic>> get audioStateStream =>
_audioStateController.stream;
Stream<int?> get simStateStream => _simStateController.stream;
Map<String, dynamic>? get currentAudioState => _currentAudioState;
// Getter for current SIM slot
static int? get getCurrentSimSlot => currentSimSlot;
// Get SIM display name for the current call
static String? getCurrentSimDisplayName() {
if (currentSimSlot == null) return null;
return "SIM ${currentSimSlot! + 1}";
}
// Cancel pending SIM switch (used when user manually hangs up)
void cancelPendingSimSwitch() {
if (_pendingSimSwitch != null) {
print('CallService: Canceling pending SIM switch due to manual hangup');
_pendingSimSwitch = null;
_manualHangupFlag = true; // Mark that hangup was manual
print('CallService: Manual hangup flag set to $_manualHangupFlag');
} else {
print('CallService: No pending SIM switch to cancel');
// Don't set manual hangup flag if there's no SIM switch to cancel
}
}
CallService() {
_channel.setMethodCallHandler((call) async {
print(
'CallService: Handling method call: ${call.method}, with args: ${call.arguments}');
switch (call.method) {
case "callAdded":
final phoneNumber = call.arguments["callId"] as String?;
final state = call.arguments["state"] as String?;
if (phoneNumber == null || state == null) {
print('CallService: Invalid callAdded args: $call.arguments');
return;
}
final decodedPhoneNumber =
Uri.decodeComponent(phoneNumber.replaceFirst('tel:', ''));
print('CallService: Decoded phone number: $decodedPhoneNumber');
if (_activeCallNumber != decodedPhoneNumber) {
currentPhoneNumber = decodedPhoneNumber;
if (currentDisplayName == null ||
currentDisplayName == currentPhoneNumber) {
await _fetchContactInfo(decodedPhoneNumber);
}
}
print(
'CallService: Call added, number: $currentPhoneNumber, displayName: $currentDisplayName, state: $state');
_callStateController.add(state);
if (state == "ringing") {
_handleIncomingCall(decodedPhoneNumber);
} else {
_navigateToCallPage();
}
break;
case "callStateChanged":
final state = call.arguments["state"] as String?;
wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false;
if (state == null) {
print(
'CallService: Invalid callStateChanged args: $call.arguments');
return;
}
print(
'CallService: State changed to $state, wasPhoneLocked: $wasPhoneLocked');
_callStateController.add(state);
if (state == "disconnected" || state == "disconnecting") {
print('CallService: ========== CALL DISCONNECTED ==========');
print(
'CallService: _pendingSimSwitch: ${_pendingSimSwitch != null}');
print('CallService: _manualHangupFlag: $_manualHangupFlag');
print('CallService: _isCallPageVisible: $_isCallPageVisible');
// Always close call page on disconnection - SIM switching should not prevent this
print('CallService: Calling _closeCallPage() on call disconnection');
_closeCallPage();
// Reset manual hangup flag after successful page close
if (_manualHangupFlag) {
print(
'CallService: Resetting manual hangup flag after page close');
_manualHangupFlag = false;
}
if (wasPhoneLocked) {
await _channel.invokeMethod("callEndedFromFlutter");
}
// Notify history page to add the latest call
// Add a small delay to ensure call log is updated by the system
Timer(const Duration(milliseconds: 500), () {
HistoryPageState.addNewCallToHistory();
});
_activeCallNumber = null;
// Handle pending SIM switch after call is disconnected
_handlePendingSimSwitch();
} else if (state == "active" || state == "dialing") {
final phoneNumber = call.arguments["callId"] as String?;
if (phoneNumber != null &&
_activeCallNumber !=
Uri.decodeComponent(phoneNumber.replaceFirst('tel:', ''))) {
currentPhoneNumber =
Uri.decodeComponent(phoneNumber.replaceFirst('tel:', ''));
if (currentDisplayName == null ||
currentDisplayName == currentPhoneNumber) {
await _fetchContactInfo(currentPhoneNumber!);
}
} else if (currentPhoneNumber != null &&
_activeCallNumber != currentPhoneNumber) {
if (currentDisplayName == null ||
currentDisplayName == currentPhoneNumber) {
await _fetchContactInfo(currentPhoneNumber!);
}
} else {
print(
'CallService: Skipping fetch, active call: $_activeCallNumber, current: $currentPhoneNumber, displayName: $currentDisplayName');
}
_navigateToCallPage();
} else if (state == "ringing") {
final phoneNumber = call.arguments["callId"] as String?;
if (phoneNumber == null) {
print('CallService: Invalid ringing callId: $call.arguments');
return;
}
final decodedPhoneNumber =
Uri.decodeComponent(phoneNumber.replaceFirst('tel:', ''));
if (_activeCallNumber != decodedPhoneNumber) {
currentPhoneNumber = decodedPhoneNumber;
if (currentDisplayName == null ||
currentDisplayName == currentPhoneNumber) {
await _fetchContactInfo(decodedPhoneNumber);
}
}
_handleIncomingCall(decodedPhoneNumber);
}
break;
case "callEnded":
case "callRemoved":
wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false;
print('CallService: ========== CALL ENDED/REMOVED ==========');
print('CallService: wasPhoneLocked: $wasPhoneLocked');
print('CallService: _pendingSimSwitch: ${_pendingSimSwitch != null}');
print('CallService: _manualHangupFlag: $_manualHangupFlag');
print('CallService: _isCallPageVisible: $_isCallPageVisible');
// Always close call page when call ends - SIM switching should not prevent this
print('CallService: Calling _closeCallPage() on call ended/removed');
_closeCallPage();
// Reset manual hangup flag after closing page
if (_manualHangupFlag) {
print(
'CallService: Resetting manual hangup flag after callEnded');
_manualHangupFlag = false;
}
if (wasPhoneLocked) {
await _channel.invokeMethod("callEndedFromFlutter");
}
// Notify history page to add the latest call
// Add a small delay to ensure call log is updated by the system
Timer(const Duration(milliseconds: 500), () {
HistoryPageState.addNewCallToHistory();
});
currentPhoneNumber = null;
currentDisplayName = null;
currentThumbnail = null;
currentSimSlot = null; // Reset SIM slot when call ends
_simStateController.add(null); // Notify UI that SIM is cleared
_activeCallNumber = null;
break;
case "incomingCallFromNotification":
final phoneNumber = call.arguments["phoneNumber"] as String?;
wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false;
if (phoneNumber == null) {
print(
'CallService: Invalid incomingCallFromNotification args: $call.arguments');
return;
}
final decodedPhoneNumber = Uri.decodeComponent(phoneNumber);
if (_activeCallNumber != decodedPhoneNumber) {
currentPhoneNumber = decodedPhoneNumber;
if (currentDisplayName == null ||
currentDisplayName == currentPhoneNumber) {
await _fetchContactInfo(decodedPhoneNumber);
}
}
print(
'CallService: Incoming call from notification: $decodedPhoneNumber, displayName: $currentDisplayName, wasPhoneLocked: $wasPhoneLocked');
_handleIncomingCall(decodedPhoneNumber);
break;
case "audioStateChanged":
final route = call.arguments["route"] as int?;
final muted = call.arguments["muted"] as bool?;
final speaker = call.arguments["speaker"] as bool?;
print(
'CallService: Audio state changed, route: $route, muted: $muted, speaker: $speaker');
final audioState = {
"route": route,
"muted": muted,
"speaker": speaker,
};
_currentAudioState = audioState;
_audioStateController.add(audioState);
break;
}
});
}
Future<String?> getCallState() async {
try {
final state = await _channel.invokeMethod('getCallState');
print('CallService: getCallState returned: $state');
return state as String?;
} catch (e) {
print('CallService: Error getting call state: $e');
return null;
}
}
Future<Map<String, dynamic>> muteCall(BuildContext context,
{required bool mute}) async {
try {
print('CallService: Toggling mute to $mute');
final result = await _channel.invokeMethod('muteCall', {'mute': mute});
print('CallService: muteCall result: $result');
final resultMap = Map<String, dynamic>.from(result as Map);
if (resultMap['status'] != 'success') {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to toggle mute')),
);
}
return resultMap;
} catch (e) {
print('CallService: Error toggling mute: $e');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error toggling mute: $e')),
);
return {'status': 'error', 'message': e.toString()};
}
}
Future<Map<String, dynamic>> speakerCall(BuildContext context,
{required bool speaker}) async {
try {
print('CallService: Toggling speaker to $speaker');
final result =
await _channel.invokeMethod('speakerCall', {'speaker': speaker});
print('CallService: speakerCall result: $result');
return Map<String, dynamic>.from(result);
} catch (e) {
print('CallService: Error toggling speaker: $e');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to toggle speaker: $e')),
);
return {'status': 'error', 'message': e.toString()};
}
}
void dispose() {
_callStateController.close();
_audioStateController.close();
}
Future<void> _fetchContactInfo(String phoneNumber) async {
try {
print('CallService: Fetching contact info for $phoneNumber');
final contacts = await _contactService.fetchContacts();
print('CallService: Retrieved ${contacts.length} contacts');
final normalizedPhoneNumber = _normalizePhoneNumber(phoneNumber);
print('CallService: Normalized phone number: $normalizedPhoneNumber');
for (var contact in contacts) {
for (var phone in contact.phones) {
final normalizedContactNumber = _normalizePhoneNumber(phone.number);
print(
'CallService: Comparing $normalizedPhoneNumber with contact number $normalizedContactNumber');
if (normalizedContactNumber == normalizedPhoneNumber) {
currentDisplayName = contact.displayName;
currentThumbnail = contact.thumbnail;
print(
'CallService: Found contact - displayName: $currentDisplayName, thumbnail: ${currentThumbnail != null ? "present" : "null"}');
return;
}
}
}
currentDisplayName = phoneNumber;
currentThumbnail = null;
print(
'CallService: No contact match, using phoneNumber as displayName: $currentDisplayName');
} catch (e) {
print('CallService: Error fetching contact info: $e');
currentDisplayName = phoneNumber;
currentThumbnail = null;
}
}
String _normalizePhoneNumber(String number) {
return number
.replaceAll(RegExp(r'[\s\-\(\)]'), '')
.replaceFirst(RegExp(r'^\+'), '');
}
void _handleIncomingCall(String phoneNumber) {
if (_activeCallNumber == phoneNumber && _isCallPageVisible) {
print(
'CallService: Incoming call for $phoneNumber already active, skipping');
return;
}
_activeCallNumber = phoneNumber;
final context = navigatorKey.currentContext;
if (context == null) {
print(
'CallService: Context is null, queuing incoming call: $phoneNumber');
_pendingCall = {"phoneNumber": phoneNumber};
Future.delayed(Duration(milliseconds: 500), () => _checkPendingCall());
} else {
_navigateToIncomingCallPage(context);
}
}
Future<void> _checkPendingCall() async {
if (_pendingCall == null) {
print('CallService: No pending call to process');
return;
}
final phoneNumber = _pendingCall!["phoneNumber"];
if (_activeCallNumber == phoneNumber && _isCallPageVisible) {
print(
'CallService: Pending call for $phoneNumber already active, clearing');
_pendingCall = null;
return;
}
final context = navigatorKey.currentContext;
if (context != null) {
print('CallService: Processing queued call: $phoneNumber');
currentPhoneNumber = phoneNumber;
_activeCallNumber = phoneNumber;
await _fetchContactInfo(phoneNumber);
_navigateToIncomingCallPage(context);
_pendingCall = null;
} else {
print('CallService: Context still null, retrying...');
Future.delayed(Duration(milliseconds: 500), () => _checkPendingCall());
}
}
void _navigateToCallPage() {
if (_isNavigating) {
print('CallService: Navigation already in progress, skipping');
return;
}
_isNavigating = true;
final context = navigatorKey.currentContext;
if (context == null) {
print('CallService: Cannot navigate to CallPage, context is null');
_isNavigating = false;
return;
}
final currentRoute = ModalRoute.of(context)?.settings.name ?? 'unknown';
print(
'CallService: Navigating to CallPage, Visible: $_isCallPageVisible, Current Route: $currentRoute, Active Call: $_activeCallNumber, DisplayName: $currentDisplayName');
if (_isCallPageVisible &&
currentRoute == '/call' &&
_activeCallNumber == currentPhoneNumber) {
print(
'CallService: CallPage already visible for $_activeCallNumber, skipping navigation');
_isNavigating = false;
return;
}
if (_isCallPageVisible &&
currentRoute == '/incoming_call' &&
_activeCallNumber == currentPhoneNumber) {
print(
'CallService: Popping IncomingCallPage before navigating to CallPage');
Navigator.pop(context);
_isCallPageVisible = false;
}
if (currentPhoneNumber == null) {
print(
'CallService: Cannot navigate to CallPage, currentPhoneNumber is null');
_isNavigating = false;
return;
}
_activeCallNumber = currentPhoneNumber;
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: '/call'),
builder: (context) => CallPage(
displayName: currentDisplayName ?? currentPhoneNumber!,
phoneNumber: currentPhoneNumber!,
thumbnail: currentThumbnail,
),
),
).then((_) {
_isCallPageVisible = false;
_isNavigating = false;
print('CallService: CallPage popped, _isCallPageVisible set to false');
});
_isCallPageVisible = true;
}
void _navigateToIncomingCallPage(BuildContext context) {
if (_isNavigating) {
print('CallService: Navigation already in progress, skipping');
return;
}
_isNavigating = true;
final currentRoute = ModalRoute.of(context)?.settings.name ?? 'unknown';
print(
'CallService: Navigating to IncomingCallPage, Visible: $_isCallPageVisible, Current Route: $currentRoute, Active Call: $_activeCallNumber, DisplayName: $currentDisplayName');
if (_isCallPageVisible &&
currentRoute == '/incoming_call' &&
_activeCallNumber == currentPhoneNumber) {
print(
'CallService: IncomingCallPage already visible for $_activeCallNumber, skipping navigation');
_isNavigating = false;
return;
}
if (_isCallPageVisible && currentRoute == '/call') {
print('CallService: CallPage visible, not showing IncomingCallPage');
_isNavigating = false;
return;
}
if (currentPhoneNumber == null) {
print(
'CallService: Cannot navigate to IncomingCallPage, currentPhoneNumber is null');
_isNavigating = false;
return;
}
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: '/incoming_call'),
builder: (context) => IncomingCallPage(
displayName: currentDisplayName ?? currentPhoneNumber!,
phoneNumber: currentPhoneNumber!,
thumbnail: currentThumbnail,
),
),
).then((_) {
_isCallPageVisible = false;
_isNavigating = false;
print(
'CallService: IncomingCallPage popped, _isCallPageVisible set to false');
});
_isCallPageVisible = true;
}
void _closeCallPage() {
final context = navigatorKey.currentContext;
if (context == null) {
print('CallService: Cannot close page, context is null');
return;
}
// Only attempt to close if a call page is actually visible
if (!_isCallPageVisible) {
print('CallService: Call page already closed');
return;
}
print(
'CallService: Closing call page, _isCallPageVisible: $_isCallPageVisible, _pendingSimSwitch: ${_pendingSimSwitch != null}, _manualHangupFlag: $_manualHangupFlag');
// Use popUntil to ensure we go back to the home page
try {
Navigator.popUntil(context, (route) => route.isFirst);
_isCallPageVisible = false;
print('CallService: Used popUntil to return to home page');
} catch (e) {
print('CallService: Error with popUntil, trying regular pop: $e');
if (Navigator.canPop(context)) {
Navigator.pop(context);
_isCallPageVisible = false;
print('CallService: Used regular pop as fallback');
} else {
print('CallService: No page to pop, setting _isCallPageVisible to false');
_isCallPageVisible = false;
}
}
_activeCallNumber = null;
}
Future<Map<String, dynamic>> makeGsmCall(
BuildContext context, {
required String phoneNumber,
String? displayName,
Uint8List? thumbnail,
}) async {
try {
// Load default SIM slot from settings
final prefs = await SharedPreferences.getInstance();
final simSlot = prefs.getInt('default_sim_slot') ?? 0;
return await makeGsmCallWithSim(
context,
phoneNumber: phoneNumber,
displayName: displayName,
thumbnail: thumbnail,
simSlot: simSlot,
);
} catch (e) {
print("CallService: Error making call: $e");
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Error making call: $e")),
);
return {"status": "error", "message": e.toString()};
}
}
Future<Map<String, dynamic>> makeGsmCallWithSim(
BuildContext context, {
required String phoneNumber,
String? displayName,
Uint8List? thumbnail,
required int simSlot,
}) async {
try {
if (_activeCallNumber == phoneNumber && _isCallPageVisible) {
print('CallService: Call already active for $phoneNumber, skipping');
return {
"status": "already_active",
"message": "Call already in progress"
};
}
currentPhoneNumber = phoneNumber;
currentDisplayName = displayName ?? phoneNumber;
currentThumbnail = thumbnail;
currentSimSlot = simSlot; // Track the SIM slot being used
_simStateController.add(simSlot); // Notify UI of SIM change
if (displayName == null || thumbnail == null) {
await _fetchContactInfo(phoneNumber);
}
print(
'CallService: Making GSM call to $phoneNumber, displayName: $currentDisplayName, simSlot: $simSlot');
final result = await _channel.invokeMethod(
'makeGsmCall', {"phoneNumber": phoneNumber, "simSlot": simSlot});
print('CallService: makeGsmCall result: $result');
final resultMap = Map<String, dynamic>.from(result as Map);
if (resultMap["status"] != "calling") {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Failed to initiate call")),
);
}
return resultMap;
} catch (e) {
print("CallService: Error making call: $e");
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Error making call: $e")),
);
return {"status": "error", "message": e.toString()};
}
}
// Pending SIM switch data
static Map<String, dynamic>? _pendingSimSwitch;
static bool _manualHangupFlag = false; // Track if hangup was manual
// Getter to check if there's a pending SIM switch
static bool get hasPendingSimSwitch => _pendingSimSwitch != null;
Future<Map<String, dynamic>> hangUpCall(BuildContext context) async {
try {
print('CallService: ========== HANGUP INITIATED ==========');
print('CallService: _pendingSimSwitch: ${_pendingSimSwitch != null}');
print('CallService: _manualHangupFlag: $_manualHangupFlag');
print('CallService: _isCallPageVisible: $_isCallPageVisible');
final result = await _channel.invokeMethod('hangUpCall');
print('CallService: hangUpCall result: $result');
final resultMap = Map<String, dynamic>.from(result as Map);
if (resultMap["status"] != "ended") {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Failed to end call")),
);
} else {
// If hangup was successful, ensure call page closes after a short delay
// This is a fallback in case the native call state events don't fire properly
Future.delayed(const Duration(milliseconds: 1500), () {
if (_isCallPageVisible) {
print(
'CallService: FALLBACK - Force closing call page after hangup');
_closeCallPage();
_manualHangupFlag = false; // Reset flag
}
});
}
return resultMap;
} catch (e) {
print("CallService: Error hanging up call: $e");
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Error hanging up call: $e")),
);
return {"status": "error", "message": e.toString()};
}
}
Future<void> switchSimAndRedial({
required String phoneNumber,
required String displayName,
required int simSlot,
Uint8List? thumbnail,
}) async {
try {
print(
'CallService: Starting SIM switch to slot $simSlot for $phoneNumber');
// Store the redial information for after hangup
_pendingSimSwitch = {
'phoneNumber': phoneNumber,
'displayName': displayName,
'simSlot': simSlot,
'thumbnail': thumbnail,
};
// Hang up the current call - this will trigger the disconnected state
await _channel.invokeMethod('hangUpCall');
print(
'CallService: Hangup initiated, waiting for disconnection to complete redial');
} catch (e) {
print('CallService: Error during SIM switch: $e');
_pendingSimSwitch = null;
rethrow;
}
}
void _handlePendingSimSwitch() async {
if (_pendingSimSwitch == null) return;
final switchData = _pendingSimSwitch!;
_pendingSimSwitch = null;
try {
print('CallService: Executing pending SIM switch redial');
// Wait a moment to ensure the previous call is fully disconnected
await Future.delayed(const Duration(
milliseconds: 1000)); // Store the new call info for the redial
currentPhoneNumber = switchData['phoneNumber'];
currentDisplayName = switchData['displayName'];
currentThumbnail = switchData['thumbnail'];
currentSimSlot = switchData['simSlot']; // Track the new SIM slot
_simStateController.add(switchData['simSlot']); // Notify UI of SIM change
// Make the new call with the selected SIM
final result = await _channel.invokeMethod('makeGsmCall', {
'phoneNumber': switchData['phoneNumber'],
'simSlot': switchData['simSlot'],
});
print('CallService: SIM switch redial result: $result');
// Show success feedback
final context = navigatorKey.currentContext;
if (context != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Switched to SIM ${switchData['simSlot'] + 1} and redialing...'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
print('CallService: Error during SIM switch redial: $e');
// Show error feedback and close the call page
final context = navigatorKey.currentContext;
if (context != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to redial with new SIM: $e'),
backgroundColor: Colors.red,
),
);
// Close the call page since redial failed
_closeCallPage();
}
}
}
}