feat: call notification outside app | fix: hangup close callScreen correctly
All checks were successful
/ mirror (push) Successful in 4s
/ build (push) Successful in 8m27s
/ build-stealth (push) Successful in 8m34s

This commit is contained in:
Florian Griffon 2025-03-24 16:31:16 +02:00
parent 72dd30995e
commit 4f2d2d5d2b
5 changed files with 191 additions and 48 deletions

View File

@ -12,6 +12,7 @@
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" /> <uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" /> <uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
<uses-permission android:name="android.permission.PROCESS_OUTGOING_CALLS" /> <uses-permission android:name="android.permission.PROCESS_OUTGOING_CALLS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:label="Icing Dialer" android:label="Icing Dialer"

View File

@ -26,18 +26,26 @@ class MainActivity : FlutterActivity() {
private val CALL_CHANNEL = "call_service" private val CALL_CHANNEL = "call_service"
private val TAG = "MainActivity" private val TAG = "MainActivity"
private val REQUEST_CODE_SET_DEFAULT_DIALER = 1001 private val REQUEST_CODE_SET_DEFAULT_DIALER = 1001
private val REQUEST_CODE_CALL_LOG_PERMISSION = 1002
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Log.d(TAG, "onCreate started") Log.d(TAG, "onCreate started")
Log.d(TAG, "Waiting for Flutter to signal permissions") Log.d(TAG, "Waiting for Flutter to signal permissions")
handleIncomingCallIntent(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleIncomingCallIntent(intent)
} }
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
Log.d(TAG, "Configuring Flutter engine") Log.d(TAG, "Configuring Flutter engine")
MyInCallService.channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALL_CHANNEL) MyInCallService.channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALL_CHANNEL)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALL_CHANNEL) MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALL_CHANNEL)
.setMethodCallHandler { call, result -> .setMethodCallHandler { call, result ->
when (call.method) { when (call.method) {
@ -60,22 +68,31 @@ class MainActivity : FlutterActivity() {
} }
} }
"hangUpCall" -> { "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()
))
true
} ?: false
if (success) { if (success) {
result.success(mapOf("status" to "ended")) result.success(mapOf("status" to "ended"))
} else { } 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" -> { "answerCall" -> {
val success = MyInCallService.currentCall?.let { val success = MyInCallService.currentCall?.let {
it.answer(0) // 0 for default video state (audio-only) it.answer(0)
Log.d(TAG, "Answered call") Log.d(TAG, "Answered call")
true true
} ?: false } ?: false
if (success) { if (success) {
result.success(mapOf("status" to "answered")) result.success(mapOf("status" to "answered"))
} else { } else {
Log.w(TAG, "No active call to answer")
result.error("ANSWER_FAILED", "No active call to answer", null) result.error("ANSWER_FAILED", "No active call to answer", null)
} }
} }
@ -84,13 +101,20 @@ class MainActivity : FlutterActivity() {
} }
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, KEYSTORE_CHANNEL) 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) MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALLLOG_CHANNEL)
.setMethodCallHandler { call, result -> .setMethodCallHandler { call, result ->
if (call.method == "getCallLogs") { if (call.method == "getCallLogs") {
val callLogs = getCallLogs() if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALL_LOG) == PackageManager.PERMISSION_GRANTED) {
result.success(callLogs) 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 { } else {
result.notImplemented() result.notImplemented()
} }
@ -148,6 +172,18 @@ class MainActivity : FlutterActivity() {
} }
} }
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, 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<Map<String, Any?>> { private fun getCallLogs(): List<Map<String, Any?>> {
val logsList = mutableListOf<Map<String, Any?>>() val logsList = mutableListOf<Map<String, Any?>>()
val cursor: Cursor? = contentResolver.query( val cursor: Cursor? = contentResolver.query(
@ -171,4 +207,16 @@ class MainActivity : FlutterActivity() {
} }
return logsList return logsList
} }
private fun handleIncomingCallIntent(intent: Intent?) {
intent?.let {
if (it.getBooleanExtra("isIncomingCall", false)) {
val phoneNumber = it.getStringExtra("phoneNumber")
Log.d(TAG, "Received incoming call intent for $phoneNumber")
MyInCallService.channel?.invokeMethod("incomingCallFromNotification", mapOf(
"phoneNumber" to phoneNumber
))
}
}
}
} }

View File

