alexstyl / contactstore

A modern, strongly-typed contacts API for Android.
https://alexstyl.github.io/contactstore
Apache License 2.0
440 stars 15 forks source link

Possibility of Supporting ContactsAccountType ? #6

Closed NLLAPPS closed 3 years ago

NLLAPPS commented 3 years ago

See https://developer.android.com/guide/topics/providers/contacts-provider#ContactsFile

https://cs.android.com/android/platform/superproject/+/master:packages/apps/Contacts/src/com/android/contacts/model/account/AccountTypeProvider.java

https://cs.android.com/android/platform/superproject/+/master:packages/apps/Contacts/src/com/android/contacts/model/account/ExternalAccountType.java

https://stackoverflow.com/questions/35992096/how-to-showhandle-contact-details-intents-of-apps

Android has ContactsFile concept where external apps such as WhatsApp can integrate in to Contacts database with deep links to contacts.

It is extremely complicated (at least to me) to get that data in a modern way as you need to read and parse the contacts file for each app.

Newer versions prevent scanning of packages and I am not sure if there is a way to find packages with ContactsFile but, an easier option to add some known package names and only scan contact database for those.

For example I currently do something like

enum class KnownAccountMimeProviders(val packageName: String, val title: String) {

    WHATS_APP("com.whatsapp", "WhatsApp"),
    SIGNAL("org.thoughtcrime.securesms", "Signal"),
    TELEGRAM("org.telegram.messenger", "Telegram"),
    VIBER("com.viber.voip", "Viber"),
    KIK("kik.android", "Kik"),
    DUO("com.google.android.apps.tachyon", "Duo"),
    THREEMA("ch.threema.app", "Threema");

    companion object {
        private val map = values().associateBy(KnownAccountMimeProviders::packageName)
        fun fromPackageName(packageName: String) = map[packageName]

    }

}
    private fun getAllKnownAccountMimes(): List<AccountMime> {
        val startTime = System.currentTimeMillis()
        val items = mutableListOf<AccountMime>()
        val projection = arrayOf(
            ContactsContract.Data.CONTACT_ID,
            ContactsContract.Data._ID,
            ContactsContract.Data.MIMETYPE,
            ContactsContract.RawContacts.ACCOUNT_TYPE,
            ContactsContract.Data.DATA1,
            ContactsContract.Data.DATA2,
            ContactsContract.Data.DATA3,
            //07/07/21 Adding DATA4 and DATA5 as Duo stores titles there as (DATA4 Voice call on Duo) (DATA5 Voice call on Duo +123456989)
            ContactsContract.Data.DATA4,
            ContactsContract.Data.DATA5
        )

        val selection = KnownAccountMimeProviders
            .values()
            //Spaces in the beginning and end are important!!"
            .joinToString(separator = " OR ") { "${ContactsContract.RawContacts.ACCOUNT_TYPE} = ?" }
            .ifEmpty { null }

        val selectionArgs = if (selection != null) {
            KnownAccountMimeProviders.values().map { it.packageName }.toTypedArray()
        } else {
            null
        }

        if (CLog.isDebug()) {
            CLog.log(logTag, "getAllAccountMimes() -> selection: $selection")
        }

        try {
            applicationContext.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, selectionArgs, null)?.use { cursor ->
                while (cursor.moveToNext()) {
                    try {
                        if (CLog.isDebug()) {
                            CLog.log(logTag, "getAllAccountMimes() -> ACCOUNT_TYPE/Package Name: ${cursor.getStringFromColumn(ContactsContract.RawContacts.ACCOUNT_TYPE)}")
                            CLog.log(logTag, "getAllAccountMimes() -> MIMETYPE: ${cursor.getStringFromColumn(ContactsContract.Data.MIMETYPE)}")
                        }

                        val accountPackage = cursor.getStringFromColumn(ContactsContract.RawContacts.ACCOUNT_TYPE).extNullIfEmptyOrValue()
                            ?: continue
                        val mime = cursor.getStringFromColumn(ContactsContract.Data.MIMETYPE).extNullIfEmptyOrValue()
                            ?: continue

                        val id = cursor.getLongFromColumn(ContactsContract.Data._ID)

                        val contactId = cursor.getLongFromColumn(ContactsContract.Data.CONTACT_ID)

                        val accountMimeDataSet = mutableListOf<AccountMimeData>()

                        cursor.getStringFromColumn(ContactsContract.Data.DATA1).extNullIfEmptyOrValue()?.let {
                            accountMimeDataSet.add(AccountMimeData(ContactsContract.Data.DATA1, it))
                        }

                        cursor.getStringFromColumn(ContactsContract.Data.DATA2).extNullIfEmptyOrValue()?.let {
                            accountMimeDataSet.add(AccountMimeData(ContactsContract.Data.DATA2, it))
                        }

                        cursor.getStringFromColumn(ContactsContract.Data.DATA3).extNullIfEmptyOrValue()?.let {
                            accountMimeDataSet.add(AccountMimeData(ContactsContract.Data.DATA3, it))
                        }

                        cursor.getStringFromColumn(ContactsContract.Data.DATA4).extNullIfEmptyOrValue()?.let {
                            accountMimeDataSet.add(AccountMimeData(ContactsContract.Data.DATA4, it))
                        }

                        cursor.getStringFromColumn(ContactsContract.Data.DATA5).extNullIfEmptyOrValue()?.let {
                            accountMimeDataSet.add(AccountMimeData(ContactsContract.Data.DATA5, it))
                        }

                        val accountMime = AccountMimeProvider.provideFromBaseAccountMime(contactId = contactId, id = id, accountType = accountPackage, mimeType = mime, accountMimeDataSet = accountMimeDataSet)

                        if (accountMime !is UnknownAccountMime) {
                            items.add(accountMime)
                        } else {

                        }
                    } catch (e: Exception) {

                    }

                }
            }
        } catch (e: Exception) {

        }

        return items
    }

