package com.dmc.callcrm.data import android.Manifest import android.content.Context import android.content.pm.PackageManager import android.provider.CallLog import androidx.core.content.ContextCompat /** Reads the device CallLog and produces the data shown in the employee UI. */ class CallStats(private val ctx: Context) { data class CallItem( val id: String, // CallLog.Calls._ID — stable per-device key for notes/recordings val number: String, val name: String?, val type: Int, // CallLog.Calls TYPE val dateMs: Long, val durationSec: Int, val simSlot: Int?, val source: String = "phone" // "phone" (regular CallLog call) or "whatsapp" (merged in separately) ) data class Analytics( val total: Int = 0, val totalDur: Long = 0, val incoming: Int = 0, val incomingDur: Long = 0, // answered incoming val outgoing: Int = 0, val outgoingDur: Long = 0, // ALL outgoing (answered + not picked up) val missed: Int = 0, val rejected: Int = 0, val neverAttended: Int = 0, // unanswered incoming val notPickup: Int = 0, // unanswered outgoing (client didn't pick up) val uniqueCalls: Int = 0, // distinct numbers, any call in range val connectedCalls: Int = 0, // answered incoming + answered outgoing val connectedDur: Long = 0, val uniqueConnectedCalls: Int = 0, // distinct numbers among connected calls only val workingHoursSec: Long = 0 // first-call to last-call span in range ) data class DayBreakdown( val label: String, val total: Int, val incoming: Int, val outgoing: Int, val missed: Int ) data class ContactItem(val number: String, val name: String?, val totalCalls: Int) /** Aggregate stats for one phone number, used by the Analysis tab's "record holder" screens. */ data class NumberDetail( val number: String, val name: String?, val totalCalls: Int, val incomingCalls: Int, val outgoingCalls: Int, val missedCalls: Int, val rejectedCalls: Int, val totalDurationSec: Long, val incomingDurationSec: Long, val outgoingDurationSec: Long ) /** One specific call record, used by the "Longest Call" detail screen. */ data class SingleCallDetail( val number: String, val name: String?, val dateMs: Long, val type: Int, val durationSec: Int ) /** Both the per-day and per-call average-duration breakdowns, for connected calls only. */ data class AvgDurationResult( val perDayAvgSec: Long, val days: Int, val perDayTotalSec: Long, val perCallAvgSec: Long, val incomingCalls: Int, val incomingDurSec: Long, val incomingAvgSec: Long, val outgoingCalls: Int, val outgoingDurSec: Long, val outgoingAvgSec: Long, val totalCalls: Int, val totalDurSec: Long, val totalAvgSec: Long ) private data class RawCall(val number: String, val name: String?, val type: Int, val dateMs: Long, val durationSec: Int) // Resolved once per instance: only count calls on the SIM registered at signup — // same filter Syncer.kt uses for the server and RecordingScanner.kt uses for matching. private val phoneAccountId: String? by lazy { SimUtil.resolvePhoneAccountId(ctx, Prefs(ctx).simSlot) } private fun ok() = ContextCompat.checkSelfPermission(ctx, Manifest.permission.READ_CALL_LOG) == PackageManager.PERMISSION_GRANTED // A call log row with no PHONE_ACCOUNT_ID at all (common on older entries, before // consistent dual-SIM tracking) used to be treated as "always matches" once a SIM // was registered — which barely showed up in recent()'s 300-call window, but added // up noticeably in contacts(), which scans the entire call history. Now such rows // are excluded once a specific SIM is registered, same as a row from the other SIM. private fun simMatches(acc: String?): Boolean = phoneAccountId == null || acc == phoneAccountId /** Most recent calls (optionally filtered by type: 1 in, 2 out, 3 missed). */ fun recent(limit: Int = 300, typeFilter: Int? = null): List { if (!ok()) return emptyList() val proj = arrayOf( CallLog.Calls._ID, CallLog.Calls.NUMBER, CallLog.Calls.CACHED_NAME, CallLog.Calls.TYPE, CallLog.Calls.DATE, CallLog.Calls.DURATION, CallLog.Calls.PHONE_ACCOUNT_ID ) val sel = typeFilter?.let { "${CallLog.Calls.TYPE} = ?" } val args = typeFilter?.let { arrayOf(it.toString()) } val out = ArrayList() ctx.contentResolver.query( CallLog.Calls.CONTENT_URI, proj, sel, args, "${CallLog.Calls.DATE} DESC" )?.use { c -> val iId = c.getColumnIndex(CallLog.Calls._ID) val iNum = c.getColumnIndex(CallLog.Calls.NUMBER) val iName = c.getColumnIndex(CallLog.Calls.CACHED_NAME) val iType = c.getColumnIndex(CallLog.Calls.TYPE) val iDate = c.getColumnIndex(CallLog.Calls.DATE) val iDur = c.getColumnIndex(CallLog.Calls.DURATION) val iAcc = c.getColumnIndex(CallLog.Calls.PHONE_ACCOUNT_ID) while (c.moveToNext() && out.size < limit) { if (iAcc >= 0 && !simMatches(c.getString(iAcc))) continue out.add( CallItem( id = c.getString(iId) ?: "", number = c.getString(iNum) ?: "Unknown", name = if (iName >= 0) c.getString(iName) else null, type = c.getInt(iType), dateMs = c.getLong(iDate), durationSec = c.getInt(iDur), simSlot = null, source = "phone" ) ) } } return out } /** * Aggregate analytics for a date range. * @param excludeDigits phone numbers (digits-only, matched by suffix) to leave OUT of the * totals — the app's cache of Manage -> Exclude Phone Numbers ("Ex. No." filter). * @param deselectedTypes CallLog.Calls TYPE values (as strings) to leave OUT of the totals * — the Analytics "Filter" dialog's call-type checkboxes. */ fun analytics( fromMs: Long, toMs: Long, excludeDigits: Set = emptySet(), deselectedTypes: Set = emptySet() ): Analytics { if (!ok()) return Analytics() val proj = arrayOf( CallLog.Calls.TYPE, CallLog.Calls.DATE, CallLog.Calls.DURATION, CallLog.Calls.NUMBER, CallLog.Calls.PHONE_ACCOUNT_ID ) val sel = "${CallLog.Calls.DATE} BETWEEN ? AND ?" val args = arrayOf(fromMs.toString(), toMs.toString()) var total = 0; var td = 0L var inc = 0; var incD = 0L; var out = 0; var outD = 0L var missed = 0; var rej = 0; var never = 0; var notPick = 0 var connected = 0; var connectedD = 0L var minDate = Long.MAX_VALUE; var maxDate = Long.MIN_VALUE val allNumbers = HashSet() val connectedNumbers = HashSet() ctx.contentResolver.query(CallLog.Calls.CONTENT_URI, proj, sel, args, null)?.use { c -> val iType = c.getColumnIndex(CallLog.Calls.TYPE) val iDur = c.getColumnIndex(CallLog.Calls.DURATION) val iNum = c.getColumnIndex(CallLog.Calls.NUMBER) val iDate = c.getColumnIndex(CallLog.Calls.DATE) val iAcc = c.getColumnIndex(CallLog.Calls.PHONE_ACCOUNT_ID) while (c.moveToNext()) { if (iAcc >= 0 && !simMatches(c.getString(iAcc))) continue val t = c.getInt(iType); val d = c.getInt(iDur); val date = c.getLong(iDate) if (deselectedTypes.contains(t.toString())) continue val numRaw = c.getString(iNum) ?: "" if (excludeDigits.isNotEmpty()) { val digits = numRaw.filter { it.isDigit() } if (digits.isNotEmpty() && excludeDigits.any { digits.endsWith(it) || it.endsWith(digits) }) continue } total++; td += d if (date < minDate) minDate = date if (date > maxDate) maxDate = date if (numRaw.isNotEmpty()) allNumbers.add(numRaw) when (t) { CallLog.Calls.INCOMING_TYPE -> { if (d > 0) { inc++; incD += d connected++; connectedD += d if (numRaw.isNotEmpty()) connectedNumbers.add(numRaw) } else never++ } CallLog.Calls.OUTGOING_TYPE -> { out++; outD += d if (d > 0) { connected++; connectedD += d if (numRaw.isNotEmpty()) connectedNumbers.add(numRaw) } else notPick++ } CallLog.Calls.MISSED_TYPE -> missed++ CallLog.Calls.REJECTED_TYPE -> rej++ } } } val workingSec = if (total > 0) maxOf(0L, (maxDate - minDate) / 1000) else 0L return Analytics( total = total, totalDur = td, incoming = inc, incomingDur = incD, outgoing = out, outgoingDur = outD, missed = missed, rejected = rej, neverAttended = never, notPickup = notPick, uniqueCalls = allNumbers.size, connectedCalls = connected, connectedDur = connectedD, uniqueConnectedCalls = connectedNumbers.size, workingHoursSec = workingSec ) } /** Per-day call counts across a range, for the Analytics "Analysis" tab. */ fun dayBreakdown(fromMs: Long, toMs: Long): List { if (!ok()) return emptyList() val proj = arrayOf(CallLog.Calls.TYPE, CallLog.Calls.DATE, CallLog.Calls.PHONE_ACCOUNT_ID) val sel = "${CallLog.Calls.DATE} BETWEEN ? AND ?" val args = arrayOf(fromMs.toString(), toMs.toString()) val df = java.text.SimpleDateFormat("dd MMM yyyy", java.util.Locale.getDefault()) data class Acc(var total: Int = 0, var inc: Int = 0, var out: Int = 0, var missed: Int = 0) val byDay = LinkedHashMap() ctx.contentResolver.query(CallLog.Calls.CONTENT_URI, proj, sel, args, "${CallLog.Calls.DATE} ASC")?.use { c -> val iType = c.getColumnIndex(CallLog.Calls.TYPE) val iDate = c.getColumnIndex(CallLog.Calls.DATE) val iAcc = c.getColumnIndex(CallLog.Calls.PHONE_ACCOUNT_ID) while (c.moveToNext()) { if (iAcc >= 0 && !simMatches(c.getString(iAcc))) continue val label = df.format(java.util.Date(c.getLong(iDate))) val acc = byDay.getOrPut(label) { Acc() } acc.total++ when (c.getInt(iType)) { CallLog.Calls.INCOMING_TYPE -> acc.inc++ CallLog.Calls.OUTGOING_TYPE -> acc.out++ CallLog.Calls.MISSED_TYPE -> acc.missed++ } } } return byDay.entries.reversed().map { (label, a) -> DayBreakdown(label, a.total, a.inc, a.out, a.missed) } } /** Contacts aggregated by number, most-called first. */ fun contacts(limit: Int = 200): List { if (!ok()) return emptyList() val proj = arrayOf(CallLog.Calls.NUMBER, CallLog.Calls.CACHED_NAME, CallLog.Calls.PHONE_ACCOUNT_ID) val counts = LinkedHashMap>() ctx.contentResolver.query( CallLog.Calls.CONTENT_URI, proj, null, null, "${CallLog.Calls.DATE} DESC" )?.use { c -> val iNum = c.getColumnIndex(CallLog.Calls.NUMBER) val iName = c.getColumnIndex(CallLog.Calls.CACHED_NAME) val iAcc = c.getColumnIndex(CallLog.Calls.PHONE_ACCOUNT_ID) while (c.moveToNext()) { if (iAcc >= 0 && !simMatches(c.getString(iAcc))) continue val num = c.getString(iNum) ?: continue val name = if (iName >= 0) c.getString(iName) else null val prev = counts[num] counts[num] = Pair(prev?.first ?: name, (prev?.second ?: 0) + 1) } } return counts.entries .map { ContactItem(it.key, it.value.first, it.value.second) } .sortedByDescending { it.totalCalls } .take(limit) } /** Single query, shared by all the Analysis-tab "record holder" and Top-10 functions below, * so each doesn't re-query the CallLog content provider on its own. */ private fun filteredCalls( fromMs: Long, toMs: Long, excludeDigits: Set, deselectedTypes: Set ): List { if (!ok()) return emptyList() val proj = arrayOf( CallLog.Calls.NUMBER, CallLog.Calls.CACHED_NAME, CallLog.Calls.TYPE, CallLog.Calls.DATE, CallLog.Calls.DURATION, CallLog.Calls.PHONE_ACCOUNT_ID ) val sel = "${CallLog.Calls.DATE} BETWEEN ? AND ?" val args = arrayOf(fromMs.toString(), toMs.toString()) val out = ArrayList() ctx.contentResolver.query(CallLog.Calls.CONTENT_URI, proj, sel, args, null)?.use { c -> val iNum = c.getColumnIndex(CallLog.Calls.NUMBER) val iName = c.getColumnIndex(CallLog.Calls.CACHED_NAME) val iType = c.getColumnIndex(CallLog.Calls.TYPE) val iDate = c.getColumnIndex(CallLog.Calls.DATE) val iDur = c.getColumnIndex(CallLog.Calls.DURATION) val iAcc = c.getColumnIndex(CallLog.Calls.PHONE_ACCOUNT_ID) while (c.moveToNext()) { if (iAcc >= 0 && !simMatches(c.getString(iAcc))) continue val t = c.getInt(iType) if (deselectedTypes.contains(t.toString())) continue val num = c.getString(iNum) ?: "" if (excludeDigits.isNotEmpty()) { val digits = num.filter { it.isDigit() } if (digits.isNotEmpty() && excludeDigits.any { digits.endsWith(it) || it.endsWith(digits) }) continue } out.add(RawCall(num, if (iName >= 0) c.getString(iName) else null, t, c.getLong(iDate), c.getInt(iDur))) } } return out } private fun buildNumberDetail(number: String, calls: List): NumberDetail { val name = calls.firstOrNull { !it.name.isNullOrBlank() }?.name var inc = 0; var out = 0; var missed = 0; var rej = 0 var incD = 0L; var outD = 0L; var totalD = 0L for (c in calls) { totalD += c.durationSec when (c.type) { CallLog.Calls.INCOMING_TYPE -> { inc++; incD += c.durationSec } CallLog.Calls.OUTGOING_TYPE -> { out++; outD += c.durationSec } CallLog.Calls.MISSED_TYPE -> missed++ CallLog.Calls.REJECTED_TYPE -> rej++ } } return NumberDetail(number, name, calls.size, inc, out, missed, rej, totalD, incD, outD) } /** The number with the most call log entries (any type) in the range. */ fun topCallerDetail(fromMs: Long, toMs: Long, excludeDigits: Set, deselectedTypes: Set): NumberDetail? { val calls = filteredCalls(fromMs, toMs, excludeDigits, deselectedTypes) if (calls.isEmpty()) return null val top = calls.groupBy { it.number }.maxByOrNull { it.value.size } ?: return null return buildNumberDetail(top.key, top.value) } /** The number with the highest cumulative call duration in the range. */ fun highestTotalDurationDetail(fromMs: Long, toMs: Long, excludeDigits: Set, deselectedTypes: Set): NumberDetail? { val calls = filteredCalls(fromMs, toMs, excludeDigits, deselectedTypes) if (calls.isEmpty()) return null val top = calls.groupBy { it.number }.maxByOrNull { g -> g.value.sumOf { it.durationSec.toLong() } } ?: return null return buildNumberDetail(top.key, top.value) } /** The single call record with the longest duration in the range. */ fun longestCallDetail(fromMs: Long, toMs: Long, excludeDigits: Set, deselectedTypes: Set): SingleCallDetail? { val top = filteredCalls(fromMs, toMs, excludeDigits, deselectedTypes).maxByOrNull { it.durationSec } ?: return null return SingleCallDetail(top.number, top.name, top.dateMs, top.type, top.durationSec) } /** Per-day and per-call average duration of CONNECTED calls only (duration > 0). */ fun avgDurationStats(fromMs: Long, toMs: Long, excludeDigits: Set, deselectedTypes: Set): AvgDurationResult { val calls = filteredCalls(fromMs, toMs, excludeDigits, deselectedTypes).filter { it.durationSec > 0 } val incCalls = calls.filter { it.type == CallLog.Calls.INCOMING_TYPE } val outCalls = calls.filter { it.type == CallLog.Calls.OUTGOING_TYPE } val incDur = incCalls.sumOf { it.durationSec.toLong() } val outDur = outCalls.sumOf { it.durationSec.toLong() } val totalDur = incDur + outDur val totalCalls = incCalls.size + outCalls.size val df = java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.getDefault()) val days = calls.map { df.format(java.util.Date(it.dateMs)) }.distinct().size return AvgDurationResult( perDayAvgSec = if (days > 0) totalDur / days else 0, days = days, perDayTotalSec = totalDur, perCallAvgSec = if (totalCalls > 0) totalDur / totalCalls else 0, incomingCalls = incCalls.size, incomingDurSec = incDur, incomingAvgSec = if (incCalls.isNotEmpty()) incDur / incCalls.size else 0, outgoingCalls = outCalls.size, outgoingDurSec = outDur, outgoingAvgSec = if (outCalls.isNotEmpty()) outDur / outCalls.size else 0, totalCalls = totalCalls, totalDurSec = totalDur, totalAvgSec = if (totalCalls > 0) totalDur / totalCalls else 0 ) } /** Top N numbers by call count or total duration, for the Top 10 list screens. * @param direction "all" | "incoming" | "outgoing" */ fun topList( fromMs: Long, toMs: Long, excludeDigits: Set, deselectedTypes: Set, direction: String, byDuration: Boolean, limit: Int = 10 ): List { var calls = filteredCalls(fromMs, toMs, excludeDigits, deselectedTypes) calls = when (direction) { "incoming" -> calls.filter { it.type == CallLog.Calls.INCOMING_TYPE } "outgoing" -> calls.filter { it.type == CallLog.Calls.OUTGOING_TYPE } else -> calls.filter { it.type == CallLog.Calls.INCOMING_TYPE || it.type == CallLog.Calls.OUTGOING_TYPE } } val details = calls.groupBy { it.number }.map { (num, g) -> buildNumberDetail(num, g) } return if (byDuration) details.sortedByDescending { it.totalDurationSec }.take(limit) else details.sortedByDescending { it.totalCalls }.take(limit) } }