Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add limited support for call redirection #444

Merged
merged 1 commit into from
Oct 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**)
Expand All @@ -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:
Expand Down
121 changes: 83 additions & 38 deletions app/src/main/java/com/chiller3/bcr/output/CallMetadataCollector.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
Expand All @@ -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<PhoneNumber?, String?> {
// 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()
Expand All @@ -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) {
Expand All @@ -124,35 +131,60 @@ 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",
)?.use { cursor ->
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(
Expand Down Expand Up @@ -197,16 +229,29 @@ 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,
getContactDisplayName(it, allowBlockingCalls),
)
}

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,
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/com/chiller3/bcr/output/PhoneNumber.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 -> {
Expand Down
Loading