package com.dmc.callcrm.service import android.Manifest import android.app.Notification import android.app.Service import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.media.AudioManager import android.media.MediaRecorder import android.net.Uri import android.os.Build import android.os.IBinder import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import com.dmc.callcrm.ApiConfig import com.dmc.callcrm.App import com.dmc.callcrm.R import com.dmc.callcrm.data.Prefs import com.dmc.callcrm.data.RecordingScanner import com.dmc.callcrm.network.ApiClient import com.dmc.callcrm.sync.RecordingUploader import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.io.File class CallMonitorService : Service() { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private var recorder: MediaRecorder? = null private var recFile: File? = null private var recStartMs: Long = 0L private var savedAudioMode: Int? = null override fun onBind(intent: Intent?): IBinder? = null override fun onCreate() { super.onCreate() startForeground(NOTIF_ID, buildNotification()) startCallRequestPolling() } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { ACTION_REC_START -> startRecording() ACTION_REC_STOP -> stopRecordingAndUpload() else -> { // Plain "keep me alive" ping — also used on boot and app launch. // (Routine call-log syncing is already handled by SyncWorker separately.) } } return START_STICKY } override fun onDestroy() { scope.cancel() super.onDestroy() } private fun buildNotification(): Notification { val channelId = "dmc_call_monitor" val nm = getSystemService(android.app.NotificationManager::class.java) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = android.app.NotificationChannel( channelId, "Call monitor", android.app.NotificationManager.IMPORTANCE_LOW ) nm?.createNotificationChannel(channel) } return NotificationCompat.Builder(this, channelId) .setContentTitle("DMC Call CRM") .setContentText("Watching for calls…") .setSmallIcon(R.drawable.ic_call) .setOngoing(true) .build() } // ---------------- Quick Call extension: click-to-call polling ---------------- /** * Polls call_requests_poll.php for pending Quick Call clicks from the browser * extension. Each request the server returns has already been marked * "delivered" server-side, so it's safe to just place/open the call for every * number this call returns. * * Polling interval: previously a fixed 4 seconds, forever — meaning roughly * 21,000+ network requests a day per device even when nobody used Quick Call * at all, which was a significant, avoidable battery and mobile-data drain. * Now backs off exponentially on consecutive failures (offline, server * hiccup) up to a cap, and resets to the base interval the moment a poll * succeeds again — so a healthy connection still gets reasonably prompt * click-to-call behavior, but a phone sitting offline (in a pocket, no * signal) isn't hammering the network every 4 seconds for hours. */ private fun startCallRequestPolling() { scope.launch { val prefs = Prefs(applicationContext) var currentDelayMs = BASE_POLL_DELAY_MS while (true) { delay(currentDelayMs) if (!prefs.isRegistered) continue try { val resp = ApiClient.service.callRequestsPoll(prefs.bearer()) val requests = resp.body()?.requests.orEmpty() for (req in requests) { android.util.Log.d(TAG, "Quick Call request -> calling ${req.number}") val hasCallPermission = ContextCompat.checkSelfPermission( applicationContext, Manifest.permission.CALL_PHONE ) == PackageManager.PERMISSION_GRANTED val action = if (hasCallPermission) Intent.ACTION_CALL else Intent.ACTION_DIAL val i = Intent(action, Uri.parse("tel:${req.number}")) i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) try { startActivity(i) } catch (e: Exception) { android.util.Log.w(TAG, "couldn't place/open call: ${e.message}") try { val fallback = Intent(Intent.ACTION_DIAL, Uri.parse("tel:${req.number}")) fallback.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) startActivity(fallback) } catch (_: Exception) { /* nothing more we can do */ } } } // Successful round trip (even with zero requests) — back to the base interval. currentDelayMs = BASE_POLL_DELAY_MS } catch (e: Exception) { // Offline or server hiccup — back off so a dead connection doesn't // keep polling every few seconds for hours on end. currentDelayMs = (currentDelayMs * 2).coerceAtMost(MAX_POLL_DELAY_MS) } } } } // ---------------- mic recording ---------------- private fun micModeEnabled(): Boolean = ApiConfig.recordingMode() == "mic" || ApiConfig.recordingMode() == "both" private fun hasMicPermission(): Boolean = ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED private fun enableSpeaker() { val am = getSystemService(Context.AUDIO_SERVICE) as AudioManager savedAudioMode = am.mode am.mode = AudioManager.MODE_IN_COMMUNICATION am.isSpeakerphoneOn = true } private fun restoreSpeaker() { if (!ApiConfig.AUTO_SPEAKER_DURING_CALL) return val am = getSystemService(Context.AUDIO_SERVICE) as AudioManager am.isSpeakerphoneOn = false savedAudioMode?.let { am.mode = it } savedAudioMode = null } private fun safeRelease() { try { recorder?.release() } catch (_: Exception) {} recorder = null } private fun startRecording() { if (!micModeEnabled() || !hasMicPermission() || recorder != null) { android.util.Log.w( TAG, "startRecording skipped: micMode=${micModeEnabled()} hasPermission=${hasMicPermission()} recorderAlreadyActive=${recorder != null}" ) return } try { val dir = File(getExternalFilesDir(null), "callrec").apply { mkdirs() } recStartMs = System.currentTimeMillis() val f = File(dir, "rec_$recStartMs.m4a") if (ApiConfig.AUTO_SPEAKER_DURING_CALL) enableSpeaker() @Suppress("DEPRECATION") val r = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) MediaRecorder(this) else MediaRecorder() r.setAudioSource(MediaRecorder.AudioSource.MIC) r.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) r.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) r.setAudioSamplingRate(ApiConfig.MIC_SAMPLE_RATE) r.setAudioEncodingBitRate(ApiConfig.MIC_BITRATE) r.setOutputFile(f.absolutePath) r.prepare() r.start() recorder = r recFile = f android.util.Log.d(TAG, "recording started -> ${f.absolutePath}") } catch (e: Exception) { android.util.Log.e(TAG, "startRecording FAILED: ${e.javaClass.simpleName}: ${e.message}", e) safeRelease() restoreSpeaker() } } private fun stopRecordingAndUpload() { val r = recorder val f = recFile val startMs = recStartMs recorder = null recFile = null if (r == null) { android.util.Log.w(TAG, "stopRecordingAndUpload called but no recorder was active") } try { r?.stop() } catch (e: Exception) { android.util.Log.w(TAG, "recorder.stop() failed (call likely too short): ${e.message}") } safeRelease() restoreSpeaker() if (f == null || !f.exists() || f.length() < 2048) { android.util.Log.w(TAG, "no usable recording file (exists=${f?.exists()} size=${f?.length()})") f?.delete(); return } scope.launch { delay(5000) val match = RecordingScanner(applicationContext).matchAtTime(System.currentTimeMillis()) val number = match?.number ?: "unknown" val date = match?.dateMs ?: startMs val dur = match?.durationSec ?: ((System.currentTimeMillis() - startMs) / 1000).toInt() val type = match?.type ?: "unknown" android.util.Log.d(TAG, "uploading ${f.name} (${f.length()} bytes) for $number, matched=${match != null}") val ok = RecordingUploader.uploadOne( applicationContext, f, number, date, dur, type, match?.contactName, match?.deviceCallId ) android.util.Log.d(TAG, "upload result for ${f.name}: $ok") if (ok) f.delete() } } companion object { private const val TAG = "CallMonitorService" private const val NOTIF_ID = 1001 const val ACTION_REC_START = "com.dmc.callcrm.REC_START" const val ACTION_REC_STOP = "com.dmc.callcrm.REC_STOP" // Quick Call polling: base interval and backoff cap. Raise BASE_POLL_DELAY_MS // further (e.g. to 20-30s) if click-to-call responsiveness at 15s is still // fine for how your team actually uses it, and you want to trim battery use // even further. private const val BASE_POLL_DELAY_MS = 15_000L private const val MAX_POLL_DELAY_MS = 120_000L fun start(ctx: Context) { val i = Intent(ctx, CallMonitorService::class.java) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) ctx.startForegroundService(i) else ctx.startService(i) } fun startRecording(ctx: Context) { val i = Intent(ctx, CallMonitorService::class.java).setAction(ACTION_REC_START) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) ctx.startForegroundService(i) else ctx.startService(i) } fun stopRecording(ctx: Context) { val i = Intent(ctx, CallMonitorService::class.java).setAction(ACTION_REC_STOP) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) ctx.startForegroundService(i) else ctx.startService(i) } } }