/simplecallerid

A simple app to demonstrate displaying your apps user information as caller ID in Android's calling app.

Primary LanguageKotlin

Simple Caller ID App

Here at SimpleNexus, we sell software to mortgage companies. These companies can have hundreds of loan officers, and thousands of users. Company admins and especially loan officers wanted a way to differentiate between their app users and spam calls. A recent deploy to our app displays app user data as caller ID. Now when admins and loan officers receive phone calls from users not saved in their phone they can confidentally answer. This caller ID functionality has many more practical applications.

Purpose

This article teaches how to display your users as caller ID data. Developing this in the SimpleNexus app was extremely difficult because of lack of documentation. The most helpful documentation found on this subject was in the Android docs regarding ContactsContract.Directory and ContactsContract.PhoneLookup. But who doesn't like some real life code examples to learn from?

Get Started

Clone or fork the Sample Caller ID App repository. The master branch is the starting point for this tutorial. The callerid branch is the final product of this tutorial.

Get a phone or Google Voice account with a phone number not in your contacts app.

You may need to disable spam filtering on your Android device.

Content Providers

One of the key native elements in Android development is the ContentProvider. We will need to create one to send information to apps that want phone numbers:

class CallerIDProvider : ContentProvider() {
  private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH)
  override fun onCreate(): Boolean {
    context?.let {
      val authority = it.getString(R.string.callerid_authority)
      uriMatcher.addURI(authority, "directories", DIRECTORIES)
    }
    return true
  }
  override fun query(uri: Uri, projection: Array<out String>?, selection: String?, selectionArgs: Array<out String>?, sortOrder: String?): Cursor? {
    when (uriMatcher.match(uri)) {
        DIRECTORIES -> { /* TODO */ }
    }
    return null
  }

  override fun getType(uri: Uri): String? {
    return null
  }

  override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int {
    throw UnsupportedOperationException()
  }

  override fun insert(uri: Uri, values: ContentValues?): Uri? {
    throw UnsupportedOperationException()
  }

  override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<String>?): Int {
    throw UnsupportedOperationException()
  }

  companion object {
    private const val DIRECTORIES = 1
  }
}

In the Android Manifest:

<provider
    android:name=".callerid.CallerIDProvider"
    android:authorities="@string/callerid_authority"
    android:readPermission="android.permission.READ_CONTACTS"
    android:enabled="true"
    android:exported="true">
    <meta-data
      android:name="android.content.ContactDirectory"
      android:value="true"/>
</provider>

If you're like me, you've never seen some of this stuff before. So let me explain what is happening here. The key elements to this content provider are:

  • The query method: All ContentProviders need a query method. Any service asking for information from an app will send a URI matching what kind of data they want, and a projection reference of which columns it wants. The result is a cursor with the data requested.
  • The uriMatcher: As mentioned in the documentation linked above regarding ContactContract.Directory, to display "directory" information (such as phone numbers and email addresses), the app must first display directory account information. This has to be unique from all other apps on the device. More will be explained later on what to return in response to the "directories" uri.
  • The <meta-data>: This is how the system know that your app can provide directory information. Much like an intent. Important also to notice, the provider has "authorities". It is important to use the same authority throughout your content provider, but the value is not as important. For this app my authority was com.example.simplecallerid.callerid.

Directories

The first step is to display directory account information. These values can be arbitrary, they just need to be unique. In the query method, fill a MatrixCursor with the right column data and send it back:

DIRECTORIES -> {
    val label = context?.getString(R.string.app_name) ?: return null
    val cursor = MatrixCursor(projection)
    projection?.map { column ->
      when (column) {
        Directory.ACCOUNT_NAME,
        Directory.ACCOUNT_TYPE,
        Directory.DISPLAY_NAME -> label
        Directory.TYPE_RESOURCE_ID -> R.string.app_name
        Directory.EXPORT_SUPPORT -> Directory.EXPORT_SUPPORT_SAME_ACCOUNT_ONLY
        Directory.SHORTCUT_SUPPORT -> Directory.SHORTCUT_SUPPORT_NONE
        else -> null
      }
    }?.let { cursor.addRow(it) }
    return cursor
}

