diff --git a/dialer/android/app/src/main/AndroidManifest.xml b/dialer/android/app/src/main/AndroidManifest.xml index fcaf71f..4c1cb5f 100644 --- a/dialer/android/app/src/main/AndroidManifest.xml +++ b/dialer/android/app/src/main/AndroidManifest.xml @@ -12,6 +12,8 @@ + + ? = null + private var wasPhoneLocked: Boolean = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) Log.d(TAG, "onCreate started") - Log.d(TAG, "Waiting for Flutter to signal permissions") + wasPhoneLocked = intent.getBooleanExtra("wasPhoneLocked", false) + Log.d(TAG, "Was phone locked at start: $wasPhoneLocked") + updateLockScreenFlags(intent) + handleIncomingCallIntent(intent) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + wasPhoneLocked = intent.getBooleanExtra("wasPhoneLocked", false) + Log.d(TAG, "onNewIntent, wasPhoneLocked: $wasPhoneLocked") + updateLockScreenFlags(intent) + handleIncomingCallIntent(intent) + } + + private fun updateLockScreenFlags(intent: Intent?) { + val isIncomingCall = intent?.getBooleanExtra("isIncomingCall", false) ?: false + if (isIncomingCall && wasPhoneLocked) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(true) + setTurnScreenOn(true) + } else { + @Suppress("DEPRECATION") + window.addFlags( + android.view.WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or + android.view.WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + ) + } + Log.d(TAG, "Enabled showWhenLocked and turnScreenOn for incoming call") + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(false) + setTurnScreenOn(false) + } else { + @Suppress("DEPRECATION") + window.clearFlags( + android.view.WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or + android.view.WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + ) + } + Log.d(TAG, "Disabled showWhenLocked and turnScreenOn for normal usage") + } } override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) Log.d(TAG, "Configuring Flutter engine") - MyInCallService.channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALL_CHANNEL) + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALL_CHANNEL) .setMethodCallHandler { call, result -> when (call.method) { "permissionsGranted" -> { Log.d(TAG, "Received permissionsGranted from Flutter") + pendingIncomingCall?.let { (phoneNumber, showScreen) -> + if (showScreen) { + MyInCallService.channel?.invokeMethod("incomingCallFromNotification", mapOf( + "phoneNumber" to phoneNumber, + "wasPhoneLocked" to wasPhoneLocked + )) + pendingIncomingCall = null + } + } checkAndRequestDefaultDialer() result.success(true) } @@ -60,37 +112,104 @@ class MainActivity : FlutterActivity() { } } "hangUpCall" -> { - val success = CallService.hangUpCall(this) + val success = MyInCallService.currentCall?.let { + it.disconnect() + Log.d(TAG, "Call disconnected") + MyInCallService.channel?.invokeMethod("callEnded", mapOf( + "callId" to it.details.handle.toString(), + "wasPhoneLocked" to wasPhoneLocked + )) + true + } ?: false if (success) { result.success(mapOf("status" to "ended")) + if (wasPhoneLocked) { + Log.d(TAG, "Finishing and removing task after hangup, phone was locked") + finishAndRemoveTask() + } } else { - result.error("HANGUP_FAILED", "Failed to end call", null) + Log.w(TAG, "No active call to hang up") + result.error("HANGUP_FAILED", "No active call to hang up", null) } } "answerCall" -> { val success = MyInCallService.currentCall?.let { - it.answer(0) // 0 for default video state (audio-only) + it.answer(0) Log.d(TAG, "Answered call") true } ?: false if (success) { result.success(mapOf("status" to "answered")) } else { + Log.w(TAG, "No active call to answer") result.error("ANSWER_FAILED", "No active call to answer", null) } } + "callEndedFromFlutter" -> { + Log.d(TAG, "Call ended from Flutter, wasPhoneLocked: $wasPhoneLocked") + if (wasPhoneLocked) { + finishAndRemoveTask() + Log.d(TAG, "Finishing and removing task after call ended, phone was locked") + } + result.success(true) + } + "getCallState" -> { + val stateStr = when (MyInCallService.currentCall?.state) { + android.telecom.Call.STATE_ACTIVE -> "active" + android.telecom.Call.STATE_RINGING -> "ringing" + android.telecom.Call.STATE_DIALING -> "dialing" + android.telecom.Call.STATE_DISCONNECTED -> "disconnected" + android.telecom.Call.STATE_DISCONNECTING -> "disconnecting" + else -> "unknown" + } + Log.d(TAG, "getCallState called, returning: $stateStr") + result.success(stateStr) + } + "muteCall" -> { + val mute = call.argument("mute") ?: false + val success = MyInCallService.currentCall?.let { + MyInCallService.toggleMute(mute) + } ?: false + if (success) { + Log.d(TAG, "Mute call set to $mute") + result.success(mapOf("status" to "success")) + } else { + Log.w(TAG, "No active call or failed to mute") + result.error("MUTE_FAILED", "No active call or failed to mute", null) + } + } + "speakerCall" -> { + val speaker = call.argument("speaker") ?: false + val success = MyInCallService.currentCall?.let { + MyInCallService.toggleSpeaker(speaker) + } ?: false + if (success) { + Log.d(TAG, "Speaker call set to $speaker") + result.success(mapOf("status" to "success")) + } else { + Log.w(TAG, "No active call or failed to set speaker") + result.error("SPEAKER_FAILED", "No active call or failed to set speaker", null) + } + } else -> result.notImplemented() } } MethodChannel(flutterEngine.dartExecutor.binaryMessenger, KEYSTORE_CHANNEL) - .setMethodCallHandler { call, result -> KeystoreHelper(call, result).handleMethodCall() } + .setMethodCallHandler { call, result -> + KeystoreHelper(call, result).handleMethodCall() + } MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALLLOG_CHANNEL) .setMethodCallHandler { call, result -> if (call.method == "getCallLogs") { - val callLogs = getCallLogs() - result.success(callLogs) + 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() } @@ -109,8 +228,6 @@ class MainActivity : FlutterActivity() { val intent = roleManager.createRequestRoleIntent(RoleManager.ROLE_DIALER) startActivityForResult(intent, REQUEST_CODE_SET_DEFAULT_DIALER) Log.d(TAG, "Launched RoleManager intent for default dialer on API 29+") - } else { - Log.d(TAG, "RoleManager: Available=${roleManager.isRoleAvailable(RoleManager.ROLE_DIALER)}, Held=${roleManager.isRoleHeld(RoleManager.ROLE_DIALER)}") } } else { val intent = Intent(TelecomManager.ACTION_CHANGE_DEFAULT_DIALER) @@ -148,6 +265,18 @@ class MainActivity : FlutterActivity() { } } + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == REQUEST_CODE_CALL_LOG_PERMISSION) { + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + Log.d(TAG, "Call log permission granted") + MyInCallService.channel?.invokeMethod("callLogPermissionGranted", null) + } else { + Log.w(TAG, "Call log permission denied") + } + } + } + private fun getCallLogs(): List> { val logsList = mutableListOf>() val cursor: Cursor? = contentResolver.query( @@ -171,4 +300,25 @@ class MainActivity : FlutterActivity() { } return logsList } + + private fun handleIncomingCallIntent(intent: Intent?) { + intent?.let { + if (it.getBooleanExtra("isIncomingCall", false)) { + val phoneNumber = it.getStringExtra("phoneNumber") + val showScreen = it.getBooleanExtra("showIncomingCallScreen", false) + Log.d(TAG, "Received incoming call intent for $phoneNumber, showScreen=$showScreen, wasPhoneLocked=$wasPhoneLocked") + if (showScreen) { + if (MyInCallService.channel != null) { + MyInCallService.channel?.invokeMethod("incomingCallFromNotification", mapOf( + "phoneNumber" to phoneNumber, + "wasPhoneLocked" to wasPhoneLocked + )) + } else { + pendingIncomingCall = Pair(phoneNumber, true) + Log.d(TAG, "Flutter channel not ready, storing pending call: $phoneNumber") + } + } + } + } + } } \ No newline at end of file diff --git a/dialer/android/app/src/main/kotlin/com/icing/dialer/services/MyInCallService.kt b/dialer/android/app/src/main/kotlin/com/icing/dialer/services/MyInCallService.kt index 48b7edd..5469c6d 100644 --- a/dialer/android/app/src/main/kotlin/com/icing/dialer/services/MyInCallService.kt +++ b/dialer/android/app/src/main/kotlin/com/icing/dialer/services/MyInCallService.kt @@ -1,8 +1,19 @@ package com.icing.dialer.services +import android.app.KeyguardManager +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.media.AudioManager +import android.os.Build import android.telecom.Call import android.telecom.InCallService +import android.telecom.CallAudioState import android.util.Log +import androidx.core.app.NotificationCompat +import com.icing.dialer.activities.MainActivity import io.flutter.plugin.common.MethodChannel class MyInCallService : InCallService() { @@ -10,6 +21,37 @@ class MyInCallService : InCallService() { var channel: MethodChannel? = null var currentCall: Call? = null private const val TAG = "MyInCallService" + private const val NOTIFICATION_CHANNEL_ID = "incoming_call_channel" + private const val NOTIFICATION_ID = 1 + var wasPhoneLocked: Boolean = false + private var instance: MyInCallService? = null + + fun toggleMute(mute: Boolean): Boolean { + return instance?.let { service -> + try { + service.setMuted(mute) + Log.d(TAG, "Requested to set call mute state to $mute") + true + } catch (e: Exception) { + Log.e(TAG, "Failed to set mute state: $e") + false + } + } ?: false + } + + fun toggleSpeaker(speaker: Boolean): Boolean { + return instance?.let { service -> + try { + val route = if (speaker) CallAudioState.ROUTE_SPEAKER else CallAudioState.ROUTE_EARPIECE + service.setAudioRoute(route) + Log.d(TAG, "Requested to set audio route to $route") + true + } catch (e: Exception) { + Log.e(TAG, "Failed to set audio route: $e") + false + } + } ?: false + } } private val callCallback = object : Call.Callback() { @@ -26,44 +68,135 @@ class MyInCallService : InCallService() { Log.d(TAG, "State changed: $stateStr for call ${call.details.handle}") channel?.invokeMethod("callStateChanged", mapOf( "callId" to call.details.handle.toString(), - "state" to stateStr + "state" to stateStr, + "wasPhoneLocked" to wasPhoneLocked )) - if (state == Call.STATE_DISCONNECTED || state == Call.STATE_DISCONNECTING) { - Log.d(TAG, "Call ended: ${call.details.handle}") - channel?.invokeMethod("callEnded", mapOf("callId" to call.details.handle.toString())) + if (state == Call.STATE_RINGING) { + val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + wasPhoneLocked = keyguardManager.isKeyguardLocked + Log.d(TAG, "Phone locked at ringing: $wasPhoneLocked") + showIncomingCallScreen(call.details.handle.toString().replace("tel:", "")) + } else if (state == Call.STATE_DISCONNECTED || state == Call.STATE_DISCONNECTING) { + Log.d(TAG, "Call ended: ${call.details.handle}, wasPhoneLocked: $wasPhoneLocked") + channel?.invokeMethod("callEnded", mapOf( + "callId" to call.details.handle.toString(), + "wasPhoneLocked" to wasPhoneLocked + )) currentCall = null + cancelNotification() } } } override fun onCallAdded(call: Call) { super.onCallAdded(call) + instance = this currentCall = call val stateStr = when (call.state) { Call.STATE_DIALING -> "dialing" Call.STATE_ACTIVE -> "active" Call.STATE_RINGING -> "ringing" - else -> "dialing" // Default for outgoing + else -> "dialing" } Log.d(TAG, "Call added: ${call.details.handle}, state: $stateStr") channel?.invokeMethod("callAdded", mapOf( "callId" to call.details.handle.toString(), "state" to stateStr )) + if (stateStr == "ringing") { + val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + wasPhoneLocked = keyguardManager.isKeyguardLocked + Log.d(TAG, "Phone locked at call added: $wasPhoneLocked") + showIncomingCallScreen(call.details.handle.toString().replace("tel:", "")) + } call.registerCallback(callCallback) + if (callAudioState != null) { + val audioState = callAudioState + channel?.invokeMethod("audioStateChanged", mapOf( + "route" to audioState.route, + "muted" to audioState.isMuted, + "speaker" to (audioState.route == CallAudioState.ROUTE_SPEAKER) + )) + } else { + Log.w("MyInCallService", "callAudioState is null in onCallAdded") + } } override fun onCallRemoved(call: Call) { super.onCallRemoved(call) - Log.d(TAG, "Call removed: ${call.details.handle}") + Log.d(TAG, "Call removed: ${call.details.handle}, wasPhoneLocked: $wasPhoneLocked") call.unregisterCallback(callCallback) - channel?.invokeMethod("callRemoved", mapOf("callId" to call.details.handle.toString())) + channel?.invokeMethod("callRemoved", mapOf( + "callId" to call.details.handle.toString(), + "wasPhoneLocked" to wasPhoneLocked + )) currentCall = null + instance = null + cancelNotification() } - override fun onCallAudioStateChanged(state: android.telecom.CallAudioState) { + override fun onCallAudioStateChanged(state: CallAudioState) { super.onCallAudioStateChanged(state) - Log.d(TAG, "Audio state changed: route=${state.route}") - channel?.invokeMethod("audioStateChanged", mapOf("route" to state.route)) + Log.d(TAG, "Audio state changed: route=${state.route}, muted=${state.isMuted}") + channel?.invokeMethod("audioStateChanged", mapOf( + "route" to state.route, + "muted" to state.isMuted, + "speaker" to (state.route == CallAudioState.ROUTE_SPEAKER) + )) + } + + private fun showIncomingCallScreen(phoneNumber: String) { + val intent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + putExtra("phoneNumber", phoneNumber) + putExtra("isIncomingCall", true) + putExtra("showIncomingCallScreen", true) + putExtra("wasPhoneLocked", wasPhoneLocked) + } + + val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + if (keyguardManager.isKeyguardLocked) { + startActivity(intent) + Log.d(TAG, "Launched MainActivity directly for locked screen, phoneNumber: $phoneNumber") + } else { + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + "Incoming Calls", + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "Notifications for incoming calls" + enableVibration(true) + setShowBadge(true) + } + notificationManager.createNotificationChannel(channel) + } + + val pendingIntent = PendingIntent.getActivity( + this, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0) + ) + + val notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_dialog_alert) + .setContentTitle("Incoming Call") + .setContentText("Call from $phoneNumber") + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setFullScreenIntent(pendingIntent, true) + .setAutoCancel(true) + .setOngoing(true) + .build() + + startActivity(intent) + notificationManager.notify(NOTIFICATION_ID, notification) + Log.d(TAG, "Launched MainActivity with notification for unlocked screen, phoneNumber: $phoneNumber") + } + } + + private fun cancelNotification() { + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel(NOTIFICATION_ID) + Log.d(TAG, "Notification canceled") } } \ No newline at end of file diff --git a/dialer/lib/domain/services/call_service.dart b/dialer/lib/domain/services/call_service.dart index c348b59..1c6d242 100644 --- a/dialer/lib/domain/services/call_service.dart +++ b/dialer/lib/domain/services/call_service.dart @@ -1,154 +1,442 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../../presentation/features/call/call_page.dart'; import '../../presentation/features/call/incoming_call_page.dart'; // Import the new page +import 'contact_service.dart'; class CallService { static const MethodChannel _channel = MethodChannel('call_service'); static String? currentPhoneNumber; + static String? currentDisplayName; + static Uint8List? currentThumbnail; static bool _isCallPageVisible = false; + static Map? _pendingCall; + static bool wasPhoneLocked = false; + static String? _activeCallNumber; + static bool _isNavigating = false; + final ContactService _contactService = ContactService(); + final _callStateController = StreamController.broadcast(); + final _audioStateController = StreamController>.broadcast(); + Map? _currentAudioState; static final GlobalKey navigatorKey = GlobalKey(); + + Stream get callStateStream => _callStateController.stream; + Stream> get audioStateStream => _audioStateController.stream; + Map? get currentAudioState => _currentAudioState; CallService() { _channel.setMethodCallHandler((call) async { - final context = navigatorKey.currentContext; - print('CallService: Received method ${call.method} with args ${call.arguments}'); - if (context == null) { - print('CallService: Navigator context is null, cannot navigate'); - return; - } - + print('CallService: Handling method call: ${call.method}, with args: ${call.arguments}'); switch (call.method) { case "callAdded": - final phoneNumber = call.arguments["callId"] as String; - final state = call.arguments["state"] as String; - currentPhoneNumber = phoneNumber.replaceFirst('tel:', ''); - print('CallService: Call added, number: $currentPhoneNumber, state: $state'); + final phoneNumber = call.arguments["callId"] as String?; + final state = call.arguments["state"] as String?; + if (phoneNumber == null || state == null) { + print('CallService: Invalid callAdded args: $call.arguments'); + return; + } + final decodedPhoneNumber = Uri.decodeComponent(phoneNumber.replaceFirst('tel:', '')); + print('CallService: Decoded phone number: $decodedPhoneNumber'); + if (_activeCallNumber != decodedPhoneNumber) { + currentPhoneNumber = decodedPhoneNumber; + if (currentDisplayName == null || currentDisplayName == currentPhoneNumber) { + await _fetchContactInfo(decodedPhoneNumber); + } + } + print('CallService: Call added, number: $currentPhoneNumber, displayName: $currentDisplayName, state: $state'); + _callStateController.add(state); if (state == "ringing") { - _navigateToIncomingCallPage(context); + _handleIncomingCall(decodedPhoneNumber); } else { - _navigateToCallPage(context); + _navigateToCallPage(); } break; case "callStateChanged": - final state = call.arguments["state"] as String; - print('CallService: State changed to $state'); + final state = call.arguments["state"] as String?; + wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false; + if (state == null) { + print('CallService: Invalid callStateChanged args: $call.arguments'); + return; + } + print('CallService: State changed to $state, wasPhoneLocked: $wasPhoneLocked'); + _callStateController.add(state); if (state == "disconnected" || state == "disconnecting") { - _closeCallPage(context); + _closeCallPage(); + if (wasPhoneLocked) { + await _channel.invokeMethod("callEndedFromFlutter"); + } + _activeCallNumber = null; } else if (state == "active" || state == "dialing") { - _navigateToCallPage(context); + final phoneNumber = call.arguments["callId"] as String?; + if (phoneNumber != null && _activeCallNumber != Uri.decodeComponent(phoneNumber.replaceFirst('tel:', ''))) { + currentPhoneNumber = Uri.decodeComponent(phoneNumber.replaceFirst('tel:', '')); + if (currentDisplayName == null || currentDisplayName == currentPhoneNumber) { + await _fetchContactInfo(currentPhoneNumber!); + } + } else if (currentPhoneNumber != null && _activeCallNumber != currentPhoneNumber) { + if (currentDisplayName == null || currentDisplayName == currentPhoneNumber) { + await _fetchContactInfo(currentPhoneNumber!); + } + } else { + print('CallService: Skipping fetch, active call: $_activeCallNumber, current: $currentPhoneNumber, displayName: $currentDisplayName'); + } + _navigateToCallPage(); } else if (state == "ringing") { - _navigateToIncomingCallPage(context); + final phoneNumber = call.arguments["callId"] as String?; + if (phoneNumber == null) { + print('CallService: Invalid ringing callId: $call.arguments'); + return; + } + final decodedPhoneNumber = Uri.decodeComponent(phoneNumber.replaceFirst('tel:', '')); + if (_activeCallNumber != decodedPhoneNumber) { + currentPhoneNumber = decodedPhoneNumber; + if (currentDisplayName == null || currentDisplayName == currentPhoneNumber) { + await _fetchContactInfo(decodedPhoneNumber); + } + } + _handleIncomingCall(decodedPhoneNumber); } break; case "callEnded": case "callRemoved": - print('CallService: Call ended/removed'); - _closeCallPage(context); + wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false; + print('CallService: Call ended/removed, wasPhoneLocked: $wasPhoneLocked'); + _closeCallPage(); + if (wasPhoneLocked) { + await _channel.invokeMethod("callEndedFromFlutter"); + } currentPhoneNumber = null; + currentDisplayName = null; + currentThumbnail = null; + _activeCallNumber = null; + break; + case "incomingCallFromNotification": + final phoneNumber = call.arguments["phoneNumber"] as String?; + wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false; + if (phoneNumber == null) { + print('CallService: Invalid incomingCallFromNotification args: $call.arguments'); + return; + } + final decodedPhoneNumber = Uri.decodeComponent(phoneNumber); + if (_activeCallNumber != decodedPhoneNumber) { + currentPhoneNumber = decodedPhoneNumber; + if (currentDisplayName == null || currentDisplayName == currentPhoneNumber) { + await _fetchContactInfo(decodedPhoneNumber); + } + } + print('CallService: Incoming call from notification: $decodedPhoneNumber, displayName: $currentDisplayName, wasPhoneLocked: $wasPhoneLocked'); + _handleIncomingCall(decodedPhoneNumber); + break; + case "audioStateChanged": + final route = call.arguments["route"] as int?; + final muted = call.arguments["muted"] as bool?; + final speaker = call.arguments["speaker"] as bool?; + print('CallService: Audio state changed, route: $route, muted: $muted, speaker: $speaker'); + final audioState = { + "route": route, + "muted": muted, + "speaker": speaker, + }; + _currentAudioState = audioState; + _audioStateController.add(audioState); break; } }); } - void _navigateToCallPage(BuildContext context) { - if (_isCallPageVisible && ModalRoute.of(context)?.settings.name == '/call') { - print('CallService: CallPage already visible, skipping navigation'); + Future getCallState() async { + try { + final state = await _channel.invokeMethod('getCallState'); + print('CallService: getCallState returned: $state'); + return state as String?; + } catch (e) { + print('CallService: Error getting call state: $e'); + return null; + } + } + + Future> muteCall(BuildContext context, {required bool mute}) async { + try { + print('CallService: Toggling mute to $mute'); + final result = await _channel.invokeMethod('muteCall', {'mute': mute}); + print('CallService: muteCall result: $result'); + final resultMap = Map.from(result as Map); + if (resultMap['status'] != 'success') { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to toggle mute')), + ); + } + return resultMap; + } catch (e) { + print('CallService: Error toggling mute: $e'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error toggling mute: $e')), + ); + return {'status': 'error', 'message': e.toString()}; + } + } + + Future> speakerCall(BuildContext context, {required bool speaker}) async { + try { + print('CallService: Toggling speaker to $speaker'); + final result = await _channel.invokeMethod('speakerCall', {'speaker': speaker}); + print('CallService: speakerCall result: $result'); + return Map.from(result); + } catch (e) { + print('CallService: Error toggling speaker: $e'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to toggle speaker: $e')), + ); + return {'status': 'error', 'message': e.toString()}; + } + } + + void dispose() { + _callStateController.close(); + _audioStateController.close(); + } + + Future _fetchContactInfo(String phoneNumber) async { + try { + print('CallService: Fetching contact info for $phoneNumber'); + final contacts = await _contactService.fetchContacts(); + print('CallService: Retrieved ${contacts.length} contacts'); + final normalizedPhoneNumber = _normalizePhoneNumber(phoneNumber); + print('CallService: Normalized phone number: $normalizedPhoneNumber'); + for (var contact in contacts) { + for (var phone in contact.phones) { + final normalizedContactNumber = _normalizePhoneNumber(phone.number); + print('CallService: Comparing $normalizedPhoneNumber with contact number $normalizedContactNumber'); + if (normalizedContactNumber == normalizedPhoneNumber) { + currentDisplayName = contact.displayName; + currentThumbnail = contact.thumbnail; + print('CallService: Found contact - displayName: $currentDisplayName, thumbnail: ${currentThumbnail != null ? "present" : "null"}'); + return; + } + } + } + currentDisplayName = phoneNumber; + currentThumbnail = null; + print('CallService: No contact match, using phoneNumber as displayName: $currentDisplayName'); + } catch (e) { + print('CallService: Error fetching contact info: $e'); + currentDisplayName = phoneNumber; + currentThumbnail = null; + } + } + + String _normalizePhoneNumber(String number) { + return number.replaceAll(RegExp(r'[\s\-\(\)]'), '').replaceFirst(RegExp(r'^\+'), ''); + } + + void _handleIncomingCall(String phoneNumber) { + if (_activeCallNumber == phoneNumber && _isCallPageVisible) { + print('CallService: Incoming call for $phoneNumber already active, skipping'); return; } - print('CallService: Navigating to CallPage'); + _activeCallNumber = phoneNumber; + + final context = navigatorKey.currentContext; + if (context == null) { + print('CallService: Context is null, queuing incoming call: $phoneNumber'); + _pendingCall = {"phoneNumber": phoneNumber}; + Future.delayed(Duration(milliseconds: 500), () => _checkPendingCall()); + } else { + _navigateToIncomingCallPage(context); + } + } + + Future _checkPendingCall() async { + if (_pendingCall == null) { + print('CallService: No pending call to process'); + return; + } + + final phoneNumber = _pendingCall!["phoneNumber"]; + if (_activeCallNumber == phoneNumber && _isCallPageVisible) { + print('CallService: Pending call for $phoneNumber already active, clearing'); + _pendingCall = null; + return; + } + + final context = navigatorKey.currentContext; + if (context != null) { + print('CallService: Processing queued call: $phoneNumber'); + currentPhoneNumber = phoneNumber; + _activeCallNumber = phoneNumber; + await _fetchContactInfo(phoneNumber); + _navigateToIncomingCallPage(context); + _pendingCall = null; + } else { + print('CallService: Context still null, retrying...'); + Future.delayed(Duration(milliseconds: 500), () => _checkPendingCall()); + } + } + + void _navigateToCallPage() { + if (_isNavigating) { + print('CallService: Navigation already in progress, skipping'); + return; + } + _isNavigating = true; + + final context = navigatorKey.currentContext; + if (context == null) { + print('CallService: Cannot navigate to CallPage, context is null'); + _isNavigating = false; + return; + } + final currentRoute = ModalRoute.of(context)?.settings.name ?? 'unknown'; + print('CallService: Navigating to CallPage, Visible: $_isCallPageVisible, Current Route: $currentRoute, Active Call: $_activeCallNumber, DisplayName: $currentDisplayName'); + if (_isCallPageVisible && currentRoute == '/call' && _activeCallNumber == currentPhoneNumber) { + print('CallService: CallPage already visible for $_activeCallNumber, skipping navigation'); + _isNavigating = false; + return; + } + if (_isCallPageVisible && currentRoute == '/incoming_call' && _activeCallNumber == currentPhoneNumber) { + print('CallService: Popping IncomingCallPage before navigating to CallPage'); + Navigator.pop(context); + _isCallPageVisible = false; + } + if (currentPhoneNumber == null) { + print('CallService: Cannot navigate to CallPage, currentPhoneNumber is null'); + _isNavigating = false; + return; + } + _activeCallNumber = currentPhoneNumber; Navigator.pushReplacement( context, MaterialPageRoute( settings: const RouteSettings(name: '/call'), builder: (context) => CallPage( - displayName: currentPhoneNumber!, + displayName: currentDisplayName ?? currentPhoneNumber!, phoneNumber: currentPhoneNumber!, - thumbnail: null, + thumbnail: currentThumbnail, ), ), ).then((_) { _isCallPageVisible = false; + _isNavigating = false; + print('CallService: CallPage popped, _isCallPageVisible set to false'); }); _isCallPageVisible = true; } void _navigateToIncomingCallPage(BuildContext context) { - if (_isCallPageVisible && ModalRoute.of(context)?.settings.name == '/incoming_call') { - print('CallService: IncomingCallPage already visible, skipping navigation'); + if (_isNavigating) { + print('CallService: Navigation already in progress, skipping'); + return; + } + _isNavigating = true; + + final currentRoute = ModalRoute.of(context)?.settings.name ?? 'unknown'; + print('CallService: Navigating to IncomingCallPage, Visible: $_isCallPageVisible, Current Route: $currentRoute, Active Call: $_activeCallNumber, DisplayName: $currentDisplayName'); + if (_isCallPageVisible && currentRoute == '/incoming_call' && _activeCallNumber == currentPhoneNumber) { + print('CallService: IncomingCallPage already visible for $_activeCallNumber, skipping navigation'); + _isNavigating = false; + return; + } + if (_isCallPageVisible && currentRoute == '/call') { + print('CallService: CallPage visible, not showing IncomingCallPage'); + _isNavigating = false; + return; + } + if (currentPhoneNumber == null) { + print('CallService: Cannot navigate to IncomingCallPage, currentPhoneNumber is null'); + _isNavigating = false; return; } - print('CallService: Navigating to IncomingCallPage'); Navigator.push( context, MaterialPageRoute( settings: const RouteSettings(name: '/incoming_call'), builder: (context) => IncomingCallPage( - displayName: currentPhoneNumber!, + displayName: currentDisplayName ?? currentPhoneNumber!, phoneNumber: currentPhoneNumber!, - thumbnail: null, + thumbnail: currentThumbnail, ), ), ).then((_) { _isCallPageVisible = false; + _isNavigating = false; + print('CallService: IncomingCallPage popped, _isCallPageVisible set to false'); }); _isCallPageVisible = true; } - void _closeCallPage(BuildContext context) { - if (!_isCallPageVisible) { - print('CallService: CallPage not visible, skipping pop'); + void _closeCallPage() { + final context = navigatorKey.currentContext; + if (context == null) { + print('CallService: Cannot close page, context is null'); return; } + print('CallService: Closing call page, _isCallPageVisible: $_isCallPageVisible'); if (Navigator.canPop(context)) { - print('CallService: Popping CallPage'); + print('CallService: Popping call page'); Navigator.pop(context); _isCallPageVisible = false; + } else { + print('CallService: No page to pop'); } + _activeCallNumber = null; } - Future makeGsmCall( + Future> makeGsmCall( BuildContext context, { required String phoneNumber, String? displayName, Uint8List? thumbnail, }) async { try { + if (_activeCallNumber == phoneNumber && _isCallPageVisible) { + print('CallService: Call already active for $phoneNumber, skipping'); + return {"status": "already_active", "message": "Call already in progress"}; + } currentPhoneNumber = phoneNumber; - print('CallService: Making GSM call to $phoneNumber'); + currentDisplayName = displayName ?? phoneNumber; + currentThumbnail = thumbnail; + 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: makeGsmCall result: $result'); - if (result["status"] != "calling") { + final resultMap = Map.from(result as Map); + if (resultMap["status"] != "calling") { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text("Failed to initiate call")), ); } + return resultMap; } catch (e) { print("CallService: Error making call: $e"); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text("Error making call: $e")), ); - rethrow; + return {"status": "error", "message": e.toString()}; } } - Future hangUpCall(BuildContext context) async { + Future> hangUpCall(BuildContext context) async { try { print('CallService: Hanging up call'); final result = await _channel.invokeMethod('hangUpCall'); print('CallService: hangUpCall result: $result'); - if (result["status"] != "ended") { + final resultMap = Map.from(result as Map); + if (resultMap["status"] != "ended") { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text("Failed to end call")), ); } + return resultMap; } catch (e) { print("CallService: Error hanging up call: $e"); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text("Error hanging up call: $e")), ); - rethrow; + return {"status": "error", "message": e.toString()}; } } } \ No newline at end of file diff --git a/dialer/lib/presentation/features/composition/composition.dart b/dialer/lib/presentation/features/composition/composition.dart index 3b5d6ce..fe4d525 100644 --- a/dialer/lib/presentation/features/composition/composition.dart +++ b/dialer/lib/presentation/features/composition/composition.dart @@ -18,11 +18,7 @@ class _CompositionPageState extends State { List _allContacts = []; List _filteredContacts = []; final ContactService _contactService = ContactService(); - - // Instantiate the ObfuscateService final ObfuscateService _obfuscateService = ObfuscateService(); - - // Instantiate the CallService final CallService _callService = CallService(); @override @@ -40,8 +36,13 @@ class _CompositionPageState extends State { void _filterContacts() { setState(() { _filteredContacts = _allContacts.where((contact) { - final phoneMatch = contact.phones.any((phone) => - phone.number.replaceAll(RegExp(r'\D'), '').contains(dialedNumber)); + bool phoneMatch = contact.phones.any((phone) { + final rawPhoneNumber = phone.number; + final strippedPhoneNumber = rawPhoneNumber.replaceAll(RegExp(r'\D'), ''); + final strippedDialedNumber = dialedNumber.replaceAll(RegExp(r'\D'), ''); + return rawPhoneNumber.contains(dialedNumber) || + strippedPhoneNumber.contains(strippedDialedNumber); + }); final nameMatch = contact.displayName .toLowerCase() .contains(dialedNumber.toLowerCase()); @@ -57,6 +58,13 @@ class _CompositionPageState extends State { }); } + void _onPlusPress() { + setState(() { + dialedNumber += '+'; + _filterContacts(); + }); + } + void _onDeletePress() { setState(() { if (dialedNumber.isNotEmpty) { @@ -73,7 +81,6 @@ class _CompositionPageState extends State { }); } - // Function to call a contact's number using the CallService void _makeCall(String phoneNumber) async { try { await _callService.makeGsmCall(context, phoneNumber: phoneNumber); @@ -82,10 +89,12 @@ class _CompositionPageState extends State { }); } catch (e) { debugPrint("Error making call: $e"); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to make call: $e')), + ); } } - // Function to send an SMS to a contact's number void _launchSms(String phoneNumber) async { final uri = Uri(scheme: 'sms', path: phoneNumber); if (await canLaunchUrl(uri)) { @@ -95,6 +104,20 @@ class _CompositionPageState extends State { } } + void _addContact() async { + if (await FlutterContacts.requestPermission()) { + final newContact = Contact() + ..phones = [Phone(dialedNumber.isNotEmpty ? dialedNumber : '')]; + final updatedContact = await FlutterContacts.openExternalInsert(newContact); + if (updatedContact != null) { + _fetchContacts(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Contact added successfully!')), + ); + } + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -103,7 +126,6 @@ class _CompositionPageState extends State { children: [ Column( children: [ - // Top half: Display contacts matching dialed number Expanded( flex: 2, child: Container( @@ -115,57 +137,51 @@ class _CompositionPageState extends State { children: [ Expanded( child: ListView( - children: _filteredContacts.isNotEmpty - ? _filteredContacts.map((contact) { - final phoneNumber = contact.phones.isNotEmpty - ? contact.phones.first.number - : 'No phone number'; - return ListTile( - title: Text( - _obfuscateService.obfuscateData(contact.displayName), - style: const TextStyle(color: Colors.white), + children: [ + ..._filteredContacts.map((contact) { + final phoneNumber = contact.phones.isNotEmpty + ? contact.phones.first.number + : 'No phone number'; + return ListTile( + title: Text( + _obfuscateService.obfuscateData(contact.displayName), + style: const TextStyle(color: Colors.white), + ), + subtitle: Text( + _obfuscateService.obfuscateData(phoneNumber), + style: const TextStyle(color: Colors.grey), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: Icon(Icons.phone, color: Colors.green[300], size: 20), + onPressed: () => _makeCall(phoneNumber), + ), + IconButton( + icon: Icon(Icons.message, color: Colors.blue[300], size: 20), + onPressed: () => _launchSms(phoneNumber), + ), + ], + ), + onTap: () {}, + ); + }).toList(), + ListTile( + title: const Text( + 'Add a contact', + style: TextStyle(color: Colors.white), ), - subtitle: Text( - _obfuscateService.obfuscateData(phoneNumber), - style: const TextStyle(color: Colors.grey), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // Call button (Now using CallService) - IconButton( - icon: Icon(Icons.phone, - color: Colors.green[300], - size: 20), - onPressed: () { - _makeCall(phoneNumber); // Make a call using CallService - }, - ), - // Message button - IconButton( - icon: Icon(Icons.message, - color: Colors.blue[300], - size: 20), - onPressed: () { - _launchSms(phoneNumber); - }, - ), - ], - ), - onTap: () { - // Handle contact selection if needed - }, - ); - }).toList() - : [], + trailing: Icon(Icons.add, color: Colors.grey[600]), + onTap: _addContact, + ), + ], ), ), ], ), ), ), - - // Bottom half: Dialpad and Dialed number display with erase button Expanded( flex: 2, child: Container( @@ -173,7 +189,6 @@ class _CompositionPageState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.start, children: [ - // Display dialed number with erase button Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -182,61 +197,57 @@ class _CompositionPageState extends State { alignment: Alignment.center, child: Text( dialedNumber, - style: const TextStyle( - fontSize: 24, color: Colors.white), + style: const TextStyle(fontSize: 24, color: Colors.white), overflow: TextOverflow.ellipsis, ), ), ), - IconButton( - onPressed: _onClearPress, - icon: const Icon(Icons.backspace, - color: Colors.white), + GestureDetector( + onTap: _onDeletePress, + onLongPress: _onClearPress, + child: const Padding( + padding: EdgeInsets.all(8.0), + child: Icon(Icons.backspace, color: Colors.white), + ), ), ], ), const SizedBox(height: 10), - - // Dialpad Expanded( child: SingleChildScrollView( child: Column( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ Row( - mainAxisAlignment: - MainAxisAlignment.spaceEvenly, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - _buildDialButton('1'), - _buildDialButton('2'), - _buildDialButton('3'), + _buildDialButton('1', Colors.white), + _buildDialButton('2', Colors.white), + _buildDialButton('3', Colors.white), ], ), Row( - mainAxisAlignment: - MainAxisAlignment.spaceEvenly, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - _buildDialButton('4'), - _buildDialButton('5'), - _buildDialButton('6'), + _buildDialButton('4', Colors.white), + _buildDialButton('5', Colors.white), + _buildDialButton('6', Colors.white), ], ), Row( - mainAxisAlignment: - MainAxisAlignment.spaceEvenly, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - _buildDialButton('7'), - _buildDialButton('8'), - _buildDialButton('9'), + _buildDialButton('7', Colors.white), + _buildDialButton('8', Colors.white), + _buildDialButton('9', Colors.white), ], ), Row( - mainAxisAlignment: - MainAxisAlignment.spaceEvenly, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - _buildDialButton('*'), - _buildDialButton('0'), - _buildDialButton('#'), + _buildDialButton('*', const Color.fromRGBO(117, 117, 117, 1)), + _buildDialButtonWithPlus('0'), + _buildDialButton('#', const Color.fromRGBO(117, 117, 117, 1)), ], ), ], @@ -249,26 +260,28 @@ class _CompositionPageState extends State { ), ], ), - - // Add Contact Button Positioned( bottom: 20.0, left: 0, right: 0, child: Center( - child: AddContactButton(), + child: ElevatedButton( + onPressed: dialedNumber.isNotEmpty ? () => _makeCall(dialedNumber) : null, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green[700], + shape: const CircleBorder(), + padding: const EdgeInsets.all(20), + ), + child: const Icon(Icons.phone, color: Colors.white, size: 30), + ), ), ), - - // Top Row with Back Arrow Positioned( top: 40.0, left: 16.0, child: IconButton( icon: const Icon(Icons.arrow_back, color: Colors.white), - onPressed: () { - Navigator.pop(context); - }, + onPressed: () => Navigator.pop(context), ), ), ], @@ -276,7 +289,7 @@ class _CompositionPageState extends State { ); } - Widget _buildDialButton(String number) { + Widget _buildDialButton(String number, Color textColor) { return ElevatedButton( onPressed: () => _onNumberPress(number), style: ElevatedButton.styleFrom( @@ -286,11 +299,38 @@ class _CompositionPageState extends State { ), child: Text( number, - style: const TextStyle( - fontSize: 24, - color: Colors.white, - ), + style: TextStyle(fontSize: 24, color: textColor), ), ); } -} + + Widget _buildDialButtonWithPlus(String number) { + return Stack( + alignment: Alignment.center, + children: [ + GestureDetector( + onLongPress: _onPlusPress, + child: ElevatedButton( + onPressed: () => _onNumberPress(number), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.black, + shape: const CircleBorder(), + padding: const EdgeInsets.all(16), + ), + child: Text( + number, + style: const TextStyle(fontSize: 24, color: Colors.white), + ), + ), + ), + Positioned( + bottom: 8, + child: Text( + '+', + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/dialer/lib/presentation/features/history/history_page.dart b/dialer/lib/presentation/features/history/history_page.dart index e193119..1c940e1 100644 --- a/dialer/lib/presentation/features/history/history_page.dart +++ b/dialer/lib/presentation/features/history/history_page.dart @@ -37,7 +37,7 @@ class HistoryPage extends StatefulWidget { } class _HistoryPageState extends State - with SingleTickerProviderStateMixin { + with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin { List histories = []; bool loading = true; int? _expandedIndex; @@ -47,10 +47,13 @@ class _HistoryPageState extends State // Create a MethodChannel instance. static const MethodChannel _channel = MethodChannel('com.example.calllog'); + @override + bool get wantKeepAlive => true; // Preserve state when switching pages + @override void didChangeDependencies() { super.didChangeDependencies(); - if (loading) { + if (loading && histories.isEmpty) { _buildHistories(); } } @@ -154,9 +157,9 @@ class _HistoryPageState extends State List contacts = contactState.contacts; List callHistories = []; - // Process each log entry. - for (var entry in nativeLogs) { - // Each entry is a Map with keys: number, type, date, duration. + // 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; @@ -202,6 +205,8 @@ class _HistoryPageState extends State 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. @@ -277,6 +282,7 @@ 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) { @@ -424,7 +430,12 @@ class _HistoryPageState extends State icon: const Icon(Icons.phone, color: Colors.green), onPressed: () async { if (contact.phones.isNotEmpty) { - _callService.makeGsmCall(context, phoneNumber: contact.phones.first.number); + await _callService.makeGsmCall( + context, + phoneNumber: contact.phones.first.number, + displayName: contact.displayName, + thumbnail: contact.thumbnail, + ); } else { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( diff --git a/docs/beta_test_plan.md b/docs/beta_test_plan.md index dfefa39..d4d5e4d 100644 --- a/docs/beta_test_plan.md +++ b/docs/beta_test_plan.md @@ -27,14 +27,13 @@ The protocol definition will include as completed: - Handshakes - Real-time data-stream encryption (and decryption) - Encrypted stream compression -- Transmission over audio stream -- Minimal error correction in audio-based transmission -- Error handling and user prevention +- Transmission over audio stream (at least one modulation type) +- First steps in FEC (Forward Error Correction): detecting half of transmission errors And should include prototype or scratches functionalities, among which: -- Embedded silent data transmission (silently transmit light data during an encrypted phone call) +- Embedded silent data transmission (such as DTMF) - On-the-fly key exchange (does not require prior key exchange, sacrifying some security) -- Strong error correction +- Stronger FEC: detecting >80%, correcting 20% of transmission errors #### The Icing dialer (based on Icing kotlin library, an Icing protocol implementation) @@ -128,16 +127,15 @@ The remote bank advisor asks him to authenticate, making him type his password o By using the Icing protocol, not only would Jeff and the bank be assured that the informations are transmitted safely, but also that the call is coming from Jeff's phone and not an impersonator. -Elise is a 42 years-old extreme reporter. -After interviewing Russians opposition's leader, the FSB is looking to interview her. -She tries to stay discreet and hidden, but those measures constrains her to barely receive cellular network. -She suspects her phone line to be monitored, so the best she can do to call safely, is to use her Icing dialer. +Elise, 42 years-old, is a journalist covering sensitive topics. +Her work draws attention from people who want to know what she's saying - and to whom. +Forced to stay discreet, with unreliable signal and a likely monitored phone line, +she uses Icing dialer to make secure calls without exposing herself. -Paul, a 22 years-old developer working for a big company, decides to go to China for vacations. +Paul, a 22 years-old developer, is enjoying its vacations abroad. But everything goes wrong! The company's product he works on, is failling in the middle of the day and no one is -qualified to fix it. Paul doesn't have WiFi and his phone plan only covers voice calls in China. -With Icing dialer, he can call his collegues and help fix the -problem, safe from potential Chinese spies. +qualified to fix it. Paul doesn't have WiFi and his phone plan only covers voice calls in his country. +With Icing dialer, he can call his collegues and help fix the problem, completely safe. ## Evaluation Criteria ### Protocol and lib