SuriDevs Logo
Android Backup Strategies: Implementing Contacts, SMS, and App Data Backup

Android Backup Strategies: Implementing Contacts, SMS, and App Data Backup

By Sagar Maiyad  Oct 18, 2025

"Can you add a backup feature?"

Simple request. Should take a day, maybe two. That's what I told myself before spending a week fighting with Android's content providers, permissions, and the realization that contacts are stored across like five different tables for some reason.

Content Providers: The Part Nobody Warns You About

Android stores user data (contacts, SMS, call logs) in content providers. You query them through ContentResolver:

val cursor = contentResolver.query(
    ContactsContract.Contacts.CONTENT_URI,
    null,
    null,
    null,
    null
)

Looks simple. It's not. Every content provider has its own schema. They're all different. The documentation is... let's say "sparse."

The Contact Table Nightmare

I assumed contacts were stored in one table. One row per contact. Makes sense, right?

Nope. A single contact can have data scattered across multiple tables - phones in one place, emails in another, organization info somewhere else. You have to query each separately and stitch them together.

data class BackupContact(
    val displayName: String,
    val phones: List<Phone>,
    val emails: List<Email>,
    val organization: String?,
    val note: String?
)

data class Phone(val number: String, val type: Int)
data class Email(val address: String, val type: Int)

class ContactsBackupManager(
    private val contentResolver: ContentResolver
) {
    @RequiresPermission(Manifest.permission.READ_CONTACTS)
    fun getAllContacts(): List<BackupContact> {
        val contacts = mutableListOf<BackupContact>()

        val cursor = contentResolver.query(
            ContactsContract.Contacts.CONTENT_URI,
            arrayOf(
                ContactsContract.Contacts._ID,
                ContactsContract.Contacts.DISPLAY_NAME_PRIMARY
            ),
            null, null,
            ContactsContract.Contacts.DISPLAY_NAME_PRIMARY
        )

        cursor?.use {
            while (it.moveToNext()) {
                val id = it.getString(0)
                val name = it.getString(1) ?: continue

                contacts.add(
                    BackupContact(
                        displayName = name,
                        phones = getPhones(id),
                        emails = getEmails(id),
                        organization = getOrganization(id),
                        note = getNote(id)
                    )
                )
            }
        }

        return contacts
    }

    private fun getPhones(contactId: String): List<Phone> {
        val phones = mutableListOf<Phone>()

        val cursor = contentResolver.query(
            ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
            arrayOf(
                ContactsContract.CommonDataKinds.Phone.NUMBER,
                ContactsContract.CommonDataKinds.Phone.TYPE
            ),
            "${ContactsContract.CommonDataKinds.Phone.CONTACT_ID} = ?",
            arrayOf(contactId),
            null
        )

        cursor?.use {
            while (it.moveToNext()) {
                phones.add(Phone(it.getString(0) ?: "", it.getInt(1)))
            }
        }

        return phones
    }

    private fun getEmails(contactId: String): List<Email> {
        val emails = mutableListOf<Email>()

        val cursor = contentResolver.query(
            ContactsContract.CommonDataKinds.Email.CONTENT_URI,
            arrayOf(
                ContactsContract.CommonDataKinds.Email.ADDRESS,
                ContactsContract.CommonDataKinds.Email.TYPE
            ),
            "${ContactsContract.CommonDataKinds.Email.CONTACT_ID} = ?",
            arrayOf(contactId),
            null
        )

        cursor?.use {
            while (it.moveToNext()) {
                emails.add(Email(it.getString(0) ?: "", it.getInt(1)))
            }
        }

        return emails
    }

    private fun getOrganization(contactId: String): String? {
        val cursor = contentResolver.query(
            ContactsContract.Data.CONTENT_URI,
            arrayOf(ContactsContract.CommonDataKinds.Organization.COMPANY),
            "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?",
            arrayOf(contactId, ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE),
            null
        )

        return cursor?.use { if (it.moveToFirst()) it.getString(0) else null }
    }

    private fun getNote(contactId: String): String? {
        val cursor = contentResolver.query(
            ContactsContract.Data.CONTENT_URI,
            arrayOf(ContactsContract.CommonDataKinds.Note.NOTE),
            "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?",
            arrayOf(contactId, ContactsContract.CommonDataKinds.Note.CONTENT_ITEM_TYPE),
            null
        )

        return cursor?.use { if (it.moveToFirst()) it.getString(0) else null }
    }
}

