Skip to content

Commit

Permalink
Add support for using contact groups in record rules
Browse files Browse the repository at this point in the history
This is unfortunately a relatively big change because unlike single
contacts, Android does not provide a standard intent for apps to select
a contact group. We have to implement our own contact group picker UI.

Following our current convention, the most specific rule wins as a side
effect of how rules are sorted. If a call matches both a contact and a
contact group, the contact rule has higher precedence.

Fixes: #536

Signed-off-by: Andrew Gunnerson <accounts+github@chiller3.com>
  • Loading branch information
chenxiaolong committed Sep 22, 2024
1 parent 45f113c commit 48cb283
Show file tree
Hide file tree
Showing 34 changed files with 746 additions and 314 deletions.
4 changes: 4 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@
</intent-filter>
</activity>

<activity
android:name=".rule.PickContactGroupActivity"
android:exported="false" />

<activity
android:name=".rule.RecordRulesActivity"
android:exported="false" />
Expand Down
131 changes: 130 additions & 1 deletion app/src/main/java/com/chiller3/bcr/Contact.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,48 @@ package com.chiller3.bcr
import android.Manifest
import android.content.Context
import android.net.Uri
import android.os.Parcelable
import android.provider.ContactsContract
import androidx.annotation.RequiresPermission
import androidx.core.database.getLongOrNull
import androidx.core.database.getStringOrNull
import com.chiller3.bcr.output.PhoneNumber
import kotlinx.parcelize.Parcelize

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

private val PROJECTION_GROUP_MEMBERSHIP = arrayOf(
ContactsContract.CommonDataKinds.GroupMembership.GROUP_ROW_ID,
ContactsContract.CommonDataKinds.GroupMembership.GROUP_SOURCE_ID,
)

private val CONTACT_GROUP_PROJECTION = arrayOf(
ContactsContract.Groups._ID,
ContactsContract.Groups.SOURCE_ID,
ContactsContract.Groups.TITLE,
)

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

sealed interface GroupLookup {
data class RowId(val id: Long): GroupLookup

data class SourceId(val id: String): GroupLookup
}

@Parcelize
data class ContactGroupInfo(
val rowId: Long,
val sourceId: String,
val title: String,
) : Parcelable

@RequiresPermission(Manifest.permission.READ_CONTACTS)
fun findContactsByPhoneNumber(context: Context, number: PhoneNumber): Iterator<ContactInfo> {
val rawNumber = number.toString()
Expand All @@ -35,7 +63,7 @@ fun findContactsByPhoneNumber(context: Context, number: PhoneNumber): Iterator<C
}

@RequiresPermission(Manifest.permission.READ_CONTACTS)
fun findContactByLookupKey(context: Context, lookupKey: String): ContactInfo? {
fun getContactByLookupKey(context: Context, lookupKey: String): ContactInfo? {
val uri = ContactsContract.Contacts.CONTENT_LOOKUP_URI.buildUpon()
.appendPath(lookupKey)
.build()
Expand All @@ -57,3 +85,104 @@ fun findContactsByUri(context: Context, uri: Uri) = iterator {
}
}
}

@RequiresPermission(Manifest.permission.READ_CONTACTS)
fun getContactGroupMemberships(context: Context, lookupKey: String) = iterator {
val selection = buildString {
append(ContactsContract.CommonDataKinds.GroupMembership.LOOKUP_KEY)
append(" = ? AND ")
append(ContactsContract.CommonDataKinds.GroupMembership.MIMETYPE)
append(" = ?")
}
val selectionArgs = arrayOf(
lookupKey,
ContactsContract.CommonDataKinds.GroupMembership.CONTENT_ITEM_TYPE,
)

context.contentResolver.query(
ContactsContract.Data.CONTENT_URI,
PROJECTION_GROUP_MEMBERSHIP,
selection,
selectionArgs,
null,
)?.let { cursor ->
val indexRowId = cursor.getColumnIndexOrThrow(
ContactsContract.CommonDataKinds.GroupMembership.GROUP_ROW_ID)
val indexSourceId = cursor.getColumnIndexOrThrow(
ContactsContract.CommonDataKinds.GroupMembership.GROUP_SOURCE_ID)

if (cursor.moveToFirst()) {
cursor.getLongOrNull(indexRowId)?.let {
yield(GroupLookup.RowId(it))
}
cursor.getStringOrNull(indexSourceId)?.let {
yield(GroupLookup.SourceId(it))
}

while (cursor.moveToNext()) {
cursor.getLongOrNull(indexRowId)?.let {
yield(GroupLookup.RowId(it))
}
cursor.getStringOrNull(indexSourceId)?.let {
yield(GroupLookup.SourceId(it))
}
}
}
}
}

@RequiresPermission(Manifest.permission.READ_CONTACTS)
fun getContactGroupById(context: Context, id: GroupLookup): ContactGroupInfo? {
var selectionArgs: Array<String>? = null
val selection = buildString {
when (id) {
is GroupLookup.RowId -> {
append(ContactsContract.Groups._ID)
append(" = ")
append(id.id)
}
is GroupLookup.SourceId -> {
append(ContactsContract.Groups.SOURCE_ID)
append(" = ?")

selectionArgs = arrayOf(id.id)
}
}
}

return findContactGroups(context, selection, selectionArgs).asSequence().firstOrNull()
}