Now the system knows your app can provide a few kinds of data. It will send URI's like contacts/lookup/*, phone_lookup/*, data/emails/lookup/* etc.

Phone Lookup: Caller ID

The important URI for a caller ID app is phone_lookup/*. Add the following to your ContentProvider:

// In the onCreate
uriMatcher.addURI(authority, "phone_lookup/*", PHONE_LOOKUP)

// In the query when block
PHONE_LOOKUP -> { /* TODO */ }

// In the companion object
private const val PHONE_LOOKUP = 2

Let's get the data:

// In the UserDao
@Query("SELECT * FROM user_table")
suspend fun getUsersList(): List<User>

// In the UserRpository
suspend fun getUser(phoneNumber: String) = withContext(Dispatchers.Default) {
  val users = userDao.getUsersList()
  users.firstOrNull { it.hasPhone(phoneNumber) }
}

// In the CallerIDProvider
private var userRepository: UserRepository? = null

// In the onCreate context let block
val userDao = UserDatabase.getDatabase(it).userDao()
userRepository = UserRepository(userDao)

Now all that's left to do is return a cursor with your contact data:

PHONE_LOOKUP -> {
  userRepository?.let { userRepo ->
    val phoneNumber = uri.pathSegments[1]
    val cursor = MatrixCursor(projection)
    val user = runBlocking(Dispatchers.IO) { userRepo.getUser(phoneNumber) }
    user?.let { u ->
      projection?.map { column ->
        when (column) {
          PhoneLookup._ID -> -1
          PhoneLookup.DISPLAY_NAME -> u.fullName
          PhoneLookup.LABEL -> u.phoneLabel
          else -> null
        }
      }?.let { cursor.addRow(it) }
    }
    return cursor
  }
}

You should now be able to run your app on your phone. Add a user with a phone number you don't have saved to a contact on your phone. Call yourself from that phone number and you should see something like this:

Caller ID Photos

This section teaches how to add photos to the data return in caller ID. Minor edits are going to be made to the CallerIDProvider:

First, a little setup is required:

// At the class level
private lateinit var authorityUri: Uri

// In the onCreate() after assigning val authority
authorityUri = Uri.parse("content://$authority")

// In the companion object
private const val PRIMARY_PHOTO = 3
private const val PRIMARY_PHOTO_URI = "photo/primary_photo"

Now provide support for the photo/primary_photo query. Update the uriMatcher:

uriMatcher.apply {
    addURI(authority, "directories", DIRECTORIES)
    addURI(authority, "phone_lookup/*", PHONE_LOOKUP)
    addURI(authority, PRIMARY_PHOTO_URI, PRIMARY_PHOTO)
}

Then update the query method:

PHONE_LOOKUP -> {
    userRepository?.let { userRepo ->
        val phoneNumber = uri.pathSegments[1]
        val cursor = MatrixCursor(projection)
        val user = runBlocking(Dispatchers.IO) { userRepo.getUser(phoneNumber) }
        user?.let { u ->
            projection?.map { column ->
                when (column) {
                    PhoneLookup._ID -> -1
                    PhoneLookup.DISPLAY_NAME -> u.fullName
                    PhoneLookup.LABEL -> u.phoneLabel
                    PhoneLookup.PHOTO_THUMBNAIL_URI,
                    PhoneLookup.PHOTO_URI -> Uri.withAppendedPath(authorityUri, PRIMARY_PHOTO_URI)
                    else -> null
                }
            }?.let { cursor.addRow(it) }
        }
        return cursor
    }
}

What this does is tells the provider to start looking for an asset when PRIMARY_PHOTO_URI is requested. The provider will look in openAssetFile:

override fun openAssetFile(uri: Uri, mode: String): AssetFileDescriptor? {
    return when(uriMatcher.match(uri)) {
        PRIMARY_PHOTO -> context?.resources?.openRawResourceFd(R.raw.phineas)
        else -> null
    }
}

This solution provides a photo that is obviously hard-coded, but hopefully this gets you pointed in the right direction to implement it your way. As it stands now, it should look like this:

Play around with it, have fun, and think of more ways this can be used.

Credit: