feat: APP IS DEFAULT DIALER
All checks were successful
/ mirror (push) Successful in 5s
/ build-stealth (push) Successful in 8m46s
/ build (push) Successful in 8m39s

This commit is contained in:
Florian Griffon 2025-03-14 00:21:21 +02:00
parent e4ad9726ae
commit 5704fa1607
7 changed files with 314 additions and 286 deletions

View File

@ -1,8 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.icing.dialer"> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.icing.dialer">
<uses-feature android:name="android.hardware.telephony" android:required="true" /> <uses-feature android:name="android.hardware.telephony" android:required="true" />
<uses-permission android:name="android.permission.READ_CONTACTS" /> <uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.WRITE_CONTACTS" /> <uses-permission android:name="android.permission.WRITE_CONTACTS" />
<uses-permission android:name="android.permission.CALL_PHONE" /> <uses-permission android:name="android.permission.CALL_PHONE" />
<uses-permission android:name="android.permission.SEND_SMS" /> <uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.READ_BLOCKED_NUMBERS" /> <uses-permission android:name="android.permission.READ_BLOCKED_NUMBERS" />
@ -12,11 +12,6 @@
<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.READ_CALL_LOG" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
<uses-permission android:name="android.permission.PROCESS_OUTGOING_CALLS" />
<application <application
android:label="Icing Dialer" android:label="Icing Dialer"
@ -39,35 +34,21 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!-- Dialer intent filters --> <!-- Dialer intent filters (required for default dialer eligibility) -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.DIAL" /> <action android:name="android.intent.action.DIAL" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.CALL" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Dialer intent filters -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.DIAL" /> <action android:name="android.intent.action.DIAL" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.CALL" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="tel" /> <data android:scheme="tel" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.CALL" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
@ -75,27 +56,36 @@
</intent-filter> </intent-filter>
</activity> </activity>
<service <service
android:name=".services.MyInCallService"
android:permission="android.permission.BIND_INCALL_SERVICE"
android:exported="true">
<intent-filter>
<action android:name="android.telecom.InCallService" />
</intent-filter>
<meta-data
android:name="android.telecom.IN_CALL_SERVICE_UI"
android:value="true" />
</service>
<!-- Custom ConnextionService, will be needed at some point when we implement our own protocol -->
<!-- <service
android:name=".services.CallConnectionService" android:name=".services.CallConnectionService"
android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE" android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.telecom.ConnectionService" /> <action android:name="android.telecom.ConnectionService" />
</intent-filter> </intent-filter>
</service> </service> -->
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
</application> </application>
<!-- Required to query activities that can process text -->
<!-- Required to query activities that can process text -->
<queries> <queries>
<intent> <intent>
<action android:name="android.intent.action.PROCESS_TEXT" /> <action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" /> <data android:mimeType="text/plain" />
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent> </intent>
</queries> </queries>
</manifest> </manifest>

View File

@ -1,21 +1,21 @@
package com.icing.dialer.activities package com.icing.dialer.activities
import android.Manifest import android.Manifest
import android.content.ComponentName import android.app.role.RoleManager
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.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.CallLog import android.provider.CallLog
import android.telecom.PhoneAccount
import android.telecom.PhoneAccountHandle
import android.telecom.TelecomManager import android.telecom.TelecomManager
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.icing.dialer.KeystoreHelper import com.icing.dialer.KeystoreHelper
import com.icing.dialer.services.CallConnectionService
import com.icing.dialer.services.CallService import com.icing.dialer.services.CallService
import com.icing.dialer.services.MyInCallService
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
@ -25,6 +25,7 @@ class MainActivity : FlutterActivity() {
private val CALLLOG_CHANNEL = "com.example.calllog" private val CALLLOG_CHANNEL = "com.example.calllog"
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
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -32,132 +33,84 @@ class MainActivity : FlutterActivity() {
Log.d(TAG, "Waiting for Flutter to signal permissions") Log.d(TAG, "Waiting for Flutter to signal permissions")
} }
private fun registerPhoneAccount() {
val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager
val phoneAccountHandle =
PhoneAccountHandle(
ComponentName(this, CallConnectionService::class.java),
"IcingDialerAccount"
)
Log.d(TAG, "PhoneAccountHandle component: ${phoneAccountHandle.componentName}")
val phoneAccount =
PhoneAccount.builder(phoneAccountHandle, "Icing Dialer")
.setCapabilities(
PhoneAccount.CAPABILITY_CALL_PROVIDER or
PhoneAccount.CAPABILITY_CONNECTION_MANAGER
)
.build()
telecomManager.registerPhoneAccount(phoneAccount)
CallService.setPhoneAccountHandle(phoneAccountHandle)
Log.d(TAG, "PhoneAccount registered: ${phoneAccountHandle.id}")
val registeredAccounts = telecomManager.callCapablePhoneAccounts
Log.d(TAG, "Registered PhoneAccounts: ${registeredAccounts.joinToString()}")
if (!registeredAccounts.contains(phoneAccountHandle)) {
Log.w(TAG, "PhoneAccount not found in callCapablePhoneAccounts")
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CALL_PHONE) ==
PackageManager.PERMISSION_GRANTED
) {
val uri = Uri.parse("tel:1234567890")
val extras =
Bundle().apply {
putParcelable(
TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE,
phoneAccountHandle
)
}
telecomManager.placeCall(uri, extras)
Log.d(TAG, "Triggered dummy call to bind CallConnectionService")
} else {
Log.w(TAG, "CALL_PHONE permission not granted, cannot test binding")
}
} else {
Log.d(TAG, "PhoneAccount successfully found in callCapablePhoneAccounts")
}
}
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")
CallConnectionService.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) {
"permissionsGranted" -> { "permissionsGranted" -> {
Log.d(TAG, "Received permissionsGranted from Flutter") Log.d(TAG, "Received permissionsGranted from Flutter")
registerPhoneAccount() checkAndRequestDefaultDialer()
checkAndRequestDefaultDialer() result.success(true)
result.success(true)
}
"makeGsmCall" -> {
val phoneNumber = call.argument<String>("phoneNumber")
if (phoneNumber != null) {
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" -> {
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()
} }
"makeGsmCall" -> {
val phoneNumber = call.argument<String>("phoneNumber")
if (phoneNumber != null) {
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" -> {
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()
} }
}
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, KEYSTORE_CHANNEL) MethodChannel(flutterEngine.dartExecutor.binaryMessenger, KEYSTORE_CHANNEL)
.setMethodCallHandler { call, result -> .setMethodCallHandler { call, result -> KeystoreHelper(call, result).handleMethodCall() }
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() val callLogs = getCallLogs()
result.success(callLogs) result.success(callLogs)
} else { } else {
result.notImplemented() result.notImplemented()
}
} }
}
} }
private fun checkAndRequestDefaultDialer() { private fun checkAndRequestDefaultDialer() {
val tm = getSystemService(TELECOM_SERVICE) as TelecomManager val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager
val currentDefault = tm.defaultDialerPackage val currentDefault = telecomManager.defaultDialerPackage
Log.d(TAG, "Current default dialer: $currentDefault, My package: $packageName") Log.d(TAG, "Current default dialer: $currentDefault, My package: $packageName")
if (currentDefault != packageName) { if (currentDefault != packageName) {
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
Intent(TelecomManager.ACTION_CHANGE_DEFAULT_DIALER) val roleManager = getSystemService(Context.ROLE_SERVICE) as RoleManager
.putExtra( if (roleManager.isRoleAvailable(RoleManager.ROLE_DIALER) && !roleManager.isRoleHeld(RoleManager.ROLE_DIALER)) {
TelecomManager.EXTRA_CHANGE_DEFAULT_DIALER_PACKAGE_NAME, val intent = roleManager.createRequestRoleIntent(RoleManager.ROLE_DIALER)
packageName startActivityForResult(intent, REQUEST_CODE_SET_DEFAULT_DIALER)
) Log.d(TAG, "Launched RoleManager intent for default dialer on API 29+")
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } else {
try { Log.d(TAG, "RoleManager: Available=${roleManager.isRoleAvailable(RoleManager.ROLE_DIALER)}, Held=${roleManager.isRoleHeld(RoleManager.ROLE_DIALER)}")
startActivityForResult(intent, 1001) }
Log.d(TAG, "Default dialer prompt launched with requestCode 1001") } else {
} catch (e: Exception) { val intent = Intent(TelecomManager.ACTION_CHANGE_DEFAULT_DIALER)
Log.e(TAG, "Failed to launch default dialer prompt: ${e.message}", e) .putExtra(TelecomManager.EXTRA_CHANGE_DEFAULT_DIALER_PACKAGE_NAME, packageName)
launchDefaultAppsSettings() .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
startActivityForResult(intent, REQUEST_CODE_SET_DEFAULT_DIALER)
Log.d(TAG, "Launched TelecomManager intent for default dialer")
} catch (e: Exception) {
Log.e(TAG, "Failed to launch default dialer prompt: ${e.message}", e)
launchDefaultAppsSettings()
}
} }
} else { } else {
Log.d(TAG, "Already the default dialer") Log.d(TAG, "Already the default dialer")
@ -173,11 +126,11 @@ class MainActivity : FlutterActivity() {
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data) super.onActivityResult(requestCode, resultCode, data)
Log.d(TAG, "onActivityResult: requestCode=$requestCode, resultCode=$resultCode, data=$data") Log.d(TAG, "onActivityResult: requestCode=$requestCode, resultCode=$resultCode, data=$data")
if (requestCode == 1001) { if (requestCode == REQUEST_CODE_SET_DEFAULT_DIALER) {
if (resultCode == RESULT_OK) { if (resultCode == RESULT_OK) {
Log.d(TAG, "User accepted default dialer change") Log.d(TAG, "User accepted default dialer change")
} else { } else {
Log.d(TAG, "Default dialer prompt canceled (resultCode=$resultCode)") Log.w(TAG, "Default dialer prompt canceled or failed (resultCode=$resultCode)")
launchDefaultAppsSettings() launchDefaultAppsSettings()
} }
} }
@ -185,14 +138,13 @@ class MainActivity : FlutterActivity() {
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? = val cursor: Cursor? = contentResolver.query(
contentResolver.query( CallLog.Calls.CONTENT_URI,
CallLog.Calls.CONTENT_URI, null,
null, null,
null, null,
null, CallLog.Calls.DATE + " DESC"
CallLog.Calls.DATE + " DESC" )
)
cursor?.use { cursor?.use {
while (it.moveToNext()) { while (it.moveToNext()) {
val number = it.getString(it.getColumnIndexOrThrow(CallLog.Calls.NUMBER)) val number = it.getString(it.getColumnIndexOrThrow(CallLog.Calls.NUMBER))
@ -200,13 +152,12 @@ class MainActivity : FlutterActivity() {
val date = it.getLong(it.getColumnIndexOrThrow(CallLog.Calls.DATE)) val date = it.getLong(it.getColumnIndexOrThrow(CallLog.Calls.DATE))
val duration = it.getLong(it.getColumnIndexOrThrow(CallLog.Calls.DURATION)) val duration = it.getLong(it.getColumnIndexOrThrow(CallLog.Calls.DURATION))
val map = val map = mutableMapOf<String, Any?>(
mutableMapOf<String, Any?>( "number" to number,
"number" to number, "type" to type,
"type" to type, "date" to date,
"date" to date, "duration" to duration
"duration" to duration )
)
logsList.add(map) logsList.add(map)
} }
} }

View File

@ -1,82 +1,82 @@
package com.icing.dialer.services // package com.icing.dialer.services
import android.telecom.Connection // import android.telecom.Connection
import android.telecom.ConnectionService // import android.telecom.ConnectionService
import android.telecom.PhoneAccountHandle // import android.telecom.PhoneAccountHandle
import android.telecom.TelecomManager // import android.telecom.TelecomManager
import android.telecom.DisconnectCause // import android.telecom.DisconnectCause
import android.util.Log // import android.util.Log
import io.flutter.plugin.common.MethodChannel // import io.flutter.plugin.common.MethodChannel
class CallConnectionService : ConnectionService() { // class CallConnectionService : ConnectionService() {
companion object { // companion object {
var channel: MethodChannel? = null // var channel: MethodChannel? = null
private const val TAG = "CallConnectionService" // private const val TAG = "CallConnectionService"
} // }
init { // init {
Log.d(TAG, "CallConnectionService initialized") // Log.d(TAG, "CallConnectionService initialized")
} // }
override fun onCreate() { // override fun onCreate() {
super.onCreate() // super.onCreate()
Log.d(TAG, "Service created") // Log.d(TAG, "Service created")
} // }
override fun onDestroy() { // override fun onDestroy() {
super.onDestroy() // super.onDestroy()
Log.d(TAG, "Service destroyed") // Log.d(TAG, "Service destroyed")
} // }
override fun onCreateOutgoingConnection( // override fun onCreateOutgoingConnection(
connectionManagerPhoneAccount: PhoneAccountHandle?, // connectionManagerPhoneAccount: PhoneAccountHandle?,
request: android.telecom.ConnectionRequest // request: android.telecom.ConnectionRequest
): Connection { // ): Connection {
Log.d(TAG, "Creating outgoing connection for ${request.address}, account: $connectionManagerPhoneAccount") // Log.d(TAG, "Creating outgoing connection for ${request.address}, account: $connectionManagerPhoneAccount")
val connection = object : Connection() { // val connection = object : Connection() {
override fun onStateChanged(state: Int) { // override fun onStateChanged(state: Int) {
super.onStateChanged(state) // super.onStateChanged(state)
Log.d(TAG, "Connection state changed: $state") // Log.d(TAG, "Connection state changed: $state")
val stateStr = when (state) { // val stateStr = when (state) {
STATE_DIALING -> "dialing" // STATE_DIALING -> "dialing"
STATE_ACTIVE -> "active" // STATE_ACTIVE -> "active"
STATE_DISCONNECTED -> "disconnected" // STATE_DISCONNECTED -> "disconnected"
else -> "unknown" // else -> "unknown"
} // }
channel?.invokeMethod("callStateChanged", mapOf("state" to stateStr, "phoneNumber" to request.address.toString())) // channel?.invokeMethod("callStateChanged", mapOf("state" to stateStr, "phoneNumber" to request.address.toString()))
} // }
override fun onDisconnect() { // override fun onDisconnect() {
Log.d(TAG, "Connection disconnected") // Log.d(TAG, "Connection disconnected")
setDisconnected(DisconnectCause(DisconnectCause.LOCAL)) // setDisconnected(DisconnectCause(DisconnectCause.LOCAL))
destroy() // destroy()
} // }
} // }
connection.setAddress(request.address, TelecomManager.PRESENTATION_ALLOWED) // connection.setAddress(request.address, TelecomManager.PRESENTATION_ALLOWED)
connection.setInitialized() // connection.setInitialized()
connection.setDialing() // connection.setDialing()
return connection // return connection
} // }
override fun onCreateIncomingConnection( // override fun onCreateIncomingConnection(
connectionManagerPhoneAccount: PhoneAccountHandle?, // connectionManagerPhoneAccount: PhoneAccountHandle?,
request: android.telecom.ConnectionRequest // request: android.telecom.ConnectionRequest
): Connection { // ): Connection {
Log.d(TAG, "Creating incoming connection for ${request.address}, account: $connectionManagerPhoneAccount") // Log.d(TAG, "Creating incoming connection for ${request.address}, account: $connectionManagerPhoneAccount")
val connection = object : Connection() { // val connection = object : Connection() {
override fun onAnswer() { // override fun onAnswer() {
Log.d(TAG, "Connection answered") // Log.d(TAG, "Connection answered")
setActive() // setActive()
} // }
override fun onDisconnect() { // override fun onDisconnect() {
Log.d(TAG, "Connection disconnected") // Log.d(TAG, "Connection disconnected")
setDisconnected(DisconnectCause(DisconnectCause.LOCAL)) // setDisconnected(DisconnectCause(DisconnectCause.LOCAL))
destroy() // destroy()
} // }
} // }
connection.setAddress(request.address, TelecomManager.PRESENTATION_ALLOWED) // connection.setAddress(request.address, TelecomManager.PRESENTATION_ALLOWED)
connection.setRinging() // connection.setRinging()
return connection // return connection
} // }
} // }

View File

@ -1,24 +1,17 @@
package com.icing.dialer.services package com.icing.dialer.services
import android.Manifest
import android.content.Context import android.content.Context
import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.telecom.TelecomManager import android.telecom.TelecomManager
import android.telecom.PhoneAccountHandle
import android.telephony.TelephonyManager
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.Manifest
object CallService { object CallService {
private var phoneAccountHandle: PhoneAccountHandle? = null private val TAG = "CallService"
fun setPhoneAccountHandle(handle: PhoneAccountHandle) {
phoneAccountHandle = handle
}
fun makeGsmCall(context: Context, phoneNumber: String): Boolean { fun makeGsmCall(context: Context, phoneNumber: String): Boolean {
return try { return try {
@ -26,37 +19,36 @@ object CallService {
val uri = Uri.parse("tel:$phoneNumber") val uri = Uri.parse("tel:$phoneNumber")
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CALL_PHONE) == PackageManager.PERMISSION_GRANTED) { if (ContextCompat.checkSelfPermission(context, Manifest.permission.CALL_PHONE) == PackageManager.PERMISSION_GRANTED) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { telecomManager.placeCall(uri, Bundle())
val extras = Bundle() Log.d(TAG, "Initiated call to $phoneNumber")
extras.putParcelable(TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, phoneAccountHandle) true
telecomManager.placeCall(uri, extras)
true
} else {
Log.e("CallService", "GSM call not supported below Android M")
false
}
} else { } else {
Log.e("CallService", "CALL_PHONE permission not granted") Log.e(TAG, "CALL_PHONE permission not granted")
false false
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e("CallService", "Error making GSM call: ${e.message}") Log.e(TAG, "Error making GSM call: ${e.message}", e)
false false
} }
} }
fun hangUpCall(context: Context): Boolean { fun hangUpCall(context: Context): Boolean {
return try { return try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { if (MyInCallService.currentCall != null) {
MyInCallService.currentCall?.disconnect()
Log.d(TAG, "Disconnected active call via MyInCallService")
true
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
telecomManager.endCall() telecomManager.endCall()
Log.d(TAG, "Ended call via TelecomManager (no active call in MyInCallService)")
true true
} else { } else {
Log.e("CallService", "Hangup call only supported on Android P or later") Log.e(TAG, "No active call and hangup not supported below Android P")
false false
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e("CallService", "Error hanging up call: ${e.message}") Log.e(TAG, "Error hanging up call: ${e.message}", e)
false false
} }
} }

View File

@ -0,0 +1,60 @@
package com.icing.dialer.services
import android.telecom.Call
import android.telecom.InCallService
import io.flutter.plugin.common.MethodChannel
class MyInCallService : InCallService() {
companion object {
var channel: MethodChannel? = null
var currentCall: Call? = null
}
private val callCallback = object : Call.Callback() {
override fun onStateChanged(call: Call, state: Int) {
super.onStateChanged(call, state)
val stateStr = when (state) {
Call.STATE_DIALING -> "dialing"
Call.STATE_ACTIVE -> "active"
Call.STATE_DISCONNECTED -> "disconnected"
Call.STATE_DISCONNECTING -> "disconnecting"
else -> "unknown"
}
channel?.invokeMethod("callStateChanged", mapOf(
"callId" to call.details.handle.toString(),
"state" to stateStr
))
if (state == Call.STATE_DISCONNECTED || state == Call.STATE_DISCONNECTING) {
channel?.invokeMethod("callEnded", mapOf("callId" to call.details.handle.toString()))
currentCall = null
}
}
}
override fun onCallAdded(call: Call) {
super.onCallAdded(call)
currentCall = call
val stateStr = when (call.state) {
Call.STATE_DIALING -> "dialing"
Call.STATE_ACTIVE -> "active"
else -> "unknown"
}
channel?.invokeMethod("callAdded", mapOf(
"callId" to call.details.handle.toString(),
"state" to stateStr
))
call.registerCallback(callCallback)
}
override fun onCallRemoved(call: Call) {
super.onCallRemoved(call)
call.unregisterCallback(callCallback)
channel?.invokeMethod("callRemoved", mapOf("callId" to call.details.handle.toString()))
currentCall = null
}
override fun onCallAudioStateChanged(state: android.telecom.CallAudioState) {
super.onCallAudioStateChanged(state)
channel?.invokeMethod("audioStateChanged", mapOf("route" to state.route))
}
}

View File

@ -37,6 +37,7 @@ Future<void> _requestPermissions() async {
Map<Permission, PermissionStatus> statuses = await [ Map<Permission, PermissionStatus> statuses = await [
Permission.phone, Permission.phone,
Permission.contacts, Permission.contacts,
Permission.microphone,
].request(); ].request();
if (statuses.values.every((status) => status.isGranted)) { if (statuses.values.every((status) => status.isGranted)) {
print("All required permissions granted"); print("All required permissions granted");

View File

@ -1,37 +1,66 @@
import 'package:flutter/services.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../features/call/call_page.dart'; import '../features/call/call_page.dart';
class CallService { class CallService {
static const MethodChannel _channel = MethodChannel('call_service'); static const MethodChannel _channel = MethodChannel('call_service');
static String? currentPhoneNumber; static String? currentPhoneNumber;
// Add a GlobalKey for Navigator
static final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
CallService() { CallService() {
_channel.setMethodCallHandler((call) async { _channel.setMethodCallHandler((call) async {
if (call.method == "callStateChanged") { final context = navigatorKey.currentContext;
final state = call.arguments["state"] as String; if (context == null) return;
final phoneNumber = call.arguments["phoneNumber"] as String;
if (state == "dialing" || state == "active") { switch (call.method) {
Navigator.push( case "callAdded":
navigatorKey.currentContext!, final phoneNumber = call.arguments["callId"] as String; // tel:1234567890
MaterialPageRoute( final state = call.arguments["state"] as String;
builder: (context) => CallPage( currentPhoneNumber = phoneNumber.replaceFirst('tel:', ''); // Extract number
displayName: phoneNumber, // Replace with contact lookup if available if (state == "dialing" || state == "active") {
phoneNumber: phoneNumber, Navigator.push(
thumbnail: null, context,
MaterialPageRoute(
builder: (context) => CallPage(
displayName: currentPhoneNumber!, // Replace with contact lookup if available
phoneNumber: currentPhoneNumber!,
thumbnail: null,
),
), ),
), );
); }
} else if (state == "disconnected") { break;
Navigator.pop(navigatorKey.currentContext!); case "callStateChanged":
} final state = call.arguments["state"] as String;
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,
),
),
);
}
}
break;
case "callEnded":
case "callRemoved":
Navigator.pop(context);
currentPhoneNumber = null;
break;
} }
}); });
} }
// Add a GlobalKey for Navigator
static final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
Future<void> makeGsmCall( Future<void> makeGsmCall(
BuildContext context, { BuildContext context, {
required String phoneNumber, required String phoneNumber,
@ -42,15 +71,17 @@ class CallService {
currentPhoneNumber = phoneNumber; currentPhoneNumber = phoneNumber;
final result = await _channel.invokeMethod('makeGsmCall', {"phoneNumber": phoneNumber}); final result = await _channel.invokeMethod('makeGsmCall', {"phoneNumber": phoneNumber});
if (result["status"] == "calling") { if (result["status"] == "calling") {
// CallPage will be shown via CallConnectionService callback // CallPage will be shown via MyInCallService callback
} else if (result["status"] == "pending_default_dialer") { } else {
print("Waiting for user to set app as default dialer");
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Please set this app as your default dialer to proceed")), SnackBar(content: Text("Failed to initiate call")),
); );
} }
} catch (e) { } catch (e) {
print("Error making call: $e"); print("Error making call: $e");
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Error making call: $e")),
);
rethrow; rethrow;
} }
} }
@ -59,11 +90,14 @@ class CallService {
try { try {
final result = await _channel.invokeMethod('hangUpCall'); final result = await _channel.invokeMethod('hangUpCall');
if (result["status"] == "ended") { if (result["status"] == "ended") {
Navigator.pop(context); // Navigator.pop will be handled by MyInCallService callback
} }
} catch (e) { } catch (e) {
print("Error hanging up call: $e"); print("Error hanging up call: $e");
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Error hanging up call: $e")),
);
rethrow; rethrow;
} }
} }
} }