Skip to content
Try RMDialer free for 3 days, no card3-day trial, no card Become our whitelabel reseller partnerBecome a reseller partner
← All teams & reporting guides

How to Read Call Logs in Android Programmatically in Kotlin

By · 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_NAME instead of a contacts lookup per row. The system already resolved the name when the call happened. Looking each number up in ContactsContract is 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 LIMIT in the sort order. Many old answers write "date DESC LIMIT 50". Current call log providers throw Invalid token LIMIT for it, and if your code swallows the exception, the log simply looks empty.

The CallLog.Calls columns and type values

ColumnWhat it holds
NUMBERThe number as the phone stored it
TYPEThe call type, see below
DATEWhen the call started, epoch milliseconds
DURATIONTalk time in seconds
CACHED_NAMEThe contact name the system resolved
GEOCODED_LOCATIONThe region of the number, when known
PHONE_ACCOUNT_IDWhich SIM or calling account carried the call
PHONE_ACCOUNT_COMPONENT_NAMEWhich app’s calling service carried it
TYPE valueConstantMeaning
1INCOMING_TYPEAnswered incoming call
2OUTGOING_TYPECall you placed
3MISSED_TYPEIncoming call nobody answered
4VOICEMAIL_TYPEVoicemail
5REJECTED_TYPEIncoming call declined
6BLOCKED_TYPEBlocked number
7ANSWERED_EXTERNALLY_TYPEAnswered 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.

  1. The app-op is denied. READ_CALL_LOG is 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 while checkSelfPermission still answers GRANTED. 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
    }
  2. 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.

  3. 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 SecurityException around 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 DATE of 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 DATE forward 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
Questions

What else do people ask about this?

How do I get call logs in Android programmatically?

Declare android.permission.READ_CALL_LOG in the manifest, request it at runtime, then call contentResolver.query on CallLog.Calls.CONTENT_URI with the columns you need, such as NUMBER, TYPE, DATE, DURATION and CACHED_NAME. Read the rows with a Cursor, off the main thread.

What permission is needed to read call logs in Android?

android.permission.READ_CALL_LOG, a dangerous permission that must be granted at runtime. On Google Play the app must also qualify under the SMS and Call Log policy and declare the use in the Permissions Declaration Form.

Why does my call log query return zero rows when the permission is granted?

Three common reasons: the READ_CALL_LOG app-op is denied (the permission still reads as granted, the provider just returns nothing), your sort order contains a LIMIT clause the provider rejects, or another app became the default dialer and the role-granted permission was taken back. Check the app-op and log any exception from the query.

How do I limit the number of call log rows on Android 11 and above?

Pass a Bundle to contentResolver.query with ContentResolver.QUERY_ARG_LIMIT, and QUERY_ARG_SORT_COLUMNS with QUERY_ARG_SORT_DIRECTION for the order. Putting LIMIT in the sortOrder string fails with "Invalid token LIMIT" on current call log providers.

What do the CallLog.Calls TYPE values mean?

1 incoming, 2 outgoing, 3 missed, 4 voicemail, 5 rejected, 6 blocked and 7 answered externally (picked up on another device).

Is the call log DATE in seconds or milliseconds?

DATE is milliseconds since the epoch and DURATION is seconds. Treating DATE as seconds puts every call in 1970.

How do I know when a new call is added to the call log?

Register a ContentObserver on CallLog.Calls.CONTENT_URI. onChange fires when a call ends and the row is written; then read the rows newer than the last DATE you processed.

How do I sync Android call logs to my server?

Store the DATE of the last call you sent, read newer rows oldest-first in pages, post each page to your server keyed on a stable id so a retry never creates duplicates, and move the stored DATE forward only after the server accepts the page. WorkManager runs it reliably in the background.

Can I read call logs on iPhone the same way?

No. iOS does not give any app access to the phone call log, so there is no equivalent of CallLog.Calls on iPhone.

Can I get call logs from all my team's phones in one place?

Only if an app on each phone reads the log and sends it to a shared backend. RMDialer does that as the dialer on each phone, and its call log API then returns every phone's calls from one GET endpoint.

Is there an API to get call logs from Android phones?

