Added possibility to save recording and delete call from history

This commit is contained in:
Juha Heinanen
2025-12-07 02:58:14 +02:00
parent 673b25d6cd
commit 5a5c520539
5 changed files with 249 additions and 119 deletions

View File

@ -1116,7 +1116,14 @@ class BaresipService: Service() {
delay(500)
val rxFile = File(call.dumpfiles[0])
val txFile = File(call.dumpfiles[1])
val mergedFile = File(filesPath, "${rxFile.nameWithoutExtension}_stereo.wav")
val mergedFileName = rxFile.name
.replace("dump", "rec")
.replace("=>", "-")
.replace("sip:", "")
.replace("-enc", "")
.replace("*", "#")
.replace(";user=phone", "")
val mergedFile = File(filesPath, mergedFileName)
if (Utils.mergeWavFiles(rxFile, txFile, mergedFile)) {
Log.d(TAG, "Automatic merge succeeded.")
history.recording = arrayOf(mergedFile.absolutePath, "")

View File

@ -5,8 +5,12 @@ import android.media.AudioAttributes
import android.media.MediaPlayer
import android.text.format.DateUtils
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -50,6 +54,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -84,7 +90,7 @@ fun NavGraphBuilder.callDetailsScreenRoute(navController: NavController, viewMod
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CallDetailsScreen(navController: NavController, callRow: CallRow) {
val detailsState = remember { callRow.details.toMutableStateList() }
Scaffold(
modifier = Modifier
.fillMaxSize()
@ -122,13 +128,31 @@ private fun CallDetailsScreen(navController: NavController, callRow: CallRow) {
}
},
content = { contentPadding ->
CallDetailsContent(LocalContext.current, contentPadding, callRow)
CallDetailsContent(
LocalContext.current,
contentPadding,
callRow,
detailsState,
onDelete = { detail ->
detailsState.remove(detail)
// If the list becomes empty, you might want to pop back
if (detailsState.isEmpty()) {
navController.popBackStack()
}
}
)
},
)
}
@Composable
private fun CallDetailsContent(ctx: Context, contentPadding: PaddingValues, callRow: CallRow) {
private fun CallDetailsContent(
ctx: Context,
contentPadding: PaddingValues,
callRow: CallRow,
details: SnapshotStateList<Details>,
onDelete: (Details) -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
@ -137,7 +161,7 @@ private fun CallDetailsContent(ctx: Context, contentPadding: PaddingValues, call
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Peer(ctx, callRow)
Details(ctx, callRow.details)
Details(ctx, details, onDelete)
}
}
@ -155,7 +179,7 @@ private fun Peer(ctx: Context, callRow: CallRow) {
}
@Composable
private fun Details(ctx: Context, details: ArrayList<Details>) {
private fun Details(ctx: Context, details: SnapshotStateList<Details>, onDelete: (Details) -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = stringResource(R.string.direction),
fontSize = 16.sp,
@ -216,7 +240,7 @@ private fun Details(ctx: Context, details: ArrayList<Details>) {
)
}
Spacer(modifier = Modifier.width(78.dp))
val durationText = startTime(detail)
val durationText = startTime(detail, onDelete)
Spacer(modifier = Modifier.weight(1f))
Duration(ctx, detail, durationText)
}
@ -225,7 +249,8 @@ private fun Details(ctx: Context, details: ArrayList<Details>) {
}
@Composable
private fun startTime(detail: Details): String {
private fun startTime(detail: Details, onDelete: (Details) -> Unit): String {
val startTime = detail.startTime
val stopTime = detail.stopTime
val startTimeText: String
@ -257,32 +282,111 @@ private fun startTime(detail: Details): String {
durationText = DateUtils.formatElapsedTime(duration)
}
}
Text(text = startTimeText)
val showDialog = remember { mutableStateOf(false) }
val positiveAction = remember { mutableStateOf({}) }
CustomElements.AlertDialog(
showDialog = showDialog,
title = stringResource(R.string.confirmation),
message = stringResource(R.string.delete_call_alert),
positiveButtonText = stringResource(R.string.delete),
onPositiveClicked = {
CallHistoryNew.remove(detail.startTime, detail.stopTime)
onDelete(detail)
},
negativeButtonText = stringResource(R.string.cancel)
)
Text(
text = startTimeText,
modifier = Modifier.combinedClickable(
onClick = {},
onLongClick = {
positiveAction.value = {
CallHistoryNew.remove(startTime, stopTime)
}
showDialog.value = true
}
)
)
return durationText
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun Duration(ctx: Context, detail: Details, durationText: String) {
val showDialog = remember { mutableStateOf(false) }
val showPlaybackDialog = remember { mutableStateOf(false) }
val showDownloadDialog = remember { mutableStateOf(false) }
// NOTE: If detail.recording is modified elsewhere, this reference sees the change
// because Array is mutable, but Compose won't trigger a redraw.
val recording = detail.recording
val mediaPlayer = remember { MediaPlayer() }
val scope = rememberCoroutineScope()
// 1. Setup the File Saver Launcher
val saveLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("audio/x-wav")
) { uri ->
uri?.let { destinationUri ->
scope.launch(Dispatchers.IO) {
try {
val sourceFile = File(recording[0])
if (sourceFile.exists()) {
ctx.contentResolver.openOutputStream(destinationUri)?.use { output ->
FileInputStream(sourceFile).use { input ->
input.copyTo(output)
}
}
withContext(Dispatchers.Main) {
Toast.makeText(ctx, ctx.getString(R.string.recording_saved),
Toast.LENGTH_SHORT).show()
}
} else {
withContext(Dispatchers.Main) {
Toast.makeText(ctx, "Source file not found",
Toast.LENGTH_SHORT).show()
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to save file: $e")
withContext(Dispatchers.Main) {
Toast.makeText(ctx, "Save failed", Toast.LENGTH_SHORT).show()
}
}
}
}
}
PlaybackDialog(
showDialog = showDialog,
showDialog = showPlaybackDialog,
mediaPlayer = mediaPlayer,
onStop = {
if (mediaPlayer.isPlaying) {
mediaPlayer.stop()
}
mediaPlayer.reset()
showDialog.value = false
showPlaybackDialog.value = false
}
)
// 2. Download Confirmation Dialog
if (showDownloadDialog.value) {
CustomElements.AlertDialog(
showDialog = showDownloadDialog,
title = stringResource(R.string.save_recording),
message = stringResource(R.string.save_recording_question),
positiveButtonText = stringResource(R.string.save),
onPositiveClicked = {
showDownloadDialog.value = false
val suggestedName = File(recording[0]).name
saveLauncher.launch(suggestedName)
},
negativeButtonText = stringResource(R.string.cancel)
)
}
val hasRecording = recording[0] != ""
if (hasRecording) {
Text(
@ -290,111 +394,114 @@ private fun Duration(ctx: Context, detail: Details, durationText: String) {
color = MaterialTheme.colorScheme.error,
modifier = Modifier
.padding(end = 12.dp)
.clickable(onClick = {
if (!mediaPlayer.isPlaying) {
mediaPlayer.reset()
scope.launch(Dispatchers.IO) {
var finalFile: File? = null
// RE-EVALUATE STATE INSIDE THE CLICK LISTENER
val currentIsRaw = recording[0] != "" && recording[1] != ""
val currentIsMerged = recording[0] != "" && recording[1] == ""
.combinedClickable(
onLongClick = {
showDownloadDialog.value = true
},
onClick = {
if (!mediaPlayer.isPlaying) {
mediaPlayer.reset()
scope.launch(Dispatchers.IO) {
var finalFile: File? = null
// RE-EVALUATE STATE INSIDE THE CLICK LISTENER
val currentIsRaw = recording[0] != "" && recording[1] != ""
val currentIsMerged = recording[0] != "" && recording[1] == ""
if (currentIsRaw) {
val fileIn = File(recording[0])
val fileOut = File(recording[1])
// SAFETY CHECK: If the raw file is gone, the background service
// likely finished merging just now.
if (!fileIn.exists()) {
// Try to find the merged file based on naming convention as fallback
val expectedMergedName = "merged_${fileIn.nameWithoutExtension}_${fileOut.nameWithoutExtension}.wav"
val fallbackMerged = File(BaresipService.filesPath + "/recordings", expectedMergedName)
if (fallbackMerged.exists()) {
Log.d(TAG, "Raw file missing, found merged fallback: ${fallbackMerged.name}")
finalFile = fallbackMerged
// Update state to match reality
recording[0] = fallbackMerged.absolutePath
recording[1] = ""
if (currentIsRaw) {
val fileIn = File(recording[0])
val fileOut = File(recording[1])
// SAFETY CHECK: If the raw file is gone, the background service
// likely finished merging just now.
if (!fileIn.exists()) {
// Try to find the merged file based on naming convention as fallback
val expectedMergedName = "merged_${fileIn.nameWithoutExtension}_${fileOut.nameWithoutExtension}.wav"
val fallbackMerged = File(BaresipService.filesPath + "/recordings", expectedMergedName)
if (fallbackMerged.exists()) {
Log.d(TAG, "Raw file missing, found merged fallback: ${fallbackMerged.name}")
finalFile = fallbackMerged
// Update state to match reality
recording[0] = fallbackMerged.absolutePath
recording[1] = ""
} else {
Log.e(TAG, "Raw file missing and fallback not found: ${recording[0]}")
}
} else {
Log.e(TAG, "Raw file missing and fallback not found: ${recording[0]}")
}
} else {
// Normal Raw processing
val mergedFileName = "merged_${fileIn.nameWithoutExtension}_${fileOut.nameWithoutExtension}.wav"
val mergedFile = File(BaresipService.filesPath + "/recordings", mergedFileName)
if (mergedFile.exists()) {
Log.d(TAG, "Using already merged file: ${mergedFile.name}")
finalFile = mergedFile
} else {
if (Utils.mergeWavFiles(fileIn, fileOut, mergedFile)) {
// Normal Raw processing
val mergedFileName = "merged_${fileIn.nameWithoutExtension}_${fileOut.nameWithoutExtension}.wav"
val mergedFile = File(BaresipService.filesPath + "/recordings", mergedFileName)
if (mergedFile.exists()) {
Log.d(TAG, "Using already merged file: ${mergedFile.name}")
finalFile = mergedFile
} else {
if (Utils.mergeWavFiles(fileIn, fileOut, mergedFile)) {
finalFile = mergedFile
}
}
// If merge successful, update state and delete originals
if (finalFile != null && finalFile.exists()) {
recording[0] = finalFile.absolutePath
recording[1] = ""
try {
if (fileIn.exists()) fileIn.delete()
if (fileOut.exists()) fileOut.delete()
CallHistoryNew.save()
} catch (e: Exception) {
Log.w(TAG, "MergeWav: Failed to delete original files: ${e.message}")
}
}
}
} else if (currentIsMerged) {
val f = File(recording[0])
if (f.exists()) {
Log.d(TAG, "Using already merged file: ${recording[0]}")
finalFile = f
} else {
Log.e(TAG, "Merged file record exists but file is missing: ${recording[0]}")
}
}
// If merge successful, update state and delete originals
withContext(Dispatchers.Main) {
if (finalFile != null && finalFile.exists()) {
recording[0] = finalFile.absolutePath
recording[1] = ""
try {
if (fileIn.exists()) fileIn.delete()
if (fileOut.exists()) fileOut.delete()
CallHistoryNew.save()
mediaPlayer.apply {
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.setUsage(AudioAttributes.USAGE_MEDIA)
.build()
)
val fis = FileInputStream(finalFile)
setDataSource(fis.fd)
fis.close()
setOnPreparedListener {
it.start()
showPlaybackDialog.value = true
}
setOnCompletionListener {
showPlaybackDialog.value = false
it.reset()
}
prepareAsync()
}
} catch (e: Exception) {
Log.w(TAG, "MergeWav: Failed to delete original files: ${e.message}")
Log.e(TAG, "Playback failed: $e")
Toast.makeText(ctx, "Playback error",
Toast.LENGTH_SHORT).show()
}
} else {
Toast.makeText(ctx, "Failed to process audio file",
Toast.LENGTH_SHORT).show()
}
}
} else if (currentIsMerged) {
// We are in merged state
val f = File(recording[0])
if (f.exists()) {
Log.d(TAG, "Using already merged file: ${recording[0]}")
finalFile = f
} else {
Log.e(TAG, "Merged file record exists but file is missing: ${recording[0]}")
}
}
withContext(Dispatchers.Main) {
if (finalFile != null && finalFile.exists()) {
try {
mediaPlayer.apply {
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.setUsage(AudioAttributes.USAGE_MEDIA)
.build()
)
val fis = FileInputStream(finalFile)
setDataSource(fis.fd)
fis.close()
setOnPreparedListener {
it.start()
Log.d(TAG, "Started playback")
showDialog.value = true
}
setOnCompletionListener {
Log.d(TAG, "Playback complete")
showDialog.value = false
it.reset()
}
prepareAsync()
}
} catch (e: Exception) {
Log.e(TAG, "Playback failed: $e")
Toast.makeText(ctx, "Playback error", Toast.LENGTH_SHORT).show()
}
} else {
Toast.makeText(ctx, "Failed to process audio file", Toast.LENGTH_SHORT).show()
}
}
} else {
mediaPlayer.stop()
mediaPlayer.reset()
showPlaybackDialog.value = false
}
} else {
mediaPlayer.stop()
mediaPlayer.reset()
showDialog.value = false
}
})
)
)
} else {
Text(text = durationText, modifier = Modifier.padding(end = 12.dp))
@ -410,8 +517,6 @@ private fun PlaybackDialog(
if (showDialog.value) {
// State to hold progress (0.0f to 1.0f)
var currentProgress by remember { mutableFloatStateOf(0f) }
// Formatted time strings
var currentPositionText by remember { mutableStateOf("00:00") }
var totalDurationText by remember { mutableStateOf("00:00") }
@ -445,9 +550,7 @@ private fun PlaybackDialog(
) {
LinearProgressIndicator(
progress = { currentProgress },
modifier = Modifier
.fillMaxWidth()
.height(8.dp),
modifier = Modifier.fillMaxWidth().height(8.dp),
)
Spacer(modifier = Modifier.height(8.dp))
Row(
@ -461,9 +564,7 @@ private fun PlaybackDialog(
},
confirmButton = {
TextButton(
onClick = {
onStop()
}
onClick = { onStop() }
) {
Text(stringResource(R.string.stop))
}

View File

@ -50,6 +50,18 @@ class CallHistoryNew(val aor: String, val peerUri: String, val direction: String
save()
}
fun remove(startTime: GregorianCalendar?, stopTime: GregorianCalendar) {
val iterator = BaresipService.callHistory.iterator()
while (iterator.hasNext()) {
val h = iterator.next()
if (h.startTime == startTime && h.stopTime == stopTime) {
deleteRecordingFiles(h.recording)
iterator.remove()
}
}
save()
}
fun save() {
Log.d(TAG, "Saving history of ${BaresipService.callHistory.size} calls")
val file = File(BaresipService.filesPath + "/call_history")

View File

@ -254,8 +254,6 @@
<string name="decrypt_password">Palauta salasanalla</string>
<string name="delete_account">Haluatko poistaa tilin \'%1$s\'\?</string>
<!-- Baresip Service -->
<string name="reply">Vastaa</string>
<string name="save">Talleta</string>
<string name="is_calling">soittaa</string>
<string name="missed_call_from">Vastaamaton puhelu soittajalta</string>
<string name="missed_calls">Vastaamattomia puheluita</string>
@ -271,8 +269,6 @@
<string name="peer">Kumppani</string>
<string name="time">Aika</string>
<string name="calls_duration">Kesto</string>
<string name="playing_recording">Tallenteen kuuntelu …</string>
<string name="stop">Lopeta</string>
<string name="calls_add_delete_question">Haluatko luoda uuden yhteystiedon \'%1$s\' tai poistaa
%2$s puheluhistoriasta\?
</string>
@ -284,6 +280,12 @@
<string name="call_answered_elsewhere">Puheluun vastattu muualla</string>
<string name="call_missed">Puheluun ei vastattu</string>
<string name="call_rejected">Puhelu hylätty</string>
<!-- Call Details Activity -->
<string name="playing_recording">Tallenteen kuuntelu …</string>
<string name="save_recording">Talleta tallenne</string>
<string name="save_recording_question">Haluatko tallentaa tämän tallenteen?</string>
<string name="recording_saved">Tallenne tallennettu</string>
<string name="delete_call_alert">Haluatko poistaa tämän puhelun historiata?</string>
<!-- Chat Activity -->
<string name="chat_with">Viestiketju %1$s</string>
<string name="new_message">Uusi viesti</string>
@ -460,6 +462,9 @@
<string name="info">Tieto</string>
<string name="notice">Huomio</string>
<string name="cancel">Peruuta</string>
<string name="stop">Lopeta</string>
<string name="reply">Vastaa</string>
<string name="save">Talleta</string>
<string name="ok">OK</string>
<string name="yes">Kyllä</string>
<string name="no">Ei</string>

View File

@ -242,8 +242,6 @@
<string name="decrypt_password">Decrypt Password</string>
<string name="delete_account">Do you want to delete account \'%1$s\'\?</string>
<!-- Baresip Service -->
<string name="reply">Reply</string>
<string name="save">Save</string>
<string name="is_calling">is calling</string>
<string name="missed_call_from">Missed call from</string>
<string name="missed_calls">Missed calls</string>
@ -260,8 +258,6 @@
<string name="direction">Direction</string>
<string name="time">Time</string>
<string name="calls_duration">Duration</string>
<string name="playing_recording">Playing recording …</string>
<string name="stop">Stop</string>
<string name="calls_add_delete_question">Do you want to add \'%1$s\' to contacts or delete
%2$s from call history\?</string>
<string name="calls_delete_question">Do you want to delete \'%1$s\' %2$s from call history\?</string>
@ -272,6 +268,12 @@
<string name="call_answered_elsewhere">Call answered elsewhere</string>
<string name="call_missed">Call missed</string>
<string name="call_rejected">Call rejected</string>
<!-- Call Details Activity -->
<string name="playing_recording">Playing recording …</string>
<string name="save_recording">Save Recording</string>
<string name="save_recording_question">Do you want to save this recording?</string>
<string name="recording_saved">Recording saved</string>
<string name="delete_call_alert">Do you want to delete this call from history?</string>
<!-- Chat Activity -->
<string name="chat_with">Chat with %1$s</string>
<string name="new_message">New message</string>
@ -438,6 +440,9 @@
<string name="info">Info</string>
<string name="notice">Notice</string>
<string name="cancel">Cancel</string>
<string name="stop">Stop</string>
<string name="reply">Reply</string>
<string name="save">Save</string>
<string name="ok">OK</string>
<string name="yes">Yes</string>
<string name="no">No</string>