As you see I rely on DATA2/3etc but that is eror prone and can fail if developer changes it. It can be extracted from ContactsFile and parsed that way.

alexstyl commented 3 years ago

I have a couple of questions:

1) How do you use that AccountMime object afterwards? 2) How do you know which intent to launch for each mimetype?

NLLAPPS commented 3 years ago

PS: Sorry about the formatting. Cannot seem to get it righthere.

It is used to start call or send message or any other action that originating app adds. Mime type provided by the originating app. An app that wants to add its intents to contacts database uses ContactsContract.Data.MIMETYPE to provide the mime type and title etc. and ACCOUNT_TYPE is the package name,

For example, for WhatsApp there are 3

<ContactsSource
  xmlns:android="http://schemas.android.com/apk/res/android">
    <ContactsDataKind android:icon="@mipmap/icon" android:mimeType="vnd.android.cursor.item/vnd.com.whatsapp.profile" android:summaryColumn="data2" android:detailColumn="data3" android:detailSocialSummary="true" />
    <ContactsDataKind android:icon="@mipmap/icon" android:mimeType="vnd.android.cursor.item/vnd.com.whatsapp.voip.call" android:summaryColumn="data2" android:detailColumn="data3" />
    <ContactsDataKind android:icon="@mipmap/icon" android:mimeType="vnd.android.cursor.item/vnd.com.whatsapp.video.call" android:summaryColumn="data2" android:detailColumn="data3" />
</ContactsSource>

summaryColumn and detailSocialSummary indicates columns that holds the title etc

You build intent like below

Uri uri = ContentUris.withAppendedId(Data.CONTENT_URI, 247);
Intent i = new Intent(Intent.ACTION_VIEW, uri);
i.setType("vnd.android.cursor.item/vnd.com.whatsapp.profile");

Here is my provider from my first post

