How to (actually) send DTMF on Android without being the default call app
What ?
Today, I will share my solution to a problem I recently encountered : sending DTMF inputs during a call.
Despite it seeming trivial, there’s actually no built-in solution for my use case, and I had to write something from scratch to make it work.
But, what is the use case ?
A bit of context, sir
Recently, I worked on LifeCompanion, an open-source, free, and highly customizable digital assistant. It supports individuals with motor, sensory, or cognitive disabilities by offering features like speech synthesis, virtual keyboards, pictographic communication, and compatibility with assistive devices. Developed since 2015, it promotes autonomy and social participation.
With some colleagues at my school, we were tasked to add a plugin to handle communication with an Android phone (send and read SMS, and take calls). This is a great force of LifeCompanion : being able to extend itself through plugins.
To clarify, 2 teams of students made the original plugins a year prior (messages and calls parts), and we were tasked to merge them into a singular plugin.
Still, many features were missing, and DTMF was one of them.
Also, the code of the Android app is written in Kotlin but the implementation can be transferred to Java code.
But what is DTMF ?
DTMF, aka Dual-tone multi-frequency signaling is a system that uses specific frequencies to convey a character. I won’t go over the Wikipedia page (y’all can read), but all you should know is that this system is still in use to this day when composing on the Keypad during a call (ex : when you’re prompted to enter your client number or to re listen your voice message).
And while this is an essential part of calling apps, the team that worked on the call part of the plugin didn’t implement it. So naturally, I decided to do it. What could possibly go wrong ?
Turns out, there’s no API for it
Or, is it ?
Well, the class android.telecom.Call have a method playDtmfTone() (and obviously stopDtmfTone()). But to use these functions, you need to have access to the current Call object, and to do so you need to be the default phone app. Although feasible, it would require to implement all features that users require from a regular call app (dialer, contacts list, call history, voicemail, …), and honestly we didn’t had the time nor the motivation (and imagine all the edge cases to handle !).
Obviously, I didn’t catched that so I started to write code that would not work…
At this moment, it would be great for you to understand quickly how the app we created works.
Basically, the phone is connected with the PC through a cable, and we communicate via ADB.
When we need to request or send data to the phone, we start an intent with extra data, which is a Base64-ed JSON with fields explaining what we want to get/send.
The app then proceeds to pass the data to the relevant controller, which will do its magic, and if needed, output JSON to a specific folder on the phone that LifeCompanion will poll regularly to know if the response is ready to obtain.
So for example, if we want to send the DTMF “8”, we will send from the pcadb shell am start-foreground-service -a org.lifecompanion.phonecontrolapp.services.JSONProcessingService --es extra_data eyJzZW5kZXIiOiAicGMiLCJ0eXBlIjogImNhbGwiLCJzdWJ0eXBlIjoibnVtcGFkX2lucHV0IiwiZGF0YSI6eyJkdG1mIjogIjgifX0=, the extra data symbolizing this :{ "sender": "pc", "type": "call", "subtype": "numpad_input", "data": { "dtmf": "8" } }The app will then process this, and pass the data to the
services.CallController, which will call the right function. Also at this point we don’t care if the DTMF has been sent correctly, so there’s norequest_idin the JSON, we don’t check if it has worked or not.
Finally, file paths for Kotlin files are relative tosrc/main/java/org/lifecompanion/phonecontrolappunless specified otherwise (ressource files for example).
So, in the following code examples I will only focus on the DTMF-related pieces, leaving the rest of the Call and JSON processing logic aside. You can find more about the app itself on the repo.
Oh, the misery
// services/CallService.kt
package org.lifecompanion.phonecontrolapp.services
import android.telecom.Call
import org.lifecompanion.phonecontrolapp.services.CallStateListener
// ...
class CallService : Service(), CallStateListener {
private var currentCall: Call? = null
// ...
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
// ...
CallWatcher.callStateListener = this
}
override fun onCallStateChanged(call: Call?, isIncoming: Boolean, isActive: Boolean, phoneNumber: String?) {
currentCall = call
// ...
}
private fun sendDtmf(dtmf: String) {
if (currentCall == null) {
return
}
try {
currentCall?.playDtmfTone(dtmf)
// Pause between tones to ensure proper transmission
Thread.sleep(300)
currentCall?.stopDtmfTone()
} catch (e: Exception) {
// womp womp
}
}
// ...
// services/CallStateListener.kt
package org.lifecompanion.phonecontrolapp.services
import android.telecom.Call
interface CallStateListener {
fun onCallStateChanged(
call: Call?,
isIncoming: Boolean,
isActive: Boolean,
phoneNumber: String?
)
}
// services/CallWatcher.kt
package org.lifecompanion.phonecontrolapp.services
import android.telecom.Call
import android.telecom.InCallService
import org.lifecompanion.phonecontrolapp.services.CallStateListener
class CallWatcher : InCallService() {
companion object {
private const val TAG = "CallWatcher"
var callStateListener: CallStateListener? = null
}
override fun onCallAdded(call: Call) {
super.onCallAdded(call)
call.registerCallback(callStateCallback)
notifyStateChange(call)
}
override fun onCallRemoved(call: Call) {
super.onCallRemoved(call)
call.unregisterCallback(callStateCallback)
notifyStateChange(null)
}
private val callStateCallback = object : Call.Callback() {
override fun onStateChanged(call: Call, state: Int) {
super.onStateChanged(call, state)
notifyStateChange(call)
}
}
private fun getPhoneNumber(call: Call?): String? {
return call?.details?.handle?.schemeSpecificPart
}
private fun notifyStateChange(call: Call?) {
val isIncoming = call?.details?.state == Call.STATE_RINGING
val isActive = call?.details?.state == Call.STATE_ACTIVE
val phoneNumber = getPhoneNumber(call)
callStateListener?.onCallStateChanged(call, isIncoming, isActive, phoneNumber)
}
}
While this code should work (probably not), as you can see we extend InCallService(), and that would require us to be the default phone app.
Everybody wants to be my enemy
So because it didn’t worked, I decided to do everyone’s favorite activity : searching for documentation :)
What I found is crazy : devs asked for this feature all the way back in 2008 ! It was marked as Won’t Fix (Obsolete) in 2014, and even the patches submitted by some devs were rejected in 2021.
From what I was able to read, it apparently is possible for an app to send DTMF tones but only over VoIP. But guess what ? It is deprecated by now !
So, time for more searches ! And I browsed everyone’s second favorite website : StackOverflow (I come from a time where LLMs weren’t the meta).
And across all the related questions, I haven’t found any single answer :(
- This one suggests that we can send it at call time, but not during a call (and also that one).
- This one was about another technology (that one is the same question but with other answers that confirms what I said previously).
- This one tells us that we can “fake” it by playing the frequency ourselves (but that work only when the speakerphone is enabled).
- This is the post that confirmed the
playDtmfTone()method. - And finally this one is a very interesting approach to the problem, although not working.
Okay, so I guess it’s time for more broad searches, right ?
- A random library that I found but for a completely separate project.
- An API that someone tried to create but that doesn’t work sadly.
- I know that we would need to be the default phone app !
- A lot of tutorials have suggested to use a separate app to emit the according frequencies (or for us, to embed them in the app).
Hmm… What does AIs have to say about this ?
Well all of them had either the brilliant idea to suggest android.telecom.Call.startDtmfTone() but without telling me that we need to be the default phone app, or they would straight up hallucinate methods. Even o1 had trouble helping me in this task or provide alternate ways to handle it.
The revelation
At this point I was convinced that it wasn’t feasible. If it wasn’t for Andriy Antonov’s solution !
This guy had a brilliant idea : Android phones have accessibility services, which allows to emulate clicks on the screen. Why not using them to click on the keypad buttons directly !
However, his solution had some flaws, notably the fact that we needed the screen coordinates of the buttons, and our app needed to work on all kind of Android devices (including tablets), which made this impossible. But hey, that’s nothing that code couldn’t solve… :)
Note
Update : After the publication of this blog post, Andriy shared more details about the backstory of his implementation in a LinkedIn post.
Time to lock in
Here’s the very first implementation that I made :
// services/DTMFAccessibilityService.kt
package org.lifecompanion.phonecontrolapp.services
import android.accessibilityservice.AccessibilityService
import android.util.Log
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
class DTMFAccessibilityService : AccessibilityService() {
companion object {
private const val TAG = "DTMFAccessibilityService"
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
if (event == null || event.source == null) return
Log.i(TAG, "Accessibility event received: ${event.eventType}")
val rootNode = rootInActiveWindow ?: return
if (isInCallScreen(rootNode)) {
Log.i(TAG, "In call screen detected")
if (!isKeypadOpen(rootNode)) {
openKeypad(rootNode)
}
}
}
private fun isInCallScreen(node: AccessibilityNodeInfo): Boolean {
// Check for specific elements that indicate the call screen
return node.packageName?.contains("dialer", ignoreCase = true) == true
}
private fun isKeypadOpen(node: AccessibilityNodeInfo): Boolean {
// Check for the presence of specific keypad elements that are unique to the keypad
val keypadElements = listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "*", "#")
return keypadElements.any { element ->
node.findAccessibilityNodeInfosByText(element).isNotEmpty()
}
}
private fun openKeypad(node: AccessibilityNodeInfo) {
// Find and click the button to open the keypad
val keypadButtonTexts = listOf("Keypad", "Clavier")
for (text in keypadButtonTexts) {
val keypadButton = node.findAccessibilityNodeInfosByText(text).firstOrNull()
if (keypadButton != null) {
keypadButton.performAction(AccessibilityNodeInfo.ACTION_CLICK)
Log.i(TAG, "Keypad opened with text: $text")
return
}
}
Log.i(TAG, "Keypad button not found")
}
fun pressKeypadButton(buttonText: String) {
val rootNode = rootInActiveWindow ?: return
if (isInCallScreen(rootNode)) {
if (!isKeypadOpen(rootNode)) {
openKeypad(rootNode)
}
searchAndClick(rootNode, buttonText)
}
}
private fun searchAndClick(node: AccessibilityNodeInfo, buttonText: String) {
if (node.text?.toString() == buttonText && node.isClickable) {
node.performAction(AccessibilityNodeInfo.ACTION_CLICK)
Log.i(TAG, "Clicked button: $buttonText")
return
}
for (i in 0 until node.childCount) {
node.getChild(i)?.let { searchAndClick(it, buttonText) }
}
}
override fun onInterrupt() {
Log.i(TAG, "Accessibility Service Interrupted")
}
}
// services/CallService.kt
package org.lifecompanion.phonecontrolapp.services
import android.content.Intent
// ...
class CallService : Service() {
// ...
private fun sendDtmf(dtmf: String) {
val intent = Intent(this, DTMFAccessibilityService::class.java)
startService(intent)
val dtmfService = DTMFAccessibilityService()
dtmfService.pressKeypadButton(dtmf)
}
}
Obviously we need to ask for accessibility service privilege :
// MainActivity.kt
package org.lifecompanion.phonecontrolapp
import android.app.Activity
import android.app.AlertDialog
import android.content.Intent
import android.os.Bundle
import android.provider.Settings
import org.lifecompanion.phonecontrolapp.services.DTMFAccessibilityService
// ...
class MainActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// ...
promptEnableAccessibilityService()
}
// ...
private fun isAccessibilityServiceEnabled(service: Class<*>): Boolean {
val enabledServices = Settings.Secure.getString(
contentResolver,
Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES
) ?: return false
val colonSplitter = enabledServices.split(":")
val serviceName = componentName.flattenToString().replace(packageName, service.name)
return colonSplitter.any { it.equals(serviceName, ignoreCase = true) }
}
private fun promptEnableAccessibilityService() {
if (!isAccessibilityServiceEnabled(DTMFAccessibilityService::class.java)) {
AlertDialog.Builder(this)
.setTitle("Enable Accessibility service")
.setMessage("This app requires the Accessibility service to emulate keyboard inputs during calls. Please enable it in the Accessibility settings. You may need to do this on every restart.")
.setPositiveButton("Open settings") { _, _ ->
val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)
startActivity(intent)
}
.setNegativeButton("Already done", null)
.show()
}
}
}
And finally, to declare the accessibility service itself :
<!-- src/main/res/values/strings.xml -->
<resources>
<!-- ... -->
<string name="accessibility_service_description">Accessibility service for the LifeCompanion app</string>
</resources>
<!-- src/main/res/xml/accessibility_service_config.xml -->
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service
xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/accessibility_service_description"
android:accessibilityEventTypes="typeWindowContentChanged|typeViewClicked"
android:accessibilityFeedbackType="feedbackGeneric"
android:notificationTimeout="100"
android:canRetrieveWindowContent="true"
android:accessibilityFlags="flagReportViewIds" />
So, what the hell are we actually doing here ?
- We declare an accessibility service that is triggered everytime the content of a window changes or when a view is clicked, and ask to get the view IDs.
- On the
CallService, we create theDTMFAccessibilityServiceand call its function. - On the Accessibility service, whenever we receive an event we check if it’s from the active window. If it is, we check if we’re in the call screen and then open the keypad if it isn’t.
isInCallScreen()is very primitive and only checks the presence ofdialerin the window’s package name.isKeypadOpen()checks if any of the keypad elements (0-9,*and#) is present on the node (which represents the active window in a tree). At first it returnedtrueonly if all of them were present but it didn’t worked so I usedanyinstead.openKeypad()searches for a button in the node that contains in its text “Keypad” or its french version, “Clavier” (the app was made mainly for french users so I had to include it), and clicks on it.- The public
pressKeypadButton()does all of the above and callssearchAndClick()which clicks on any node that matches exactly the input string and is clickable.
Obviously, it goes without saying that this is not optimal, and far from working :
- Due to the incessant calls to
openKeypad()on the accessibility events, and the barely working check functions, the keypad was constantly opening and closing (which was funny to stare at). - The logic to check if we’re in a call screen and if the keypad is open are very barebones and not flexible.
- The call screen possesses the phone number of the callee, which obviously will always match our
anyclause. - Overall it wasn’t even able to click on the keypad buttons…
So, it is time for fixes.
Make it workey pretty please 🙏
The first edit I did was to comment the code in onAccessibilityEvent() to avoid the keypad from flickering in my screen.
Then, I edited the logic in the isKeypadOpen() method :
private fun isKeypadOpen(node: AccessibilityNodeInfo): Boolean {
// Count the number of keypad elements on the screen
val initialCount = countKeypadElements(node)
// Trigger openKeypad and count again
openKeypad(node)
val afterOpenCount = countKeypadElements(node)
// Determine if the keypad is open based on the counts
val isOpen = afterOpenCount > initialCount
// Revert the state by triggering openKeypad again
openKeypad(node)
return isOpen
}
private fun countKeypadElements(node: AccessibilityNodeInfo): Int {
val keypadElements = listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "*", "#")
return keypadElements.sumBy { element ->
node.findAccessibilityNodeInfosByText(element).size
}
}
The logic is simple : we count the number of each element that should be present on the keypad. Then, we click on its button and count again.
If there’s more elements, it means it is open. If not, we closed it, so we click on it again.
I thought I was clever writing this, but honestly it was still bad : because we call this function anyway at each pressKeypadButton() call, we would close and reopen the keypad at every click.
Or we open only once per phone call, but if the user closed the keypad by accident, then we couldn’t reopen it and any button presses would fail.
So it is time for…
The final solution
After detailing the goddamn implementation I did and our motive, o1 came in clutch and was finally able to help in this task. Here’s the code changes that we did based off some of its recommendations :
<!-- src/main/res/xml/accessibility_service_config.xml -->
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service
xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/accessibility_service_description"
android:accessibilityEventTypes="typeWindowContentChanged|typeViewClicked|typeWindowStateChanged"
android:accessibilityFlags="flagReportViewIds|flagIncludeNotImportantViews"
android:accessibilityFeedbackType="feedbackGeneric"
android:notificationTimeout="100"
android:canRetrieveWindowContent="true" />
// services/DTMFAccessibilityService.kt
package org.lifecompanion.phonecontrolapp.services
import android.accessibilityservice.AccessibilityService
import android.content.Context
import android.content.Intent
import android.content.pm.ResolveInfo
import android.util.Log
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
object DTMFAccessibilityServiceSingleton {
var instance: DTMFAccessibilityService? = null
}
class DTMFAccessibilityService : AccessibilityService() {
companion object {
private const val TAG = "LC-DTMFAccessibilityService"
// Known dialer package names, more may be needed depending of the default call app
private val KNOWN_DIALER_PACKAGES: List<String> by lazy {
getPackagesOfDialerApps().apply {
val additionalPackages = listOf(
"com.google.android.dialer",
"com.android.dialer",
"com.samsung.android.incallui"
)
additionalPackages.forEach { pkg ->
if (!this.contains(pkg)) {
this.add(pkg)
}
}
}
}
// Common text or contentDescriptions for opening the dialpad
private val DIALPAD_TOGGLE_KEYWORDS = listOf("Keypad", "Clavier", "Dial pad", "Show dial pad")
private fun getPackagesOfDialerApps(): MutableList<String> {
val packageNames = mutableListOf<String>()
val context = DTMFAccessibilityServiceSingleton.instance?.applicationContext
if (context != null) {
val intent = Intent(Intent.ACTION_DIAL)
val resolveInfos: List<ResolveInfo> = context.packageManager.queryIntentActivities(intent, 0)
for (resolveInfo in resolveInfos) {
val activityInfo = resolveInfo.activityInfo
packageNames.add(activityInfo.applicationInfo.packageName)
}
} else {
Log.e(TAG, "Context is null, cannot get dialer packages")
}
return packageNames
}
}
// Keep a reference to the latest root node
private var _rootNode: AccessibilityNodeInfo? = null
override fun onCreate() {
super.onCreate()
DTMFAccessibilityServiceSingleton.instance = this
}
override fun onDestroy() {
super.onDestroy()
DTMFAccessibilityServiceSingleton.instance = null
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
if (event == null) {
return
}
// Whenever there's a window content/state change, update our root node reference
if (event.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED || event.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED) {
_rootNode = rootInActiveWindow
}
}
override fun onServiceConnected() {
super.onServiceConnected()
DTMFAccessibilityServiceSingleton.instance = this
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
intent?.let {
val action = it.getStringExtra("action")
val buttonText = it.getStringExtra("button_text")
if (action == "press_keypad_button" && buttonText != null) {
pressKeypadButton(buttonText)
}
}
return START_NOT_STICKY
}
/**
* Heuristically checks if the current screen belongs to a known dialer or in-call UI.
*/
private fun isInCallScreen(rootNode: AccessibilityNodeInfo): Boolean {
val pkg = rootNode.packageName?.toString() ?: return false
return KNOWN_DIALER_PACKAGES.any { pkg.contains(it, ignoreCase = true) }
}
/**
* Check if the dial pad is open by searching for all digits 0-9 plus * and # in the hierarchy.
*/
private fun areAllDigitsVisible(rootNode: AccessibilityNodeInfo): Boolean {
val required = listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "*", "#")
for (digit in required) {
val nodesWithDigit = rootNode.findAccessibilityNodeInfosByText(digit)
if (nodesWithDigit.isNullOrEmpty()) {
return false
}
}
return true
}
/**
* Attempt to find a toggle button (by text or contentDescription) to open the dialpad.
* If we already see digits on the screen, we skip toggling.
*/
private fun openDialPadIfNeeded(rootNode: AccessibilityNodeInfo) {
// If we already see the digits 0-9, #, * in the node tree, the dial pad is probably open
val allDigitsPresent = areAllDigitsVisible(rootNode)
if (allDigitsPresent) {
return
}
// Otherwise, BFS to find a dialpad toggle
val queue = ArrayDeque<AccessibilityNodeInfo>()
queue.add(rootNode)
while (queue.isNotEmpty()) {
val node = queue.removeFirst()
// Check text & contentDescription
val textStr = node.text?.toString() ?: ""
val descStr = node.contentDescription?.toString() ?: ""
// If either text or contentDescription matches known keywords
if (DIALPAD_TOGGLE_KEYWORDS.any { keyword ->
textStr.contains(keyword, ignoreCase = true) ||
descStr.contains(keyword, ignoreCase = true)
}
) {
if (node.isClickable) {
node.performAction(AccessibilityNodeInfo.ACTION_CLICK)
Thread.sleep(300)
return
}
}
for (i in 0 until node.childCount) {
node.getChild(i)?.let { queue.add(it) }
}
}
Log.w(TAG, "Could not find any dialpad toggle button in the current UI.")
}
/**
* Checks if a dialer button's text or contentDescription is relevant for this digit.
* e.g. digit = "2" matches "2", "2 ABC", "2,ABC", "2." ...
*/
private fun isDialerButtonMatch(nodeText: String?, digit: String): Boolean {
if (nodeText == null || nodeText.isEmpty()) {
return false
}
val escapedDigit = Regex.escape(digit)
val pattern = Regex("^$escapedDigit[\\s,]*(.*)?$", RegexOption.IGNORE_CASE)
return nodeText.matches(pattern)
}
/**
* Find and click a single digit (0-9, *, #).
* Returns true if the digit was successfully clicked, false otherwise.
*/
private fun pressKeyDigit(rootNode: AccessibilityNodeInfo, digit: String): Boolean {
// BFS
val queue = ArrayDeque<AccessibilityNodeInfo>()
queue.add(rootNode)
while (queue.isNotEmpty()) {
val node = queue.removeFirst()
// Skip system UI or non-dialer packages
val nodePackage = node.packageName?.toString() ?: ""
if (!KNOWN_DIALER_PACKAGES.any { nodePackage.contains(it, ignoreCase = true) }) {
// This node isn't from a recognized dialer package. Skip its subtree.
continue
}
val textStr = node.text?.toString()
val descStr = node.contentDescription?.toString()
// If either text or contentDescription is a partial match
if (isDialerButtonMatch(textStr, digit) || isDialerButtonMatch(descStr, digit)) {
if (node.isClickable) {
node.performAction(AccessibilityNodeInfo.ACTION_CLICK)
Thread.sleep(300)
return true
}
}
for (i in 0 until node.childCount) {
node.getChild(i)?.let { queue.add(it) }
}
}
return false
}
/**
* External entry point:
* 1) Check if we are in a known dialer UI
* 2) Open the dial pad if needed
* 3) Press the requested button
*/
fun pressKeypadButton(dtmfString: String) {
val rootNode = _rootNode ?: run {
Log.w(TAG, "No root node available. Are we sure the dialer is in the foreground ?")
return
}
if (!isInCallScreen(rootNode)) {
Log.w(TAG, "We are not in a recognized in-call/dialer screen !")
return
}
openDialPadIfNeeded(rootNode)
if (dtmfString.matches(Regex("[0-9*#]"))) {
var success = pressKeyDigit(rootNode, dtmfString)
if (!success) {
// We probably just opened the dial pad
Thread.sleep(300)
success = pressKeyDigit(rootNode, dtmfString)
if (!success) {
Log.e(TAG, "Failed to press digit : $dtmfString")
}
}
} else {
Log.e(TAG, "Invalid DTMF char : $dtmfString")
}
}
override fun onInterrupt() {
DTMFAccessibilityServiceSingleton.instance = null
}
}
// services/CallService.kt
// ...
private fun sendDtmf(dtmf: String) {
val intent = Intent(this, DTMFAccessibilityService::class.java)
startService(intent)
DTMFAccessibilityServiceSingleton.instance?.pressKeypadButton(dtmf)
}
Time for some explanations once again :)
- We created (quickly) a singleton to avoid creating another
DTMFAccessibilityServiceobject at each call. - We added a bit more possible names for the keypad button. I’m sorry, but if you want to use this code, you might need to i18n it depending on the countries you’re targeting.
- The detection of the call app name is way much improved ! We first get the list of package names from all installed apps that can handle calls on the phone, and add 3 common packages just in case.
- Everytime we get an accessibility event (we added when the state of a window changes, and asked also for the “non important views”), we would edit a global root node that we process. This ensures reactivity to content changes (ex the keypad just opened or closed) and also helps us working on an always up to date node tree.
- We also improved the detection of “visible” keypad elements (we require all of them to be apparent on the screen).
- If the keypad isn’t opened, we do a quick BFS on the root node to find the button and click on it.
- When calling
pressKeypadButton(), we perform all required actions (as before), and check if the DTMF symbol is an accepted one (technicallyA-Dexists but nobody uses them) withRegex("[0-9*#]"). - We then call
pressKeyDigit()to perform a BFS on the root node tree again, but this time to search for the DTMF’s button. Note that we sleep 300ms after performing any click to let the UI (and root node object) update. We also take care of skipping anything where the buttons will not be present. - To be really sure, we check in the button’s text and description for the content we search for with a good ol’ regex :
"^$dtmf[\\s,]*(.*)?$".
Obviously, the DTMF button name is in the description, not the text ! For example, the button 2 have a null text, and 2,ABC as a description.
But we still had to do a quick change. You see, every button press worked, except *, which hanged up the call. Well it was because we had to escape it, as it matched any text on the regex !
The end
It was really fun to make the code and write this blog post, but seriously frustrating that there is no standard API to do it in a non convoluted way, 17 years after the release of Android !
I hope that this helped you to implement such a basic feature in your app (although working with accessibility services is a pain in the rear end), and don’t hesitate to follow me on random places (feel free to start a convo).
This blog will contain random writeups like this one (technical) and more random standard things too, thoughts, …