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.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.RecordingScanner import com.dmc.callcrm.sync.RecordingUploader import com.dmc.callcrm.sync.Syncer 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 /** * Foreground service. Two jobs: * 1) triggers call-log sync, * 2) records the call from the MIC (Android 10-13 fallback) and uploads it * tagged with the client number, matched from the call log. * * Clean both-sided call-stream capture isn't allowed for normal apps on * Android 10+, so mic capture gets the employee reliably and the client only * on speakerphone (see ApiConfig.AUTO_SPEAKER_DURING_CALL). */ class CallMonitorService : Service() { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private var recorder: MediaRecorder? = null private var recFile: File? = null private var recStartMs = 0L private var prevSpeakerOn = false private var prevMode = AudioManager.MODE_NORMAL override fun onCreate() { super.onCreate() startForeground(NOTIF_ID, buildNotification()) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { ACTION_REC_START -> startRecording() ACTION_REC_STOP -> stopRecordingAndUpload() else -> scope.launch { Syncer.run(applicationContext) } } return START_STICKY } // ---------------- mic recording ---------------- private fun micModeEnabled() = ApiConfig.RECORDING_MODE == "mic" || ApiConfig.RECORDING_MODE == "both" private fun hasMicPermission() = ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED 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) // MIC (not VOICE_COMM) so speaker leak isn't AEC-cancelled 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) { // Device blocked mic during call, or recorder busy. Clean up. 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 } // Let the call log settle, then match the client number and upload. 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() // uploaded; free device storage } } private fun enableSpeaker() { val am = getSystemService(AUDIO_SERVICE) as AudioManager prevMode = am.mode prevSpeakerOn = am.isSpeakerphoneOn am.mode = AudioManager.MODE_IN_COMMUNICATION @Suppress("DEPRECATION") am.isSpeakerphoneOn = true } private fun restoreSpeaker() { if (!ApiConfig.AUTO_SPEAKER_DURING_CALL) return try { val am = getSystemService(AUDIO_SERVICE) as AudioManager @Suppress("DEPRECATION") am.isSpeakerphoneOn = prevSpeakerOn am.mode = prevMode } catch (_: Exception) {} } private fun safeRelease() { try { recorder?.reset() } catch (_: Exception) {} try { recorder?.release() } catch (_: Exception) {} recorder = null } // ---------------- boilerplate ---------------- private fun buildNotification(): Notification = NotificationCompat.Builder(this, App.CHANNEL_ID) .setContentTitle(getString(R.string.notif_title)) .setContentText(getString(R.string.notif_text)) .setSmallIcon(android.R.drawable.stat_sys_upload) .setOngoing(true) .setPriority(NotificationCompat.PRIORITY_LOW) .build() override fun onDestroy() { safeRelease() scope.cancel() super.onDestroy() } override fun onBind(intent: Intent?): IBinder? = null 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" fun start(ctx: Context) = send(ctx, null) fun startRecording(ctx: Context) = send(ctx, ACTION_REC_START) fun stopRecording(ctx: Context) = send(ctx, ACTION_REC_STOP) private fun send(ctx: Context, action: String?) { val i = Intent(ctx, CallMonitorService::class.java) if (action != null) i.action = action if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) ctx.startForegroundService(i) else ctx.startService(i) } } }