Skip to content

Commit

Permalink
Add support for auto-record rules
Browse files Browse the repository at this point in the history
Every call is always recorded initially and the rules determine if the
recording should be deleted at the end of the call. The user can
override the rules during the middle of a call by using the Delete and
Restore buttons in BCR's notification. This allows the user to decide to
keep the recording later in the call, even if auto-record was disabled
for the caller.

The supported rule types are:

* Specific contact
* Unknown calls
* All other calls

If BCR is not granted the Contacts permission, then only "All other
calls" will be available. For simplicity of implementation, there is no
way to add rules for specific phone numbers--only contacts. This way
Android can do the hard work of performing phone number comparisons.

Rules are processed in order and while the rule matching mechanism can
handle an arbitrary rule order, the configuration interface enforces
that all the contact rules come first in sorted order, followed by
"Unknown calls" and "All other calls". This is again done for simplicity
of implementation. This should be sufficient for most use cases, with
the caveat that conference calls will follow whichever rule matches
first for any of the participants in the call.

This rule mechanism replaces the old "initially paused" setting. Pausing
and unpausing no longer has any relation to whether a recording is kept.
The old setting will be migrated to the new "Unknown calls" and "All
other calls" rules.

Fixes: #320

Signed-off-by: Andrew Gunnerson <[email protected]>
  • Loading branch information
chenxiaolong committed May 4, 2023
1 parent 718ac05 commit 0a169a9
Show file tree
Hide file tree
Showing 30 changed files with 1,049 additions and 126 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ BCR is a simple Android call recording app for rooted devices or devices running
* FLAC - Lossless, larger files
* WAV/PCM - Lossless, largest files, least CPU usage
* Supports Android's Storage Access Framework (can record to SD cards, USB devices, etc.)
* Per-contact auto-record rules
* Quick settings toggle
* Material You dynamic theming
* No persistent notification unless a recording is in progress
Expand Down Expand Up @@ -84,7 +85,7 @@ As the name alludes, BCR intends to be a basic as possible. The project will hav
* `READ_CALL_LOG` (**optional**)
* If allowed, the name as shown in the call log can be added to the output filename.
* `READ_CONTACTS` (**optional**)
* If allowed, the contact name can be added to the output filename.
* 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**)
* If allowed, the SIM slot for devices with multiple active SIMs is added to the output filename.
* `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` (**optional**)
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,9 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<activity
android:name=".rule.RecordRulesActivity"
android:exported="false" />
</application>
</manifest>
14 changes: 9 additions & 5 deletions app/src/main/java/com/chiller3/bcr/ChipGroupCentered.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@ import java.lang.Integer.max
import java.lang.Integer.min

/** Hacky wrapper around [ChipGroup] to make every row individually centered. */
class ChipGroupCentered(context: Context, attrs: AttributeSet?, defStyleAttr: Int) :
ChipGroup(context, attrs, defStyleAttr) {
class ChipGroupCentered : ChipGroup {
private val _rowCountField = javaClass.superclass.superclass.getDeclaredField("rowCount")
private var rowCountField
get() = _rowCountField.getInt(this)
Expand All @@ -21,10 +20,15 @@ class ChipGroupCentered(context: Context, attrs: AttributeSet?, defStyleAttr: In
_rowCountField.isAccessible = true
}

constructor(context: Context, attrs: AttributeSet?) :
this(context, attrs, com.google.android.material.R.attr.chipGroupStyle)
@Suppress("unused")
constructor(context: Context) : super(context)

constructor(context: Context) : this(context, null)
@Suppress("unused")
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)

@Suppress("unused")
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) :
super(context, attrs, defStyleAttr)

