package com.dmc.callcrm.ui import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.media.MediaPlayer import android.net.Uri import android.os.Bundle import android.provider.CallLog import android.text.Editable import android.text.TextWatcher import android.view.LayoutInflater import android.widget.PopupMenu import android.view.View import android.view.ViewGroup import android.widget.EditText import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.dmc.callcrm.ApiConfig import com.dmc.callcrm.R import com.dmc.callcrm.data.CallStats import com.dmc.callcrm.data.Prefs import com.dmc.callcrm.network.ApiClient import com.dmc.callcrm.network.CallNoteRequest import com.dmc.callcrm.network.MessageTemplate import com.dmc.callcrm.network.NoteTemplate import com.google.android.material.chip.Chip import com.google.android.material.tabs.TabLayout import kotlinx.coroutines.launch class CallHistoryFragment : Fragment() { private lateinit var adapter: CallAdapter private lateinit var prefs: Prefs private var phoneCalls: List = emptyList() private var whatsappCalls: List = emptyList() private var all: List = emptyList() private var typeFilter: Int? = null private var query = "" private var mediaPlayer: MediaPlayer? = null // "phone" and/or "whatsapp" — which call methods to include, matching the // reference app's "Select Call Method" picker. Both selected by default. private val selectedMethods = mutableSetOf("phone", "whatsapp") override fun onCreateView(i: LayoutInflater, c: ViewGroup?, s: Bundle?): View = i.inflate(R.layout.fragment_list, c, false) override fun onViewCreated(v: View, s: Bundle?) { prefs = Prefs(requireContext()) v.findViewById(R.id.title).text = "Call History" adapter = CallAdapter( emptyList(), noteFor = { item -> prefs.noteFor(prefs.noteKey(item.number, item.dateMs, item.durationSec)) }, onCall = { if (it.source == "phone") dial(it.number) else notAvailableForWhatsapp() }, onWhatsapp = { openWhatsappPicker(it) }, onPlay = { if (it.source == "phone") playRecording(it) else notAvailableForWhatsapp() }, onCopy = { if (it.source == "phone") copyNumber(it) else notAvailableForWhatsapp() }, onMore = { item, anchor -> showMoreMenu(item, anchor) }, onNote = { openNoteDialog(it) } ) v.findViewById(R.id.list).apply { layoutManager = LinearLayoutManager(context); adapter = this@CallHistoryFragment.adapter } val tabs = v.findViewById(R.id.tabs) listOf("All Calls", "Incoming", "Outgoing", "Missed").forEach { tabs.addTab(tabs.newTab().setText(it)) } tabs.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { override fun onTabSelected(t: TabLayout.Tab) { typeFilter = when (t.position) { 1 -> CallLog.Calls.INCOMING_TYPE 2 -> CallLog.Calls.OUTGOING_TYPE 3 -> CallLog.Calls.MISSED_TYPE else -> null } reload() } override fun onTabUnselected(t: TabLayout.Tab) {} override fun onTabReselected(t: TabLayout.Tab) {} }) v.findViewById(R.id.search).addTextChangedListener(object : TextWatcher { override fun afterTextChanged(e: Editable?) { query = e?.toString()?.trim().orEmpty(); render() } override fun beforeTextChanged(s: CharSequence?, a: Int, b: Int, c: Int) {} override fun onTextChanged(s: CharSequence?, a: Int, b: Int, c: Int) {} }) v.findViewById(R.id.callMethodFilter).setOnClickListener { openCallMethodPicker() } reload() } private fun notAvailableForWhatsapp() { Toast.makeText(context, "Not available for WhatsApp calls", Toast.LENGTH_SHORT).show() } // ---- Call Method filter (SIM / WhatsApp) ---- private fun openCallMethodPicker() { val labels = arrayOf("SIM 1", "WhatsApp") val keys = arrayOf("phone", "whatsapp") val checked = keys.map { it in selectedMethods }.toBooleanArray() AlertDialog.Builder(requireContext()) .setTitle("Select Call Method") .setMultiChoiceItems(labels, checked) { _, which, isChecked -> if (isChecked) selectedMethods.add(keys[which]) else selectedMethods.remove(keys[which]) } .setPositiveButton("Apply") { _, _ -> render() } .setNegativeButton("Cancel", null) .show() } private fun reload() { phoneCalls = CallStats(requireContext()).recent(300, typeFilter) combineAndRender() // WhatsApp calls live server-side only (they never touch the phone's own // CallLog), so fetching them needs a network round-trip — merge them in // once they arrive rather than blocking the phone-call list on it. if (!prefs.isRegistered) return viewLifecycleOwner.lifecycleScope.launch { whatsappCalls = try { val resp = ApiClient.service.whatsappCallsList(prefs.bearer()) if (!resp.isSuccessful) { android.util.Log.w("CallHistoryFragment", "whatsappCallsList failed: HTTP ${resp.code()}") } val rawCalls = resp.body()?.calls.orEmpty() android.util.Log.d("CallHistoryFragment", "whatsappCallsList returned ${rawCalls.size} calls") rawCalls.mapNotNull { w -> val startedMs = parseServerDate(w.startedAt) if (startedMs == null) { android.util.Log.w("CallHistoryFragment", "couldn't parse started_at: '${w.startedAt}' for call ${w.id}") return@mapNotNull null } val type = when (w.callType) { "incoming" -> CallLog.Calls.INCOMING_TYPE "outgoing" -> CallLog.Calls.OUTGOING_TYPE else -> CallLog.Calls.OUTGOING_TYPE } CallStats.CallItem( id = "wa_${w.id}", number = w.contactName, name = w.contactName, type = type, dateMs = startedMs, durationSec = w.durationSec, simSlot = null, source = "whatsapp" ) } } catch (e: Exception) { android.util.Log.w("CallHistoryFragment", "whatsappCallsList fetch failed: ${e.message}", e) emptyList() } combineAndRender() } } /** Parses the server's "yyyy-MM-dd HH:mm:ss" format into epoch millis. */ private fun parseServerDate(s: String): Long? = try { val df = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.getDefault()) df.parse(s)?.time } catch (_: Exception) { null } private fun combineAndRender() { all = (phoneCalls + whatsappCalls).sortedByDescending { it.dateMs } render() } private fun render() { var list = all.filter { it.source in selectedMethods } if (query.isNotEmpty()) { list = list.filter { (it.name ?: "").contains(query, true) || it.number.contains(query) } } adapter.submit(list) } private fun dial(number: String) { try { startActivity(Intent(Intent.ACTION_DIAL, Uri.parse("tel:$number"))) } catch (_: Exception) {} } private fun copyNumber(item: CallStats.CallItem) { val cm = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager cm.setPrimaryClip(ClipData.newPlainText("Phone number", item.number)) Toast.makeText(context, "Number copied", Toast.LENGTH_SHORT).show() } private fun showMoreMenu(item: CallStats.CallItem, anchor: View) { val menu = PopupMenu(requireContext(), anchor) menu.menu.add("View call details") val hasNote = !prefs.noteFor(prefs.noteKey(item.number, item.dateMs, item.durationSec)).isNullOrBlank() if (hasNote) menu.menu.add("Remove note") menu.setOnMenuItemClickListener { mi -> when (mi.title) { "View call details" -> { val df = java.text.SimpleDateFormat("dd MMM yyyy, hh:mm a", java.util.Locale.getDefault()) AlertDialog.Builder(requireContext()) .setTitle(item.name ?: "Unknown") .setMessage( "Number: ${item.number}\n" + "Time: ${df.format(java.util.Date(item.dateMs))}\n" + "Duration: ${item.durationSec}s" + (if (item.source == "whatsapp") "\n(via WhatsApp)" else "") ) .setPositiveButton("Close", null) .show() } "Remove note" -> { val key = prefs.noteKey(item.number, item.dateMs, item.durationSec) prefs.saveNoteLocally(key, "") adapter.refreshNotes() saveNoteToServer(item, "") } } true } menu.show() } // ---- Notes ---- private fun openNoteDialog(item: CallStats.CallItem) { val key = prefs.noteKey(item.number, item.dateMs, item.durationSec) val view = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_note, null) val noteText = view.findViewById(R.id.noteText) val chipGroup = view.findViewById(R.id.chipGroup) noteText.setText(prefs.noteFor(key).orEmpty()) val dialog = AlertDialog.Builder(requireContext()) .setTitle("Call note") .setView(view) .setPositiveButton("Save", null) .setNegativeButton("Cancel", null) .create() dialog.setOnShowListener { dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { val note = noteText.text.toString() prefs.saveNoteLocally(key, note) adapter.refreshNotes() saveNoteToServer(item, note) dialog.dismiss() } } dialog.show() if (!prefs.isRegistered) return viewLifecycleOwner.lifecycleScope.launch { try { val resp = ApiClient.service.noteTemplates(prefs.bearer()) val templates = resp.body()?.templates.orEmpty() chipGroup.removeAllViews() templates.forEach { t: NoteTemplate -> val chip = Chip(requireContext()) chip.text = t.title chip.isClickable = true chip.setOnClickListener { noteText.setText(t.body ?: t.title) } chipGroup.addView(chip) } } catch (_: Exception) { /* templates are a convenience; ignore failures */ } } } private fun saveNoteToServer(item: CallStats.CallItem, note: String) { if (!prefs.isRegistered) return viewLifecycleOwner.lifecycleScope.launch { try { ApiClient.service.saveCallNote( prefs.bearer(), CallNoteRequest( number = item.number, name = item.name, type = item.type, date = item.dateMs, duration = item.durationSec, note = note, deviceCallId = item.id ) ) } catch (_: Exception) { Toast.makeText(context, "Note saved on device; will sync when online", Toast.LENGTH_SHORT).show() } } } // ---- WhatsApp ---- private fun openWhatsappPicker(item: CallStats.CallItem) { if (!prefs.isRegistered) { sendWhatsapp(item.number, null); return } viewLifecycleOwner.lifecycleScope.launch { val templates = try { ApiClient.service.messageTemplates(prefs.bearer()).body()?.templates.orEmpty() } catch (_: Exception) { emptyList() } if (templates.isEmpty()) { sendWhatsapp(item.number, null); return@launch } val labels = (listOf("No template — open chat") + templates.map { it.title }).toTypedArray() AlertDialog.Builder(requireContext()) .setTitle("Send WhatsApp message") .setItems(labels) { _, which -> val body = if (which == 0) null else templates[which - 1].body sendWhatsapp(item.number, body) } .show() } } private fun sendWhatsapp(number: String, message: String?) { val digits = number.filter { it.isDigit() } if (digits.isEmpty()) { notAvailableForWhatsapp(); return } val text = message?.let { Uri.encode(it) } val uri = if (text != null) Uri.parse("https://wa.me/$digits?text=$text") else Uri.parse("https://wa.me/$digits") try { startActivity(Intent(Intent.ACTION_VIEW, uri)) } catch (_: Exception) { Toast.makeText(context, "WhatsApp is not installed", Toast.LENGTH_SHORT).show() } } // ---- Recording playback ---- private fun playRecording(item: CallStats.CallItem) { if (!prefs.isRegistered) { Toast.makeText(context, "Register the app first to fetch recordings", Toast.LENGTH_SHORT).show() return } viewLifecycleOwner.lifecycleScope.launch { val resp = try { ApiClient.service.findRecording(prefs.bearer(), item.number, item.dateMs, item.durationSec) } catch (_: Exception) { null } val body = resp?.body() if (body?.ok != true || body.found != true || body.recordingId == null) { Toast.makeText(context, "No recording found for this call", Toast.LENGTH_SHORT).show() return@launch } showPlayerDialog(body.recordingId, prefs.apiToken.orEmpty()) } } private fun showPlayerDialog(recordingId: Long, token: String) { mediaPlayer?.release() val url = ApiConfig.recordingStreamUrl(recordingId, token) val dialog = AlertDialog.Builder(requireContext()) .setTitle("Loading recording…") .setMessage("Downloading…") .setNegativeButton("Stop") { d, _ -> mediaPlayer?.release(); mediaPlayer = null; d.dismiss() } .setCancelable(false) .create() dialog.show() // MediaPlayer's built-in HTTP streaming has proven unreliable against this // server (works instantly in a browser, but hangs for minutes via // MediaPlayer directly) — download the file first with OkHttp, which is // already proven reliable elsewhere in this app, then play it locally. viewLifecycleOwner.lifecycleScope.launch { val localFile = try { kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { val t0 = System.currentTimeMillis() val client = okhttp3.OkHttpClient.Builder() .connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS) .readTimeout(15, java.util.concurrent.TimeUnit.SECONDS) .writeTimeout(15, java.util.concurrent.TimeUnit.SECONDS) .retryOnConnectionFailure(false) .build() val request = okhttp3.Request.Builder().url(url).build() val bytes = client.newCall(request).execute().use { resp -> android.util.Log.d("RecPlay", "response received after ${System.currentTimeMillis() - t0}ms, code=${resp.code}") if (!resp.isSuccessful) throw java.io.IOException("HTTP ${resp.code}") resp.body?.bytes() ?: throw java.io.IOException("empty response") } android.util.Log.d("RecPlay", "body read after ${System.currentTimeMillis() - t0}ms, ${bytes.size} bytes") val f = java.io.File(requireContext().cacheDir, "play_$recordingId.m4a") f.writeBytes(bytes) android.util.Log.d("RecPlay", "file written after ${System.currentTimeMillis() - t0}ms") f } } catch (e: Exception) { dialog.dismiss() Toast.makeText(context, "Couldn't download the recording: ${e.message}", Toast.LENGTH_SHORT).show() return@launch } dialog.setMessage("Playing…") mediaPlayer = MediaPlayer().apply { setOnCompletionListener { dialog.setMessage("Finished") release(); mediaPlayer = null localFile.delete() } setOnErrorListener { _, _, _ -> dialog.dismiss() Toast.makeText(context, "Couldn't play the recording", Toast.LENGTH_SHORT).show() localFile.delete() true } try { setDataSource(localFile.absolutePath) prepare() // local file — synchronous prepare is fast, no need for prepareAsync start() } catch (e: Exception) { dialog.dismiss() Toast.makeText(context, "Couldn't play the recording", Toast.LENGTH_SHORT).show() localFile.delete() } } } } override fun onDestroyView() { mediaPlayer?.release(); mediaPlayer = null super.onDestroyView() } }