From 31ef1739495d3463b9b5cb73b48ca267e28d5e5c Mon Sep 17 00:00:00 2001 From: AlexisDanlos Date: Fri, 6 Jun 2025 21:52:25 +0200 Subject: [PATCH 01/12] feat: implement default SIM selection in settings --- .../java/com/example/dialer/MainActivity.java | 49 ++++++ dialer/android/gradle.properties | 2 +- dialer/lib/domain/services/call_service.dart | 156 ++++++++++++------ .../features/settings/settings.dart | 10 +- .../features/settings/sim/settings_sim.dart | 64 +++++++ 5 files changed, 230 insertions(+), 51 deletions(-) create mode 100644 dialer/android/app/src/main/java/com/example/dialer/MainActivity.java create mode 100644 dialer/lib/presentation/features/settings/sim/settings_sim.dart diff --git a/dialer/android/app/src/main/java/com/example/dialer/MainActivity.java b/dialer/android/app/src/main/java/com/example/dialer/MainActivity.java new file mode 100644 index 0000000..1fbc925 --- /dev/null +++ b/dialer/android/app/src/main/java/com/example/dialer/MainActivity.java @@ -0,0 +1,49 @@ +package com.example.dialer; + +import android.os.Bundle; +import android.net.Uri; +import android.content.Context; +import io.flutter.embedding.android.FlutterActivity; +import io.flutter.embedding.engine.FlutterEngine; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.common.MethodChannel.MethodCallHandler; +import io.flutter.plugin.common.MethodChannel.Result; +import io.flutter.plugin.common.MethodCall; +import android.telecom.TelecomManager; +import android.telecom.PhoneAccountHandle; +import java.util.List; +import java.util.Collections; + +public class MainActivity extends FlutterActivity { + private static final String CHANNEL = "call_service"; + + @Override + public void configureFlutterEngine(FlutterEngine flutterEngine) { + super.configureFlutterEngine(flutterEngine); + new MethodChannel(flutterEngine.getDartExecutor().getBinaryMessenger(), CHANNEL) + .setMethodCallHandler( + new MethodCallHandler() { + @Override + public void onMethodCall(MethodCall call, Result result) { + if (call.method.equals("makeGsmCall")) { + String phoneNumber = call.argument("phoneNumber"); + int simSlot = call.argument("simSlot"); + TelecomManager telecomManager = (TelecomManager) getSystemService(Context.TELECOM_SERVICE); + List accounts = telecomManager.getCallCapablePhoneAccounts(); + PhoneAccountHandle selectedAccount = accounts.get(simSlot < accounts.size() ? simSlot : 0); + Bundle extras = new Bundle(); + extras.putParcelable(TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, selectedAccount); + Uri uri = Uri.fromParts("tel", phoneNumber, null); + telecomManager.placeCall(uri, extras); + result.success(Collections.singletonMap("status", "calling")); + } else if (call.method.equals("hangUpCall")) { + // TODO: implement hangUpCall if needed + result.success(Collections.singletonMap("status", "ended")); + } else { + result.notImplemented(); + } + } + } + ); + } +} \ No newline at end of file diff --git a/dialer/android/gradle.properties b/dialer/android/gradle.properties index 78b3d37..513ff0d 100644 --- a/dialer/android/gradle.properties +++ b/dialer/android/gradle.properties @@ -2,4 +2,4 @@ org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryErro android.useAndroidX=true android.enableJetifier=true dev.steenbakker.mobile_scanner.useUnbundled=true -org.gradle.java.home=/usr/lib/jvm/java-17-openjdk-amd64 +# org.gradle.java.home=/usr/lib/jvm/java-17-openjdk-amd64 diff --git a/dialer/lib/domain/services/call_service.dart b/dialer/lib/domain/services/call_service.dart index 1c6d242..346f4dc 100644 --- a/dialer/lib/domain/services/call_service.dart +++ b/dialer/lib/domain/services/call_service.dart @@ -1,6 +1,7 @@ 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'; @@ -17,18 +18,22 @@ class CallService { static bool _isNavigating = false; final ContactService _contactService = ContactService(); final _callStateController = StreamController.broadcast(); - final _audioStateController = StreamController>.broadcast(); + final _audioStateController = + StreamController>.broadcast(); Map? _currentAudioState; - static final GlobalKey navigatorKey = GlobalKey(); - + static final GlobalKey navigatorKey = + GlobalKey(); + Stream get callStateStream => _callStateController.stream; - Stream> get audioStateStream => _audioStateController.stream; + Stream> get audioStateStream => + _audioStateController.stream; Map? get currentAudioState => _currentAudioState; CallService() { _channel.setMethodCallHandler((call) async { - print('CallService: Handling method call: ${call.method}, with args: ${call.arguments}'); + print( + 'CallService: Handling method call: ${call.method}, with args: ${call.arguments}'); switch (call.method) { case "callAdded": final phoneNumber = call.arguments["callId"] as String?; @@ -37,15 +42,18 @@ class CallService { print('CallService: Invalid callAdded args: $call.arguments'); return; } - final decodedPhoneNumber = Uri.decodeComponent(phoneNumber.replaceFirst('tel:', '')); + final decodedPhoneNumber = + Uri.decodeComponent(phoneNumber.replaceFirst('tel:', '')); print('CallService: Decoded phone number: $decodedPhoneNumber'); if (_activeCallNumber != decodedPhoneNumber) { currentPhoneNumber = decodedPhoneNumber; - if (currentDisplayName == null || currentDisplayName == currentPhoneNumber) { + if (currentDisplayName == null || + currentDisplayName == currentPhoneNumber) { await _fetchContactInfo(decodedPhoneNumber); } } - print('CallService: Call added, number: $currentPhoneNumber, displayName: $currentDisplayName, state: $state'); + print( + 'CallService: Call added, number: $currentPhoneNumber, displayName: $currentDisplayName, state: $state'); _callStateController.add(state); if (state == "ringing") { _handleIncomingCall(decodedPhoneNumber); @@ -57,10 +65,12 @@ class CallService { final state = call.arguments["state"] as String?; wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false; if (state == null) { - print('CallService: Invalid callStateChanged args: $call.arguments'); + print( + 'CallService: Invalid callStateChanged args: $call.arguments'); return; } - print('CallService: State changed to $state, wasPhoneLocked: $wasPhoneLocked'); + print( + 'CallService: State changed to $state, wasPhoneLocked: $wasPhoneLocked'); _callStateController.add(state); if (state == "disconnected" || state == "disconnecting") { _closeCallPage(); @@ -70,17 +80,24 @@ class CallService { _activeCallNumber = null; } 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) { + 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) { + } 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'); + print( + 'CallService: Skipping fetch, active call: $_activeCallNumber, current: $currentPhoneNumber, displayName: $currentDisplayName'); } _navigateToCallPage(); } else if (state == "ringing") { @@ -89,10 +106,12 @@ class CallService { print('CallService: Invalid ringing callId: $call.arguments'); return; } - final decodedPhoneNumber = Uri.decodeComponent(phoneNumber.replaceFirst('tel:', '')); + final decodedPhoneNumber = + Uri.decodeComponent(phoneNumber.replaceFirst('tel:', '')); if (_activeCallNumber != decodedPhoneNumber) { currentPhoneNumber = decodedPhoneNumber; - if (currentDisplayName == null || currentDisplayName == currentPhoneNumber) { + if (currentDisplayName == null || + currentDisplayName == currentPhoneNumber) { await _fetchContactInfo(decodedPhoneNumber); } } @@ -102,7 +121,8 @@ class CallService { case "callEnded": case "callRemoved": wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false; - print('CallService: Call ended/removed, wasPhoneLocked: $wasPhoneLocked'); + print( + 'CallService: Call ended/removed, wasPhoneLocked: $wasPhoneLocked'); _closeCallPage(); if (wasPhoneLocked) { await _channel.invokeMethod("callEndedFromFlutter"); @@ -116,24 +136,28 @@ class CallService { final phoneNumber = call.arguments["phoneNumber"] as String?; wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false; if (phoneNumber == null) { - print('CallService: Invalid incomingCallFromNotification args: $call.arguments'); + print( + 'CallService: Invalid incomingCallFromNotification args: $call.arguments'); return; } final decodedPhoneNumber = Uri.decodeComponent(phoneNumber); if (_activeCallNumber != decodedPhoneNumber) { currentPhoneNumber = decodedPhoneNumber; - if (currentDisplayName == null || currentDisplayName == currentPhoneNumber) { + if (currentDisplayName == null || + currentDisplayName == currentPhoneNumber) { await _fetchContactInfo(decodedPhoneNumber); } } - print('CallService: Incoming call from notification: $decodedPhoneNumber, displayName: $currentDisplayName, wasPhoneLocked: $wasPhoneLocked'); + 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'); + print( + 'CallService: Audio state changed, route: $route, muted: $muted, speaker: $speaker'); final audioState = { "route": route, "muted": muted, @@ -157,7 +181,8 @@ class CallService { } } - Future> muteCall(BuildContext context, {required bool mute}) async { + Future> muteCall(BuildContext context, + {required bool mute}) async { try { print('CallService: Toggling mute to $mute'); final result = await _channel.invokeMethod('muteCall', {'mute': mute}); @@ -178,10 +203,12 @@ class CallService { } } - Future> speakerCall(BuildContext context, {required bool speaker}) async { + Future> speakerCall(BuildContext context, + {required bool speaker}) async { try { print('CallService: Toggling speaker to $speaker'); - final result = await _channel.invokeMethod('speakerCall', {'speaker': speaker}); + final result = + await _channel.invokeMethod('speakerCall', {'speaker': speaker}); print('CallService: speakerCall result: $result'); return Map.from(result); } catch (e) { @@ -208,18 +235,21 @@ class CallService { for (var contact in contacts) { for (var phone in contact.phones) { final normalizedContactNumber = _normalizePhoneNumber(phone.number); - print('CallService: Comparing $normalizedPhoneNumber with contact number $normalizedContactNumber'); + 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"}'); + 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'); + print( + 'CallService: No contact match, using phoneNumber as displayName: $currentDisplayName'); } catch (e) { print('CallService: Error fetching contact info: $e'); currentDisplayName = phoneNumber; @@ -228,19 +258,23 @@ class CallService { } String _normalizePhoneNumber(String number) { - return number.replaceAll(RegExp(r'[\s\-\(\)]'), '').replaceFirst(RegExp(r'^\+'), ''); + 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'); + 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'); + print( + 'CallService: Context is null, queuing incoming call: $phoneNumber'); _pendingCall = {"phoneNumber": phoneNumber}; Future.delayed(Duration(milliseconds: 500), () => _checkPendingCall()); } else { @@ -256,7 +290,8 @@ class CallService { final phoneNumber = _pendingCall!["phoneNumber"]; if (_activeCallNumber == phoneNumber && _isCallPageVisible) { - print('CallService: Pending call for $phoneNumber already active, clearing'); + print( + 'CallService: Pending call for $phoneNumber already active, clearing'); _pendingCall = null; return; } @@ -289,19 +324,27 @@ class CallService { 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'); + 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'); + 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'); + print( + 'CallService: Cannot navigate to CallPage, currentPhoneNumber is null'); _isNavigating = false; return; } @@ -332,9 +375,13 @@ class CallService { _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'); + 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; } @@ -344,7 +391,8 @@ class CallService { return; } if (currentPhoneNumber == null) { - print('CallService: Cannot navigate to IncomingCallPage, currentPhoneNumber is null'); + print( + 'CallService: Cannot navigate to IncomingCallPage, currentPhoneNumber is null'); _isNavigating = false; return; } @@ -361,7 +409,8 @@ class CallService { ).then((_) { _isCallPageVisible = false; _isNavigating = false; - print('CallService: IncomingCallPage popped, _isCallPageVisible set to false'); + print( + 'CallService: IncomingCallPage popped, _isCallPageVisible set to false'); }); _isCallPageVisible = true; } @@ -372,7 +421,8 @@ class CallService { print('CallService: Cannot close page, context is null'); return; } - print('CallService: Closing call page, _isCallPageVisible: $_isCallPageVisible'); + print( + 'CallService: Closing call page, _isCallPageVisible: $_isCallPageVisible'); if (Navigator.canPop(context)) { print('CallService: Popping call page'); Navigator.pop(context); @@ -390,9 +440,15 @@ class CallService { Uint8List? thumbnail, }) async { try { + // Load default SIM slot from settings + final prefs = await SharedPreferences.getInstance(); + final simSlot = prefs.getInt('default_sim_slot') ?? 0; if (_activeCallNumber == phoneNumber && _isCallPageVisible) { print('CallService: Call already active for $phoneNumber, skipping'); - return {"status": "already_active", "message": "Call already in progress"}; + return { + "status": "already_active", + "message": "Call already in progress" + }; } currentPhoneNumber = phoneNumber; currentDisplayName = displayName ?? phoneNumber; @@ -400,8 +456,10 @@ class CallService { if (displayName == null || thumbnail == null) { await _fetchContactInfo(phoneNumber); } - print('CallService: Making GSM call to $phoneNumber, displayName: $currentDisplayName'); - final result = await _channel.invokeMethod('makeGsmCall', {"phoneNumber": 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.from(result as Map); if (resultMap["status"] != "calling") { @@ -439,4 +497,4 @@ class CallService { return {"status": "error", "message": e.toString()}; } } -} \ No newline at end of file +} diff --git a/dialer/lib/presentation/features/settings/settings.dart b/dialer/lib/presentation/features/settings/settings.dart index d6f1808..9d2cfe2 100644 --- a/dialer/lib/presentation/features/settings/settings.dart +++ b/dialer/lib/presentation/features/settings/settings.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:dialer/presentation/features/settings/call/settings_call.dart'; import 'package:dialer/presentation/features/settings/cryptography/key_management.dart'; import 'package:dialer/presentation/features/settings/blocked/settings_blocked.dart'; +import 'package:dialer/presentation/features/settings/sim/settings_sim.dart'; class SettingsPage extends StatelessWidget { const SettingsPage({super.key}); @@ -26,6 +27,12 @@ class SettingsPage extends StatelessWidget { MaterialPageRoute(builder: (context) => const BlockedNumbersPage()), ); break; + case 'Default SIM': + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const SettingsSimPage()), + ); + break; // Add more cases for other settings pages default: // Handle default or unknown settings @@ -38,7 +45,8 @@ class SettingsPage extends StatelessWidget { final settingsOptions = [ 'Calling settings', 'Key management', - 'Blocked numbers' + 'Blocked numbers', + 'Default SIM', ]; return Scaffold( diff --git a/dialer/lib/presentation/features/settings/sim/settings_sim.dart b/dialer/lib/presentation/features/settings/sim/settings_sim.dart new file mode 100644 index 0000000..c33938f --- /dev/null +++ b/dialer/lib/presentation/features/settings/sim/settings_sim.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class SettingsSimPage extends StatefulWidget { + const SettingsSimPage({super.key}); + + @override + _SettingsSimPageState createState() => _SettingsSimPageState(); +} + +class _SettingsSimPageState extends State { + int _selectedSim = 0; + + @override + void initState() { + super.initState(); + _loadDefaultSim(); + } + + void _loadDefaultSim() async { + final prefs = await SharedPreferences.getInstance(); + setState(() { + _selectedSim = prefs.getInt('default_sim_slot') ?? 0; + }); + } + + void _onSimChanged(int? value) async { + if (value != null) { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('default_sim_slot', value); + setState(() { + _selectedSim = value; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + title: const Text('Default SIM'), + ), + body: ListView( + children: [ + RadioListTile( + title: const Text('SIM 1', style: TextStyle(color: Colors.white)), + value: 0, + groupValue: _selectedSim, + onChanged: _onSimChanged, + activeColor: Colors.blue, + ), + RadioListTile( + title: const Text('SIM 2', style: TextStyle(color: Colors.white)), + value: 1, + groupValue: _selectedSim, + onChanged: _onSimChanged, + activeColor: Colors.blue, + ), + ], + ), + ); + } +} -- 2.45.2 From 29dfc0a2ac27e570ddd64278498421799cf0b93b Mon Sep 17 00:00:00 2001 From: AlexisDanlos Date: Wed, 11 Jun 2025 16:21:32 +0200 Subject: [PATCH 02/12] feat: update minimum SDK version and integrate SIM data handling --- dialer/android/app/build.gradle | 2 +- .../features/settings/sim/settings_sim.dart | 192 ++++++++++++++++-- dialer/pubspec.yaml | 3 +- 3 files changed, 177 insertions(+), 20 deletions(-) diff --git a/dialer/android/app/build.gradle b/dialer/android/app/build.gradle index eaadca1..d57b47c 100644 --- a/dialer/android/app/build.gradle +++ b/dialer/android/app/build.gradle @@ -24,7 +24,7 @@ android { applicationId = "com.icing.dialer" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion + minSdk = 23 targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName diff --git a/dialer/lib/presentation/features/settings/sim/settings_sim.dart b/dialer/lib/presentation/features/settings/sim/settings_sim.dart index c33938f..6872ea1 100644 --- a/dialer/lib/presentation/features/settings/sim/settings_sim.dart +++ b/dialer/lib/presentation/features/settings/sim/settings_sim.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sim_data_new/sim_data.dart'; class SettingsSimPage extends StatefulWidget { const SettingsSimPage({super.key}); @@ -10,13 +11,34 @@ class SettingsSimPage extends StatefulWidget { class _SettingsSimPageState extends State { int _selectedSim = 0; + SimData? _simData; + bool _isLoading = true; + String? _error; @override void initState() { super.initState(); + _loadSimCards(); _loadDefaultSim(); } + void _loadSimCards() async { + try { + final simData = await SimDataPlugin.getSimData(); + setState(() { + _simData = simData; + _isLoading = false; + _error = null; + }); + } catch (e) { + setState(() { + _isLoading = false; + _error = e.toString(); + }); + print('Error loading SIM cards: $e'); + } + } + void _loadDefaultSim() async { final prefs = await SharedPreferences.getInstance(); setState(() { @@ -41,24 +63,158 @@ class _SettingsSimPageState extends State { appBar: AppBar( title: const Text('Default SIM'), ), - body: ListView( - children: [ - RadioListTile( - title: const Text('SIM 1', style: TextStyle(color: Colors.white)), - value: 0, - groupValue: _selectedSim, - onChanged: _onSimChanged, - activeColor: Colors.blue, - ), - RadioListTile( - title: const Text('SIM 2', style: TextStyle(color: Colors.white)), - value: 1, - groupValue: _selectedSim, - onChanged: _onSimChanged, - activeColor: Colors.blue, - ), - ], - ), + body: _buildBody(), ); } + + Widget _buildBody() { + if (_isLoading) { + return const Center( + child: CircularProgressIndicator( + color: Colors.blue, + ), + ); + } + + if (_error != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + color: Colors.red, + size: 64, + ), + const SizedBox(height: 16), + Text( + 'Error loading SIM cards', + style: const TextStyle(color: Colors.white, fontSize: 18), + ), + const SizedBox(height: 8), + Text( + _error!, + style: const TextStyle(color: Colors.grey, fontSize: 14), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + setState(() { + _isLoading = true; + _error = null; + }); + _loadSimCards(); + }, + child: const Text('Retry'), + ), + const SizedBox(height: 16), + Text( + 'Fallback to default options:', + style: const TextStyle(color: Colors.grey, fontSize: 14), + ), + const SizedBox(height: 8), + _buildFallbackSimList(), + ], + ), + ); + } + + if (_simData == null || _simData!.cards.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.sim_card_alert, + color: Colors.orange, + size: 64, + ), + const SizedBox(height: 16), + const Text( + 'No SIM cards detected', + style: TextStyle(color: Colors.white, fontSize: 18), + ), + const SizedBox(height: 8), + const Text( + 'Using default options:', + style: TextStyle(color: Colors.grey, fontSize: 14), + ), + const SizedBox(height: 16), + _buildFallbackSimList(), + ], + ), + ); + } + + return ListView.builder( + itemCount: _simData!.cards.length, + itemBuilder: (context, index) { + final card = _simData!.cards[index]; + return RadioListTile( + title: Text( + _getSimDisplayName(card, index), + style: const TextStyle(color: Colors.white), + ), + subtitle: Text( + _getSimSubtitle(card), + style: const TextStyle(color: Colors.grey), + ), + value: card.slotIndex, + groupValue: _selectedSim, + onChanged: _onSimChanged, + activeColor: Colors.blue, + ); + }, + ); + } + + Widget _buildFallbackSimList() { + return Column( + children: [ + RadioListTile( + title: const Text('SIM 1', style: TextStyle(color: Colors.white)), + value: 0, + groupValue: _selectedSim, + onChanged: _onSimChanged, + activeColor: Colors.blue, + ), + RadioListTile( + title: const Text('SIM 2', style: TextStyle(color: Colors.white)), + value: 1, + groupValue: _selectedSim, + onChanged: _onSimChanged, + activeColor: Colors.blue, + ), + ], + ); + } + + String _getSimDisplayName(dynamic card, int index) { + if (card.displayName != null && card.displayName.isNotEmpty) { + return card.displayName; + } + if (card.carrierName != null && card.carrierName.isNotEmpty) { + return card.carrierName; + } + return 'SIM ${index + 1}'; + } + + String _getSimSubtitle(dynamic card) { + List subtitleParts = []; + + if (card.phoneNumber != null && card.phoneNumber.isNotEmpty) { + subtitleParts.add(card.phoneNumber); + } + + if (card.carrierName != null && card.carrierName.isNotEmpty) { + subtitleParts.add(card.carrierName); + } + + if (subtitleParts.isEmpty) { + subtitleParts.add('Slot ${card.slotIndex}'); + } + + return subtitleParts.join(' • '); + } } diff --git a/dialer/pubspec.yaml b/dialer/pubspec.yaml index 244050a..b90eac2 100644 --- a/dialer/pubspec.yaml +++ b/dialer/pubspec.yaml @@ -52,9 +52,10 @@ dependencies: audioplayers: ^6.1.0 cryptography: ^2.0.0 convert: ^3.0.1 - encrypt: ^5.0.3 + encrypt: ^5.0.3 uuid: ^4.5.1 provider: ^6.1.2 + sim_data_new: ^1.0.1 intl: any -- 2.45.2 From cdd3a470c07565b76bee279726327cc48e743d80 Mon Sep 17 00:00:00 2001 From: AlexisDanlos Date: Fri, 13 Jun 2025 15:22:11 +0200 Subject: [PATCH 03/12] feat: add SIM selection dialog and integrate SIM slot handling for calls --- .../icing/dialer/activities/MainActivity.kt | 6 +- .../com/icing/dialer/services/CallService.kt | 27 ++- dialer/lib/domain/services/call_service.dart | 24 +++ .../common/widgets/sim_selection_dialog.dart | 204 ++++++++++++++++++ .../presentation/features/call/call_page.dart | 102 ++++++++- 5 files changed, 347 insertions(+), 16 deletions(-) create mode 100644 dialer/lib/presentation/common/widgets/sim_selection_dialog.dart diff --git a/dialer/android/app/src/main/kotlin/com/icing/dialer/activities/MainActivity.kt b/dialer/android/app/src/main/kotlin/com/icing/dialer/activities/MainActivity.kt index f58cdab..d7b54b5 100644 --- a/dialer/android/app/src/main/kotlin/com/icing/dialer/activities/MainActivity.kt +++ b/dialer/android/app/src/main/kotlin/com/icing/dialer/activities/MainActivity.kt @@ -96,11 +96,11 @@ class MainActivity : FlutterActivity() { } } result.success(true) - } - "makeGsmCall" -> { + } "makeGsmCall" -> { val phoneNumber = call.argument("phoneNumber") + val simSlot = call.argument("simSlot") ?: 0 if (phoneNumber != null) { - val success = CallService.makeGsmCall(this, phoneNumber) + val success = CallService.makeGsmCall(this, phoneNumber, simSlot) if (success) { result.success(mapOf("status" to "calling", "phoneNumber" to phoneNumber)) } else { diff --git a/dialer/android/app/src/main/kotlin/com/icing/dialer/services/CallService.kt b/dialer/android/app/src/main/kotlin/com/icing/dialer/services/CallService.kt index 7958799..d3762af 100644 --- a/dialer/android/app/src/main/kotlin/com/icing/dialer/services/CallService.kt +++ b/dialer/android/app/src/main/kotlin/com/icing/dialer/services/CallService.kt @@ -13,14 +13,35 @@ import android.Manifest object CallService { private val TAG = "CallService" - fun makeGsmCall(context: Context, phoneNumber: String): Boolean { + fun makeGsmCall(context: Context, phoneNumber: String, simSlot: Int = 0): Boolean { return try { val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager val uri = Uri.parse("tel:$phoneNumber") if (ContextCompat.checkSelfPermission(context, Manifest.permission.CALL_PHONE) == PackageManager.PERMISSION_GRANTED) { - telecomManager.placeCall(uri, Bundle()) - Log.d(TAG, "Initiated call to $phoneNumber") + // Get available phone accounts (SIM cards) + val phoneAccounts = telecomManager.callCapablePhoneAccounts + + if (phoneAccounts.isNotEmpty()) { + // Select the appropriate SIM slot + val selectedAccount = if (simSlot < phoneAccounts.size) { + phoneAccounts[simSlot] + } else { + // Fallback to first available SIM if requested slot doesn't exist + phoneAccounts[0] + } + + val extras = Bundle().apply { + putParcelable(TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, selectedAccount) + } + + telecomManager.placeCall(uri, extras) + Log.d(TAG, "Initiated call to $phoneNumber using SIM slot $simSlot") + } else { + // No SIM cards available, make call without specifying SIM + telecomManager.placeCall(uri, Bundle()) + Log.d(TAG, "Initiated call to $phoneNumber without SIM selection (no SIMs available)") + } true } else { Log.e(TAG, "CALL_PHONE permission not granted") diff --git a/dialer/lib/domain/services/call_service.dart b/dialer/lib/domain/services/call_service.dart index 346f4dc..f3d17b1 100644 --- a/dialer/lib/domain/services/call_service.dart +++ b/dialer/lib/domain/services/call_service.dart @@ -443,6 +443,30 @@ class CallService { // 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> 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 { diff --git a/dialer/lib/presentation/common/widgets/sim_selection_dialog.dart b/dialer/lib/presentation/common/widgets/sim_selection_dialog.dart new file mode 100644 index 0000000..cd17034 --- /dev/null +++ b/dialer/lib/presentation/common/widgets/sim_selection_dialog.dart @@ -0,0 +1,204 @@ +import 'package:flutter/material.dart'; +import 'package:sim_data_new/sim_data.dart'; + +class SimSelectionDialog extends StatefulWidget { + final String phoneNumber; + final String displayName; + final Function(int simSlot) onSimSelected; + + const SimSelectionDialog({ + super.key, + required this.phoneNumber, + required this.displayName, + required this.onSimSelected, + }); + + @override + _SimSelectionDialogState createState() => _SimSelectionDialogState(); +} + +class _SimSelectionDialogState extends State { + SimData? _simData; + bool _isLoading = true; + String? _error; + int? _selectedSimSlot; + + @override + void initState() { + super.initState(); + _loadSimCards(); + } + + void _loadSimCards() async { + try { + final simData = await SimDataPlugin.getSimData(); + setState(() { + _simData = simData; + _isLoading = false; + _error = null; + }); + } catch (e) { + setState(() { + _isLoading = false; + _error = e.toString(); + }); + print('Error loading SIM cards: $e'); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + backgroundColor: Colors.grey[900], + title: const Text( + 'Select SIM for Call', + style: TextStyle(color: Colors.white), + ), + content: _buildContent(), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text( + 'Cancel', + style: TextStyle(color: Colors.grey), + ), + ), + if (_selectedSimSlot != null) + TextButton( + onPressed: () { + widget.onSimSelected(_selectedSimSlot!); + Navigator.of(context).pop(); + }, + child: const Text( + 'Switch SIM', + style: TextStyle(color: Colors.blue), + ), + ), + ], + ); + } + + Widget _buildContent() { + if (_isLoading) { + return const SizedBox( + height: 100, + child: Center( + child: CircularProgressIndicator(color: Colors.blue), + ), + ); + } + + if (_error != null) { + return _buildErrorContent(); + } + + if (_simData?.cards.isEmpty ?? true) { + return _buildFallbackContent(); + } + + return _buildSimList(); + } + + Widget _buildErrorContent() { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.error_outline, + color: Colors.red, + size: 48, + ), + const SizedBox(height: 16), + Text( + 'Error loading SIM cards', + style: const TextStyle(color: Colors.white), + ), + const SizedBox(height: 8), + Text( + _error!, + style: const TextStyle(color: Colors.grey, fontSize: 12), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadSimCards, + child: const Text('Retry'), + ), + ], + ); + } + + Widget _buildFallbackContent() { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildSimTile('SIM 1', 'Slot 0', 0), + _buildSimTile('SIM 2', 'Slot 1', 1), + ], + ); + } + + Widget _buildSimList() { + return Column( + mainAxisSize: MainAxisSize.min, + children: _simData!.cards.map((card) { + final index = _simData!.cards.indexOf(card); + return _buildSimTile( + _getSimDisplayName(card, index), + _getSimSubtitle(card), + card.slotIndex, + ); + }).toList(), + ); + } + + Widget _buildSimTile(String title, String subtitle, int slotIndex) { + return RadioListTile( + title: Text( + title, + style: const TextStyle(color: Colors.white), + ), + subtitle: Text( + subtitle, + style: const TextStyle(color: Colors.grey), + ), + value: slotIndex, + groupValue: _selectedSimSlot, + onChanged: (value) { + setState(() { + _selectedSimSlot = value; + }); + }, + activeColor: Colors.blue, + ); + } + + String _getSimDisplayName(dynamic card, int index) { + if (card.displayName != null && card.displayName.isNotEmpty) { + return card.displayName; + } + if (card.carrierName != null && card.carrierName.isNotEmpty) { + return card.carrierName; + } + return 'SIM ${index + 1}'; + } + + String _getSimSubtitle(dynamic card) { + List subtitleParts = []; + + if (card.phoneNumber != null && card.phoneNumber.isNotEmpty) { + subtitleParts.add(card.phoneNumber); + } + + if (card.carrierName != null && + card.carrierName.isNotEmpty && + (card.displayName == null || card.displayName.isEmpty)) { + subtitleParts.add(card.carrierName); + } + + if (subtitleParts.isEmpty) { + subtitleParts.add('Slot ${card.slotIndex}'); + } + + return subtitleParts.join(' • '); + } +} diff --git a/dialer/lib/presentation/features/call/call_page.dart b/dialer/lib/presentation/features/call/call_page.dart index 38c665b..084eae7 100644 --- a/dialer/lib/presentation/features/call/call_page.dart +++ b/dialer/lib/presentation/features/call/call_page.dart @@ -4,6 +4,7 @@ import 'package:flutter_contacts/flutter_contacts.dart'; import 'package:dialer/domain/services/call_service.dart'; import 'package:dialer/domain/services/obfuscate_service.dart'; import 'package:dialer/presentation/common/widgets/username_color_generator.dart'; +import 'package:dialer/presentation/common/widgets/sim_selection_dialog.dart'; import 'package:flutter/services.dart'; class CallPage extends StatefulWidget { @@ -180,7 +181,7 @@ class _CallPageState extends State { final result = await _callService.speakerCall(context, speaker: !isSpeaker); print('CallPage: Speaker call result: $result'); - if (result['status'] != 'success') { + if (mounted && result['status'] != 'success') { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Failed to toggle speaker: ${result['message']}')), @@ -202,10 +203,89 @@ class _CallPageState extends State { }); } - void _toggleIcingProtocol() { - setState(() { - icingProtocolOk = !icingProtocolOk; - }); + void _showSimSelectionDialog() { + showDialog( + context: context, + builder: (BuildContext context) { + return SimSelectionDialog( + phoneNumber: widget.phoneNumber, + displayName: widget.displayName, + onSimSelected: _switchToNewSim, + ); + }, + ); + } + + void _switchToNewSim(int simSlot) async { + try { + print( + 'CallPage: Switching to SIM slot $simSlot for ${widget.phoneNumber}'); + + // Check if widget is still mounted before starting + if (!mounted) { + print('CallPage: Widget unmounted, canceling SIM switch operation'); + return; + } + + // First hang up the current call + await _callService.hangUpCall(context); + + // Wait a brief moment for the call to end + await Future.delayed(const Duration(milliseconds: 500)); + + // Check if widget is still mounted before proceeding + if (!mounted) { + print('CallPage: Widget unmounted, canceling SIM switch operation'); + return; + } + + // Make a new call with the selected SIM + final result = await _callService.makeGsmCallWithSim( + context, + phoneNumber: widget.phoneNumber, + displayName: widget.displayName, + thumbnail: widget.thumbnail, + simSlot: simSlot, + ); + + // Check if widget is still mounted before showing snackbar + if (!mounted) { + print('CallPage: Widget unmounted, skipping result notification'); + return; + } + + if (result['status'] == 'calling') { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Switched to SIM ${simSlot + 1} and redialing...'), + backgroundColor: Colors.green, + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Failed to switch SIM and redial: ${result['message'] ?? 'Unknown error'}'), + backgroundColor: Colors.red, + ), + ); + } + } catch (e) { + print('CallPage: Error switching SIM: $e'); + + // Check if widget is still mounted before showing error snackbar + if (!mounted) { + print('CallPage: Widget unmounted, skipping error notification'); + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error switching SIM: $e'), + backgroundColor: Colors.red, + ), + ); + } } void _hangUp() async { @@ -228,15 +308,17 @@ class _CallPageState extends State { final newContact = Contact()..phones = [Phone(widget.phoneNumber)]; final updatedContact = await FlutterContacts.openExternalInsert(newContact); - if (updatedContact != null) { + if (mounted && updatedContact != null) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Contact added successfully!')), ); } } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Permission denied for contacts')), - ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Permission denied for contacts')), + ); + } } } @@ -510,7 +592,7 @@ class _CallPageState extends State { mainAxisSize: MainAxisSize.min, children: [ IconButton( - onPressed: () {}, + onPressed: _showSimSelectionDialog, icon: const Icon( Icons.sim_card, color: Colors.white, -- 2.45.2 From c8ea9204ffac5ed9a19f3d7b2ac3803ca2da1758 Mon Sep 17 00:00:00 2001 From: AlexisDanlos Date: Wed, 18 Jun 2025 17:33:31 +0200 Subject: [PATCH 04/12] feat: implement pending SIM switch handling and improve call state management --- dialer/lib/domain/services/call_service.dart | 110 +++++++++++++++- .../presentation/features/call/call_page.dart | 120 ++++++++---------- 2 files changed, 161 insertions(+), 69 deletions(-) diff --git a/dialer/lib/domain/services/call_service.dart b/dialer/lib/domain/services/call_service.dart index f3d17b1..4a5b2dd 100644 --- a/dialer/lib/domain/services/call_service.dart +++ b/dialer/lib/domain/services/call_service.dart @@ -73,11 +73,16 @@ class CallService { 'CallService: State changed to $state, wasPhoneLocked: $wasPhoneLocked'); _callStateController.add(state); if (state == "disconnected" || state == "disconnecting") { - _closeCallPage(); + // Only close call page if there's no pending SIM switch + if (_pendingSimSwitch == null) { + _closeCallPage(); + } if (wasPhoneLocked) { await _channel.invokeMethod("callEndedFromFlutter"); } _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 && @@ -123,7 +128,10 @@ class CallService { wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false; print( 'CallService: Call ended/removed, wasPhoneLocked: $wasPhoneLocked'); - _closeCallPage(); + // Only close call page if there's no pending SIM switch + if (_pendingSimSwitch == null) { + _closeCallPage(); + } if (wasPhoneLocked) { await _channel.invokeMethod("callEndedFromFlutter"); } @@ -421,6 +429,13 @@ class CallService { 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'); if (Navigator.canPop(context)) { @@ -428,7 +443,8 @@ class CallService { Navigator.pop(context); _isCallPageVisible = false; } else { - print('CallService: No page to pop'); + print('CallService: No page to pop, setting _isCallPageVisible to false'); + _isCallPageVisible = false; } _activeCallNumber = null; } @@ -501,6 +517,12 @@ class CallService { } } + // Pending SIM switch data + static Map? _pendingSimSwitch; + + // Getter to check if there's a pending SIM switch + static bool get hasPendingSimSwitch => _pendingSimSwitch != null; + Future> hangUpCall(BuildContext context) async { try { print('CallService: Hanging up call'); @@ -521,4 +543,86 @@ class CallService { return {"status": "error", "message": e.toString()}; } } + + Future 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']; // 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(); + } + } + } } diff --git a/dialer/lib/presentation/features/call/call_page.dart b/dialer/lib/presentation/features/call/call_page.dart index 084eae7..885c7e2 100644 --- a/dialer/lib/presentation/features/call/call_page.dart +++ b/dialer/lib/presentation/features/call/call_page.dart @@ -36,6 +36,7 @@ class _CallPageState extends State { String _callStatus = "Calling..."; StreamSubscription? _callStateSubscription; StreamSubscription>? _audioStateSubscription; + bool _isCallActive = true; // Track if call is still active bool get isNumberUnknown => widget.displayName == widget.phoneNumber; @@ -70,10 +71,19 @@ class _CallPageState extends State { try { final state = await _callService.getCallState(); print('CallPage: Initial call state: $state'); - if (mounted && state == "active") { + if (mounted) { setState(() { - _callStatus = "00:00"; - _startCallTimer(); + if (state == "active") { + _callStatus = "00:00"; + _isCallActive = true; + _startCallTimer(); + } else if (state == "disconnected" || state == "disconnecting") { + _callStatus = "Call Ended"; + _isCallActive = false; + } else { + _callStatus = "Calling..."; + _isCallActive = true; + } }); } } catch (e) { @@ -88,12 +98,23 @@ class _CallPageState extends State { setState(() { if (state == "active") { _callStatus = "00:00"; + _isCallActive = true; _startCallTimer(); } else if (state == "disconnected" || state == "disconnecting") { _callTimer?.cancel(); _callStatus = "Call Ended"; + _isCallActive = + false; // Only navigate back if there's no pending SIM switch + if (!CallService.hasPendingSimSwitch) { + Future.delayed(const Duration(seconds: 2), () { + if (mounted && Navigator.canPop(context)) { + Navigator.of(context).pop(); + } + }); + } } else { _callStatus = "Calling..."; + _isCallActive = true; } }); } @@ -219,76 +240,39 @@ class _CallPageState extends State { void _switchToNewSim(int simSlot) async { try { print( - 'CallPage: Switching to SIM slot $simSlot for ${widget.phoneNumber}'); + 'CallPage: Initiating SIM switch to slot $simSlot for ${widget.phoneNumber}'); - // Check if widget is still mounted before starting - if (!mounted) { - print('CallPage: Widget unmounted, canceling SIM switch operation'); - return; - } - - // First hang up the current call - await _callService.hangUpCall(context); - - // Wait a brief moment for the call to end - await Future.delayed(const Duration(milliseconds: 500)); - - // Check if widget is still mounted before proceeding - if (!mounted) { - print('CallPage: Widget unmounted, canceling SIM switch operation'); - return; - } - - // Make a new call with the selected SIM - final result = await _callService.makeGsmCallWithSim( - context, + // Use the CallService to handle the SIM switch logic + await _callService.switchSimAndRedial( phoneNumber: widget.phoneNumber, displayName: widget.displayName, - thumbnail: widget.thumbnail, simSlot: simSlot, + thumbnail: widget.thumbnail, ); - // Check if widget is still mounted before showing snackbar - if (!mounted) { - print('CallPage: Widget unmounted, skipping result notification'); - return; - } + print('CallPage: SIM switch initiated successfully'); + } catch (e) { + print('CallPage: Error initiating SIM switch: $e'); - if (result['status'] == 'calling') { + // Show error feedback if widget is still mounted + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Switched to SIM ${simSlot + 1} and redialing...'), - backgroundColor: Colors.green, - ), - ); - } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Failed to switch SIM and redial: ${result['message'] ?? 'Unknown error'}'), + content: Text('Error switching SIM: $e'), backgroundColor: Colors.red, ), ); } - } catch (e) { - print('CallPage: Error switching SIM: $e'); - - // Check if widget is still mounted before showing error snackbar - if (!mounted) { - print('CallPage: Widget unmounted, skipping error notification'); - return; - } - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Error switching SIM: $e'), - backgroundColor: Colors.red, - ), - ); } } void _hangUp() async { + // Don't try to hang up if call is already ended + if (!_isCallActive) { + print('CallPage: Ignoring hangup - call already ended'); + return; + } + try { print('CallPage: Initiating hangUp'); final result = await _callService.hangUpCall(context); @@ -331,9 +315,9 @@ class _CallPageState extends State { print( 'CallPage: Building UI, _callStatus: $_callStatus, route: ${ModalRoute.of(context)?.settings.name ?? "unknown"}'); return PopScope( - canPop: _callStatus == "Call Ended", + canPop: !_isCallActive, onPopInvoked: (didPop) { - if (!didPop) { + if (!didPop && _isCallActive) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Cannot leave during an active call')), ); @@ -592,10 +576,14 @@ class _CallPageState extends State { mainAxisSize: MainAxisSize.min, children: [ IconButton( - onPressed: _showSimSelectionDialog, - icon: const Icon( + onPressed: _isCallActive + ? _showSimSelectionDialog + : null, + icon: Icon( Icons.sim_card, - color: Colors.white, + color: _isCallActive + ? Colors.white + : Colors.grey, size: 32, ), ), @@ -621,15 +609,15 @@ class _CallPageState extends State { Padding( padding: const EdgeInsets.only(bottom: 16.0), child: GestureDetector( - onTap: _hangUp, + onTap: _isCallActive ? _hangUp : null, child: Container( padding: const EdgeInsets.all(12), - decoration: const BoxDecoration( - color: Colors.red, + decoration: BoxDecoration( + color: _isCallActive ? Colors.red : Colors.grey, shape: BoxShape.circle, ), - child: const Icon( - Icons.call_end, + child: Icon( + _isCallActive ? Icons.call_end : Icons.call_end, color: Colors.white, size: 32, ), -- 2.45.2 From 71485a434631bbc42618c913d85ff000c1feaedf Mon Sep 17 00:00:00 2001 From: AlexisDanlos Date: Wed, 18 Jun 2025 18:31:13 +0200 Subject: [PATCH 05/12] feat: enhance SIM management with state tracking and UI updates --- dialer/lib/domain/services/call_service.dart | 58 +++++++++++++++---- .../presentation/features/call/call_page.dart | 55 ++++++++++++++---- 2 files changed, 92 insertions(+), 21 deletions(-) diff --git a/dialer/lib/domain/services/call_service.dart b/dialer/lib/domain/services/call_service.dart index 4a5b2dd..5b95683 100644 --- a/dialer/lib/domain/services/call_service.dart +++ b/dialer/lib/domain/services/call_service.dart @@ -11,6 +11,7 @@ class CallService { 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? _pendingCall; static bool wasPhoneLocked = false; @@ -20,15 +21,38 @@ class CallService { final _callStateController = StreamController.broadcast(); final _audioStateController = StreamController>.broadcast(); + final _simStateController = StreamController.broadcast(); Map? _currentAudioState; static final GlobalKey navigatorKey = GlobalKey(); - Stream get callStateStream => _callStateController.stream; Stream> get audioStateStream => _audioStateController.stream; + Stream get simStateStream => _simStateController.stream; Map? 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, but setting manual hangup flag'); + _manualHangupFlag = + true; // Still mark as manual even if no pending switch + } + } CallService() { _channel.setMethodCallHandler((call) async { @@ -73,9 +97,15 @@ class CallService { 'CallService: State changed to $state, wasPhoneLocked: $wasPhoneLocked'); _callStateController.add(state); if (state == "disconnected" || state == "disconnecting") { - // Only close call page if there's no pending SIM switch - if (_pendingSimSwitch == null) { + // Close call page if no pending SIM switch OR if it was a manual hangup + if (_pendingSimSwitch == null || _manualHangupFlag) { _closeCallPage(); + // Only 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"); @@ -127,9 +157,8 @@ class CallService { case "callRemoved": wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false; print( - 'CallService: Call ended/removed, wasPhoneLocked: $wasPhoneLocked'); - // Only close call page if there's no pending SIM switch - if (_pendingSimSwitch == null) { + 'CallService: Call ended/removed, wasPhoneLocked: $wasPhoneLocked'); // Only close call page if there's no pending SIM switch AND no manual hangup in progress + if (_pendingSimSwitch == null && !_manualHangupFlag) { _closeCallPage(); } if (wasPhoneLocked) { @@ -138,6 +167,8 @@ class CallService { 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": @@ -493,6 +524,8 @@ class CallService { 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); } @@ -519,6 +552,7 @@ class CallService { // Pending SIM switch data static Map? _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; @@ -583,13 +617,15 @@ class CallService { 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 + await Future.delayed(const Duration( + milliseconds: 1000)); // Store the new call info for the redial currentPhoneNumber = switchData['phoneNumber']; currentDisplayName = switchData['displayName']; - currentThumbnail = - switchData['thumbnail']; // Make the new call with the selected SIM + 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'], diff --git a/dialer/lib/presentation/features/call/call_page.dart b/dialer/lib/presentation/features/call/call_page.dart index 885c7e2..2497956 100644 --- a/dialer/lib/presentation/features/call/call_page.dart +++ b/dialer/lib/presentation/features/call/call_page.dart @@ -36,6 +36,7 @@ class _CallPageState extends State { String _callStatus = "Calling..."; StreamSubscription? _callStateSubscription; StreamSubscription>? _audioStateSubscription; + StreamSubscription? _simStateSubscription; bool _isCallActive = true; // Track if call is still active bool get isNumberUnknown => widget.displayName == widget.phoneNumber; @@ -46,6 +47,7 @@ class _CallPageState extends State { _checkInitialCallState(); _listenToCallState(); _listenToAudioState(); + _listenToSimState(); _setInitialAudioState(); } @@ -54,6 +56,7 @@ class _CallPageState extends State { _callTimer?.cancel(); _callStateSubscription?.cancel(); _audioStateSubscription?.cancel(); + _simStateSubscription?.cancel(); super.dispose(); } @@ -103,15 +106,8 @@ class _CallPageState extends State { } else if (state == "disconnected" || state == "disconnecting") { _callTimer?.cancel(); _callStatus = "Call Ended"; - _isCallActive = - false; // Only navigate back if there's no pending SIM switch - if (!CallService.hasPendingSimSwitch) { - Future.delayed(const Duration(seconds: 2), () { - if (mounted && Navigator.canPop(context)) { - Navigator.of(context).pop(); - } - }); - } + _isCallActive = false; + // Let CallService handle navigation - don't navigate from here } else { _callStatus = "Calling..."; _isCallActive = true; @@ -132,6 +128,17 @@ class _CallPageState extends State { }); } + void _listenToSimState() { + _simStateSubscription = _callService.simStateStream.listen((simSlot) { + if (mounted) { + setState(() { + // UI will update automatically because we're listening to the stream + // The SIM display will show the new SIM slot + }); + } + }); + } + void _startCallTimer() { _callTimer?.cancel(); _callTimer = Timer.periodic(const Duration(seconds: 1), (timer) { @@ -274,7 +281,12 @@ class _CallPageState extends State { } try { - print('CallPage: Initiating hangUp'); + print( + 'CallPage: Initiating manual hangUp - canceling any pending SIM switch'); + + // Cancel any pending SIM switch since user is manually hanging up + _callService.cancelPendingSimSwitch(); + final result = await _callService.hangUpCall(context); print('CallPage: Hang up result: $result'); } catch (e) { @@ -386,6 +398,29 @@ class _CallPageState extends State { color: Colors.white70, ), ), + // Show SIM information if available + if (CallService.getCurrentSimDisplayName() != null) + Container( + margin: const EdgeInsets.only(top: 4.0), + padding: const EdgeInsets.symmetric( + horizontal: 8.0, vertical: 2.0), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.blue.withOpacity(0.5), + width: 1, + ), + ), + child: Text( + CallService.getCurrentSimDisplayName()!, + style: TextStyle( + fontSize: statusFontSize - 2, + color: Colors.lightBlueAccent, + fontWeight: FontWeight.w500, + ), + ), + ), ], ), ), -- 2.45.2 From 68761b6e44e7feac6f271178c6eac02dc8d3ca0b Mon Sep 17 00:00:00 2001 From: AlexisDanlos Date: Wed, 18 Jun 2025 18:58:05 +0200 Subject: [PATCH 06/12] feat: improve call handling with manual hangup support and UI updates --- dialer/lib/domain/services/call_service.dart | 59 +++++++++++++++---- .../presentation/features/call/call_page.dart | 17 ++++-- 2 files changed, 59 insertions(+), 17 deletions(-) diff --git a/dialer/lib/domain/services/call_service.dart b/dialer/lib/domain/services/call_service.dart index 5b95683..e6669f3 100644 --- a/dialer/lib/domain/services/call_service.dart +++ b/dialer/lib/domain/services/call_service.dart @@ -47,10 +47,8 @@ class CallService { _manualHangupFlag = true; // Mark that hangup was manual print('CallService: Manual hangup flag set to $_manualHangupFlag'); } else { - print( - 'CallService: No pending SIM switch to cancel, but setting manual hangup flag'); - _manualHangupFlag = - true; // Still mark as manual even if no pending switch + print('CallService: No pending SIM switch to cancel'); + // Don't set manual hangup flag if there's no SIM switch to cancel } } @@ -97,8 +95,15 @@ class CallService { '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'); + // Close call page if no pending SIM switch OR if it was a manual hangup if (_pendingSimSwitch == null || _manualHangupFlag) { + print('CallService: Condition met, calling _closeCallPage()'); _closeCallPage(); // Only reset manual hangup flag after successful page close if (_manualHangupFlag) { @@ -106,6 +111,8 @@ class CallService { 'CallService: Resetting manual hangup flag after page close'); _manualHangupFlag = false; } + } else { + print('CallService: NOT closing call page - condition not met'); } if (wasPhoneLocked) { await _channel.invokeMethod("callEndedFromFlutter"); @@ -156,10 +163,24 @@ class CallService { case "callEnded": case "callRemoved": wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false; - print( - 'CallService: Call ended/removed, wasPhoneLocked: $wasPhoneLocked'); // Only close call page if there's no pending SIM switch AND no manual hangup in progress - if (_pendingSimSwitch == null && !_manualHangupFlag) { + print('CallService: ========== CALL ENDED/REMOVED =========='); + print('CallService: wasPhoneLocked: $wasPhoneLocked'); + print('CallService: _pendingSimSwitch: ${_pendingSimSwitch != null}'); + print('CallService: _manualHangupFlag: $_manualHangupFlag'); + print('CallService: _isCallPageVisible: $_isCallPageVisible'); + + // Only close call page if there's no pending SIM switch OR if it was a manual hangup during SIM switch + if (_pendingSimSwitch == null || _manualHangupFlag) { + print('CallService: Condition met, calling _closeCallPage()'); _closeCallPage(); + // Reset manual hangup flag after closing page + if (_manualHangupFlag) { + print( + 'CallService: Resetting manual hangup flag after callEnded'); + _manualHangupFlag = false; + } + } else { + print('CallService: NOT closing call page - condition not met'); } if (wasPhoneLocked) { await _channel.invokeMethod("callEndedFromFlutter"); @@ -388,7 +409,7 @@ class CallService { return; } _activeCallNumber = currentPhoneNumber; - Navigator.pushReplacement( + Navigator.push( context, MaterialPageRoute( settings: const RouteSettings(name: '/call'), @@ -468,7 +489,7 @@ class CallService { } print( - 'CallService: Closing call page, _isCallPageVisible: $_isCallPageVisible'); + 'CallService: Closing call page, _isCallPageVisible: $_isCallPageVisible, _pendingSimSwitch: ${_pendingSimSwitch != null}, _manualHangupFlag: $_manualHangupFlag'); if (Navigator.canPop(context)) { print('CallService: Popping call page'); Navigator.pop(context); @@ -556,18 +577,34 @@ class CallService { // Getter to check if there's a pending SIM switch static bool get hasPendingSimSwitch => _pendingSimSwitch != null; - Future> hangUpCall(BuildContext context) async { try { - print('CallService: Hanging up call'); + 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.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"); diff --git a/dialer/lib/presentation/features/call/call_page.dart b/dialer/lib/presentation/features/call/call_page.dart index 2497956..c6ec56b 100644 --- a/dialer/lib/presentation/features/call/call_page.dart +++ b/dialer/lib/presentation/features/call/call_page.dart @@ -284,6 +284,12 @@ class _CallPageState extends State { print( 'CallPage: Initiating manual hangUp - canceling any pending SIM switch'); + // Immediately mark call as inactive to allow page navigation + setState(() { + _isCallActive = false; + _callStatus = "Ending Call..."; + }); + // Cancel any pending SIM switch since user is manually hanging up _callService.cancelPendingSimSwitch(); @@ -327,13 +333,12 @@ class _CallPageState extends State { print( 'CallPage: Building UI, _callStatus: $_callStatus, route: ${ModalRoute.of(context)?.settings.name ?? "unknown"}'); return PopScope( - canPop: !_isCallActive, + canPop: + true, // Always allow popping - CallService manages when it's appropriate onPopInvoked: (didPop) { - if (!didPop && _isCallActive) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Cannot leave during an active call')), - ); - } + print( + 'CallPage: PopScope onPopInvoked - didPop: $didPop, _isCallActive: $_isCallActive, _callStatus: $_callStatus'); + // No longer prevent popping during active calls - CallService handles this }, child: Scaffold( body: Container( -- 2.45.2 From f440767b3872a82f6d17a06a6206d2436edb151c Mon Sep 17 00:00:00 2001 From: AlexisDanlos Date: Wed, 18 Jun 2025 19:14:58 +0200 Subject: [PATCH 07/12] feat: add human-readable SIM name handling and update UI accordingly --- .../presentation/features/call/call_page.dart | 54 +++++++++++++++---- 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/dialer/lib/presentation/features/call/call_page.dart b/dialer/lib/presentation/features/call/call_page.dart index c6ec56b..5791afc 100644 --- a/dialer/lib/presentation/features/call/call_page.dart +++ b/dialer/lib/presentation/features/call/call_page.dart @@ -6,6 +6,7 @@ import 'package:dialer/domain/services/obfuscate_service.dart'; import 'package:dialer/presentation/common/widgets/username_color_generator.dart'; import 'package:dialer/presentation/common/widgets/sim_selection_dialog.dart'; import 'package:flutter/services.dart'; +import 'package:sim_data_new/sim_data.dart'; class CallPage extends StatefulWidget { final String displayName; @@ -38,9 +39,47 @@ class _CallPageState extends State { StreamSubscription>? _audioStateSubscription; StreamSubscription? _simStateSubscription; bool _isCallActive = true; // Track if call is still active + String? _simName; // Human-readable SIM card name bool get isNumberUnknown => widget.displayName == widget.phoneNumber; + // Fetch and update human-readable SIM name based on slot + Future _updateSimName(int? simSlot) async { + if (!mounted) return; + if (simSlot != null) { + try { + final simData = await SimDataPlugin.getSimData(); + // Find the SIM card matching the slot index, if any + dynamic card; + for (var c in simData.cards) { + if (c.slotIndex == simSlot) { + card = c; + break; + } + } + String name; + if (card != null && card.displayName.isNotEmpty) { + name = card.displayName; + } else if (card != null && card.carrierName.isNotEmpty) { + name = card.carrierName; + } else { + name = 'SIM ${simSlot + 1}'; + } + setState(() { + _simName = name; + }); + } catch (e) { + setState(() { + _simName = 'SIM ${simSlot + 1}'; + }); + } + } else { + setState(() { + _simName = null; + }); + } + } + @override void initState() { super.initState(); @@ -48,6 +87,7 @@ class _CallPageState extends State { _listenToCallState(); _listenToAudioState(); _listenToSimState(); + _updateSimName(CallService.getCurrentSimSlot); // Initial SIM name _setInitialAudioState(); } @@ -130,12 +170,7 @@ class _CallPageState extends State { void _listenToSimState() { _simStateSubscription = _callService.simStateStream.listen((simSlot) { - if (mounted) { - setState(() { - // UI will update automatically because we're listening to the stream - // The SIM display will show the new SIM slot - }); - } + _updateSimName(simSlot); }); } @@ -403,8 +438,8 @@ class _CallPageState extends State { color: Colors.white70, ), ), - // Show SIM information if available - if (CallService.getCurrentSimDisplayName() != null) + // Show SIM information if a SIM slot has been set + if (_simName != null) Container( margin: const EdgeInsets.only(top: 4.0), padding: const EdgeInsets.symmetric( @@ -418,7 +453,8 @@ class _CallPageState extends State { ), ), child: Text( - CallService.getCurrentSimDisplayName()!, + // Show human-readable SIM name plus slot number + '$_simName (SIM ${CallService.getCurrentSimSlot! + 1})', style: TextStyle( fontSize: statusFontSize - 2, color: Colors.lightBlueAccent, -- 2.45.2 From 10728ad6e0514eec0636bab65d9d53a65ef00add Mon Sep 17 00:00:00 2001 From: AlexisDanlos <91090088+AlexisDanlos@users.noreply.github.com> Date: Fri, 27 Jun 2025 15:08:00 +0200 Subject: [PATCH 08/12] feat: enhance history page with lifecycle management and background loading --- .../features/history/history_page.dart | 178 ++++++++++++++++-- .../presentation/features/home/home_page.dart | 15 +- 2 files changed, 172 insertions(+), 21 deletions(-) diff --git a/dialer/lib/presentation/features/history/history_page.dart b/dialer/lib/presentation/features/history/history_page.dart index efd9afd..968acfb 100644 --- a/dialer/lib/presentation/features/history/history_page.dart +++ b/dialer/lib/presentation/features/history/history_page.dart @@ -33,16 +33,18 @@ class HistoryPage extends StatefulWidget { const HistoryPage({Key? key}) : super(key: key); @override - _HistoryPageState createState() => _HistoryPageState(); + HistoryPageState createState() => HistoryPageState(); } -class _HistoryPageState extends State - with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin { +class HistoryPageState extends State + with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin, WidgetsBindingObserver { List histories = []; - bool loading = true; + bool _isInitialLoad = true; + bool _isBackgroundLoading = false; int? _expandedIndex; final ObfuscateService _obfuscateService = ObfuscateService(); final CallService _callService = CallService(); + Timer? _debounceTimer; // Create a MethodChannel instance. static const MethodChannel _channel = MethodChannel('com.example.calllog'); @@ -50,12 +52,52 @@ class _HistoryPageState extends State @override bool get wantKeepAlive => true; // Preserve state when switching pages + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + // Load initial data + _buildHistories(); + } + + /// Public method to trigger reload when page becomes visible + void triggerReload() { + if (!_isInitialLoad && !_isBackgroundLoading) { + _debouncedReload(); + } + } + + @override + void dispose() { + _debounceTimer?.cancel(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + // Reload data when app comes back to foreground + if (state == AppLifecycleState.resumed && !_isInitialLoad && !_isBackgroundLoading) { + _debouncedReload(); + } + } + @override void didChangeDependencies() { super.didChangeDependencies(); - if (loading && histories.isEmpty) { - _buildHistories(); - } + // didChangeDependencies is not reliable for TabBarView changes + // We'll use a different approach with RouteAware or manual detection + } + + /// Debounced reload to prevent multiple rapid reloads + void _debouncedReload() { + _debounceTimer?.cancel(); + _debounceTimer = Timer(const Duration(milliseconds: 300), () { + if (mounted && !_isBackgroundLoading) { + _reloadHistoriesInBackground(); + } + }); } Future _refreshContacts() async { @@ -130,10 +172,12 @@ class _HistoryPageState extends State // Request permission. bool hasPermission = await _requestCallLogPermission(); if (!hasPermission) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Call log permission not granted'))); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Call log permission not granted'))); + } setState(() { - loading = false; + _isInitialLoad = false; }); return; } @@ -212,10 +256,112 @@ class _HistoryPageState extends State // Sort histories by most recent. callHistories.sort((a, b) => b.date.compareTo(a.date)); - setState(() { - histories = callHistories; - loading = false; - }); + if (mounted) { + setState(() { + histories = callHistories; + _isInitialLoad = false; + }); + } + } + + /// Reload histories in the background without showing loading indicators + Future _reloadHistoriesInBackground() async { + if (_isBackgroundLoading) return; + + _isBackgroundLoading = true; + + try { + // Request permission. + bool hasPermission = await _requestCallLogPermission(); + if (!hasPermission) { + _isBackgroundLoading = false; + return; + } + + // Retrieve call logs from native code. + List nativeLogs = []; + try { + nativeLogs = await _channel.invokeMethod('getCallLogs'); + } on PlatformException catch (e) { + print("Error fetching call logs: ${e.message}"); + _isBackgroundLoading = false; + return; + } + + // Ensure contacts are loaded. + final contactState = ContactState.of(context); + if (contactState.loading) { + await Future.doWhile(() async { + await Future.delayed(const Duration(milliseconds: 100)); + return contactState.loading; + }); + } + List contacts = contactState.contacts; + + List callHistories = []; + // Process each log entry with intermittent yields to avoid freezing. + for (int i = 0; i < nativeLogs.length; i++) { + final entry = nativeLogs[i]; + final String number = entry['number'] ?? ''; + if (number.isEmpty) continue; + + // Convert timestamp to DateTime. + DateTime callDate = + DateTime.fromMillisecondsSinceEpoch(entry['date'] ?? 0); + + int typeInt = entry['type'] ?? 0; + int duration = entry['duration'] ?? 0; + String callType; + String callStatus; + + // Map integer values to call type/status. + // Commonly: 1 = incoming, 2 = outgoing, 3 = missed. + switch (typeInt) { + case 1: + callType = "incoming"; + callStatus = (duration == 0) ? "missed" : "answered"; + break; + case 2: + callType = "outgoing"; + callStatus = "answered"; + break; + case 3: + callType = "incoming"; + callStatus = "missed"; + break; + default: + callType = "unknown"; + callStatus = "unknown"; + } + + // Try to find a matching contact. + Contact? matchedContact = findContactForNumber(number, contacts); + if (matchedContact == null) { + // Create a dummy contact if not found. + matchedContact = Contact( + id: "dummy-$number", + displayName: number, + phones: [Phone(number)], + ); + } + + callHistories + .add(History(matchedContact, callDate, callType, callStatus, 1)); + // Yield every 10 iterations to avoid blocking the UI. + if (i % 10 == 0) await Future.delayed(Duration(milliseconds: 1)); + } + + // Sort histories by most recent. + callHistories.sort((a, b) => b.date.compareTo(a.date)); + + if (mounted) { + setState(() { + histories = callHistories; + }); + } + } finally { + _isBackgroundLoading = false; + } } List _buildGroupedList(List historyList) { @@ -283,9 +429,9 @@ class _HistoryPageState extends State @override Widget build(BuildContext context) { super.build(context); // required due to AutomaticKeepAliveClientMixin - final contactState = ContactState.of(context); - if (loading || contactState.loading) { + // Show loading only on initial load and if no data is available yet + if (_isInitialLoad && histories.isEmpty) { return Scaffold( backgroundColor: Colors.black, body: const Center(child: CircularProgressIndicator()), diff --git a/dialer/lib/presentation/features/home/home_page.dart b/dialer/lib/presentation/features/home/home_page.dart index 95ca9eb..fd136b6 100644 --- a/dialer/lib/presentation/features/home/home_page.dart +++ b/dialer/lib/presentation/features/home/home_page.dart @@ -19,6 +19,7 @@ class _MyHomePageState extends State with SingleTickerProviderStateM final TextEditingController _searchController = TextEditingController(); late SearchController _searchBarController; String _rawSearchInput = ''; + final GlobalKey _historyPageKey = GlobalKey(); @override void initState() { @@ -93,6 +94,10 @@ class _MyHomePageState extends State with SingleTickerProviderStateM void _handleTabIndex() { setState(() {}); + // Trigger history page reload when switching to history tab (index 1) + if (_tabController.index == 1) { + _historyPageKey.currentState?.triggerReload(); + } } void _toggleFavorite(Contact contact) async { @@ -270,11 +275,11 @@ class _MyHomePageState extends State with SingleTickerProviderStateM children: [ TabBarView( controller: _tabController, - children: const [ - FavoritesPage(), - HistoryPage(), - ContactPage(), - VoicemailPage(), + children: [ + const FavoritesPage(), + HistoryPage(key: _historyPageKey), + const ContactPage(), + const VoicemailPage(), ], ), Positioned( -- 2.45.2 From 95f747280ab5703ad63b7cd385fddb365ec15e5d Mon Sep 17 00:00:00 2001 From: AlexisDanlos <91090088+AlexisDanlos@users.noreply.github.com> Date: Fri, 27 Jun 2025 15:33:13 +0200 Subject: [PATCH 09/12] feat: add SIM name handling to call history and display in UI --- .../icing/dialer/activities/MainActivity.kt | 51 ++++++++++++- .../features/history/history_page.dart | 74 +++++++++++++++++-- 2 files changed, 119 insertions(+), 6 deletions(-) diff --git a/dialer/android/app/src/main/kotlin/com/icing/dialer/activities/MainActivity.kt b/dialer/android/app/src/main/kotlin/com/icing/dialer/activities/MainActivity.kt index d7b54b5..e157dc1 100644 --- a/dialer/android/app/src/main/kotlin/com/icing/dialer/activities/MainActivity.kt +++ b/dialer/android/app/src/main/kotlin/com/icing/dialer/activities/MainActivity.kt @@ -10,6 +10,8 @@ import android.os.Build import android.os.Bundle import android.provider.CallLog import android.telecom.TelecomManager +import android.telephony.SubscriptionManager +import android.telephony.SubscriptionInfo import android.util.Log import androidx.core.content.ContextCompat import com.icing.dialer.KeystoreHelper @@ -321,12 +323,30 @@ class MainActivity : FlutterActivity() { val type = it.getInt(it.getColumnIndexOrThrow(CallLog.Calls.TYPE)) val date = it.getLong(it.getColumnIndexOrThrow(CallLog.Calls.DATE)) val duration = it.getLong(it.getColumnIndexOrThrow(CallLog.Calls.DURATION)) + + // Extract subscription ID (SIM card info) if available + var subscriptionId: Int? = null + var simName: String? = null + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { + val subIdColumnIndex = it.getColumnIndex("subscription_id") + if (subIdColumnIndex >= 0) { + subscriptionId = it.getInt(subIdColumnIndex) + // Get the actual SIM name + simName = getSimNameFromSubscriptionId(subscriptionId) + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to get subscription_id: ${e.message}") + } val map = mutableMapOf( "number" to number, "type" to type, "date" to date, - "duration" to duration + "duration" to duration, + "subscription_id" to subscriptionId, + "sim_name" to simName ) logsList.add(map) } @@ -334,6 +354,35 @@ class MainActivity : FlutterActivity() { return logsList } + private fun getSimNameFromSubscriptionId(subscriptionId: Int?): String? { + if (subscriptionId == null) return null + + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { + val subscriptionManager = getSystemService(Context.TELEPHONY_SUBSCRIPTION_SERVICE) as SubscriptionManager + val subscriptionInfo: SubscriptionInfo? = subscriptionManager.getActiveSubscriptionInfo(subscriptionId) + + return subscriptionInfo?.let { info -> + // Try to get display name first, fallback to carrier name, then generic name + when { + !info.displayName.isNullOrBlank() && info.displayName.toString() != info.subscriptionId.toString() -> { + info.displayName.toString() + } + !info.carrierName.isNullOrBlank() -> { + info.carrierName.toString() + } + else -> "SIM ${info.simSlotIndex + 1}" + } + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to get SIM name for subscription $subscriptionId: ${e.message}") + } + + // Fallback to generic name + return "SIM ${subscriptionId + 1}" + } + private fun handleIncomingCallIntent(intent: Intent?) { intent?.let { if (it.getBooleanExtra("isIncomingCall", false)) { diff --git a/dialer/lib/presentation/features/history/history_page.dart b/dialer/lib/presentation/features/history/history_page.dart index 968acfb..868ac17 100644 --- a/dialer/lib/presentation/features/history/history_page.dart +++ b/dialer/lib/presentation/features/history/history_page.dart @@ -19,6 +19,7 @@ class History { final String callType; // 'incoming' or 'outgoing' final String callStatus; // 'missed' or 'answered' final int attempts; + final String? simName; // Name of the SIM used for the call History( this.contact, @@ -26,6 +27,7 @@ class History { this.callType, this.callStatus, this.attempts, + this.simName, ); } @@ -158,6 +160,22 @@ class HistoryPageState extends State return null; } + /// Helper: Get SIM name from subscription ID + String? _getSimNameFromSubscriptionId(int? subscriptionId) { + if (subscriptionId == null) return null; + + // Map subscription IDs to SIM names + // These values might need to be adjusted based on your device + switch (subscriptionId) { + case 0: + return "SIM 1"; + case 1: + return "SIM 2"; + default: + return "SIM ${subscriptionId + 1}"; + } + } + /// Request permission for reading call logs. Future _requestCallLogPermission() async { var status = await Permission.phone.status; @@ -247,8 +265,22 @@ class HistoryPageState extends State ); } + // Extract SIM information if available + String? simName; + if (entry.containsKey('sim_name') && entry['sim_name'] != null) { + simName = entry['sim_name'] as String; + print("DEBUG: Found sim_name: $simName for number: $number"); // Debug print + } else if (entry.containsKey('subscription_id')) { + final subId = entry['subscription_id']; + print("DEBUG: Found subscription_id: $subId for number: $number, but no sim_name"); // Debug print + simName = _getSimNameFromSubscriptionId(subId); + print("DEBUG: Mapped to SIM name: $simName"); // Debug print + } else { + print("DEBUG: No SIM info found for number: $number"); // Debug print + } + callHistories - .add(History(matchedContact, callDate, callType, callStatus, 1)); + .add(History(matchedContact, callDate, callType, callStatus, 1, simName)); // Yield every 10 iterations to avoid blocking the UI. if (i % 10 == 0) await Future.delayed(Duration(milliseconds: 1)); } @@ -345,8 +377,22 @@ class HistoryPageState extends State ); } + // Extract SIM information if available + String? simName; + if (entry.containsKey('sim_name') && entry['sim_name'] != null) { + simName = entry['sim_name'] as String; + print("DEBUG: Found sim_name: $simName for number: $number"); // Debug print + } else if (entry.containsKey('subscription_id')) { + final subId = entry['subscription_id']; + print("DEBUG: Found subscription_id: $subId for number: $number, but no sim_name"); // Debug print + simName = _getSimNameFromSubscriptionId(subId); + print("DEBUG: Mapped to SIM name: $simName"); // Debug print + } else { + print("DEBUG: No SIM info found for number: $number"); // Debug print + } + callHistories - .add(History(matchedContact, callDate, callType, callStatus, 1)); + .add(History(matchedContact, callDate, callType, callStatus, 1, simName)); // Yield every 10 iterations to avoid blocking the UI. if (i % 10 == 0) await Future.delayed(Duration(milliseconds: 1)); } @@ -559,9 +605,22 @@ class HistoryPageState extends State _obfuscateService.obfuscateData(contact.displayName), style: const TextStyle(color: Colors.white), ), - subtitle: Text( - DateFormat('MMM dd, hh:mm a').format(history.date), - style: const TextStyle(color: Colors.grey), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + DateFormat('MMM dd, hh:mm a').format(history.date), + style: const TextStyle(color: Colors.grey), + ), + if (history.simName != null) + Text( + history.simName!, + style: const TextStyle( + color: Colors.blue, + fontSize: 12, + ), + ), + ], ), trailing: Row( mainAxisSize: MainAxisSize.min, @@ -771,6 +830,11 @@ class CallDetailsPage extends StatelessWidget { label: 'Attempts:', value: '${history.attempts}', ), + if (history.simName != null) + DetailRow( + label: 'SIM Used:', + value: history.simName!, + ), const SizedBox(height: 24), if (contact.phones.isNotEmpty) DetailRow( -- 2.45.2 From e9e50dd4a218062dc56aefa3d01fb07b21bc3c6b Mon Sep 17 00:00:00 2001 From: AlexisDanlos <91090088+AlexisDanlos@users.noreply.github.com> Date: Thu, 3 Jul 2025 16:02:38 +0200 Subject: [PATCH 10/12] feat: ensure call page closes on hangup and disconnection, improving navigation flow --- dialer/lib/domain/services/call_service.dart | 63 ++++++++++--------- .../presentation/features/call/call_page.dart | 19 +++++- 2 files changed, 51 insertions(+), 31 deletions(-) diff --git a/dialer/lib/domain/services/call_service.dart b/dialer/lib/domain/services/call_service.dart index e6669f3..1113afd 100644 --- a/dialer/lib/domain/services/call_service.dart +++ b/dialer/lib/domain/services/call_service.dart @@ -101,18 +101,15 @@ class CallService { print('CallService: _manualHangupFlag: $_manualHangupFlag'); print('CallService: _isCallPageVisible: $_isCallPageVisible'); - // Close call page if no pending SIM switch OR if it was a manual hangup - if (_pendingSimSwitch == null || _manualHangupFlag) { - print('CallService: Condition met, calling _closeCallPage()'); - _closeCallPage(); - // Only reset manual hangup flag after successful page close - if (_manualHangupFlag) { - print( - 'CallService: Resetting manual hangup flag after page close'); - _manualHangupFlag = false; - } - } else { - print('CallService: NOT closing call page - condition not met'); + // 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"); @@ -169,18 +166,15 @@ class CallService { print('CallService: _manualHangupFlag: $_manualHangupFlag'); print('CallService: _isCallPageVisible: $_isCallPageVisible'); - // Only close call page if there's no pending SIM switch OR if it was a manual hangup during SIM switch - if (_pendingSimSwitch == null || _manualHangupFlag) { - print('CallService: Condition met, calling _closeCallPage()'); - _closeCallPage(); - // Reset manual hangup flag after closing page - if (_manualHangupFlag) { - print( - 'CallService: Resetting manual hangup flag after callEnded'); - _manualHangupFlag = false; - } - } else { - print('CallService: NOT closing call page - condition not met'); + // 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"); @@ -490,13 +484,22 @@ class CallService { print( 'CallService: Closing call page, _isCallPageVisible: $_isCallPageVisible, _pendingSimSwitch: ${_pendingSimSwitch != null}, _manualHangupFlag: $_manualHangupFlag'); - if (Navigator.canPop(context)) { - print('CallService: Popping call page'); - Navigator.pop(context); - _isCallPageVisible = false; - } else { - print('CallService: No page to pop, setting _isCallPageVisible to false'); + + // 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; } diff --git a/dialer/lib/presentation/features/call/call_page.dart b/dialer/lib/presentation/features/call/call_page.dart index 5791afc..aee2690 100644 --- a/dialer/lib/presentation/features/call/call_page.dart +++ b/dialer/lib/presentation/features/call/call_page.dart @@ -319,7 +319,7 @@ class _CallPageState extends State { print( 'CallPage: Initiating manual hangUp - canceling any pending SIM switch'); - // Immediately mark call as inactive to allow page navigation + // Immediately mark call as inactive to prevent further interactions setState(() { _isCallActive = false; _callStatus = "Ending Call..."; @@ -330,6 +330,12 @@ class _CallPageState extends State { final result = await _callService.hangUpCall(context); print('CallPage: Hang up result: $result'); + + // If the page is still visible after hangup, try to close it + if (mounted && ModalRoute.of(context)?.isCurrent == true) { + print('CallPage: Still visible after hangup, navigating back'); + Navigator.of(context).popUntil((route) => route.isFirst); + } } catch (e) { print('CallPage: Error hanging up: $e'); if (mounted) { @@ -367,6 +373,17 @@ class _CallPageState extends State { print( 'CallPage: Building UI, _callStatus: $_callStatus, route: ${ModalRoute.of(context)?.settings.name ?? "unknown"}'); + + // If call is disconnected and we're not actively navigating, force navigation + if ((_callStatus == "Call Ended" || !_isCallActive) && mounted) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && ModalRoute.of(context)?.isCurrent == true) { + print('CallPage: Call ended, forcing navigation back to home'); + Navigator.of(context).popUntil((route) => route.isFirst); + } + }); + } + return PopScope( canPop: true, // Always allow popping - CallService manages when it's appropriate -- 2.45.2 From 355e0403222970bf05e7f3db72bda129f36ead09 Mon Sep 17 00:00:00 2001 From: stcb <21@stcb.cc> Date: Sun, 6 Jul 2025 23:15:14 +0200 Subject: [PATCH 11/12] Upgrade Stealth to release --- dialer/stealth_local_run.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dialer/stealth_local_run.sh b/dialer/stealth_local_run.sh index ae202a9..74dd1d6 100755 --- a/dialer/stealth_local_run.sh +++ b/dialer/stealth_local_run.sh @@ -1,4 +1,4 @@ #!/usr/bin/env bash echo "Running Icing Dialer in STEALTH mode..." -flutter run --dart-define=STEALTH=true +flutter run --release --dart-define=STEALTH=true -- 2.45.2 From 34d5990a3bef9088aefa6c0edf3395387913b184 Mon Sep 17 00:00:00 2001 From: AlexisDanlos <91090088+AlexisDanlos@users.noreply.github.com> Date: Mon, 7 Jul 2025 12:08:48 +0200 Subject: [PATCH 12/12] feat: add method to retrieve latest call log and update history, enhancing call tracking --- .../icing/dialer/activities/MainActivity.kt | 71 +++++- dialer/lib/domain/services/call_service.dart | 16 ++ .../features/history/history_page.dart | 236 ++++++++++-------- 3 files changed, 203 insertions(+), 120 deletions(-) diff --git a/dialer/android/app/src/main/kotlin/com/icing/dialer/activities/MainActivity.kt b/dialer/android/app/src/main/kotlin/com/icing/dialer/activities/MainActivity.kt index e157dc1..ea618d6 100644 --- a/dialer/android/app/src/main/kotlin/com/icing/dialer/activities/MainActivity.kt +++ b/dialer/android/app/src/main/kotlin/com/icing/dialer/activities/MainActivity.kt @@ -230,16 +230,25 @@ class MainActivity : FlutterActivity() { MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALLLOG_CHANNEL) .setMethodCallHandler { call, result -> - if (call.method == "getCallLogs") { - if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALL_LOG) == PackageManager.PERMISSION_GRANTED) { - val callLogs = getCallLogs() - result.success(callLogs) - } else { - requestPermissions(arrayOf(Manifest.permission.READ_CALL_LOG), REQUEST_CODE_CALL_LOG_PERMISSION) - result.error("PERMISSION_DENIED", "Call log permission not granted", null) + when (call.method) { + "getCallLogs" -> { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALL_LOG) == PackageManager.PERMISSION_GRANTED) { + val callLogs = getCallLogs() + result.success(callLogs) + } else { + requestPermissions(arrayOf(Manifest.permission.READ_CALL_LOG), REQUEST_CODE_CALL_LOG_PERMISSION) + result.error("PERMISSION_DENIED", "Call log permission not granted", null) + } } - } else { - result.notImplemented() + "getLatestCallLog" -> { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALL_LOG) == PackageManager.PERMISSION_GRANTED) { + val latestCallLog = getLatestCallLog() + result.success(latestCallLog) + } else { + result.error("PERMISSION_DENIED", "Call log permission not granted", null) + } + } + else -> result.notImplemented() } } } @@ -354,6 +363,50 @@ class MainActivity : FlutterActivity() { return logsList } + private fun getLatestCallLog(): Map? { + val cursor: Cursor? = contentResolver.query( + CallLog.Calls.CONTENT_URI, + null, + null, + null, + CallLog.Calls.DATE + " DESC" + ) + cursor?.use { + if (it.moveToNext()) { + val number = it.getString(it.getColumnIndexOrThrow(CallLog.Calls.NUMBER)) + val type = it.getInt(it.getColumnIndexOrThrow(CallLog.Calls.TYPE)) + val date = it.getLong(it.getColumnIndexOrThrow(CallLog.Calls.DATE)) + val duration = it.getLong(it.getColumnIndexOrThrow(CallLog.Calls.DURATION)) + + // Extract subscription ID (SIM card info) if available + var subscriptionId: Int? = null + var simName: String? = null + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { + val subIdColumnIndex = it.getColumnIndex("subscription_id") + if (subIdColumnIndex >= 0) { + subscriptionId = it.getInt(subIdColumnIndex) + // Get the actual SIM name + simName = getSimNameFromSubscriptionId(subscriptionId) + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to get subscription_id: ${e.message}") + } + + return mapOf( + "number" to number, + "type" to type, + "date" to date, + "duration" to duration, + "subscription_id" to subscriptionId, + "sim_name" to simName + ) + } + } + return null + } + private fun getSimNameFromSubscriptionId(subscriptionId: Int?): String? { if (subscriptionId == null) return null diff --git a/dialer/lib/domain/services/call_service.dart b/dialer/lib/domain/services/call_service.dart index 1113afd..f641dc6 100644 --- a/dialer/lib/domain/services/call_service.dart +++ b/dialer/lib/domain/services/call_service.dart @@ -5,6 +5,8 @@ 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'); @@ -114,6 +116,13 @@ class CallService { 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(); @@ -179,6 +188,13 @@ class CallService { 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; diff --git a/dialer/lib/presentation/features/history/history_page.dart b/dialer/lib/presentation/features/history/history_page.dart index 868ac17..6acd892 100644 --- a/dialer/lib/presentation/features/history/history_page.dart +++ b/dialer/lib/presentation/features/history/history_page.dart @@ -40,9 +40,13 @@ class HistoryPage extends StatefulWidget { class HistoryPageState extends State with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin, WidgetsBindingObserver { - List histories = []; + // Static histories list shared across all instances + static List _globalHistories = []; + + // Getter to access the global histories list + List get histories => _globalHistories; + bool _isInitialLoad = true; - bool _isBackgroundLoading = false; int? _expandedIndex; final ObfuscateService _obfuscateService = ObfuscateService(); final CallService _callService = CallService(); @@ -50,6 +54,12 @@ class HistoryPageState extends State // Create a MethodChannel instance. static const MethodChannel _channel = MethodChannel('com.example.calllog'); + + // Static reference to the current instance for call-end notifications + static HistoryPageState? _currentInstance; + + // Global flag to track if history has been loaded once across all instances + static bool _hasLoadedInitialHistory = false; @override bool get wantKeepAlive => true; // Preserve state when switching pages @@ -58,31 +68,50 @@ class HistoryPageState extends State void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); - // Load initial data - _buildHistories(); + _currentInstance = this; // Register this instance + + // Only load initial data if it hasn't been loaded before + if (!_hasLoadedInitialHistory) { + _buildHistories(); + } else { + // If history was already loaded, just mark this instance as not doing initial load + _isInitialLoad = false; + } } /// Public method to trigger reload when page becomes visible void triggerReload() { - if (!_isInitialLoad && !_isBackgroundLoading) { - _debouncedReload(); - } + // Disabled automatic reloading - only load once and add new entries via addNewCallToHistory + print("HistoryPage: triggerReload called but disabled to prevent full reload"); } @override void dispose() { _debounceTimer?.cancel(); WidgetsBinding.instance.removeObserver(this); + if (_currentInstance == this) { + _currentInstance = null; // Unregister this instance + } super.dispose(); } + /// Static method to add a new call to the history list + static void addNewCallToHistory() { + _currentInstance?._addLatestCallToHistory(); + } + + /// Notify all instances to refresh UI when history changes + static void _notifyHistoryChanged() { + _currentInstance?.setState(() { + // Trigger UI rebuild for the current instance + }); + } + @override void didChangeAppLifecycleState(AppLifecycleState state) { super.didChangeAppLifecycleState(state); - // Reload data when app comes back to foreground - if (state == AppLifecycleState.resumed && !_isInitialLoad && !_isBackgroundLoading) { - _debouncedReload(); - } + // Disabled automatic reloading when app comes to foreground + print("HistoryPage: didChangeAppLifecycleState called but disabled to prevent full reload"); } @override @@ -92,16 +121,6 @@ class HistoryPageState extends State // We'll use a different approach with RouteAware or manual detection } - /// Debounced reload to prevent multiple rapid reloads - void _debouncedReload() { - _debounceTimer?.cancel(); - _debounceTimer = Timer(const Duration(milliseconds: 300), () { - if (mounted && !_isBackgroundLoading) { - _reloadHistoriesInBackground(); - } - }); - } - Future _refreshContacts() async { final contactState = ContactState.of(context); try { @@ -290,37 +309,35 @@ class HistoryPageState extends State if (mounted) { setState(() { - histories = callHistories; + _globalHistories = callHistories; _isInitialLoad = false; + _hasLoadedInitialHistory = true; // Mark that history has been loaded once }); + // Notify other instances about the initial load + _notifyHistoryChanged(); } } - /// Reload histories in the background without showing loading indicators - Future _reloadHistoriesInBackground() async { - if (_isBackgroundLoading) return; - - _isBackgroundLoading = true; - + /// Add the latest call log entry to the history list + Future _addLatestCallToHistory() async { try { - // Request permission. - bool hasPermission = await _requestCallLogPermission(); - if (!hasPermission) { - _isBackgroundLoading = false; + // Get the latest call log entry + final dynamic rawEntry = await _channel.invokeMethod('getLatestCallLog'); + + if (rawEntry == null) { + print("No latest call log entry found"); return; } - // Retrieve call logs from native code. - List nativeLogs = []; - try { - nativeLogs = await _channel.invokeMethod('getCallLogs'); - } on PlatformException catch (e) { - print("Error fetching call logs: ${e.message}"); - _isBackgroundLoading = false; - return; - } + // Convert to proper type - handle the method channel result properly + final Map latestEntry = Map.from( + (rawEntry as Map).cast() + ); - // Ensure contacts are loaded. + final String number = latestEntry['number'] ?? ''; + if (number.isEmpty) return; + + // Ensure contacts are loaded final contactState = ContactState.of(context); if (contactState.loading) { await Future.doWhile(() async { @@ -330,83 +347,80 @@ class HistoryPageState extends State } List contacts = contactState.contacts; - List callHistories = []; - // Process each log entry with intermittent yields to avoid freezing. - for (int i = 0; i < nativeLogs.length; i++) { - final entry = nativeLogs[i]; - final String number = entry['number'] ?? ''; - if (number.isEmpty) continue; + // Convert timestamp to DateTime + DateTime callDate = DateTime.fromMillisecondsSinceEpoch(latestEntry['date'] ?? 0); - // Convert timestamp to DateTime. - DateTime callDate = - DateTime.fromMillisecondsSinceEpoch(entry['date'] ?? 0); + int typeInt = latestEntry['type'] ?? 0; + int duration = latestEntry['duration'] ?? 0; + String callType; + String callStatus; - int typeInt = entry['type'] ?? 0; - int duration = entry['duration'] ?? 0; - String callType; - String callStatus; - - // Map integer values to call type/status. - // Commonly: 1 = incoming, 2 = outgoing, 3 = missed. - switch (typeInt) { - case 1: - callType = "incoming"; - callStatus = (duration == 0) ? "missed" : "answered"; - break; - case 2: - callType = "outgoing"; - callStatus = "answered"; - break; - case 3: - callType = "incoming"; - callStatus = "missed"; - break; - default: - callType = "unknown"; - callStatus = "unknown"; - } - - // Try to find a matching contact. - Contact? matchedContact = findContactForNumber(number, contacts); - if (matchedContact == null) { - // Create a dummy contact if not found. - matchedContact = Contact( - id: "dummy-$number", - displayName: number, - phones: [Phone(number)], - ); - } - - // Extract SIM information if available - String? simName; - if (entry.containsKey('sim_name') && entry['sim_name'] != null) { - simName = entry['sim_name'] as String; - print("DEBUG: Found sim_name: $simName for number: $number"); // Debug print - } else if (entry.containsKey('subscription_id')) { - final subId = entry['subscription_id']; - print("DEBUG: Found subscription_id: $subId for number: $number, but no sim_name"); // Debug print - simName = _getSimNameFromSubscriptionId(subId); - print("DEBUG: Mapped to SIM name: $simName"); // Debug print - } else { - print("DEBUG: No SIM info found for number: $number"); // Debug print - } - - callHistories - .add(History(matchedContact, callDate, callType, callStatus, 1, simName)); - // Yield every 10 iterations to avoid blocking the UI. - if (i % 10 == 0) await Future.delayed(Duration(milliseconds: 1)); + // Map integer values to call type/status + switch (typeInt) { + case 1: + callType = "incoming"; + callStatus = (duration == 0) ? "missed" : "answered"; + break; + case 2: + callType = "outgoing"; + callStatus = "answered"; + break; + case 3: + callType = "incoming"; + callStatus = "missed"; + break; + default: + callType = "unknown"; + callStatus = "unknown"; } - // Sort histories by most recent. - callHistories.sort((a, b) => b.date.compareTo(a.date)); + // Try to find a matching contact + Contact? matchedContact = findContactForNumber(number, contacts); + if (matchedContact == null) { + // Create a dummy contact if not found + matchedContact = Contact( + id: "dummy-$number", + displayName: number, + phones: [Phone(number)], + ); + } - if (mounted) { + // Extract SIM information if available + String? simName; + if (latestEntry.containsKey('sim_name') && latestEntry['sim_name'] != null) { + simName = latestEntry['sim_name'] as String; + print("DEBUG: Found sim_name: $simName for number: $number"); + } else if (latestEntry.containsKey('subscription_id')) { + final subId = latestEntry['subscription_id']; + print("DEBUG: Found subscription_id: $subId for number: $number, but no sim_name"); + simName = _getSimNameFromSubscriptionId(subId); + print("DEBUG: Mapped to SIM name: $simName"); + } else { + print("DEBUG: No SIM info found for number: $number"); + } + + // Create new history entry + History newHistory = History(matchedContact, callDate, callType, callStatus, 1, simName); + + // Check if this call is already in the list (avoid duplicates) + bool alreadyExists = _globalHistories.any((history) => + history.contact.phones.isNotEmpty && + sanitizeNumber(history.contact.phones.first.number) == sanitizeNumber(number) && + history.date.difference(callDate).abs().inSeconds < 5); // Within 5 seconds + + if (!alreadyExists && mounted) { setState(() { - histories = callHistories; + // Insert at the beginning since it's the most recent + _globalHistories.insert(0, newHistory); }); + // Notify other instances about the change + _notifyHistoryChanged(); + print("Added new call to history: $number at $callDate"); + } else { + print("Call already exists in history or widget unmounted"); } - } finally { - _isBackgroundLoading = false; + } catch (e) { + print("Error adding latest call to history: $e"); } } -- 2.45.2