fun provideFromBaseAccountMime(contactId: Long, id: Long, accountType: String, mimeType: String, accountMimeDataSet: List<AccountMimeData>): AccountMime {
        return when (KnownAccountMimeProviders.fromPackageName(accountType)) {
            KnownAccountMimeProviders.WHATS_APP -> WhatsAppAccountMime(contactId, id, accountType, mimeType, accountMimeDataSet)
            KnownAccountMimeProviders.SIGNAL -> SignalAccountMime(contactId, id, accountType, mimeType, accountMimeDataSet)
            KnownAccountMimeProviders.TELEGRAM -> TelegramAccountMime(contactId, id, accountType, mimeType, accountMimeDataSet)
            KnownAccountMimeProviders.VIBER -> ViberAccountMime(contactId, id, accountType, mimeType, accountMimeDataSet)
            KnownAccountMimeProviders.KIK -> KikAccountMime(contactId, id, accountType, mimeType, accountMimeDataSet)
            KnownAccountMimeProviders.DUO -> DuoAccountMime(contactId, id, accountType, mimeType, accountMimeDataSet)
            KnownAccountMimeProviders.THREEMA -> ThreemaAccountMime(contactId, id, accountType, mimeType, accountMimeDataSet)
            null -> UnknownAccountMime(contactId, id, accountType, mimeType, accountMimeDataSet)
        }

    }

AccountMime

abstract class AccountMime(open val contactId: Long, open val id: Long, open val accountType: String, open val mimeType: String, open val accountMimeDataSet: List<AccountMimeData>) : Parcelable {
    private val logTag = "AccountMime"
    abstract fun shouldShow(): Boolean
    abstract fun getTitle(): String?
    abstract fun getIntent(): Intent
    abstract suspend fun getIcon(context: Context): Drawable

    /**
     * For now providing our icon to each AccountMime service we support
     * We should extract it from ContactsDataKind icon
     */
    suspend fun getIconCommon(context: Context): Drawable = coroutineScope {
        try {
            getIconInternal(context)?.let {
                BitmapDrawable(context.resources, it)
            } ?: context.getDrawable(R.drawable.default_account_mime_icon)!!
        } catch (e: PackageManager.NameNotFoundException) {
            CLog.logPrintStackTrace(e)
            context.getDrawable(R.drawable.default_account_mime_icon)!!
        }

    }

    private fun getIconInternal(context: Context): Bitmap? {
        if (CLog.isDebug()) {
            CLog.log(logTag, "getAppIcon")
        }
        try {
            val drawable = context.packageManager.getApplicationIcon(accountType)
            val defaultWidthHeight = context.resources.getDimensionPixelSize(R.dimen.account_mime_icon_size)

            if (drawable is BitmapDrawable) {
                if (CLog.isDebug()) {
                    CLog.log(logTag, "getAppIcon is BitmapDrawable")
                }
                /*
                    createScaledBitmap to make sure we have defaultWidthHeight sized bitmap. Some phones gives very large icons
                    There is no need to use createScaledBitmap if bitmap is AdaptiveIconDrawable because we already create a bitmap that has dimensions of defaultWidthHeight with Bitmap.createBitmap
                 */
                return getBitmapClippedCircle(Bitmap.createScaledBitmap(drawable.bitmap, defaultWidthHeight, defaultWidthHeight, false))
            } else {

                if (ApiLevel.isOreoPlus() && drawable is AdaptiveIconDrawable) {
                    if (CLog.isDebug()) {
                        CLog.log(logTag, "getAppIcon is AdaptiveIconDrawable")
                    }

                    val layerDrawable = LayerDrawable(arrayOf<Drawable>(drawable.background, drawable.foreground))
                    Bitmap.createBitmap(defaultWidthHeight, defaultWidthHeight, Bitmap.Config.ARGB_8888)?.let {
                        val canvas = Canvas(it)
                        layerDrawable.setBounds(0, 0, canvas.width, canvas.height)
                        layerDrawable.draw(canvas)
                        return getBitmapClippedCircle(it)
                    }
                }
            }

        } catch (e: PackageManager.NameNotFoundException) {
            CLog.logPrintStackTrace(e)
        }
        return null
    }