That's a lot of code just to read contacts. And I haven't even shown restore yet.

Restoring Is Even Worse

Reading contacts is annoying. Writing them back is painful. You need batch operations to maintain the relationships between the contact and its data:

@RequiresPermission(Manifest.permission.WRITE_CONTACTS)
fun restoreContact(contact: BackupContact): Boolean {
    val operations = ArrayList<ContentProviderOperation>()

    operations.add(
        ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI)
            .withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, null)
            .withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null)
            .build()
    )

    operations.add(
        ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
            .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
            .withValue(ContactsContract.Data.MIMETYPE,
                ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
            .withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME,
                contact.displayName)
            .build()
    )

    contact.phones.forEach { phone ->
        operations.add(
            ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
                .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
                .withValue(ContactsContract.Data.MIMETYPE,
                    ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
                .withValue(ContactsContract.CommonDataKinds.Phone.NUMBER, phone.number)
                .withValue(ContactsContract.CommonDataKinds.Phone.TYPE, phone.type)
                .build()
        )
    }

    contact.emails.forEach { email ->
        operations.add(
            ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
                .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
                .withValue(ContactsContract.Data.MIMETYPE,
                    ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE)
                .withValue(ContactsContract.CommonDataKinds.Email.ADDRESS, email.address)
                .withValue(ContactsContract.CommonDataKinds.Email.TYPE, email.type)
                .build()
        )
    }

    return try {
        contentResolver.applyBatch(ContactsContract.AUTHORITY, operations)
        true
    } catch (e: Exception) {
        false
    }
}

That withValueBackReference thing? It lets you reference the ID of an operation you haven't executed yet. Took me way too long to figure that out.

SMS Backup: Simpler, But With a Catch

SMS is more straightforward to read:

data class BackupSms(
    val address: String,
    val body: String,
    val date: Long,
    val type: Int,
    val read: Boolean
)

class SmsBackupManager(private val contentResolver: ContentResolver) {

    @RequiresPermission(Manifest.permission.READ_SMS)
    fun getAllSms(): List<BackupSms> {
        val messages = mutableListOf<BackupSms>()

        val cursor = contentResolver.query(
            Telephony.Sms.CONTENT_URI,
            arrayOf(
                Telephony.Sms.ADDRESS,
                Telephony.Sms.BODY,
                Telephony.Sms.DATE,
                Telephony.Sms.TYPE,
                Telephony.Sms.READ
            ),
            null, null,
            "${Telephony.Sms.DATE} DESC"
        )

        cursor?.use {
            while (it.moveToNext()) {
                messages.add(
                    BackupSms(
                        address = it.getString(0) ?: "",
                        body = it.getString(1) ?: "",
                        date = it.getLong(2),
                        type = it.getInt(3),
                        read = it.getInt(4) == 1
                    )
                )
            }
        }

        return messages
    }
}

The catch? Since Android 4.4, only the default SMS app can write messages. Your backup app probably isn't the default SMS app. So you can backup SMS fine, but restoring? That's a whole different headache involving becoming the default SMS app temporarily.

Call Logs: Finally, Something Reasonable

Call logs actually work how you'd expect:

data class BackupCallLog(
    val number: String,
    val name: String?,
    val date: Long,
    val duration: Long,
    val type: Int
)

class CallLogBackupManager(private val contentResolver: ContentResolver) {

    @RequiresPermission(Manifest.permission.READ_CALL_LOG)
    fun getAllCallLogs(): List<BackupCallLog> {
        val logs = mutableListOf<BackupCallLog>()

        val cursor = contentResolver.query(
            CallLog.Calls.CONTENT_URI,
            arrayOf(
                CallLog.Calls.NUMBER,
                CallLog.Calls.CACHED_NAME,
                CallLog.Calls.DATE,
                CallLog.Calls.DURATION,
                CallLog.Calls.TYPE
            ),
            null, null,
            "${CallLog.Calls.DATE} DESC"
        )

        cursor?.use {
            while (it.moveToNext()) {
                logs.add(
                    BackupCallLog(
                        number = it.getString(0) ?: "",
                        name = it.getString(1),
                        date = it.getLong(2),
                        duration = it.getLong(3),
                        type = it.getInt(4)
                    )
                )
            }
        }

        return logs
    }
}

Type 1 is incoming, 2 is outgoing, 3 is missed. At least that's documented somewhere.