Yes, when the dialer on the phone provides one. RMDialer's call logs API streams every call on each user's SIM (direction, duration, outcome, the lead, the remark, the follow-up) to your own system by REST and webhooks, and accepts leads and assignments back. Working samples are open on GitHub.

How can a manager see every call the team makes?

With a call tracking app installed as the phone app on each staff phone, every incoming, outgoing and missed call on the business SIM syncs to the owner's dashboard by itself. The manager filters by employee and date to see calls made, calls connected, missed calls, talk time and the status saved after each call, without asking anyone for a report.

How to track employee calls on Android without call recording?

Install a call tracking app that becomes the phone app on each employee's Android phone. It reads the phone's own call log, so every call on the business SIM is counted with direction, duration and outcome, and syncs to the owner's report. You get who was called, when, for how long, what status was saved and what follow-up was set, and call recording is supported too, set up with you during training.

What should a daily call report for a sales team include?

Per employee: calls dialled, calls connected, missed and rejected calls, unique numbers, total talk time, and the outcomes saved (interested, follow-up, not interested, lost). Per team: the same totals plus leads moved by status and follow-ups due tomorrow. A tracking app builds this from the phones automatically, so nobody fills a register at 7 pm.

Can I connect RMDialer to my existing CRM?

Yes, in two ways. The simplest: point the app at your CRM's lead export (Google Sheet or REST endpoint) so leads flow in and get dialled. The full way: use the dialer API so calls, statuses, remarks and WhatsApp events flow back into your CRM by webhook, with leads and assignments pushed the other way.

Customer reviews

RMDialer reviews from business users

Real businesses, real numbers: how owners and sales teams across India and 12+ countries use RMDialer every day.

Rated 4.9 by customers 4.7 on Google Play 5K+ downloads India + 12+ countries
  • “Collections calling is volume work. The auto dialer clears 250 numbers per executive per day from their own SIM, promise-to-pay dates become reminders, and the daily call report shows connected versus not reachable per person. Talk time per employee ended the "phone was busy all day" excuse.”
    Ramesh Pandian Recovery manager · NBFC collections Madurai
  • “Every visa enquiry moves through counselling, documents and filing as lead statuses, and the counsellor's phone rings for each follow-up. What I check daily is the employee call logs report: calls, connected, talk time per counsellor, on my phone. The register we used to maintain is gone.”
    Arjun Mehta Branch manager · Immigration consultancy Chandigarh
  • “We use RMDialer as the dialer layer under our own CRM through the API. Our system pushes the lead and the assignment, the agent's phone dials it, and every call event comes back by webhook with duration, outcome and remark. Integration took our developer about two days with the GitHub samples.”
    Nikhil Bansal Product manager · Lending fintech Gurgaon
  • “Subscription business: every client's plan expiry is a renewal reminder and every prospect from the webinar list is auto dialled the next morning. Paid vs due on each client is visible, so collections are not in my head anymore. The daily call report keeps my two callers honest.”
    Rajesh Joshi Owner · Stock advisory service Vadodara
  • “Good telecalling CRM for a small Saudi team. Arabic app, calls from our STC SIMs, and the manager report per employee is what we bought it for. Four stars because I want a desktop app for the manager too, not only the web. Support fixed our SIM selection question the same day.”
    Ahmed Al-Qahtani Sales supervisor · Car rental company Riyadh, Saudi Arabia

Reviews are collected from customers on WhatsApp and on this website. Share yours on WhatsApp

Lead automation

Leads from 15+ platforms → auto-assigned round robin → auto dialed → updated. On its own.

Facebook, JustDial, IndiaMART, portals, sheets, Excel: every lead gets an owner, a ping and a call within minutes: and reports its own outcome.

See lead automation

Business karte ho? Business Dialer chahiye.

Set it as your phone app in one tap. Everything works like before, and then it starts working FOR you.

₹159/user/month on yearly · every feature included · Prices are per user, exclusive of taxes.

Free forever: the business dialer never expires, and needs no account

Call recording supported · our team sets it up with you during training

On iPhone and Android. The iPhone app has every feature except the native call log.

Partners Download RMDialer Now