@SuppressLint("RestrictedApi")
override fun onLayout(sizeChanged: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
Expand Down
74 changes: 74 additions & 0 deletions app/src/main/java/com/chiller3/bcr/Contact.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.chiller3.bcr

import android.Manifest
import android.content.Context
import android.net.Uri
import android.provider.ContactsContract
import androidx.annotation.RequiresPermission

private val PROJECTION = arrayOf(
ContactsContract.PhoneLookup.LOOKUP_KEY,
ContactsContract.PhoneLookup.DISPLAY_NAME,
)

data class ContactInfo(
val lookupKey: String,
val displayName: String,
)

@RequiresPermission(Manifest.permission.READ_CONTACTS)
fun findContactsByPhoneNumber(context: Context, number: String) = iterator {
// Same heuristic as InCallUI's PhoneNumberHelper.isUriNumber()
val numberIsSip = number.contains("@") || number.contains("%40")

val uri = ContactsContract.PhoneLookup.ENTERPRISE_CONTENT_FILTER_URI.buildUpon()
.appendPath(number)
.appendQueryParameter(
ContactsContract.PhoneLookup.QUERY_PARAMETER_SIP_ADDRESS,
numberIsSip.toString())
.build()

context.contentResolver.query(uri, PROJECTION, null, null, null)?.use { cursor ->
val indexLookupKey = cursor.getColumnIndexOrThrow(ContactsContract.PhoneLookup.LOOKUP_KEY)
val indexName = cursor.getColumnIndexOrThrow(ContactsContract.PhoneLookup.DISPLAY_NAME)

if (cursor.moveToFirst()) {
yield(ContactInfo(cursor.getString(indexLookupKey), cursor.getString(indexName)))

while (cursor.moveToNext()) {
yield(ContactInfo(cursor.getString(indexLookupKey), cursor.getString(indexName)))
}
}
}
}

@RequiresPermission(Manifest.permission.READ_CONTACTS)
fun findContactByLookupKey(context: Context, lookupKey: String): ContactInfo? {
val uri = ContactsContract.Contacts.CONTENT_LOOKUP_URI.buildUpon()
.appendPath(lookupKey)
.build()

context.contentResolver.query(uri, PROJECTION, null, null, null)?.use { cursor ->
val indexLookupKey = cursor.getColumnIndexOrThrow(ContactsContract.PhoneLookup.LOOKUP_KEY)
val indexName = cursor.getColumnIndexOrThrow(ContactsContract.PhoneLookup.DISPLAY_NAME)

if (cursor.moveToFirst()) {
return ContactInfo(cursor.getString(indexLookupKey), cursor.getString(indexName))
}
}

return null
}

fun getContactByUri(context: Context, uri: Uri): ContactInfo? {
context.contentResolver.query(uri, PROJECTION, null, null, null)?.use { cursor ->
val indexLookupKey = cursor.getColumnIndexOrThrow(ContactsContract.PhoneLookup.LOOKUP_KEY)
val indexName = cursor.getColumnIndexOrThrow(ContactsContract.PhoneLookup.DISPLAY_NAME)

if (cursor.moveToFirst()) {
return ContactInfo(cursor.getString(indexLookupKey), cursor.getString(indexName))
}
}

return null
}
54 changes: 49 additions & 5 deletions app/src/main/java/com/chiller3/bcr/LongClickablePreference.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,66 @@ import android.content.Context
import android.util.AttributeSet
import androidx.preference.Preference
import androidx.preference.PreferenceViewHolder
import androidx.preference.SwitchPreferenceCompat

/**
* A thin shell over [Preference] that allows registering a long click listener.
*/
class LongClickablePreference(context: Context, attrs: AttributeSet?) : Preference(context, attrs) {
class LongClickablePreference : Preference {
var onPreferenceLongClickListener: OnPreferenceLongClickListener? = null

@Suppress("unused")
constructor(context: Context) : super(context)

@Suppress("unused")
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)

@Suppress("unused")
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) :
super(context, attrs, defStyleAttr)

override fun onBindViewHolder(holder: PreferenceViewHolder) {
super.onBindViewHolder(holder)

holder.itemView.setOnLongClickListener {
onPreferenceLongClickListener?.onPreferenceLongClick(this) ?: true
val listener = onPreferenceLongClickListener
if (listener == null) {
holder.itemView.setOnLongClickListener(null)
holder.itemView.isLongClickable = false
} else {
holder.itemView.setOnLongClickListener {
listener.onPreferenceLongClick(this)
}
}
}
}

class LongClickableSwitchPreference : SwitchPreferenceCompat {
var onPreferenceLongClickListener: OnPreferenceLongClickListener? = null

@Suppress("unused")
constructor(context: Context) : super(context)

interface OnPreferenceLongClickListener {
fun onPreferenceLongClick(preference: Preference): Boolean
@Suppress("unused")
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)

@Suppress("unused")
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) :
super(context, attrs, defStyleAttr)
override fun onBindViewHolder(holder: PreferenceViewHolder) {
super.onBindViewHolder(holder)

val listener = onPreferenceLongClickListener
if (listener == null) {
holder.itemView.setOnLongClickListener(null)
holder.itemView.isLongClickable = false
} else {
holder.itemView.setOnLongClickListener {
listener.onPreferenceLongClick(this)
}
}
}
}