    private fun getBitmapClippedCircle(bitmap: Bitmap): Bitmap {
        val width = bitmap.width
        val height = bitmap.height
        val outputBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
        val scale = 2f
        val path = Path()
        path.addCircle(
                width / scale, height / scale, width.toFloat().coerceAtMost(height / scale), Path.Direction.CCW)
        val canvas = Canvas(outputBitmap)
        canvas.clipPath(path)
        canvas.drawBitmap(bitmap, 0f, 0f, null)
        return outputBitmap
    }

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is AccountMime) return false

        if (id != other.id) return false
        if (accountType != other.accountType) return false
        if (mimeType != other.mimeType) return false

        return true
    }

    override fun hashCode(): Int {
        var result = id.hashCode()
        result = 31 * result + accountType.hashCode()
        result = 31 * result + mimeType.hashCode()
        return result
    }
    companion object{
        const val NO_ID = 0L
    }
}

WhatsAppAccountMime extending AccountMime

@Keep
@Parcelize
data class WhatsAppAccountMime(override val contactId: Long, override val id: Long, override val accountType: String, override val mimeType: String, override val accountMimeDataSet: List<AccountMimeData>)
    : AccountMime(contactId, id, accountType, mimeType, accountMimeDataSet) {
    override suspend fun getIcon(context: Context): Drawable {
        return context.getDrawable(R.drawable.account_mime_whatsapp)!!
    }

    override fun getIntent(): Intent {
        return if (id != NO_ID) {
            val uri = Uri.withAppendedPath(ContactsContract.Data.CONTENT_URI, "$id")
            Intent(Intent.ACTION_VIEW, uri)
        } else {
            val uri = Uri.parse("https://wa.me/${accountMimeDataSet.first().contents}")
            Intent(Intent.ACTION_VIEW, uri).apply {
                setPackage("com.whatsapp")
            }
        }

    }

    /**
     *  TODO DATA3 gets the title such as Voice call +1 7555 .. is not right! We need to extract column name from contacts.xml instead of hardcoding it.
     *  Sample contacts.xml https://cs.android.com/android/platform/superproject/+/master:development/samples/SampleSyncAdapter/res/xml-v14/contacts.xml?q=contacts.xml
     *  Quite involved Sample of how to extract is as DataKind is at https://github.com/LineageOS/android_packages_apps_Dialer/blob/lineage-18.0/java/com/android/contacts/common/model/account/ExternalAccountType.java
     *
     */
    @IgnoredOnParcel
    private val _title = accountMimeDataSet.firstOrNull { it.dataColumn == ContactsContract.Data.DATA3 }?.contents

    /**
     * Only show it to user if it is related to the actual service.
     * Each Service also adds mime types
     * vnd.android.cursor.item/name
     * vnd.android.cursor.item/phone_v2
     *
     * Exchange accounts add
     * vnd.android.cursor.item/note
     * vnd.android.cursor.item/eas_personal
     * etc.
     * We filter those out
     *
     */
    override fun shouldShow(): Boolean {
        return mimeType.contains(accountType)
    }

    override fun getTitle() = _title

    companion object {
        fun buildMessageForNumber(context: Context, cbPhoneNumber: CbPhoneNumber): WhatsAppAccountMime {
            val accountMimeDataSet = listOf(
                    AccountMimeData("data1", cbPhoneNumber.buildWithCountryCodeWithoutPlus()),
                    AccountMimeData("data2", "WhatsApp"),
                    AccountMimeData("data3", context.getString(R.string.message_to, cbPhoneNumber.formatted))

            )
            return WhatsAppAccountMime(NO_ID, NO_ID, "com.whatsapp", "vnd.android.cursor.item/vnd.com.whatsapp.profile", accountMimeDataSet)
        }
    }
}