From 2894dce1bc800313f1b21764b8c2ad21aae9a4d6 Mon Sep 17 00:00:00 2001 From: Florian Griffon Date: Fri, 14 Mar 2025 15:35:35 +0200 Subject: [PATCH] feat: can call and receive call --- .../icing/dialer/activities/MainActivity.kt | 12 ++ .../icing/dialer/services/MyInCallService.kt | 11 +- .../lib/features/call/incoming_call_page.dart | 181 ++++++++++++++++++ .../contacts/widgets/contact_modal.dart | 4 +- dialer/lib/main.dart | 3 +- dialer/lib/services/call_service.dart | 127 ++++++++---- 6 files changed, 295 insertions(+), 43 deletions(-) create mode 100644 dialer/lib/features/call/incoming_call_page.dart diff --git a/dialer/android/app/src/main/kotlin/com/icing/dialer/activities/MainActivity.kt b/dialer/android/app/src/main/kotlin/com/icing/dialer/activities/MainActivity.kt index 8354324..1181ce9 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 @@ -67,6 +67,18 @@ class MainActivity : FlutterActivity() { result.error("HANGUP_FAILED", "Failed to end call", null) } } + "answerCall" -> { + val success = MyInCallService.currentCall?.let { + it.answer(0) // 0 for default video state (audio-only) + Log.d(TAG, "Answered call") + true + } ?: false + if (success) { + result.success(mapOf("status" to "answered")) + } else { + result.error("ANSWER_FAILED", "No active call to answer", null) + } + } else -> result.notImplemented() } } 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 c4b67c7..48b7edd 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 @@ -2,12 +2,14 @@ package com.icing.dialer.services import android.telecom.Call import android.telecom.InCallService +import android.util.Log import io.flutter.plugin.common.MethodChannel class MyInCallService : InCallService() { companion object { var channel: MethodChannel? = null var currentCall: Call? = null + private const val TAG = "MyInCallService" } private val callCallback = object : Call.Callback() { @@ -18,13 +20,16 @@ class MyInCallService : InCallService() { Call.STATE_ACTIVE -> "active" Call.STATE_DISCONNECTED -> "disconnected" Call.STATE_DISCONNECTING -> "disconnecting" + Call.STATE_RINGING -> "ringing" else -> "unknown" } + Log.d(TAG, "State changed: $stateStr for call ${call.details.handle}") channel?.invokeMethod("callStateChanged", mapOf( "callId" to call.details.handle.toString(), "state" to stateStr )) 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())) currentCall = null } @@ -37,8 +42,10 @@ class MyInCallService : InCallService() { val stateStr = when (call.state) { Call.STATE_DIALING -> "dialing" Call.STATE_ACTIVE -> "active" - else -> "unknown" + Call.STATE_RINGING -> "ringing" + else -> "dialing" // Default for outgoing } + Log.d(TAG, "Call added: ${call.details.handle}, state: $stateStr") channel?.invokeMethod("callAdded", mapOf( "callId" to call.details.handle.toString(), "state" to stateStr @@ -48,6 +55,7 @@ class MyInCallService : InCallService() { override fun onCallRemoved(call: Call) { super.onCallRemoved(call) + Log.d(TAG, "Call removed: ${call.details.handle}") call.unregisterCallback(callCallback) channel?.invokeMethod("callRemoved", mapOf("callId" to call.details.handle.toString())) currentCall = null @@ -55,6 +63,7 @@ class MyInCallService : InCallService() { override fun onCallAudioStateChanged(state: android.telecom.CallAudioState) { super.onCallAudioStateChanged(state) + Log.d(TAG, "Audio state changed: route=${state.route}") channel?.invokeMethod("audioStateChanged", mapOf("route" to state.route)) } } \ No newline at end of file diff --git a/dialer/lib/features/call/incoming_call_page.dart b/dialer/lib/features/call/incoming_call_page.dart new file mode 100644 index 0000000..2bad2eb --- /dev/null +++ b/dialer/lib/features/call/incoming_call_page.dart @@ -0,0 +1,181 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:dialer/services/call_service.dart'; +import 'package:dialer/services/obfuscate_service.dart'; +import 'package:dialer/widgets/username_color_generator.dart'; +import 'package:dialer/features/call/call_page.dart'; + +class IncomingCallPage extends StatefulWidget { + final String displayName; + final String phoneNumber; + final Uint8List? thumbnail; + + const IncomingCallPage({ + super.key, + required this.displayName, + required this.phoneNumber, + this.thumbnail, + }); + + @override + _IncomingCallPageState createState() => _IncomingCallPageState(); +} + +class _IncomingCallPageState extends State { + static const MethodChannel _channel = MethodChannel('call_service'); + final ObfuscateService _obfuscateService = ObfuscateService(); + final CallService _callService = CallService(); + bool icingProtocolOk = true; + + void _toggleIcingProtocol() { + setState(() { + icingProtocolOk = !icingProtocolOk; + }); + } + + void _answerCall() async { + try { + final result = await _channel.invokeMethod('answerCall'); + print('IncomingCallPage: Answer call result: $result'); + if (result["status"] == "answered") { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => CallPage( + displayName: widget.displayName, + phoneNumber: widget.phoneNumber, + thumbnail: widget.thumbnail, + ), + ), + ); + } + } catch (e) { + print("IncomingCallPage: Error answering call: $e"); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Error answering call: $e")), + ); + } + } + + void _declineCall() async { + try { + await _callService.hangUpCall(context); + } catch (e) { + print("IncomingCallPage: Error declining call: $e"); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Error declining call: $e")), + ); + } + } + + @override + Widget build(BuildContext context) { + const double avatarRadius = 45.0; + const double nameFontSize = 24.0; + const double statusFontSize = 16.0; + + return Scaffold( + body: Container( + color: Colors.black, + child: SafeArea( + child: Column( + children: [ + Container( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 35), + ObfuscatedAvatar( + imageBytes: widget.thumbnail, + radius: avatarRadius, + backgroundColor: generateColorFromName(widget.displayName), + fallbackInitial: widget.displayName, + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icingProtocolOk ? Icons.lock : Icons.lock_open, + color: icingProtocolOk ? Colors.green : Colors.red, + size: 16, + ), + const SizedBox(width: 4), + Text( + 'Icing protocol: ${icingProtocolOk ? "ok" : "ko"}', + style: TextStyle( + color: icingProtocolOk ? Colors.green : Colors.red, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + _obfuscateService.obfuscateData(widget.displayName), + style: const TextStyle( + fontSize: nameFontSize, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + Text( + widget.phoneNumber, + style: const TextStyle(fontSize: statusFontSize, color: Colors.white70), + ), + const Text( + 'Incoming Call...', + style: TextStyle(fontSize: statusFontSize, color: Colors.white70), + ), + ], + ), + ), + const Spacer(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32.0, vertical: 16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + GestureDetector( + onTap: _declineCall, + child: Container( + padding: const EdgeInsets.all(12), + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.call_end, + color: Colors.white, + size: 32, + ), + ), + ), + GestureDetector( + onTap: _answerCall, + child: Container( + padding: const EdgeInsets.all(12), + decoration: const BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.call, + color: Colors.white, + size: 32, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/dialer/lib/features/contacts/widgets/contact_modal.dart b/dialer/lib/features/contacts/widgets/contact_modal.dart index c9d3760..7938045 100644 --- a/dialer/lib/features/contacts/widgets/contact_modal.dart +++ b/dialer/lib/features/contacts/widgets/contact_modal.dart @@ -5,7 +5,7 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:dialer/widgets/username_color_generator.dart'; import '../../../services/block_service.dart'; import '../../../services/contact_service.dart'; -import '../../../services/call_service.dart'; // Import CallService +import '../../../services/call_service.dart'; class ContactModal extends StatefulWidget { final Contact contact; @@ -29,7 +29,7 @@ class _ContactModalState extends State { late String phoneNumber; bool isBlocked = false; final ObfuscateService _obfuscateService = ObfuscateService(); - final CallService _callService = CallService(); // Instantiate CallService + final CallService _callService = CallService(); @override void initState() { diff --git a/dialer/lib/main.dart b/dialer/lib/main.dart index 0c8068f..d3513cf 100644 --- a/dialer/lib/main.dart +++ b/dialer/lib/main.dart @@ -19,7 +19,7 @@ void main() async { // Request permissions before running the app await _requestPermissions(); - CallService(); // Initialize CallService + CallService(); runApp( MultiProvider( @@ -41,7 +41,6 @@ Future _requestPermissions() async { ].request(); if (statuses.values.every((status) => status.isGranted)) { print("All required permissions granted"); - // Signal MainActivity const channel = MethodChannel('call_service'); await channel.invokeMethod('permissionsGranted'); } else { diff --git a/dialer/lib/services/call_service.dart b/dialer/lib/services/call_service.dart index 6b6ed48..d42326e 100644 --- a/dialer/lib/services/call_service.dart +++ b/dialer/lib/services/call_service.dart @@ -1,66 +1,113 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../features/call/call_page.dart'; +import '../features/call/incoming_call_page.dart'; // Import the new page class CallService { static const MethodChannel _channel = MethodChannel('call_service'); static String? currentPhoneNumber; + static bool _isCallPageVisible = false; - // Add a GlobalKey for Navigator static final GlobalKey navigatorKey = GlobalKey(); CallService() { _channel.setMethodCallHandler((call) async { final context = navigatorKey.currentContext; - if (context == null) return; + print('CallService: Received method ${call.method} with args ${call.arguments}'); + if (context == null) { + print('CallService: Navigator context is null, cannot navigate'); + return; + } switch (call.method) { case "callAdded": - final phoneNumber = call.arguments["callId"] as String; // tel:1234567890 + final phoneNumber = call.arguments["callId"] as String; final state = call.arguments["state"] as String; - currentPhoneNumber = phoneNumber.replaceFirst('tel:', ''); // Extract number - if (state == "dialing" || state == "active") { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => CallPage( - displayName: currentPhoneNumber!, // Replace with contact lookup if available - phoneNumber: currentPhoneNumber!, - thumbnail: null, - ), - ), - ); + currentPhoneNumber = phoneNumber.replaceFirst('tel:', ''); + print('CallService: Call added, number: $currentPhoneNumber, state: $state'); + if (state == "ringing") { + _navigateToIncomingCallPage(context); + } else { + _navigateToCallPage(context); } break; case "callStateChanged": final state = call.arguments["state"] as String; + print('CallService: State changed to $state'); if (state == "disconnected" || state == "disconnecting") { - Navigator.pop(context); - } else if (state == "active") { - // Ensure CallPage is shown if not already - if (Navigator.canPop(context) && ModalRoute.of(context)?.settings.name != '/call') { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => CallPage( - displayName: currentPhoneNumber!, - phoneNumber: currentPhoneNumber!, - thumbnail: null, - ), - ), - ); - } + _closeCallPage(context); + } else if (state == "active" || state == "dialing") { + _navigateToCallPage(context); + } else if (state == "ringing") { + _navigateToIncomingCallPage(context); } break; case "callEnded": case "callRemoved": - Navigator.pop(context); + print('CallService: Call ended/removed'); + _closeCallPage(context); currentPhoneNumber = null; break; } }); } + void _navigateToCallPage(BuildContext context) { + if (_isCallPageVisible && ModalRoute.of(context)?.settings.name == '/call') { + print('CallService: CallPage already visible, skipping navigation'); + return; + } + print('CallService: Navigating to CallPage'); + Navigator.pushReplacement( + context, + MaterialPageRoute( + settings: const RouteSettings(name: '/call'), + builder: (context) => CallPage( + displayName: currentPhoneNumber!, + phoneNumber: currentPhoneNumber!, + thumbnail: null, + ), + ), + ).then((_) { + _isCallPageVisible = false; + }); + _isCallPageVisible = true; + } + + void _navigateToIncomingCallPage(BuildContext context) { + if (_isCallPageVisible && ModalRoute.of(context)?.settings.name == '/incoming_call') { + print('CallService: IncomingCallPage already visible, skipping navigation'); + return; + } + print('CallService: Navigating to IncomingCallPage'); + Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: '/incoming_call'), + builder: (context) => IncomingCallPage( + displayName: currentPhoneNumber!, + phoneNumber: currentPhoneNumber!, + thumbnail: null, + ), + ), + ).then((_) { + _isCallPageVisible = false; + }); + _isCallPageVisible = true; + } + + void _closeCallPage(BuildContext context) { + if (!_isCallPageVisible) { + print('CallService: CallPage not visible, skipping pop'); + return; + } + if (Navigator.canPop(context)) { + print('CallService: Popping CallPage'); + Navigator.pop(context); + _isCallPageVisible = false; + } + } + Future makeGsmCall( BuildContext context, { required String phoneNumber, @@ -69,16 +116,16 @@ class CallService { }) async { try { currentPhoneNumber = phoneNumber; + print('CallService: Making GSM call to $phoneNumber'); final result = await _channel.invokeMethod('makeGsmCall', {"phoneNumber": phoneNumber}); - if (result["status"] == "calling") { - // CallPage will be shown via MyInCallService callback - } else { + print('CallService: makeGsmCall result: $result'); + if (result["status"] != "calling") { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text("Failed to initiate call")), ); } } catch (e) { - print("Error making call: $e"); + print("CallService: Error making call: $e"); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text("Error making call: $e")), ); @@ -88,12 +135,16 @@ class CallService { Future hangUpCall(BuildContext context) async { try { + print('CallService: Hanging up call'); final result = await _channel.invokeMethod('hangUpCall'); - if (result["status"] == "ended") { - // Navigator.pop will be handled by MyInCallService callback + print('CallService: hangUpCall result: $result'); + if (result["status"] != "ended") { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Failed to end call")), + ); } } catch (e) { - print("Error hanging up call: $e"); + print("CallService: Error hanging up call: $e"); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text("Error hanging up call: $e")), );