From 7c7a4f28f4446224efb838132d58e0c94a649ae5 Mon Sep 17 00:00:00 2001 From: Florian Griffon Date: Tue, 4 Mar 2025 14:20:56 +0100 Subject: [PATCH] feat: call page UI --- dialer/android/.gitignore | 1 + .../android/app/src/main/AndroidManifest.xml | 61 ++-- .../icing/dialer/activities/MainActivity.kt | 96 ++++-- .../dialer/services/CallConnectionService.kt | 53 +++ .../com/icing/dialer/services/CallService.kt | 51 ++- dialer/lib/features/call/call_page.dart | 324 ++++++++++++++++++ .../lib/features/composition/composition.dart | 2 +- .../contacts/widgets/contact_modal.dart | 2 +- dialer/lib/services/call_service.dart | 38 +- 9 files changed, 550 insertions(+), 78 deletions(-) create mode 100644 dialer/android/app/src/main/kotlin/com/icing/dialer/services/CallConnectionService.kt create mode 100644 dialer/lib/features/call/call_page.dart diff --git a/dialer/android/.gitignore b/dialer/android/.gitignore index e6d71b3..ebc61c7 100644 --- a/dialer/android/.gitignore +++ b/dialer/android/.gitignore @@ -7,6 +7,7 @@ gradle-wrapper.jar /gradle.properties GeneratedPluginRegistrant.java gradle.properties +.cxx # Remember to never publicly share your keystore. # See https://flutter.dev/to/reference-keystore diff --git a/dialer/android/app/src/main/AndroidManifest.xml b/dialer/android/app/src/main/AndroidManifest.xml index e0de6a4..cc51efb 100644 --- a/dialer/android/app/src/main/AndroidManifest.xml +++ b/dialer/android/app/src/main/AndroidManifest.xml @@ -1,13 +1,15 @@ - - + + - - - + + + + + - + android:name="io.flutter.embedding.android.NormalTheme" + android:resource="@style/NormalTheme" /> - - + + + + + + + + + + + + + + + + + + + + + + + - + - - + + - + \ 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 a2397d6..53f93d7 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 @@ -3,52 +3,77 @@ package com.icing.dialer.activities import android.database.Cursor import android.os.Bundle import android.provider.CallLog +import android.telecom.TelecomManager +import android.telecom.PhoneAccountHandle +import android.telecom.PhoneAccount +import android.content.Intent +import android.content.ComponentName import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import com.icing.dialer.KeystoreHelper import com.icing.dialer.services.CallService +import com.icing.dialer.services.CallConnectionService -class MainActivity: FlutterActivity() { - // Existing channel for keystore operations. +class MainActivity : FlutterActivity() { private val KEYSTORE_CHANNEL = "com.example.keystore" - // New channel for call log access. private val CALLLOG_CHANNEL = "com.example.calllog" - private val CALL_CHANNEL = "call_service" + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + registerPhoneAccount() + checkAndRequestDefaultDialer() + } + + private fun registerPhoneAccount() { + val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager + val phoneAccountHandle = PhoneAccountHandle( + ComponentName(this, CallConnectionService::class.java), + "IcingDialerAccount" + ) + val phoneAccount = PhoneAccount.builder(phoneAccountHandle, "Icing Dialer") + .setCapabilities(PhoneAccount.CAPABILITY_CALL_PROVIDER) + .build() + telecomManager.registerPhoneAccount(phoneAccount) + } + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) - // Call service channel MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALL_CHANNEL).setMethodCallHandler { call, result -> when (call.method) { "makeGsmCall" -> { val phoneNumber = call.argument("phoneNumber") if (phoneNumber != null) { - CallService.makeGsmCall(this, phoneNumber) - result.success("Calling $phoneNumber") + val success = CallService.makeGsmCall(this, phoneNumber) + if (success) { + result.success(mapOf("status" to "calling", "phoneNumber" to phoneNumber)) + } else { + result.error("CALL_FAILED", "Failed to initiate call", null) + } } else { result.error("INVALID_PHONE_NUMBER", "Phone number is required", null) } } "hangUpCall" -> { - CallService.hangUpCall(this) - result.success("Call ended") + val success = CallService.hangUpCall(this) + if (success) { + result.success(mapOf("status" to "ended")) + } else { + result.error("HANGUP_FAILED", "Failed to end call", null) + } } else -> result.notImplemented() } } - // Set up the keystore channel. MethodChannel(flutterEngine.dartExecutor.binaryMessenger, KEYSTORE_CHANNEL) .setMethodCallHandler { call, result -> - // Delegate method calls to KeystoreHelper. KeystoreHelper(call, result).handleMethodCall() } - // Set up the call log channel. MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALLLOG_CHANNEL) .setMethodCallHandler { call, result -> if (call.method == "getCallLogs") { @@ -60,35 +85,38 @@ class MainActivity: FlutterActivity() { } } - /** - * Queries the Android call log and returns a list of maps. - * Each map contains keys: "number", "type", "date", and "duration". - */ + private fun checkAndRequestDefaultDialer() { + val tm = getSystemService(TELECOM_SERVICE) as TelecomManager + tm.defaultDialerPackage?.let { + if (it != packageName) { + val intent = Intent(TelecomManager.ACTION_CHANGE_DEFAULT_DIALER) + .putExtra(TelecomManager.EXTRA_CHANGE_DEFAULT_DIALER_PACKAGE_NAME, packageName) + startActivity(intent) + } + } + } + private fun getCallLogs(): List> { val logsList = mutableListOf>() val cursor: Cursor? = contentResolver.query( - CallLog.Calls.CONTENT_URI, - null, - null, - null, - CallLog.Calls.DATE + " DESC" + CallLog.Calls.CONTENT_URI, null, null, null, CallLog.Calls.DATE + " DESC" ) - if (cursor != null) { - while (cursor.moveToNext()) { - val number = cursor.getString(cursor.getColumnIndexOrThrow(CallLog.Calls.NUMBER)) - val type = cursor.getInt(cursor.getColumnIndexOrThrow(CallLog.Calls.TYPE)) - val date = cursor.getLong(cursor.getColumnIndexOrThrow(CallLog.Calls.DATE)) - val duration = cursor.getLong(cursor.getColumnIndexOrThrow(CallLog.Calls.DURATION)) + cursor?.use { + while (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)) - val map = HashMap() - map["number"] = number - map["type"] = type // Typically: 1 for incoming, 2 for outgoing, 3 for missed. - map["date"] = date - map["duration"] = duration + val map = mutableMapOf( + "number" to number, + "type" to type, + "date" to date, + "duration" to duration + ) logsList.add(map) } - cursor.close() } return logsList } -} +} \ No newline at end of file diff --git a/dialer/android/app/src/main/kotlin/com/icing/dialer/services/CallConnectionService.kt b/dialer/android/app/src/main/kotlin/com/icing/dialer/services/CallConnectionService.kt new file mode 100644 index 0000000..8464027 --- /dev/null +++ b/dialer/android/app/src/main/kotlin/com/icing/dialer/services/CallConnectionService.kt @@ -0,0 +1,53 @@ +package com.icing.dialer.services + +import android.telecom.Connection +import android.telecom.ConnectionService +import android.telecom.PhoneAccountHandle +import android.telecom.TelecomManager +import android.telecom.DisconnectCause +import android.net.Uri +import android.os.Bundle +import android.util.Log + +class CallConnectionService : ConnectionService() { + override fun onCreateOutgoingConnection( + connectionManagerPhoneAccount: PhoneAccountHandle?, + request: android.telecom.ConnectionRequest + ): Connection { + val connection = object : Connection() { + override fun onStateChanged(state: Int) { + super.onStateChanged(state) + Log.d("CallConnectionService", "Connection state changed: $state") + // Update Flutter UI via MethodChannel if needed + } + + override fun onDisconnect() { + setDisconnected(DisconnectCause(DisconnectCause.LOCAL)) + destroy() + } + } + connection.setAddress(request.address, TelecomManager.PRESENTATION_ALLOWED) + connection.setInitialized() + connection.setActive() + return connection + } + + override fun onCreateIncomingConnection( + connectionManagerPhoneAccount: PhoneAccountHandle?, + request: android.telecom.ConnectionRequest + ): Connection { + val connection = object : Connection() { + override fun onAnswer() { + setActive() + } + + override fun onDisconnect() { + setDisconnected(DisconnectCause(DisconnectCause.LOCAL)) + destroy() + } + } + connection.setAddress(request.address, TelecomManager.PRESENTATION_ALLOWED) + connection.setRinging() + return connection + } +} \ No newline at end of file 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 e0016dc..b9c6bd9 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 @@ -1,30 +1,55 @@ package com.icing.dialer.services +import android.Manifest import android.content.Context import android.content.Intent import android.net.Uri -import android.telecom.TelecomManager import android.os.Build +import android.telecom.TelecomManager +import android.telephony.TelephonyManager import android.util.Log +import androidx.core.content.ContextCompat +import android.content.pm.PackageManager object CallService { - - fun makeGsmCall(context: Context, phoneNumber: String) { - try { - val intent = Intent(Intent.ACTION_CALL) - intent.data = Uri.parse("tel:$phoneNumber") - context.startActivity(intent) + + fun makeGsmCall(context: Context, phoneNumber: String): Boolean { + return try { + val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager + val uri = Uri.parse("tel:$phoneNumber") + + // Check CALL_PHONE permission + if (ContextCompat.checkSelfPermission(context, Manifest.permission.CALL_PHONE) == PackageManager.PERMISSION_GRANTED) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + telecomManager.placeCall(uri, null) + true + } else { + Log.e("CallService", "GSM call not supported below Android M") + false + } + } else { + Log.e("CallService", "CALL_PHONE permission not granted") + false + } } catch (e: Exception) { Log.e("CallService", "Error making GSM call: ${e.message}") + false } } - fun hangUpCall(context: Context) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager - telecomManager.endCall() - } else { - Log.e("CallService", "Hangup call is only supported on Android P or later.") + fun hangUpCall(context: Context): Boolean { + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager + telecomManager.endCall() + true + } else { + Log.e("CallService", "Hangup call only supported on Android P or later") + false + } + } catch (e: Exception) { + Log.e("CallService", "Error hanging up call: ${e.message}") + false } } } \ No newline at end of file diff --git a/dialer/lib/features/call/call_page.dart b/dialer/lib/features/call/call_page.dart new file mode 100644 index 0000000..1416a98 --- /dev/null +++ b/dialer/lib/features/call/call_page.dart @@ -0,0 +1,324 @@ +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:dialer/services/call_service.dart'; +import 'package:dialer/services/obfuscate_service.dart'; +import 'package:dialer/widgets/username_color_generator.dart'; + +class CallPage extends StatefulWidget { + final String displayName; + final String phoneNumber; + final Uint8List? thumbnail; + + const CallPage({ + super.key, + required this.displayName, + required this.phoneNumber, + this.thumbnail, + }); + + @override + _CallPageState createState() => _CallPageState(); +} + +class _CallPageState extends State { + final ObfuscateService _obfuscateService = ObfuscateService(); + final CallService _callService = CallService(); + bool isMuted = false; + bool isSpeakerOn = false; + bool isKeypadVisible = false; + bool icingProtocolOk = true; + String _typedDigits = ""; + + void _addDigit(String digit) { + setState(() { + _typedDigits += digit; + }); + } + + void _toggleMute() { + setState(() { + isMuted = !isMuted; + }); + } + + void _toggleSpeaker() { + setState(() { + isSpeakerOn = !isSpeakerOn; + }); + } + + void _toggleKeypad() { + setState(() { + isKeypadVisible = !isKeypadVisible; + }); + } + + void _toggleIcingProtocol() { + setState(() { + icingProtocolOk = !icingProtocolOk; + }); + } + + void _hangUp() async { + try { + await _callService.hangUpCall(context); + } catch (e) { + print("Error hanging up: $e"); + } + } + + @override + Widget build(BuildContext context) { + final double avatarRadius = isKeypadVisible ? 45.0 : 45.0; + final double nameFontSize = isKeypadVisible ? 24.0 : 24.0; + final double statusFontSize = isKeypadVisible ? 16.0 : 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: [ + SizedBox(height: 35), + ObfuscatedAvatar( + imageBytes: widget.thumbnail, // Uses thumbnail if provided + 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: TextStyle( + fontSize: nameFontSize, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + Text( + widget.phoneNumber, + style: TextStyle(fontSize: statusFontSize, color: Colors.white70), + ), + Text( + 'Calling...', + style: TextStyle(fontSize: statusFontSize, color: Colors.white70), + ), + ], + ), + ), + Expanded( + child: Column( + children: [ + if (isKeypadVisible) ...[ + const Spacer(flex: 2), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + _typedDigits, + maxLines: 1, + textAlign: TextAlign.right, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 24, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + padding: EdgeInsets.zero, + onPressed: _toggleKeypad, + icon: const Icon(Icons.close, color: Colors.white), + ), + ], + ), + ), + Container( + height: MediaQuery.of(context).size.height * 0.35, + margin: const EdgeInsets.symmetric(horizontal: 20), + child: GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 3, + childAspectRatio: 1.3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + children: List.generate(12, (index) { + String label; + if (index < 9) { + label = '${index + 1}'; + } else if (index == 9) { + label = '*'; + } else if (index == 10) { + label = '0'; + } else { + label = '#'; + } + return GestureDetector( + onTap: () => _addDigit(label), + child: Container( + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.transparent, + ), + child: Center( + child: Text( + label, + style: const TextStyle(fontSize: 32, color: Colors.white), + ), + ), + ), + ); + }), + ), + ), + const Spacer(flex: 1), + ] else ...[ + const Spacer(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: _toggleMute, + icon: Icon( + isMuted ? Icons.mic_off : Icons.mic, + color: Colors.white, + size: 32, + ), + ), + Text( + isMuted ? 'Unmute' : 'Mute', + style: const TextStyle(color: Colors.white, fontSize: 14), + ), + ], + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: _toggleKeypad, + icon: const Icon(Icons.dialpad, color: Colors.white, size: 32), + ), + const Text( + 'Keypad', + style: TextStyle(color: Colors.white, fontSize: 14), + ), + ], + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: _toggleSpeaker, + icon: Icon( + isSpeakerOn ? Icons.volume_up : Icons.volume_off, + color: isSpeakerOn ? Colors.amber : Colors.white, + size: 32, + ), + ), + const Text( + 'Speaker', + style: TextStyle(color: Colors.white, fontSize: 14), + ), + ], + ), + ], + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: () {}, + icon: const Icon(Icons.person_add, color: Colors.white, size: 32), + ), + const Text('Add Contact', + style: TextStyle(color: Colors.white, fontSize: 14)), + ], + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: () {}, + icon: const Icon(Icons.sim_card, color: Colors.white, size: 32), + ), + const Text('Change SIM', + style: TextStyle(color: Colors.white, fontSize: 14)), + ], + ), + ], + ), + ], + ), + ), + const Spacer(flex: 3), + ], + ], + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: GestureDetector( + onTap: _hangUp, + 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, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/dialer/lib/features/composition/composition.dart b/dialer/lib/features/composition/composition.dart index 9bde112..b3e33bd 100644 --- a/dialer/lib/features/composition/composition.dart +++ b/dialer/lib/features/composition/composition.dart @@ -76,7 +76,7 @@ class _CompositionPageState extends State { // Function to call a contact's number using the CallService void _makeCall(String phoneNumber) async { try { - await _callService.makeGsmCall(phoneNumber); + await _callService.makeGsmCall(context, phoneNumber: phoneNumber); setState(() { dialedNumber = phoneNumber; }); diff --git a/dialer/lib/features/contacts/widgets/contact_modal.dart b/dialer/lib/features/contacts/widgets/contact_modal.dart index 9f641ad..c9d3760 100644 --- a/dialer/lib/features/contacts/widgets/contact_modal.dart +++ b/dialer/lib/features/contacts/widgets/contact_modal.dart @@ -262,7 +262,7 @@ class _ContactModalState extends State { ), onTap: () async { if (widget.contact.phones.isNotEmpty) { - await _callService.makeGsmCall(phoneNumber); + await _callService.makeGsmCall(context, phoneNumber: phoneNumber); } }, ), diff --git a/dialer/lib/services/call_service.dart b/dialer/lib/services/call_service.dart index c07027e..f02f745 100644 --- a/dialer/lib/services/call_service.dart +++ b/dialer/lib/services/call_service.dart @@ -1,23 +1,47 @@ import 'package:flutter/services.dart'; +import 'package:flutter/material.dart'; +import '../features/call/call_page.dart'; -// Service to manage call-related operations class CallService { static const MethodChannel _channel = MethodChannel('call_service'); - // Function to make a GSM call - Future makeGsmCall(String phoneNumber) async { + Future makeGsmCall( + BuildContext context, { + required String phoneNumber, + String? displayName, + Uint8List? thumbnail, // Added optional thumbnail + }) async { try { - await _channel.invokeMethod('makeGsmCall', {"phoneNumber": phoneNumber}); + final result = await _channel.invokeMethod('makeGsmCall', {"phoneNumber": phoneNumber}); + if (result["status"] == "calling") { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CallPage( + displayName: displayName ?? phoneNumber, // Fallback to phoneNumber if no name + phoneNumber: phoneNumber, + thumbnail: thumbnail, // Pass the thumbnail + ), + ), + ); + } else if (result["status"] == "pending_default_dialer") { + print("Waiting for user to set app as default dialer"); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Please set this app as your default dialer to proceed")), + ); + } } catch (e) { print("Error making call: $e"); rethrow; } } - // Function to hang up the current call - Future hangUpCall() async { + Future hangUpCall(BuildContext context) async { try { - await _channel.invokeMethod('hangUpCall'); + final result = await _channel.invokeMethod('hangUpCall'); + if (result["status"] == "ended") { + Navigator.pop(context); + } } catch (e) { print("Error hanging up call: $e"); rethrow;