From a3f9a1e8174063a0984cd8846be8e026f1775181 Mon Sep 17 00:00:00 2001 From: Andrew Gunnerson Date: Sun, 15 Oct 2023 16:48:01 -0400 Subject: [PATCH] Add limited support for call redirection Android 10 added the `CallRedirectionService` API, which allows a third party app to process outgoing calls made from the system's dialer. Some call redirection services, like Google Voice, use phone calls under the hood instead of VOIP. These calls can be recorded by BCR, but would previously show the service's proxy phone number. Unfortunately, with the way Android's APIs are designed, the only thing that's aware of the original phone number is the dialer. We have no way of querying that in BCR until the call ends and the dialer writes the entry to the call log. This commit implements support for looking up the phone number from the call log. This lookup functionality only works for non-conference calls and the call will always be classified as `All other calls` in the auto-record rules. Fixes: #443 Signed-off-by: Andrew Gunnerson --- README.md | 15 +++ .../bcr/output/CallMetadataCollector.kt | 121 ++++++++++++------ .../com/chiller3/bcr/output/PhoneNumber.kt | 2 +- 3 files changed, 99 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 70e0be875..7e1d92533 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ As the name alludes, BCR intends to be a basic as possible. The project will hav * A notification is required for running the call recording service in foreground mode or else Android will not allow access to the call audio stream. * `READ_CALL_LOG` (**optional**) * If allowed, the name as shown in the call log can be added to the output filename. + * This is also required to show the correct phone number when using call redirection apps. * `READ_CONTACTS` (**optional**) * If allowed, the contact name can be added to the output filename. It also allows auto-record rules to be set per contact. * `READ_PHONE_STATE` (**optional**) @@ -99,6 +100,20 @@ As the name alludes, BCR intends to be a basic as possible. The project will hav Note that `INTERNET` is _not_ in the list. BCR does not and will never access the network. BCR will never communicate with other apps either, except if the user explicitly taps on the `Open` or `Share` buttons in the notification shown when a recording completes. In that scenario, the target app is granted access to that single recording only. +## Call redirection + +BCR has limited support for call redirection apps, like Google Voice. Redirected calls can be recorded only if the call redirection service uses standard telephone calls behind the scenes (instead of VOIP). + +There are several limitations when recording redirected calls compared to regular calls: + +* The call must not be a conference call. Otherwise, the filename will only show the call redirection service's proxy phone number. +* Auto-record rules will not work properly. Redirected calls will never match any rules for specified contacts and will only match the `All other calls` rule. +* During the call, BCR's notification will only show the call redirection service's proxy phone number. +* BCR must be granted the call logs permission. +* The dialer app must put the original phone number into the system call log. The AOSP and Google dialer apps do, but other OEM dialer apps might not. + +These limitations exist because when a call is redirected, only the dialer app itself is aware of the original phone number. The Android telephony system is not aware of it. BCR can only find the original phone number by searching the system call log when the dialer adds the entry at the end of the call. + ## Filename template BCR supports customizing the template used for determining the output filenames of recordings. The default template is: diff --git a/app/src/main/java/com/chiller3/bcr/output/CallMetadataCollector.kt b/app/src/main/java/com/chiller3/bcr/output/CallMetadataCollector.kt index 32313b34d..b41e43a38 100644 --- a/app/src/main/java/com/chiller3/bcr/output/CallMetadataCollector.kt +++ b/app/src/main/java/com/chiller3/bcr/output/CallMetadataCollector.kt @@ -40,21 +40,7 @@ class CallMetadataCollector( update(false) } - private fun getContactDisplayName(details: Call.Details, allowManualLookup: Boolean): String? { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val name = details.contactDisplayName - if (name != null) { - return name - } - } - - // In conference calls, the telephony framework sometimes doesn't return the contact display - // name for every party in the call, so do the lookup ourselves. This is similar to what - // InCallUI does, except it doesn't even try to look at contactDisplayName. - if (isConference) { - Log.w(TAG, "Contact display name missing in conference child call") - } - + private fun getContactDisplayNameByNumber(number: PhoneNumber, allowManualLookup: Boolean): String? { // This is disabled until the very last filename update because it's synchronous. if (!allowManualLookup) { Log.d(TAG, "Manual contact lookup is disabled for this invocation") @@ -69,12 +55,6 @@ class CallMetadataCollector( Log.d(TAG, "Performing manual contact lookup") - val number = details.phoneNumber - if (number == null) { - Log.w(TAG, "Cannot determine phone number from call") - return null - } - for (contact in findContactsByPhoneNumber(context, number.toString())) { Log.d(TAG, "Found contact display name via manual lookup") return contact.displayName @@ -84,26 +64,50 @@ class CallMetadataCollector( return null } - private fun getCallLogCachedName( + private fun getContactDisplayName(details: Call.Details, allowManualLookup: Boolean): String? { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val name = details.contactDisplayName + if (name != null) { + return name + } + } + + // In conference calls, the telephony framework sometimes doesn't return the contact display + // name for every party in the call, so do the lookup ourselves. This is similar to what + // InCallUI does, except it doesn't even try to look at contactDisplayName. + if (isConference) { + Log.w(TAG, "Contact display name missing in conference child call") + } + + val number = details.phoneNumber + if (number == null) { + Log.w(TAG, "Cannot determine phone number from call") + return null + } + + return getContactDisplayNameByNumber(number, allowManualLookup) + } + + private fun getCallLogDetails( parentDetails: Call.Details, allowBlockingCalls: Boolean, - ): String? { + ): Pair { // This is disabled until the very last filename update because it's synchronous. if (!allowBlockingCalls) { Log.d(TAG, "Call log lookup is disabled for this invocation") - return null + return Pair(null, null) } if (context.checkSelfPermission(Manifest.permission.READ_CALL_LOG) != PackageManager.PERMISSION_GRANTED) { Log.w(TAG, "Permissions not granted for looking up call log") - return null + return Pair(null, null) } // The call log does not show all participants in a conference call if (isConference) { Log.w(TAG, "Skipping call log lookup due to conference call") - return null + return Pair(null, null) } val uri = CallLog.Calls.CONTENT_URI.buildUpon() @@ -114,6 +118,9 @@ class CallMetadataCollector( val start = System.nanoTime() var attempt = 1 + var number: PhoneNumber? = null + var name: String? = null + while (true) { val now = System.nanoTime() if (now >= start + CALL_LOG_QUERY_TIMEOUT_NANOS) { @@ -124,7 +131,7 @@ class CallMetadataCollector( context.contentResolver.query( uri, - arrayOf(CallLog.Calls.CACHED_NAME), + arrayOf(CallLog.Calls.CACHED_NAME, CallLog.Calls.NUMBER), "${CallLog.Calls.DATE} = ?", arrayOf(parentDetails.creationTimeMillis.toString()), "${CallLog.Calls._ID} DESC", @@ -132,27 +139,52 @@ class CallMetadataCollector( if (cursor.moveToFirst()) { Log.d(TAG, "${prefix}Found call log entry") - val index = cursor.getColumnIndex(CallLog.Calls.CACHED_NAME) - if (index != -1) { - val name = cursor.getStringOrNull(index) - if (name != null) { - Log.d(TAG, "${prefix}Found call log cached name") - return name + if (number == null) { + val index = cursor.getColumnIndex(CallLog.Calls.NUMBER) + if (index != -1) { + number = cursor.getStringOrNull(index)?.let { + Log.d(TAG, "${prefix}Found call log phone number") + PhoneNumber(it) + } + } else { + Log.d(TAG, "${prefix}Call log entry has no phone number") } } - Log.d(TAG, "${prefix}Call log entry has no cached name") + if (name == null) { + val index = cursor.getColumnIndex(CallLog.Calls.CACHED_NAME) + if (index != -1) { + name = cursor.getStringOrNull(index)?.let { + Log.d(TAG, "${prefix}Found call log cached name") + it + } + } else { + Log.d(TAG, "${prefix}Call log entry has no cached name") + } + } + + Unit } else { Log.d(TAG, "${prefix}Call log entry not found") } } attempt += 1 + + if (number != null && name != null) { + break + } + Thread.sleep(CALL_LOG_QUERY_RETRY_DELAY_MILLIS) } - Log.d(TAG, "Call log cached name not found after all ${attempt - 1} attempts") - return null + if (number != null && name != null) { + Log.d(TAG, "Found all call log details after ${attempt - 1} attempts") + } else { + Log.d(TAG, "Incomplete call log details after all ${attempt - 1} attempts") + } + + return Pair(number, name) } private fun computeMetadata( @@ -197,9 +229,9 @@ class CallMetadataCollector( simSlot = subscriptionInfo.simSlotIndex + 1 } - val callLogName = getCallLogCachedName(parentDetails, allowBlockingCalls) + val (callLogNumber, callLogName) = getCallLogDetails(parentDetails, allowBlockingCalls) - val calls = displayDetails.map { + var calls = displayDetails.map { CallPartyDetails( it.phoneNumber, it.callerDisplayName, @@ -207,6 +239,19 @@ class CallMetadataCollector( ) } + if (callLogNumber != null && !calls.any { it.phoneNumber == callLogNumber }) { + Log.w(TAG, "Call log phone number does not match any call handle") + Log.w(TAG, "Assuming call redirection and trusting call log instead") + + calls = listOf( + CallPartyDetails( + callLogNumber, + null, + getContactDisplayNameByNumber(callLogNumber, allowBlockingCalls), + ) + ) + } + return CallMetadata( timestamp, direction, diff --git a/app/src/main/java/com/chiller3/bcr/output/PhoneNumber.kt b/app/src/main/java/com/chiller3/bcr/output/PhoneNumber.kt index cd7550242..40d9972a3 100644 --- a/app/src/main/java/com/chiller3/bcr/output/PhoneNumber.kt +++ b/app/src/main/java/com/chiller3/bcr/output/PhoneNumber.kt @@ -6,7 +6,7 @@ import android.telephony.TelephonyManager import android.util.Log import java.util.Locale -class PhoneNumber(private val number: String) { +data class PhoneNumber(private val number: String) { fun format(context: Context, format: Format) = when (format) { Format.DIGITS_ONLY -> number.filter { Character.digit(it, 10) != -1 } Format.COUNTRY_SPECIFIC -> {