From aa8d32f28c6569203d771ed4d243371c37f3f072 Mon Sep 17 00:00:00 2001 From: AlexisDanlos Date: Tue, 8 Jul 2025 07:31:38 +0000 Subject: [PATCH] change-default-sim (#60) Co-authored-by: AlexisDanlos <91090088+AlexisDanlos@users.noreply.github.com> Co-authored-by: stcb <21@stcb.cc> Reviewed-on: https://git.gmoker.com/icing/monorepo/pulls/60 Co-authored-by: AlexisDanlos Co-committed-by: AlexisDanlos --- dialer/android/app/build.gradle | 2 +- .../java/com/example/dialer/MainActivity.java | 49 +++ .../icing/dialer/activities/MainActivity.kt | 128 +++++- .../com/icing/dialer/services/CallService.kt | 27 +- dialer/android/gradle.properties | 2 +- dialer/lib/domain/services/call_service.dart | 390 +++++++++++++++--- .../common/widgets/sim_selection_dialog.dart | 204 +++++++++ .../presentation/features/call/call_page.dart | 219 ++++++++-- .../features/history/history_page.dart | 266 +++++++++++- .../presentation/features/home/home_page.dart | 15 +- .../features/settings/settings.dart | 10 +- .../features/settings/sim/settings_sim.dart | 220 ++++++++++ dialer/pubspec.yaml | 3 +- dialer/stealth_local_run.sh | 2 +- 14 files changed, 1406 insertions(+), 131 deletions(-) create mode 100644 dialer/android/app/src/main/java/com/example/dialer/MainActivity.java create mode 100644 dialer/lib/presentation/common/widgets/sim_selection_dialog.dart create mode 100644 dialer/lib/presentation/features/settings/sim/settings_sim.dart 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/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/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..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 @@ -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 @@ -96,11 +98,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 { @@ -228,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() } } } @@ -321,12 +332,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 +363,79 @@ 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 + + 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/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/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..f641dc6 100644 --- a/dialer/lib/domain/services/call_service.dart +++ b/dialer/lib/domain/services/call_service.dart @@ -1,15 +1,19 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../../presentation/features/call/call_page.dart'; import '../../presentation/features/call/incoming_call_page.dart'; // Import the new page import 'contact_service.dart'; +// Import for history update callback +import '../../presentation/features/history/history_page.dart'; class CallService { static const MethodChannel _channel = MethodChannel('call_service'); static String? currentPhoneNumber; static String? currentDisplayName; static Uint8List? currentThumbnail; + static int? currentSimSlot; // Track which SIM slot is being used static bool _isCallPageVisible = false; static Map? _pendingCall; static bool wasPhoneLocked = false; @@ -17,18 +21,43 @@ class CallService { static bool _isNavigating = false; final ContactService _contactService = ContactService(); final _callStateController = StreamController.broadcast(); - final _audioStateController = StreamController>.broadcast(); + final _audioStateController = + StreamController>.broadcast(); + final _simStateController = 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; + 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'); + // Don't set manual hangup flag if there's no SIM switch to cancel + } + } CallService() { _channel.setMethodCallHandler((call) async { - print('CallService: Handling method call: ${call.method}, with args: ${call.arguments}'); + 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 +66,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,30 +89,63 @@ 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") { + print('CallService: ========== CALL DISCONNECTED =========='); + print( + 'CallService: _pendingSimSwitch: ${_pendingSimSwitch != null}'); + print('CallService: _manualHangupFlag: $_manualHangupFlag'); + print('CallService: _isCallPageVisible: $_isCallPageVisible'); + + // Always close call page on disconnection - SIM switching should not prevent this + print('CallService: Calling _closeCallPage() on call disconnection'); _closeCallPage(); + + // Reset manual hangup flag after successful page close + if (_manualHangupFlag) { + print( + 'CallService: Resetting manual hangup flag after page close'); + _manualHangupFlag = false; + } if (wasPhoneLocked) { await _channel.invokeMethod("callEndedFromFlutter"); } + + // Notify history page to add the latest call + // Add a small delay to ensure call log is updated by the system + Timer(const Duration(milliseconds: 500), () { + HistoryPageState.addNewCallToHistory(); + }); + _activeCallNumber = null; + // Handle pending SIM switch after call is disconnected + _handlePendingSimSwitch(); } else if (state == "active" || state == "dialing") { final phoneNumber = call.arguments["callId"] as String?; - if (phoneNumber != null && _activeCallNumber != Uri.decodeComponent(phoneNumber.replaceFirst('tel:', ''))) { - currentPhoneNumber = Uri.decodeComponent(phoneNumber.replaceFirst('tel:', '')); - if (currentDisplayName == null || currentDisplayName == currentPhoneNumber) { + 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 +154,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,38 +169,65 @@ 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 =========='); + print('CallService: wasPhoneLocked: $wasPhoneLocked'); + print('CallService: _pendingSimSwitch: ${_pendingSimSwitch != null}'); + print('CallService: _manualHangupFlag: $_manualHangupFlag'); + print('CallService: _isCallPageVisible: $_isCallPageVisible'); + + // Always close call page when call ends - SIM switching should not prevent this + print('CallService: Calling _closeCallPage() on call ended/removed'); _closeCallPage(); + + // Reset manual hangup flag after closing page + if (_manualHangupFlag) { + print( + 'CallService: Resetting manual hangup flag after callEnded'); + _manualHangupFlag = false; + } if (wasPhoneLocked) { await _channel.invokeMethod("callEndedFromFlutter"); } + + // Notify history page to add the latest call + // Add a small delay to ensure call log is updated by the system + Timer(const Duration(milliseconds: 500), () { + HistoryPageState.addNewCallToHistory(); + }); + currentPhoneNumber = null; currentDisplayName = null; currentThumbnail = null; + currentSimSlot = null; // Reset SIM slot when call ends + _simStateController.add(null); // Notify UI that SIM is cleared _activeCallNumber = null; break; case "incomingCallFromNotification": final phoneNumber = call.arguments["phoneNumber"] as String?; wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false; if (phoneNumber == null) { - print('CallService: Invalid incomingCallFromNotification args: $call.arguments'); + 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 +251,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 +273,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 +305,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 +328,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 +360,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,24 +394,32 @@ 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; } _activeCallNumber = currentPhoneNumber; - Navigator.pushReplacement( + Navigator.push( context, MaterialPageRoute( settings: const RouteSettings(name: '/call'), @@ -332,9 +445,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 +461,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 +479,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,13 +491,31 @@ class CallService { print('CallService: Cannot close page, context is null'); return; } - print('CallService: Closing call page, _isCallPageVisible: $_isCallPageVisible'); - if (Navigator.canPop(context)) { - print('CallService: Popping call page'); - Navigator.pop(context); + + // Only attempt to close if a call page is actually visible + if (!_isCallPageVisible) { + print('CallService: Call page already closed'); + return; + } + + print( + 'CallService: Closing call page, _isCallPageVisible: $_isCallPageVisible, _pendingSimSwitch: ${_pendingSimSwitch != null}, _manualHangupFlag: $_manualHangupFlag'); + + // Use popUntil to ensure we go back to the home page + try { + Navigator.popUntil(context, (route) => route.isFirst); _isCallPageVisible = false; - } else { - print('CallService: No page to pop'); + 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; } @@ -388,20 +525,54 @@ class CallService { required String phoneNumber, String? displayName, Uint8List? thumbnail, + }) async { + try { + // Load default SIM slot from settings + final prefs = await SharedPreferences.getInstance(); + final simSlot = prefs.getInt('default_sim_slot') ?? 0; + return await makeGsmCallWithSim( + context, + phoneNumber: phoneNumber, + displayName: displayName, + thumbnail: thumbnail, + simSlot: simSlot, + ); + } catch (e) { + print("CallService: Error making call: $e"); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Error making call: $e")), + ); + return {"status": "error", "message": e.toString()}; + } + } + + Future> makeGsmCallWithSim( + BuildContext context, { + required String phoneNumber, + String? displayName, + Uint8List? thumbnail, + required int simSlot, }) async { try { if (_activeCallNumber == phoneNumber && _isCallPageVisible) { print('CallService: Call already active for $phoneNumber, skipping'); - return {"status": "already_active", "message": "Call already in progress"}; + return { + "status": "already_active", + "message": "Call already in progress" + }; } currentPhoneNumber = phoneNumber; currentDisplayName = displayName ?? phoneNumber; currentThumbnail = thumbnail; + currentSimSlot = simSlot; // Track the SIM slot being used + _simStateController.add(simSlot); // Notify UI of SIM change if (displayName == null || thumbnail == null) { await _fetchContactInfo(phoneNumber); } - print('CallService: Making GSM call to $phoneNumber, displayName: $currentDisplayName'); - 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") { @@ -419,17 +590,40 @@ 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; 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"); @@ -439,4 +633,88 @@ class CallService { return {"status": "error", "message": e.toString()}; } } -} \ No newline at end of file + + 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']; + currentSimSlot = switchData['simSlot']; // Track the new SIM slot + _simStateController.add(switchData['simSlot']); // Notify UI of SIM change + + // Make the new call with the selected SIM + final result = await _channel.invokeMethod('makeGsmCall', { + 'phoneNumber': switchData['phoneNumber'], + 'simSlot': switchData['simSlot'], + }); + + print('CallService: SIM switch redial result: $result'); + + // Show success feedback + final context = navigatorKey.currentContext; + if (context != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Switched to SIM ${switchData['simSlot'] + 1} and redialing...'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + print('CallService: Error during SIM switch redial: $e'); + + // Show error feedback and close the call page + final context = navigatorKey.currentContext; + if (context != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to redial with new SIM: $e'), + backgroundColor: Colors.red, + ), + ); + // Close the call page since redial failed + _closeCallPage(); + } + } + } +} 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..aee2690 100644 --- a/dialer/lib/presentation/features/call/call_page.dart +++ b/dialer/lib/presentation/features/call/call_page.dart @@ -4,7 +4,9 @@ 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'; +import 'package:sim_data_new/sim_data.dart'; class CallPage extends StatefulWidget { final String displayName; @@ -35,15 +37,57 @@ class _CallPageState extends State { String _callStatus = "Calling..."; StreamSubscription? _callStateSubscription; 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(); _checkInitialCallState(); _listenToCallState(); _listenToAudioState(); + _listenToSimState(); + _updateSimName(CallService.getCurrentSimSlot); // Initial SIM name _setInitialAudioState(); } @@ -52,6 +96,7 @@ class _CallPageState extends State { _callTimer?.cancel(); _callStateSubscription?.cancel(); _audioStateSubscription?.cancel(); + _simStateSubscription?.cancel(); super.dispose(); } @@ -69,10 +114,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) { @@ -87,12 +141,16 @@ 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; + // Let CallService handle navigation - don't navigate from here } else { _callStatus = "Calling..."; + _isCallActive = true; } }); } @@ -110,6 +168,12 @@ class _CallPageState extends State { }); } + void _listenToSimState() { + _simStateSubscription = _callService.simStateStream.listen((simSlot) { + _updateSimName(simSlot); + }); + } + void _startCallTimer() { _callTimer?.cancel(); _callTimer = Timer.periodic(const Duration(seconds: 1), (timer) { @@ -180,7 +244,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,17 +266,76 @@ 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: Initiating SIM switch to slot $simSlot for ${widget.phoneNumber}'); + + // Use the CallService to handle the SIM switch logic + await _callService.switchSimAndRedial( + phoneNumber: widget.phoneNumber, + displayName: widget.displayName, + simSlot: simSlot, + thumbnail: widget.thumbnail, + ); + + print('CallPage: SIM switch initiated successfully'); + } catch (e) { + print('CallPage: Error initiating SIM switch: $e'); + + // Show error feedback if widget is still mounted + if (mounted) { + 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'); + print( + 'CallPage: Initiating manual hangUp - canceling any pending SIM switch'); + + // Immediately mark call as inactive to prevent further interactions + setState(() { + _isCallActive = false; + _callStatus = "Ending Call..."; + }); + + // 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'); + + // 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) { @@ -228,15 +351,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')), + ); + } } } @@ -248,14 +373,24 @@ class _CallPageState extends State { print( 'CallPage: Building UI, _callStatus: $_callStatus, route: ${ModalRoute.of(context)?.settings.name ?? "unknown"}'); - return PopScope( - canPop: _callStatus == "Call Ended", - onPopInvoked: (didPop) { - if (!didPop) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Cannot leave during an active call')), - ); + + // 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 + onPopInvoked: (didPop) { + print( + 'CallPage: PopScope onPopInvoked - didPop: $didPop, _isCallActive: $_isCallActive, _callStatus: $_callStatus'); + // No longer prevent popping during active calls - CallService handles this }, child: Scaffold( body: Container( @@ -320,6 +455,30 @@ class _CallPageState extends State { color: Colors.white70, ), ), + // 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( + 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( + // Show human-readable SIM name plus slot number + '$_simName (SIM ${CallService.getCurrentSimSlot! + 1})', + style: TextStyle( + fontSize: statusFontSize - 2, + color: Colors.lightBlueAccent, + fontWeight: FontWeight.w500, + ), + ), + ), ], ), ), @@ -510,10 +669,14 @@ class _CallPageState extends State { mainAxisSize: MainAxisSize.min, children: [ IconButton( - onPressed: () {}, - icon: const Icon( + onPressed: _isCallActive + ? _showSimSelectionDialog + : null, + icon: Icon( Icons.sim_card, - color: Colors.white, + color: _isCallActive + ? Colors.white + : Colors.grey, size: 32, ), ), @@ -539,15 +702,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, ), diff --git a/dialer/lib/presentation/features/history/history_page.dart b/dialer/lib/presentation/features/history/history_page.dart index efd9afd..6acd892 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, ); } @@ -33,29 +35,90 @@ 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 { - List histories = []; - bool loading = true; +class HistoryPageState extends State + with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin, WidgetsBindingObserver { + // Static histories list shared across all instances + static List _globalHistories = []; + + // Getter to access the global histories list + List get histories => _globalHistories; + + bool _isInitialLoad = true; int? _expandedIndex; final ObfuscateService _obfuscateService = ObfuscateService(); final CallService _callService = CallService(); + Timer? _debounceTimer; // 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 + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _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() { + // 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); + // Disabled automatic reloading when app comes to foreground + print("HistoryPage: didChangeAppLifecycleState called but disabled to prevent full reload"); + } + @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 } Future _refreshContacts() async { @@ -116,6 +179,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; @@ -130,10 +209,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; } @@ -203,8 +284,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)); } @@ -212,10 +307,121 @@ 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(() { + _globalHistories = callHistories; + _isInitialLoad = false; + _hasLoadedInitialHistory = true; // Mark that history has been loaded once + }); + // Notify other instances about the initial load + _notifyHistoryChanged(); + } + } + + /// Add the latest call log entry to the history list + Future _addLatestCallToHistory() async { + try { + // Get the latest call log entry + final dynamic rawEntry = await _channel.invokeMethod('getLatestCallLog'); + + if (rawEntry == null) { + print("No latest call log entry found"); + return; + } + + // Convert to proper type - handle the method channel result properly + final Map latestEntry = Map.from( + (rawEntry as Map).cast() + ); + + 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 { + await Future.delayed(const Duration(milliseconds: 100)); + return contactState.loading; + }); + } + List contacts = contactState.contacts; + + // Convert timestamp to DateTime + DateTime callDate = DateTime.fromMillisecondsSinceEpoch(latestEntry['date'] ?? 0); + + int typeInt = latestEntry['type'] ?? 0; + int duration = latestEntry['duration'] ?? 0; + String callType; + String callStatus; + + // 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"; + } + + // 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 (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(() { + // 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"); + } + } catch (e) { + print("Error adding latest call to history: $e"); + } } List _buildGroupedList(List historyList) { @@ -283,9 +489,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()), @@ -413,9 +619,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, @@ -625,6 +844,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( 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( 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..6872ea1 --- /dev/null +++ b/dialer/lib/presentation/features/settings/sim/settings_sim.dart @@ -0,0 +1,220 @@ +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}); + + @override + _SettingsSimPageState createState() => _SettingsSimPageState(); +} + +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(() { + _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: _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 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