fun findContactGroups(
context: Context,
selection: String? = null,
selectionArgs: Array<String>? = null,
) = iterator {
context.contentResolver.query(
ContactsContract.Groups.CONTENT_URI,
CONTACT_GROUP_PROJECTION,
selection,
selectionArgs,
null,
)?.use { cursor ->
val indexRowId = cursor.getColumnIndexOrThrow(ContactsContract.Groups._ID)
val indexSourceId = cursor.getColumnIndexOrThrow(ContactsContract.Groups.SOURCE_ID)
val indexTitle = cursor.getColumnIndexOrThrow(ContactsContract.Groups.TITLE)

if (cursor.moveToFirst()) {
yield(ContactGroupInfo(
cursor.getLong(indexRowId),
cursor.getString(indexSourceId),
cursor.getString(indexTitle),
))

while (cursor.moveToNext()) {
yield(ContactGroupInfo(
cursor.getLong(indexRowId),
cursor.getString(indexSourceId),
cursor.getString(indexTitle),
))
}
}
}
}
65 changes: 65 additions & 0 deletions app/src/main/java/com/chiller3/bcr/PreferenceBaseActivity.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.chiller3.bcr

import android.os.Bundle
import android.view.MenuItem
import android.view.ViewGroup
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams
import androidx.fragment.app.Fragment
import com.chiller3.bcr.databinding.SettingsActivityBinding

abstract class PreferenceBaseActivity : AppCompatActivity() {
protected abstract val titleResId: Int

protected abstract val showUpButton: Boolean

protected abstract fun createFragment(): Fragment

override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)

val binding = SettingsActivityBinding.inflate(layoutInflater)
setContentView(binding.root)

if (savedInstanceState == null) {
supportFragmentManager
.beginTransaction()
.replace(R.id.settings, createFragment())
.commit()
}

ViewCompat.setOnApplyWindowInsetsListener(binding.toolbar) { v, windowInsets ->
val insets = windowInsets.getInsets(
WindowInsetsCompat.Type.systemBars()
or WindowInsetsCompat.Type.displayCutout()
)

v.updateLayoutParams<ViewGroup.MarginLayoutParams> {
leftMargin = insets.left
topMargin = insets.top
rightMargin = insets.right
}

WindowInsetsCompat.CONSUMED
}

setSupportActionBar(binding.toolbar)
supportActionBar!!.setDisplayHomeAsUpEnabled(showUpButton)

setTitle(titleResId)
}

override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
android.R.id.home -> {
onBackPressedDispatcher.onBackPressed()
true
}
else -> super.onOptionsItemSelected(item)
}
}
}
43 changes: 43 additions & 0 deletions app/src/main/java/com/chiller3/bcr/PreferenceBaseFragment.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.chiller3.bcr

import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.preference.PreferenceFragmentCompat
import androidx.recyclerview.widget.RecyclerView

abstract class PreferenceBaseFragment : PreferenceFragmentCompat() {
override fun onCreateRecyclerView(
inflater: LayoutInflater,
parent: ViewGroup,
savedInstanceState: Bundle?
): RecyclerView {
val view = super.onCreateRecyclerView(inflater, parent, savedInstanceState)

view.clipToPadding = false

ViewCompat.setOnApplyWindowInsetsListener(view) { v, windowInsets ->
val insets = windowInsets.getInsets(
WindowInsetsCompat.Type.systemBars()
or WindowInsetsCompat.Type.displayCutout()
)

// This is a little bit ugly in landscape mode because the divider lines for categories
// extend into the inset area. However, it's worth applying the left/right padding here
// anyway because it allows the inset area to be used for scrolling instead of just
// being a useless dead zone.
v.updatePadding(
bottom = insets.bottom,
left = insets.left,
right = insets.right,
)

WindowInsetsCompat.CONSUMED
}

return view
}
}
4 changes: 2 additions & 2 deletions app/src/main/java/com/chiller3/bcr/Preferences.kt
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ class Preferences(initialContext: Context) {
private const val PREF_FORCE_DIRECT_BOOT = "force_direct_boot"
const val PREF_MIGRATE_DIRECT_BOOT = "migrate_direct_boot"

const val PREF_ADD_RULE = "add_rule"
const val PREF_RULE_PREFIX = "rule_"
const val PREF_ADD_CONTACT_RULE = "add_contact_rule"
const val PREF_ADD_CONTACT_GROUP_RULE = "add_contact_group_rule"

// Not associated with a UI preference
private const val PREF_DEBUG_MODE = "debug_mode"
Expand Down
25 changes: 25 additions & 0 deletions app/src/main/java/com/chiller3/bcr/rule/PickContactGroup.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.chiller3.bcr.rule

import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.activity.result.contract.ActivityResultContract
import androidx.core.content.IntentCompat
import com.chiller3.bcr.ContactGroupInfo

/** Launch our own picker for contact groups. There is no standard Android component for this. */
class PickContactGroup : ActivityResultContract<Void?, ContactGroupInfo?>() {
override fun createIntent(context: Context, input: Void?): Intent {
return Intent(context, PickContactGroupActivity::class.java)
}

override fun parseResult(resultCode: Int, intent: Intent?): ContactGroupInfo? {
return intent.takeIf { resultCode == Activity.RESULT_OK }?.let {
IntentCompat.getParcelableExtra(
it,
PickContactGroupActivity.RESULT_CONTACT_GROUP,
ContactGroupInfo::class.java,
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.chiller3.bcr.rule

import androidx.fragment.app.Fragment
import com.chiller3.bcr.PreferenceBaseActivity
import com.chiller3.bcr.R

class PickContactGroupActivity : PreferenceBaseActivity() {
override val titleResId: Int = R.string.pick_contact_group_title

override val showUpButton: Boolean = true

override fun createFragment(): Fragment = PickContactGroupFragment()

companion object {
const val RESULT_CONTACT_GROUP = "contact_group"
}
}
Loading

0 comments on commit 48cb283

Please sign in to comment.