@ -1,8 +1,16 @@
package com.icing.dialer.services package com.icing.dialer.services
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.telecom.Call import android.telecom.Call
import android.telecom.InCallService import android.telecom.InCallService
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat
import com.icing.dialer.activities.MainActivity
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
class MyInCallService : InCallService() { class MyInCallService : InCallService() {
@ -10,6 +18,8 @@ class MyInCallService : InCallService() {
var channel: MethodChannel? = null var channel: MethodChannel? = null
var currentCall: Call? = null var currentCall: Call? = null
private const val TAG = "MyInCallService" private const val TAG = "MyInCallService"
private const val NOTIFICATION_CHANNEL_ID = "incoming_call_channel"
private const val NOTIFICATION_ID = 1
} }
private val callCallback = object : Call.Callback() { private val callCallback = object : Call.Callback() {
@ -28,10 +38,13 @@ class MyInCallService : InCallService() {
"callId" to call.details.handle.toString(), "callId" to call.details.handle.toString(),
"state" to stateStr "state" to stateStr
)) ))
if (state == Call.STATE_DISCONNECTED || state == Call.STATE_DISCONNECTING) { if (state == Call.STATE_RINGING) {
showIncomingCallNotification(call.details.handle.toString().replace("tel:", ""))
} else if (state == Call.STATE_DISCONNECTED || state == Call.STATE_DISCONNECTING) {
Log.d(TAG, "Call ended: ${call.details.handle}") Log.d(TAG, "Call ended: ${call.details.handle}")
channel?.invokeMethod("callEnded", mapOf("callId" to call.details.handle.toString())) channel?.invokeMethod("callEnded", mapOf("callId" to call.details.handle.toString()))
currentCall = null currentCall = null
cancelNotification()
} }
} }
} }
@ -43,13 +56,16 @@ class MyInCallService : InCallService() {
Call.STATE_DIALING -> "dialing" Call.STATE_DIALING -> "dialing"
Call.STATE_ACTIVE -> "active" Call.STATE_ACTIVE -> "active"
Call.STATE_RINGING -> "ringing" Call.STATE_RINGING -> "ringing"
else -> "dialing" // Default for outgoing else -> "dialing"
} }
Log.d(TAG, "Call added: ${call.details.handle}, state: $stateStr") Log.d(TAG, "Call added: ${call.details.handle}, state: $stateStr")
channel?.invokeMethod("callAdded", mapOf( channel?.invokeMethod("callAdded", mapOf(
"callId" to call.details.handle.toString(), "callId" to call.details.handle.toString(),
"state" to stateStr "state" to stateStr
)) ))
if (stateStr == "ringing") {
showIncomingCallNotification(call.details.handle.toString().replace("tel:", ""))
}
call.registerCallback(callCallback) call.registerCallback(callCallback)
} }
@ -59,6 +75,7 @@ class MyInCallService : InCallService() {
call.unregisterCallback(callCallback) call.unregisterCallback(callCallback)
channel?.invokeMethod("callRemoved", mapOf("callId" to call.details.handle.toString())) channel?.invokeMethod("callRemoved", mapOf("callId" to call.details.handle.toString()))
currentCall = null currentCall = null
cancelNotification()
} }
override fun onCallAudioStateChanged(state: android.telecom.CallAudioState) { override fun onCallAudioStateChanged(state: android.telecom.CallAudioState) {
@ -66,4 +83,55 @@ class MyInCallService : InCallService() {
Log.d(TAG, "Audio state changed: route=${state.route}") Log.d(TAG, "Audio state changed: route=${state.route}")
channel?.invokeMethod("audioStateChanged", mapOf("route" to state.route)) channel?.invokeMethod("audioStateChanged", mapOf("route" to state.route))
} }
private fun showIncomingCallNotification(phoneNumber: String) {
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Create notification channel (Android 8.0+)
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)
}
// Intent to open MainActivity with phone number
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
putExtra("phoneNumber", phoneNumber)
putExtra("isIncomingCall", true)
}
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)
)
// Build notification
val notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_alert) // Replace with your app icon
.setContentTitle("Incoming Call")
.setContentText("Call from $phoneNumber")
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setOngoing(true) // Keep visible until call ends
.build()
notificationManager.notify(NOTIFICATION_ID, notification)
Log.d(TAG, "Notification shown for incoming call from $phoneNumber")
}
private fun cancelNotification() {
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(NOTIFICATION_ID)
Log.d(TAG, "Notification canceled")
}
} }

View File