Scheduling Backups That Actually Run

Users want automatic daily backups. WorkManager handles this, but I messed up my first attempt by not handling retries properly:

class BackupWorker(
    context: Context,
    params: WorkerParameters
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {
        return try {
            val backupManager = BackupManager(applicationContext)
            backupManager.performFullBackup()
            Result.success()
        } catch (e: Exception) {
            if (runAttemptCount < 3) Result.retry() else Result.failure()
        }
    }

    companion object {
        fun scheduleDaily(context: Context) {
            val request = PeriodicWorkRequestBuilder<BackupWorker>(1, TimeUnit.DAYS)
                .setConstraints(
                    Constraints.Builder()
                        .setRequiresBatteryNotLow(true)
                        .build()
                )
                .build()

            WorkManager.getInstance(context)
                .enqueueUniquePeriodicWork(
                    "daily_backup",
                    ExistingPeriodicWorkPolicy.KEEP,
                    request
                )
        }
    }
}

The battery constraint is important. Users will blame your app if their phone dies overnight because of backup jobs running during low battery.

The Partial Failure Problem

Backup can fail for one data type but succeed for others. Users denied SMS permission but granted contacts? You should still backup what you can:

sealed class BackupResult {
    data class Success(val path: String, val itemCount: Int) : BackupResult()
    data class PartialSuccess(val path: String, val errors: List<String>) : BackupResult()
    data class Failure(val error: String) : BackupResult()
}

suspend fun performBackup(): BackupResult {
    val errors = mutableListOf<String>()
    var contacts: List<BackupContact> = emptyList()
    var messages: List<BackupSms> = emptyList()
    var callLogs: List<BackupCallLog> = emptyList()

    try {
        contacts = contactsBackupManager.getAllContacts()
    } catch (e: SecurityException) {
        errors.add("Contacts: permission denied")
    }

    try {
        messages = smsBackupManager.getAllSms()
    } catch (e: SecurityException) {
        errors.add("SMS: permission denied")
    }

    try {
        callLogs = callLogBackupManager.getAllCallLogs()
    } catch (e: SecurityException) {
        errors.add("Call logs: permission denied")
    }

    val backup = FullBackup(
        contacts = contacts,
        messages = messages,
        callLogs = callLogs
    )

    val totalItems = contacts.size + messages.size + callLogs.size

    return try {
        val file = saveBackup(backup)
        if (errors.isEmpty()) {
            BackupResult.Success(file.absolutePath, totalItems)
        } else {
            BackupResult.PartialSuccess(file.absolutePath, errors)
        }
    } catch (e: IOException) {
        BackupResult.Failure(e.message ?: "Save failed")
    }
}

First version of my code would just crash if any permission was missing. Users were not happy.

Where I Stored Things Wrong

I initially saved backups to cache directory. Seemed fine during testing. Then I got reports of backups disappearing. Turns out Android clears cache when storage is low. Lost backup files are worse than no backup at all.

class BackupStorageManager(private val context: Context) {

    // This is what I use now - survives cache clearing
    fun getInternalBackupDir(): File {
        val dir = File(context.filesDir, "backups")
        if (!dir.exists()) dir.mkdirs()
        return dir
    }

    // For user-accessible backups
    fun getExternalBackupDir(): File? {
        return context.getExternalFilesDir("backups")?.also {
            if (!it.exists()) it.mkdirs()
        }
    }
}

What Bit Me

Forgot cursor cleanup once. Memory leak in production. Always use cursor.use {}.

Ran content provider queries on the main thread "just for testing." Left it in a release build. UI froze for users with thousands of contacts.

Didn't add version numbers to my backup format. Changed the format later. Couldn't restore old backups. Now every backup has a version field.

Restored the same backup twice during testing. Created duplicate contacts. Now I check for duplicates before restore. Should've done that from the start.

Looking Back

Backup is one of those features nobody thinks about until they need it. And when they need it, it better work perfectly.

The content provider APIs are verbose and kind of annoying, but they're predictable once you understand them. Wrap them in clean abstractions and don't do what I did with the cache directory.

Android Backup ContentResolver Kotlin

Author

Sagar Maiyad
Written By
Sagar Maiyad

Sagar Maiyad - Android developer specializing in Kotlin, Jetpack Compose, and modern Android architecture. Sharing practical tutorials and real-world development insights.

View All Posts →

Latest Post

Latest Tags