package com.dmc.callcrm.data import android.content.Context import android.net.Uri import android.os.Environment import android.provider.CallLog import androidx.documentfile.provider.DocumentFile import java.io.File /** * Harvests audio files written by the phone's BUILT-IN call recorder. * * Since Android 10 apps can't record the call stream themselves, the reliable * approach (used by Callyzer et al.) is: turn on auto call-recording in the * device dialer, then read the resulting files. This class finds new files and * matches each one to the client number by comparing the file's modified time * to the call log. */ class RecordingScanner(private val ctx: Context) { data class Rec( val file: File, val clientNumber: String, val callDateMs: Long, val durationSec: Int, val callType: String, // incoming | outgoing | unknown val contactName: String?, val deviceCallId: String? ) /** * Same as [Rec] but sourced via SAF (a user-granted folder URI) instead of a raw * File path — for folders the plain File API can see but can't read the contents of * (confirmed MIUI behavior: canRead()==false / list()==null on the call_rec folder * even with MANAGE_EXTERNAL_STORAGE granted). See [scanSaf]. */ data class SafRec( val docUri: Uri, val name: String, val lastModified: Long, val length: Long, val clientNumber: String, val callDateMs: Long, val durationSec: Int, val callType: String, val contactName: String?, val deviceCallId: String? ) private val audioExt = setOf("m4a", "mp3", "amr", "wav", "ogg", "aac", "3gp") // Resolved once per scanner instance: only calls on the registered SIM count, // the same filter Syncer.kt uses for the server and CallStats.kt uses in-app. private val phoneAccountId: String? by lazy { SimUtil.resolvePhoneAccountId(ctx, Prefs(ctx).simSlot) } /** * Common recorder directories across OEMs. This list is necessarily best-effort — * exact folder names vary by OEM software version and sometimes by region/carrier, * and none of these beyond Samsung have been confirmed against real hardware in * this project. If a manufacturer's recordings aren't found, the first thing to * check is the ACTUAL folder path on that specific device (Files app -> Internal * Storage, find where the native recorder saved a test call), then add it here. */ private fun candidateDirs(): List { val root = Environment.getExternalStorageDirectory() // /storage/emulated/0 val paths = listOf( // Samsung (confirmed working) "Call", "Recordings/Call", "Recordings/Call Recordings", // Xiaomi / Redmi / POCO (MIUI) — path changed across MIUI versions "MIUI/sound_recorder/call_rec", // older MIUI "Recordings/PhoneRecord", // some MIUI 12+ builds "Recorder/Call", // Vivo (Funtouch OS / OriginOS) "Record/Call", "Recordings/CallRecord", // OnePlus (OxygenOS, both pre- and post-ColorOS-merge) "Music/Recordings/Call Recordings", "Music/Recordings/Call", // Oppo / Realme (ColorOS) "Recordings/Call Recordings", // same as OnePlus post-merge "CallRecordings", // Generic / fallback locations seen across multiple OEMs "Sounds", "PhoneRecord", "Music/Recordings", "Recordings", "Android/data/com.android.dialer/files" ) return paths.map { File(root, it) }.filter { it.isDirectory }.distinct() } /** * Diagnostic only (not used in the normal scan path) — shows exactly what this device * can see, without any date/extension/upload filtering. For each reachable path this * prints the absolute path (to cross-check against what a file manager shows in its * address bar/properties), canRead(), a SHALLOW listing count via list(), and a * recursive count via walkTopDown() — if shallow list() is 0/null but the folder * "exists", that's a listing-permission problem (MANAGE_EXTERNAL_STORAGE not actually * effective for this specific folder, common with MIUI's own protected-folder rules * even when the general toggle is on) rather than a wrong-path problem. */ fun debugDump(): String { val root = Environment.getExternalStorageDirectory() val allPaths = listOf( "Call", "Recordings/Call", "Recordings/Call Recordings", "MIUI/sound_recorder/call_rec", "Recordings/PhoneRecord", "Recorder/Call", "Record/Call", "Recordings/CallRecord", "Music/Recordings/Call Recordings", "Music/Recordings/Call", "CallRecordings", "Sounds", "PhoneRecord", "Music/Recordings", "Recordings", "Android/data/com.android.dialer/files" ) val sb = StringBuilder() for (p in allPaths) { val d = File(root, p) if (d.isDirectory) { val canRead = d.canRead() val shallow = d.list() // raw OS listing, no filtering val recursive = d.walkTopDown().maxDepth(3).filter { it.isFile }.toList() sb.append("$p\n") sb.append(" path: ${d.absolutePath}\n") sb.append(" canRead: $canRead, list(): ${shallow?.size ?: "null"}, recursive files: ${recursive.size}\n") recursive.maxByOrNull { it.lastModified() }?.let { sb.append(" newest: ${it.name}\n") } } } return if (sb.isEmpty()) "No candidate directories reachable at all (permission?)" else sb.toString() } /** Return recordings whose file is newer than [sinceMs] and not already uploaded. */ fun scan(sinceMs: Long, alreadyUploaded: Set): List { val out = ArrayList() for (dir in candidateDirs()) { // maxDepth 3 (not 2): a few OEMs nest one level deeper than expected // (e.g. a per-year or per-app-version subfolder under the recordings dir). dir.walkTopDown().maxDepth(3).forEach { f -> if (!f.isFile) return@forEach if (f.extension.lowercase() !in audioExt) return@forEach if (f.lastModified() <= sinceMs) return@forEach if (f.absolutePath in alreadyUploaded) return@forEach if (f.length() < 2048) return@forEach // skip empty/partial val match = matchCall(f.lastModified()) out.add( Rec( file = f, clientNumber = match?.number ?: numberFromName(f.name) ?: "unknown", callDateMs = match?.date ?: f.lastModified(), durationSec = match?.duration ?: 0, callType = match?.type ?: "unknown", contactName = match?.name, deviceCallId = match?.id ) ) } } return out.distinctBy { it.file.absolutePath }.sortedBy { it.file.lastModified() } } /** * Same as [scan] but reads through a SAF tree URI the user granted via a folder * picker (see Prefs.safRecordingsTreeUri), instead of a raw File path. Use this when * [debugDump] shows a folder that "exists" but has canRead=false / list()=null — SAF * goes through the DocumentsProvider rather than the raw filesystem, which MIUI (and * some other OEM skins) allow even when it blocks plain File-based listing of the * same folder. * * [alreadyUploaded] uses the document URI's string form as the dedupe key (raw file * paths aren't available/stable through SAF), so keep this in a separate Prefs set * from the File-based [scan]'s alreadyUploaded (see Prefs.uploadedSafRecordings). */ fun scanSaf(treeUri: Uri, sinceMs: Long, alreadyUploaded: Set): List { val root = DocumentFile.fromTreeUri(ctx, treeUri) ?: return emptyList() val out = ArrayList() fun walk(dir: DocumentFile, depth: Int) { if (depth > 3) return for (child in dir.listFiles()) { if (child.isDirectory) { walk(child, depth + 1); continue } val name = child.name ?: continue val ext = name.substringAfterLast('.', "").lowercase() if (ext !in audioExt) continue if (child.lastModified() <= sinceMs) continue if (child.uri.toString() in alreadyUploaded) continue if (child.length() < 2048) continue val match = matchCall(child.lastModified()) out.add( SafRec( docUri = child.uri, name = name, lastModified = child.lastModified(), length = child.length(), clientNumber = match?.number ?: numberFromName(name) ?: "unknown", callDateMs = match?.date ?: child.lastModified(), durationSec = match?.duration ?: 0, callType = match?.type ?: "unknown", contactName = match?.name, deviceCallId = match?.id ) ) } } walk(root, 0) return out.distinctBy { it.docUri.toString() }.sortedBy { it.lastModified } } private data class CallMatch( val number: String, val date: Long, val duration: Int, val type: String, val name: String?, val id: String? ) /** Public match result for the mic-recorder path. */ data class Match( val number: String, val dateMs: Long, val durationSec: Int, val type: String, val contactName: String?, val deviceCallId: String? ) /** Find the client number/details for a call that ended near [endMs]. */ fun matchAtTime(endMs: Long): Match? { val m = matchCall(endMs) ?: return null return Match(m.number, m.date, m.duration, m.type, m.name, m.id) } /** * Find the call whose END time (date + duration) is closest to the file's * modified time, within a 2-minute tolerance, restricted to the registered * SIM when we're able to resolve it. */ private fun matchCall(fileMs: Long): CallMatch? { 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 ) // look at calls in a window around the file time val from = fileMs - 30 * 60_000L val sel = "${CallLog.Calls.DATE} >= ?" val args = arrayOf(from.toString()) var best: CallMatch? = null var bestDelta = Long.MAX_VALUE try { 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()) { // SIM filter: skip calls on a different SIM than the one registered. if (phoneAccountId != null && iAcc >= 0) { val acc = c.getString(iAcc) if (acc != null && acc != phoneAccountId) continue } val date = c.getLong(iDate) val dur = c.getInt(iDur) val endMs = date + dur * 1000L val delta = kotlin.math.abs(endMs - fileMs) if (delta < bestDelta) { bestDelta = delta best = CallMatch( number = c.getString(iNum) ?: "unknown", date = date, duration = dur, type = when (c.getInt(iType)) { 1 -> "incoming"; 2 -> "outgoing"; else -> "unknown" }, name = if (iName >= 0) c.getString(iName) else null, id = c.getString(iId) ) } } } } catch (_: SecurityException) { return null } return if (bestDelta <= 120_000L) best else null // 2-minute tolerance } /** Fallback: pull a phone-number-looking token out of the filename. */ private fun numberFromName(name: String): String? = Regex("(\\+?\\d[\\d\\-\\s]{6,15}\\d)").find(name)?.value?.replace(Regex("[\\s-]"), "") }