@ -61,9 +61,16 @@ class _CallPageState extends State<CallPage> {
void _hangUp() async { void _hangUp() async {
try { try {
await _callService.hangUpCall(context); final result = await _callService.hangUpCall(context);
print('CallPage: Hang up result: $result');
if (result["status"] == "ended" && mounted && Navigator.canPop(context)) {
Navigator.pop(context);
}
} catch (e) { } catch (e) {
print("Error hanging up: $e"); print("CallPage: Error hanging up: $e");
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Error hanging up: $e")),
);
} }
} }
@ -86,9 +93,10 @@ class _CallPageState extends State<CallPage> {
children: [ children: [
SizedBox(height: 35), SizedBox(height: 35),
ObfuscatedAvatar( ObfuscatedAvatar(
imageBytes: widget.thumbnail, // Uses thumbnail if provided imageBytes: widget.thumbnail,
radius: avatarRadius, radius: avatarRadius,
backgroundColor: generateColorFromName(widget.displayName), backgroundColor:
generateColorFromName(widget.displayName),
fallbackInitial: widget.displayName, fallbackInitial: widget.displayName,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@ -122,11 +130,13 @@ class _CallPageState extends State<CallPage> {
), ),
Text( Text(
widget.phoneNumber, widget.phoneNumber,
style: TextStyle(fontSize: statusFontSize, color: Colors.white70), style: TextStyle(
fontSize: statusFontSize, color: Colors.white70),
), ),
Text( Text(
'Calling...', 'Calling...',
style: TextStyle(fontSize: statusFontSize, color: Colors.white70), style: TextStyle(
fontSize: statusFontSize, color: Colors.white70),
), ),
], ],
), ),
@ -157,7 +167,8 @@ class _CallPageState extends State<CallPage> {
IconButton( IconButton(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
onPressed: _toggleKeypad, onPressed: _toggleKeypad,
icon: const Icon(Icons.close, color: Colors.white), icon:
const Icon(Icons.close, color: Colors.white),
), ),
], ],
), ),
@ -193,7 +204,8 @@ class _CallPageState extends State<CallPage> {
child: Center( child: Center(
child: Text( child: Text(
label, label,
style: const TextStyle(fontSize: 32, color: Colors.white), style: const TextStyle(
fontSize: 32, color: Colors.white),
), ),
), ),
), ),
@ -225,7 +237,8 @@ class _CallPageState extends State<CallPage> {
), ),
Text( Text(
isMuted ? 'Unmute' : 'Mute', isMuted ? 'Unmute' : 'Mute',
style: const TextStyle(color: Colors.white, fontSize: 14), style: const TextStyle(
color: Colors.white, fontSize: 14),
), ),
], ],
), ),
@ -234,11 +247,13 @@ class _CallPageState extends State<CallPage> {
children: [ children: [
IconButton( IconButton(
onPressed: _toggleKeypad, onPressed: _toggleKeypad,
icon: const Icon(Icons.dialpad, color: Colors.white, size: 32), icon: const Icon(Icons.dialpad,
color: Colors.white, size: 32),
), ),
const Text( const Text(
'Keypad', 'Keypad',
style: TextStyle(color: Colors.white, fontSize: 14), style: TextStyle(
color: Colors.white, fontSize: 14),
), ),
], ],
), ),
@ -248,14 +263,19 @@ class _CallPageState extends State<CallPage> {
IconButton( IconButton(
onPressed: _toggleSpeaker, onPressed: _toggleSpeaker,
icon: Icon( icon: Icon(
isSpeakerOn ? Icons.volume_up : Icons.volume_off, isSpeakerOn
color: isSpeakerOn ? Colors.amber : Colors.white, ? Icons.volume_up
: Icons.volume_off,
color: isSpeakerOn
? Colors.amber
: Colors.white,
size: 32, size: 32,
), ),
), ),
const Text( const Text(
'Speaker', 'Speaker',
style: TextStyle(color: Colors.white, fontSize: 14), style: TextStyle(
color: Colors.white, fontSize: 14),
), ),
], ],
), ),
@ -270,10 +290,12 @@ class _CallPageState extends State<CallPage> {
children: [ children: [
IconButton( IconButton(
onPressed: () {}, onPressed: () {},
icon: const Icon(Icons.person_add, color: Colors.white, size: 32), icon: const Icon(Icons.person_add,
color: Colors.white, size: 32),
), ),
const Text('Add Contact', const Text('Add Contact',
style: TextStyle(color: Colors.white, fontSize: 14)), style: TextStyle(
color: Colors.white, fontSize: 14)),
], ],
), ),
Column( Column(
@ -281,10 +303,12 @@ class _CallPageState extends State<CallPage> {
children: [ children: [
IconButton( IconButton(
onPressed: () {}, onPressed: () {},
icon: const Icon(Icons.sim_card, color: Colors.white, size: 32), icon: const Icon(Icons.sim_card,
color: Colors.white, size: 32),
), ),
const Text('Change SIM', const Text('Change SIM',
style: TextStyle(color: Colors.white, fontSize: 14)), style: TextStyle(
color: Colors.white, fontSize: 14)),
], ],
), ),
], ],
@ -321,4 +345,4 @@ class _CallPageState extends State<CallPage> {
), ),
); );
} }
} }

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../features/call/call_page.dart'; import '../features/call/call_page.dart';
import '../features/call/incoming_call_page.dart'; // Import the new page import '../features/call/incoming_call_page.dart';
class CallService { class CallService {
static const MethodChannel _channel = MethodChannel('call_service'); static const MethodChannel _channel = MethodChannel('call_service');
@ -13,7 +13,6 @@ class CallService {
CallService() { CallService() {
_channel.setMethodCallHandler((call) async { _channel.setMethodCallHandler((call) async {
final context = navigatorKey.currentContext; final context = navigatorKey.currentContext;
print('CallService: Received method ${call.method} with args ${call.arguments}');
if (context == null) { if (context == null) {
print('CallService: Navigator context is null, cannot navigate'); print('CallService: Navigator context is null, cannot navigate');
return; return;
@ -48,17 +47,19 @@ class CallService {
_closeCallPage(context); _closeCallPage(context);
currentPhoneNumber = null; currentPhoneNumber = null;
break; break;
case "incomingCallFromNotification":
final phoneNumber = call.arguments["phoneNumber"] as String;
currentPhoneNumber = phoneNumber;
print('CallService: Incoming call from notification: $phoneNumber');
_navigateToIncomingCallPage(context);
break;
} }
}); });
} }
void _navigateToCallPage(BuildContext context) { 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'); print('CallService: Navigating to CallPage');
Navigator.pushReplacement( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
settings: const RouteSettings(name: '/call'), settings: const RouteSettings(name: '/call'),
@ -70,15 +71,12 @@ class CallService {
), ),
).then((_) { ).then((_) {
_isCallPageVisible = false; _isCallPageVisible = false;
print('CallService: CallPage popped, _isCallPageVisible set to false');
}); });
_isCallPageVisible = true; _isCallPageVisible = true;
} }
void _navigateToIncomingCallPage(BuildContext context) { 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'); print('CallService: Navigating to IncomingCallPage');
Navigator.push( Navigator.push(
context, context,
@ -92,23 +90,23 @@ class CallService {
), ),
).then((_) { ).then((_) {
_isCallPageVisible = false; _isCallPageVisible = false;
print('CallService: IncomingCallPage popped, _isCallPageVisible set to false');
}); });
_isCallPageVisible = true; _isCallPageVisible = true;
} }
void _closeCallPage(BuildContext context) { void _closeCallPage(BuildContext context) {
if (!_isCallPageVisible) { print('CallService: Attempting to close call page, _isCallPageVisible: $_isCallPageVisible');
print('CallService: CallPage not visible, skipping pop');
return;
}
if (Navigator.canPop(context)) { if (Navigator.canPop(context)) {
print('CallService: Popping CallPage'); print('CallService: Popping call page');
Navigator.pop(context); Navigator.pop(context);
_isCallPageVisible = false; _isCallPageVisible = false;
} else {
print('CallService: No page to pop');
} }
} }
Future<void> makeGsmCall( Future<Map<String, dynamic>> makeGsmCall(
BuildContext context, { BuildContext context, {
required String phoneNumber, required String phoneNumber,
String? displayName, String? displayName,
@ -119,11 +117,13 @@ class CallService {
print('CallService: Making GSM call to $phoneNumber'); print('CallService: Making GSM call to $phoneNumber');
final result = await _channel.invokeMethod('makeGsmCall', {"phoneNumber": phoneNumber}); final result = await _channel.invokeMethod('makeGsmCall', {"phoneNumber": phoneNumber});
print('CallService: makeGsmCall result: $result'); print('CallService: makeGsmCall result: $result');
if (result["status"] != "calling") { final resultMap = Map<String, dynamic>.from(result as Map); // Safe cast
if (resultMap["status"] != "calling") {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Failed to initiate call")), SnackBar(content: Text("Failed to initiate call")),
); );
} }
return resultMap;
} catch (e) { } catch (e) {
print("CallService: Error making call: $e"); print("CallService: Error making call: $e");
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@ -133,16 +133,18 @@ class CallService {
} }
} }
Future<void> hangUpCall(BuildContext context) async { Future<Map<String, dynamic>> hangUpCall(BuildContext context) async {
try { try {
print('CallService: Hanging up call'); print('CallService: Hanging up call');
final result = await _channel.invokeMethod('hangUpCall'); final result = await _channel.invokeMethod('hangUpCall');
print('CallService: hangUpCall result: $result'); print('CallService: hangUpCall result: $result');
if (result["status"] != "ended") { final resultMap = Map<String, dynamic>.from(result as Map); // Safe cast
if (resultMap["status"] != "ended") {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Failed to end call")), SnackBar(content: Text("Failed to end call")),
); );
} }
return resultMap;
} catch (e) { } catch (e) {
print("CallService: Error hanging up call: $e"); print("CallService: Error hanging up call: $e");
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(