feat: incoming call notification works in any scenario
All checks were successful
/ mirror (push) Successful in 4s

This commit is contained in:
Florian Griffon 2025-04-10 20:09:25 +03:00 committed by stcb
parent ffbd7bdfaa
commit 5820111341
3 changed files with 159 additions and 82 deletions

View File

@ -6,11 +6,8 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.database.Cursor import android.database.Cursor
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.CallLog import android.provider.CallLog
import android.telecom.TelecomManager import android.telecom.TelecomManager
import android.util.Log import android.util.Log
@ -29,18 +26,22 @@ class MainActivity : FlutterActivity() {
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 private val REQUEST_CODE_CALL_LOG_PERMISSION = 1002
private var pendingIncomingCall: Pair<String?, Boolean>? = null // Store incoming call data private var pendingIncomingCall: Pair<String?, Boolean>? = null
private var wasPhoneLocked: Boolean = false
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") wasPhoneLocked = intent.getBooleanExtra("wasPhoneLocked", false)
Log.d(TAG, "Was phone locked at start: $wasPhoneLocked")
handleIncomingCallIntent(intent) handleIncomingCallIntent(intent)
} }
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent) setIntent(intent)
wasPhoneLocked = intent.getBooleanExtra("wasPhoneLocked", false)
Log.d(TAG, "onNewIntent, wasPhoneLocked: $wasPhoneLocked")
handleIncomingCallIntent(intent) handleIncomingCallIntent(intent)
} }
@ -54,13 +55,22 @@ class MainActivity : FlutterActivity() {
when (call.method) { when (call.method) {
"permissionsGranted" -> { "permissionsGranted" -> {
Log.d(TAG, "Received permissionsGranted from Flutter") 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() checkAndRequestDefaultDialer()
result.success(true) result.success(true)
} }
"makeGsmCall" -> { "makeGsmCall" -> {
val phoneNumber = call.argument<String>("phoneNumber") val phoneNumber = call.argument<String>("phoneNumber")
if (phoneNumber != null) { if (phoneNumber != null) {
val success = CallService.makeGsmCall(this, phoneNumber) // Use CallService val success = CallService.makeGsmCall(this, phoneNumber)
if (success) { if (success) {
result.success(mapOf("status" to "calling", "phoneNumber" to phoneNumber)) result.success(mapOf("status" to "calling", "phoneNumber" to phoneNumber))
} else { } else {
@ -75,12 +85,17 @@ class MainActivity : FlutterActivity() {
it.disconnect() it.disconnect()
Log.d(TAG, "Call disconnected") Log.d(TAG, "Call disconnected")
MyInCallService.channel?.invokeMethod("callEnded", mapOf( MyInCallService.channel?.invokeMethod("callEnded", mapOf(
"callId" to it.details.handle.toString() "callId" to it.details.handle.toString(),
"wasPhoneLocked" to wasPhoneLocked
)) ))
true true
} ?: false } ?: false
if (success) { if (success) {
result.success(mapOf("status" to "ended")) result.success(mapOf("status" to "ended"))
if (wasPhoneLocked) {
Log.d(TAG, "Finishing and removing task after hangup, phone was locked")
finishAndRemoveTask()
}
} else { } else {
Log.w(TAG, "No active call to hang up") Log.w(TAG, "No active call to hang up")
result.error("HANGUP_FAILED", "No active call to hang up", null) result.error("HANGUP_FAILED", "No active call to hang up", null)
@ -99,15 +114,11 @@ class MainActivity : FlutterActivity() {
result.error("ANSWER_FAILED", "No active call to answer", null) result.error("ANSWER_FAILED", "No active call to answer", null)
} }
} }
"flutterReady" -> { // New method to signal Flutter is initialized "callEndedFromFlutter" -> {
Log.d(TAG, "Flutter is ready") Log.d(TAG, "Call ended from Flutter, wasPhoneLocked: $wasPhoneLocked")
pendingIncomingCall?.let { (phoneNumber, showScreen) -> if (wasPhoneLocked) {
if (showScreen) { finishAndRemoveTask()
MyInCallService.channel?.invokeMethod("incomingCallFromNotification", mapOf( Log.d(TAG, "Finishing and removing task after call ended, phone was locked")
"phoneNumber" to phoneNumber
))
pendingIncomingCall = null // Clear after handling
}
} }
result.success(true) result.success(true)
} }
@ -148,8 +159,6 @@ class MainActivity : FlutterActivity() {
val intent = roleManager.createRequestRoleIntent(RoleManager.ROLE_DIALER) val intent = roleManager.createRequestRoleIntent(RoleManager.ROLE_DIALER)
startActivityForResult(intent, REQUEST_CODE_SET_DEFAULT_DIALER) startActivityForResult(intent, REQUEST_CODE_SET_DEFAULT_DIALER)
Log.d(TAG, "Launched RoleManager intent for default dialer on API 29+") 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 { } else {
val intent = Intent(TelecomManager.ACTION_CHANGE_DEFAULT_DIALER) val intent = Intent(TelecomManager.ACTION_CHANGE_DEFAULT_DIALER)
@ -228,14 +237,14 @@ class MainActivity : FlutterActivity() {
if (it.getBooleanExtra("isIncomingCall", false)) { if (it.getBooleanExtra("isIncomingCall", false)) {
val phoneNumber = it.getStringExtra("phoneNumber") val phoneNumber = it.getStringExtra("phoneNumber")
val showScreen = it.getBooleanExtra("showIncomingCallScreen", false) val showScreen = it.getBooleanExtra("showIncomingCallScreen", false)
Log.d(TAG, "Received incoming call intent for $phoneNumber, showScreen=$showScreen") Log.d(TAG, "Received incoming call intent for $phoneNumber, showScreen=$showScreen, wasPhoneLocked=$wasPhoneLocked")
if (showScreen) { if (showScreen) {
if (MyInCallService.channel != null) { if (MyInCallService.channel != null) {
MyInCallService.channel?.invokeMethod("incomingCallFromNotification", mapOf( MyInCallService.channel?.invokeMethod("incomingCallFromNotification", mapOf(
"phoneNumber" to phoneNumber "phoneNumber" to phoneNumber,
"wasPhoneLocked" to wasPhoneLocked
)) ))
} else { } else {
// Store the intent data if Flutter isn't ready yet
pendingIncomingCall = Pair(phoneNumber, true) pendingIncomingCall = Pair(phoneNumber, true)
Log.d(TAG, "Flutter channel not ready, storing pending call: $phoneNumber") Log.d(TAG, "Flutter channel not ready, storing pending call: $phoneNumber")
} }

View File

@ -1,5 +1,6 @@
package com.icing.dialer.services package com.icing.dialer.services
import android.app.KeyguardManager
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
@ -20,6 +21,7 @@ class MyInCallService : InCallService() {
private const val TAG = "MyInCallService" private const val TAG = "MyInCallService"
private const val NOTIFICATION_CHANNEL_ID = "incoming_call_channel" private const val NOTIFICATION_CHANNEL_ID = "incoming_call_channel"
private const val NOTIFICATION_ID = 1 private const val NOTIFICATION_ID = 1
var wasPhoneLocked: Boolean = false
} }
private val callCallback = object : Call.Callback() { private val callCallback = object : Call.Callback() {
@ -36,13 +38,20 @@ class MyInCallService : InCallService() {
Log.d(TAG, "State changed: $stateStr for call ${call.details.handle}") Log.d(TAG, "State changed: $stateStr for call ${call.details.handle}")
channel?.invokeMethod("callStateChanged", mapOf( channel?.invokeMethod("callStateChanged", mapOf(
"callId" to call.details.handle.toString(), "callId" to call.details.handle.toString(),
"state" to stateStr "state" to stateStr,
"wasPhoneLocked" to wasPhoneLocked
)) ))
if (state == Call.STATE_RINGING) { 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:", "")) showIncomingCallScreen(call.details.handle.toString().replace("tel:", ""))
} else if (state == Call.STATE_DISCONNECTED || state == Call.STATE_DISCONNECTING) { } 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}, wasPhoneLocked: $wasPhoneLocked")
channel?.invokeMethod("callEnded", mapOf("callId" to call.details.handle.toString())) channel?.invokeMethod("callEnded", mapOf(
"callId" to call.details.handle.toString(),
"wasPhoneLocked" to wasPhoneLocked
))
currentCall = null currentCall = null
cancelNotification() cancelNotification()
} }
@ -64,6 +73,9 @@ class MyInCallService : InCallService() {
"state" to stateStr "state" to stateStr
)) ))
if (stateStr == "ringing") { 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:", "")) showIncomingCallScreen(call.details.handle.toString().replace("tel:", ""))
} }
call.registerCallback(callCallback) call.registerCallback(callCallback)
@ -71,9 +83,12 @@ class MyInCallService : InCallService() {
override fun onCallRemoved(call: Call) { override fun onCallRemoved(call: Call) {
super.onCallRemoved(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) 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 currentCall = null
cancelNotification() cancelNotification()
} }
@ -90,41 +105,47 @@ class MyInCallService : InCallService() {
putExtra("phoneNumber", phoneNumber) putExtra("phoneNumber", phoneNumber)
putExtra("isIncomingCall", true) putExtra("isIncomingCall", true)
putExtra("showIncomingCallScreen", true) putExtra("showIncomingCallScreen", true)
putExtra("wasPhoneLocked", wasPhoneLocked)
} }
startActivity(intent)
Log.d(TAG, "Launched MainActivity to show incoming call screen for $phoneNumber")
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (keyguardManager.isKeyguardLocked) {
val channel = NotificationChannel( startActivity(intent)
NOTIFICATION_CHANNEL_ID, Log.d(TAG, "Launched MainActivity directly for locked screen, phoneNumber: $phoneNumber")
"Incoming Calls", } else {
NotificationManager.IMPORTANCE_HIGH val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
).apply { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
description = "Notifications for incoming calls" val channel = NotificationChannel(
enableVibration(true) NOTIFICATION_CHANNEL_ID,
setShowBadge(true) "Incoming Calls",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Notifications for incoming calls"
enableVibration(true)
setShowBadge(true)
}
notificationManager.createNotificationChannel(channel)
} }
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")
} }
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)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setOngoing(true)
.build()
notificationManager.notify(NOTIFICATION_ID, notification)
Log.d(TAG, "Notification shown for incoming call from $phoneNumber")
} }
private fun cancelNotification() { private fun cancelNotification() {

View File

@ -7,17 +7,14 @@ class CallService {
static const MethodChannel _channel = MethodChannel('call_service'); static const MethodChannel _channel = MethodChannel('call_service');
static String? currentPhoneNumber; static String? currentPhoneNumber;
static bool _isCallPageVisible = false; static bool _isCallPageVisible = false;
static Map<String, dynamic>? _pendingCall;
static bool wasPhoneLocked = false;
static final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>(); static final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
CallService() { CallService() {
_channel.setMethodCallHandler((call) async { _channel.setMethodCallHandler((call) async {
final context = navigatorKey.currentContext; print('CallService: Handling method call: ${call.method}');
if (context == null) {
print('CallService: Navigator context is null, cannot navigate');
return;
}
switch (call.method) { switch (call.method) {
case "callAdded": case "callAdded":
final phoneNumber = call.arguments["callId"] as String; final phoneNumber = call.arguments["callId"] as String;
@ -25,43 +22,84 @@ class CallService {
currentPhoneNumber = phoneNumber.replaceFirst('tel:', ''); currentPhoneNumber = phoneNumber.replaceFirst('tel:', '');
print('CallService: Call added, number: $currentPhoneNumber, state: $state'); print('CallService: Call added, number: $currentPhoneNumber, state: $state');
if (state == "ringing") { if (state == "ringing") {
_navigateToIncomingCallPage(context); _handleIncomingCall(phoneNumber);
} else { } else {
_navigateToCallPage(context); _navigateToCallPage();
} }
break; break;
case "callStateChanged": case "callStateChanged":
final state = call.arguments["state"] as String; final state = call.arguments["state"] as String;
print('CallService: State changed to $state'); wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false;
print('CallService: State changed to $state, wasPhoneLocked: $wasPhoneLocked');
if (state == "disconnected" || state == "disconnecting") { if (state == "disconnected" || state == "disconnecting") {
_closeCallPage(context); _closeCallPage();
if (wasPhoneLocked) {
_channel.invokeMethod("callEndedFromFlutter");
}
} else if (state == "active" || state == "dialing") { } else if (state == "active" || state == "dialing") {
_navigateToCallPage(context); _navigateToCallPage();
} else if (state == "ringing") { } else if (state == "ringing") {
_navigateToIncomingCallPage(context); final phoneNumber = call.arguments["callId"] as String;
_handleIncomingCall(phoneNumber.replaceFirst('tel:', ''));
} }
break; break;
case "callEnded": case "callEnded":
case "callRemoved": case "callRemoved":
print('CallService: Call ended/removed'); wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false;
_closeCallPage(context); print('CallService: Call ended/removed, wasPhoneLocked: $wasPhoneLocked');
_closeCallPage();
if (wasPhoneLocked) {
_channel.invokeMethod("callEndedFromFlutter");
}
currentPhoneNumber = null; currentPhoneNumber = null;
break; break;
case "incomingCallFromNotification": case "incomingCallFromNotification":
final phoneNumber = call.arguments["phoneNumber"] as String; final phoneNumber = call.arguments["phoneNumber"] as String;
wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false;
currentPhoneNumber = phoneNumber; currentPhoneNumber = phoneNumber;
print('CallService: Incoming call from notification: $phoneNumber'); print('CallService: Incoming call from notification: $phoneNumber, wasPhoneLocked: $wasPhoneLocked');
_navigateToIncomingCallPage(context); _handleIncomingCall(phoneNumber);
break; break;
} }
}); });
// Signal Flutter is ready
WidgetsFlutterBinding.ensureInitialized();
_channel.invokeMethod("flutterReady");
} }
void _navigateToCallPage(BuildContext context) { void _handleIncomingCall(String 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);
}
}
void _checkPendingCall() {
if (_pendingCall != null) {
final context = navigatorKey.currentContext;
if (context != null) {
print('CallService: Processing queued call: ${_pendingCall!["phoneNumber"]}');
currentPhoneNumber = _pendingCall!["phoneNumber"];
_navigateToIncomingCallPage(context);
_pendingCall = null;
} else {
print('CallService: Context still null, retrying...');
Future.delayed(Duration(milliseconds: 500), () => _checkPendingCall());
}
}
}
void _navigateToCallPage() {
final context = navigatorKey.currentContext;
if (context == null) {
print('CallService: Cannot navigate to CallPage, context is null');
return;
}
if (_isCallPageVisible) {
print('CallService: CallPage already visible, skipping navigation');
return;
}
print('CallService: Navigating to CallPage'); print('CallService: Navigating to CallPage');
Navigator.push( Navigator.push(
context, context,
@ -81,6 +119,10 @@ class CallService {
} }
void _navigateToIncomingCallPage(BuildContext context) { void _navigateToIncomingCallPage(BuildContext context) {
if (_isCallPageVisible) {
print('CallService: IncomingCallPage already visible, skipping navigation');
return;
}
print('CallService: Navigating to IncomingCallPage'); print('CallService: Navigating to IncomingCallPage');
Navigator.push( Navigator.push(
context, context,
@ -99,8 +141,13 @@ class CallService {
_isCallPageVisible = true; _isCallPageVisible = true;
} }
void _closeCallPage(BuildContext context) { void _closeCallPage() {
print('CallService: Attempting to close call page, _isCallPageVisible: $_isCallPageVisible'); 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)) { if (Navigator.canPop(context)) {
print('CallService: Popping call page'); print('CallService: Popping call page');
Navigator.pop(context); Navigator.pop(context);
@ -133,7 +180,7 @@ class CallService {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Error making call: $e")), SnackBar(content: Text("Error making call: $e")),
); );
rethrow; return {"status": "error", "message": e.toString()};
} }
} }
@ -154,7 +201,7 @@ class CallService {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Error hanging up call: $e")), SnackBar(content: Text("Error hanging up call: $e")),
); );
rethrow; return {"status": "error", "message": e.toString()};
} }
} }
} }