interface OnPreferenceLongClickListener {
fun onPreferenceLongClick(preference: Preference): Boolean
}
1 change: 1 addition & 0 deletions app/src/main/java/com/chiller3/bcr/Notifications.kt
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ class Notifications(
setContentTitle(context.getText(titleResId))
if (message != null) {
setContentText(message)
style = Notification.BigTextStyle().bigText(message)
}
setSmallIcon(iconResId)
setContentIntent(pendingIntent)
Expand Down
29 changes: 7 additions & 22 deletions app/src/main/java/com/chiller3/bcr/OutputFilenameGenerator.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.CallLog
import android.provider.ContactsContract
import android.telecom.Call
import android.telecom.PhoneAccount
import android.telephony.PhoneNumberUtils
Expand Down Expand Up @@ -211,7 +210,8 @@ class OutputFilenameGenerator(
return null
}

if (!Permissions.isGranted(context, Manifest.permission.READ_CONTACTS)) {
if (context.checkSelfPermission(Manifest.permission.READ_CONTACTS) !=
PackageManager.PERMISSION_GRANTED) {
Log.w(TAG, "Permissions not granted for looking up contacts")
return null
}
Expand All @@ -224,25 +224,9 @@ class OutputFilenameGenerator(
return null
}

// Same heuristic as InCallUI's PhoneNumberHelper.isUriNumber()
val numberIsSip = number.contains("@") || number.contains("%40")

val uri = ContactsContract.PhoneLookup.ENTERPRISE_CONTENT_FILTER_URI.buildUpon()
.appendPath(number)
.appendQueryParameter(
ContactsContract.PhoneLookup.QUERY_PARAMETER_SIP_ADDRESS,
numberIsSip.toString())
.build()

context.contentResolver.query(
uri, arrayOf(ContactsContract.PhoneLookup.DISPLAY_NAME), null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val index = cursor.getColumnIndex(ContactsContract.PhoneLookup.DISPLAY_NAME)
if (index != -1) {
Log.d(TAG, "Found contact display name via manual lookup")
return cursor.getStringOrNull(index)
}
}
for (contact in findContactsByPhoneNumber(context, number)) {
Log.d(TAG, "Found contact display name via manual lookup")
return contact.displayName
}

Log.d(TAG, "Contact not found via manual lookup")
Expand All @@ -259,7 +243,8 @@ class OutputFilenameGenerator(
return null
}

if (!Permissions.isGranted(context, Manifest.permission.READ_CALL_LOG)) {
if (context.checkSelfPermission(Manifest.permission.READ_CALL_LOG) !=
PackageManager.PERMISSION_GRANTED) {
Log.w(TAG, "Permissions not granted for looking up call log")
return null
}
Expand Down
25 changes: 11 additions & 14 deletions app/src/main/java/com/chiller3/bcr/Permissions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,13 @@ object Permissions {
Manifest.permission.READ_PHONE_STATE,
)

fun isGranted(context: Context, permission: String) =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED

/**
* Check if all permissions required for call recording have been granted.
*/
fun haveRequired(context: Context): Boolean =
REQUIRED.all { isGranted(context, it) }
REQUIRED.all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}

/**
* Check if battery optimizations are currently disabled for this app.
Expand All @@ -46,19 +45,17 @@ object Permissions {
/**
* Get intent for opening the app info page in the system settings.
*/
fun getAppInfoIntent(context: Context): Intent {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data = Uri.fromParts("package", context.packageName, null)
return intent
}
fun getAppInfoIntent(context: Context) = Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null),
)

/**
* Get intent for requesting the disabling of battery optimization for this app.
*/
@SuppressLint("BatteryLife")
fun getInhibitBatteryOptIntent(context: Context): Intent {
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
intent.data = Uri.fromParts("package", context.packageName, null)
return intent
}
fun getInhibitBatteryOptIntent(context: Context) = Intent(
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
Uri.fromParts("package", context.packageName, null),
)
}
Loading

0 comments on commit 0a169a9

Please sign in to comment.