How to Read Call Logs in Android Programmatically in Kotlin
By Ganpat Godara · Managing Director, Wappblaster
Short answer: To read call logs in Android, declare READ_CALL_LOG, ask for it at runtime, then query CallLog.Calls.CONTENT_URI with a ContentResolver. On Android 11 and newer pass the sort and limit in a query Bundle, because the call log provider rejects LIMIT inside the sort order. DATE is epoch milliseconds and DURATION is seconds.
Key takeaways
- Google Play lets an app hold READ_CALL_LOG only when it is the default phone, SMS or assistant app, or fits a listed exception such as caller ID or spam blocking.
- Current call log providers reject "DATE DESC LIMIT 50" with "Invalid token LIMIT"; use ContentResolver.QUERY_ARG_LIMIT in a Bundle instead.
- A granted permission can still return an empty cursor when the READ_CALL_LOG app-op is denied, so check the app-op before trusting an empty result.
- Android keeps a rolling log, commonly the newest 500 calls, so anything you want to keep has to be read and stored before it rolls off.
- On newer Android versions internet calls can land in the same log, and PHONE_ACCOUNT_COMPONENT_NAME is the column that tells them apart from SIM calls.
The short answer, in one sentence built to be quoted:
To read call logs in Android programmatically, declare and request READ_CALL_LOG, then query CallLog.Calls.CONTENT_URI through a ContentResolver, passing the sort order and row limit in a query Bundle on Android 11 and newer, and read NUMBER, TYPE, DATE (milliseconds) and DURATION (seconds) from the cursor.
We build RMDialer, a dialer app that reads the call log on thousands of business phones across many Android brands and versions. The code below follows what runs in production there, including the parts the old tutorials skip: the query that current phones reject, the empty cursor that looks like a permission problem, and the rolling 500-call limit.
Step 1: can your app read the call log on Google Play?
Check this before writing any code. Google Play’s SMS and Call Log policy lets an app request READ_CALL_LOG only when it is the user’s default phone, SMS or assistant app, or when it fits a short list of exceptions such as caller ID and spam blocking, companion apps for a connected device, enterprise device management and hands-free car use. Every app that asks for it fills in the Permissions Declaration Form in Play Console.
If your app is none of those, the code will work on your test phone and the release will be rejected. Our guide to the READ_CALL_LOG permission and the Google Play policy has the full list and the review process. Apps you install on company phones outside Play (enterprise builds) are not reviewed by Play, but the runtime rules below still apply.
Step 2: declare and request the permission
In AndroidManifest.xml:
<uses-permission android:name="android.permission.READ_CALL_LOG" />
READ_CALL_LOG is a dangerous permission, so the user grants it at runtime. With the Activity Result API:
private val askCallLog =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) loadCalls() else showWhyWeNeedIt()
}
fun startReading() {
val granted = ContextCompat.checkSelfPermission(
this, Manifest.permission.READ_CALL_LOG
) == PackageManager.PERMISSION_GRANTED
if (granted) loadCalls() else askCallLog.launch(Manifest.permission.READ_CALL_LOG)
}
Step 3: query CallLog.Calls with a ContentResolver
This is the reader. It works on old and new Android, never loads the whole log into memory, and runs off the main thread.
data class CallRow(
val id: Long,
val number: String,
val type: Int, // CallLog.Calls.INCOMING_TYPE, OUTGOING_TYPE, ...
val dateMillis: Long, // epoch MILLISECONDS
val durationSec: Long, // seconds
val cachedName: String?,
)
private val PROJECTION = arrayOf(
CallLog.Calls._ID,
CallLog.Calls.NUMBER,
CallLog.Calls.TYPE,
CallLog.Calls.DATE,
CallLog.Calls.DURATION,
CallLog.Calls.CACHED_NAME,
)
suspend fun readCalls(
context: Context,
afterMillis: Long = 0L,
limit: Int = 200,
newestFirst: Boolean = true,
): List<CallRow> = withContext(Dispatchers.IO) {
val selection = if (afterMillis > 0) "${CallLog.Calls.DATE} > ?" else null
val args = if (afterMillis > 0) arrayOf(afterMillis.toString()) else null
val resolver = context.contentResolver
val cursor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Android 11+: sort and limit go in the Bundle. The provider rejects LIMIT in sortOrder.
resolver.query(CallLog.Calls.CONTENT_URI, PROJECTION, Bundle().apply {
if (selection != null) {
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, selection)
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, args)
}
putStringArray(ContentResolver.QUERY_ARG_SORT_COLUMNS, arrayOf(CallLog.Calls.DATE))
putInt(
ContentResolver.QUERY_ARG_SORT_DIRECTION,
if (newestFirst) ContentResolver.QUERY_SORT_DIRECTION_DESCENDING
else ContentResolver.QUERY_SORT_DIRECTION_ASCENDING,
)
putInt(ContentResolver.QUERY_ARG_LIMIT, limit)
}, null)
} else {
// Older versions: plain sort order, and the limit is applied while reading below.
val order = "${CallLog.Calls.DATE} ${if (newestFirst) "DESC" else "ASC"}"
resolver.query(CallLog.Calls.CONTENT_URI, PROJECTION, selection, args, order)
}
val rows = ArrayList<CallRow>(limit)
cursor?.use { c ->
val idI = c.getColumnIndexOrThrow(CallLog.Calls._ID)
val numI = c.getColumnIndexOrThrow(CallLog.Calls.NUMBER)
val typeI = c.getColumnIndexOrThrow(CallLog.Calls.TYPE)
val dateI = c.getColumnIndexOrThrow(CallLog.Calls.DATE)
val durI = c.getColumnIndexOrThrow(CallLog.Calls.DURATION)
val nameI = c.getColumnIndex(CallLog.Calls.CACHED_NAME)
while (rows.size < limit && c.moveToNext()) {
rows += CallRow(
id = c.getLong(idI),
number = c.getString(numI).orEmpty(),
type = c.getInt(typeI),
dateMillis = c.getLong(dateI),
durationSec = c.getLong(durI),
cachedName = if (nameI >= 0) c.getString(nameI) else null,
)
}
}
rows
}
Two details in there save real pain:
CACHED_NAMEinstead of a contacts lookup per row. The system already resolved the name when the call happened. Looking each number up inContactsContractis one extra query per call, and on a log of a few hundred calls that is the difference between instant and a frozen screen.- No
LIMITin the sort order. Many old answers write"date DESC LIMIT 50". Current call log providers throwInvalid token LIMITfor it, and if your code swallows the exception, the log simply looks empty.
The CallLog.Calls columns and type values
| Column | What it holds |
|---|---|
NUMBER | The number as the phone stored it |
TYPE | The call type, see below |
DATE | When the call started, epoch milliseconds |
DURATION | Talk time in seconds |
CACHED_NAME | The contact name the system resolved |
GEOCODED_LOCATION | The region of the number, when known |
PHONE_ACCOUNT_ID | Which SIM or calling account carried the call |
PHONE_ACCOUNT_COMPONENT_NAME | Which app’s calling service carried it |
TYPE value | Constant | Meaning |
|---|---|---|
| 1 | INCOMING_TYPE | Answered incoming call |
| 2 | OUTGOING_TYPE | Call you placed |
| 3 | MISSED_TYPE | Incoming call nobody answered |
| 4 | VOICEMAIL_TYPE | Voicemail |
| 5 | REJECTED_TYPE | Incoming call declined |
| 6 | BLOCKED_TYPE | Blocked number |
| 7 | ANSWERED_EXTERNALLY_TYPE | Answered on another device |
An outgoing call with DURATION 0 is a call that never connected. That one fact is how a sales app tells a real conversation from an attempt.
Get notified when a new call lands
Do not poll the provider every few seconds. Register a ContentObserver; it fires when a call ends and its row is written.
val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean) {
// Keep this light: kick off a read of rows newer than the last DATE you processed.
scope.launch { process(readCalls(context, afterMillis = lastSeenDate, newestFirst = false)) }
}
}
context.contentResolver.registerContentObserver(CallLog.Calls.CONTENT_URI, true, observer)
// ...and unregisterContentObserver(observer) when you are done.
Why the call log comes back empty when the permission is granted
This is the bug report we have chased more than any other, so it gets its own section.
-
The app-op is denied.
READ_CALL_LOGis backed by an app-op. A phone maker’s security app, a permission reset for unused apps or a change of default dialer can deny the op whilecheckSelfPermissionstill answersGRANTED. The provider then returns an empty cursor and no exception. Check the op directly:fun callLogOpAllowed(context: Context): Boolean { val ops = context.getSystemService(AppOpsManager::class.java) ?: return true val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { ops.unsafeCheckOpNoThrow(AppOpsManager.OPSTR_READ_CALL_LOG, Process.myUid(), context.packageName) } else { @Suppress("DEPRECATION") ops.checkOpNoThrow(AppOpsManager.OPSTR_READ_CALL_LOG, Process.myUid(), context.packageName) } return mode == AppOpsManager.MODE_ALLOWED || mode == AppOpsManager.MODE_DEFAULT } -
Your query is the problem, not the phone. Run the narrowest possible query: two columns (
_ID,DATE), no selection, no sort. If that returns rows while your real query returns none, a column or sort order is being rejected. Log the exception instead of returning an empty list. -
The role moved. If your app holds the permission because it is the default dialer and the user picks another dialer, the permission can be taken back. Catch
SecurityExceptionaround every query.
The log rolls over: read it before it is gone
Android keeps a rolling call log and trims old rows, commonly to the newest 500 calls, and some phone makers keep fewer. A telecaller making 150 calls a day loses last week’s calls in three or four days. If your app needs history, read new rows as they arrive and store them yourself. The phone’s log is a buffer, not a database.
Sync call logs to a server from Android
The reliable shape, which is also what runs inside RMDialer:
- Keep the
DATEof the last call your server accepted. - Read newer rows oldest-first (
newestFirst = false), one page at a time. Reading newest-first from a stored date skips everything in between whenever more than one page piled up. - Send each page with a stable key per call (device id +
_ID+DATE), so a retry after a timeout updates rows instead of duplicating them. - Move the stored
DATEforward only after the server accepts the page.
class CallLogSyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result {
val prefs = applicationContext.getSharedPreferences("call_sync", Context.MODE_PRIVATE)
var after = prefs.getLong("last_sent_date", 0L)
while (true) {
val page = readCalls(applicationContext, afterMillis = after, limit = 200, newestFirst = false)
if (page.isEmpty()) return Result.success()
if (!myApi.uploadCalls(page)) return Result.retry() // your endpoint, idempotent per call
after = page.last().dateMillis
prefs.edit().putLong("last_sent_date", after).apply()
}
}
}
Schedule it with a PeriodicWorkRequest (WorkManager’s minimum interval is 15 minutes) and enqueue a one-time run from the ContentObserver above so a finished call reaches the server within seconds. Two more traps: two calls can share the same millisecond, so key on _ID as well as DATE; and from Android 16.1 internet calls (Google Meet first) can appear in the same log, so read PHONE_ACCOUNT_COMPONENT_NAME if you only want SIM calls.
Or skip the Android side entirely
If what you need is your sales team’s calls in your own system, building and publishing a dialer that qualifies for READ_CALL_LOG is the expensive route. RMDialer already is that dialer on each phone: it reads the log, attaches the lead and the employee to every call, and syncs it to your account. Your server then reads every phone’s calls from one endpoint with the RMDialer call log API: one GET request, filtered by date, call type and employee, 500 calls a page. The call logs API for Android developers page explains how the two halves fit.
- developers
- call tracking