Call history/recording improvements

This commit is contained in:
Juha Heinanen
2025-12-06 08:28:00 +02:00
parent 1bd304fee9
commit 373c1feb00
5 changed files with 189 additions and 125 deletions

View File

@ -64,7 +64,6 @@ import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.tutpro.baresip.CallRow.Details
import com.tutpro.baresip.CustomElements.AlertDialog
import com.tutpro.baresip.CustomElements.verticalScrollbar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@ -266,6 +265,8 @@ private fun startTime(detail: Details): String {
private fun Duration(ctx: Context, detail: Details, durationText: String) {
val showDialog = 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()
@ -282,10 +283,8 @@ private fun Duration(ctx: Context, detail: Details, durationText: String) {
}
)
val isRawState = recording[0] != "" && recording[1] != ""
val isMergedState = recording[0] != "" && recording[1] == ""
if (isRawState || isMergedState) {
val hasRecording = recording[0] != ""
if (hasRecording) {
Text(
text = durationText,
color = MaterialTheme.colorScheme.error,
@ -296,49 +295,65 @@ private fun Duration(ctx: Context, detail: Details, durationText: String) {
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 (isRawState) {
if (currentIsRaw) {
val fileIn = File(recording[0])
val fileOut = File(recording[1])
val mergedFileName =
"merged_${fileIn.nameWithoutExtension}_${fileOut.nameWithoutExtension}.wav"
val mergedFile = File(BaresipService.filesPath + "/tmp", mergedFileName)
// 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 {
// 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 {
File(BaresipService.filesPath + "/tmp").mkdirs()
if (Utils.mergeWavFiles(fileIn, fileOut, mergedFile)) {
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}")
}
}
}
// If merge successful, update state and delete originals
if (finalFile != null && finalFile.exists()) {
// Update the object state
// We put the merged path in [0] and clear [1]
recording[0] = finalFile.absolutePath
recording[1] = ""
// Delete the original raw files
try {
if (fileIn.exists()) fileIn.delete()
if (fileOut.exists()) fileOut.delete()
// Persist changes so the app remembers the file is merged
CallHistoryNew.save()
} catch (e: Exception) {
Log.w(TAG, "MergeWav: Failed to delete original files: ${e.message}")
}
} 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]}")
}
}
else {
// We are already in merged state
Log.d(TAG, "Using already merged file: ${recording[0]}")
finalFile = File(recording[0])
}
withContext(Dispatchers.Main) {
if (finalFile != null && finalFile.exists()) {
@ -369,14 +384,12 @@ private fun Duration(ctx: Context, detail: Details, durationText: String) {
Log.e(TAG, "Playback failed: $e")
Toast.makeText(ctx, "Playback error", Toast.LENGTH_SHORT).show()
}
}
else {
} else {
Toast.makeText(ctx, "Failed to process audio file", Toast.LENGTH_SHORT).show()
}
}
}
}
else {
} else {
mediaPlayer.stop()
mediaPlayer.reset()
showDialog.value = false

View File

@ -15,14 +15,14 @@ class CallHistoryNew(val aor: String, val peerUri: String, val direction: String
var startTime: GregorianCalendar? = null
var stopTime = GregorianCalendar() // Set to time when call is closed
var rejected = false
var recording = arrayOf("", "") // Encoder and decoder recording files
var recording = arrayOf("", "") // Encoder and decoder recording files, merged file is in [0]
fun add() {
BaresipService.callHistory.add(this)
val aorSpecificHistory = BaresipService.callHistory.filter { it.aor == this.aor }
if (aorSpecificHistory.size > CALL_HISTORY_SIZE) {
val oldestToRemove = aorSpecificHistory.first()
deleteRecording(oldestToRemove.recording)
deleteRecordingFiles(oldestToRemove.recording)
BaresipService.callHistory.remove(oldestToRemove)
}
save()
@ -43,7 +43,7 @@ class CallHistoryNew(val aor: String, val peerUri: String, val direction: String
for (i in BaresipService.callHistory.indices.reversed()) {
val h = BaresipService.callHistory[i]
if (h.aor == aor) {
deleteRecording(h.recording)
deleteRecordingFiles(h.recording)
BaresipService.callHistory.removeAt(i)
}
}
@ -82,11 +82,17 @@ class CallHistoryNew(val aor: String, val peerUri: String, val direction: String
}
}
fun deleteRecording(recording: Array<String>) {
fun deleteRecordingFiles(recording: Array<String>) {
Utils.deleteFile(File(recording[0]))
Utils.deleteFile(File(recording[1]))
}
fun clearRecordings() {
for (h in BaresipService.callHistory) {
h.recording = arrayOf("", "")
}
}
@Suppress("UNUSED")
fun print() {
for (h in BaresipService.callHistory)

View File

@ -41,6 +41,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
@ -60,6 +61,9 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType
@ -86,13 +90,28 @@ private fun CallsScreen(navController: NavController, viewModel: ViewModel, aor:
val account = Account.ofAor(aor)!!
val callHistory: MutableState<List<CallRow>> = remember { mutableStateOf(emptyList()) }
var isHistoryLoaded by remember { mutableStateOf(false) }
LaunchedEffect(aor) {
var refreshTrigger by remember { mutableStateOf(0) }
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(aor, refreshTrigger) {
callHistory.value = loadCallHistory(aor)
isHistoryLoaded = true
}
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
refreshTrigger++
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
BackHandler(enabled = true) {
account.missedCalls = false
navController.popBackStack()
@ -199,7 +218,6 @@ private fun TopAppBar(navController: NavController, account: Account, callHistor
}
showDialog.value = true
}
disable, enable -> {
account.callHistory = !account.callHistory
Account.saveAccounts()
@ -478,12 +496,12 @@ private fun loadCallHistory(aor: String): MutableList<CallRow> {
private fun removeFromHistory(callHistory: MutableState<List<CallRow>>, callRow: CallRow) {
for (details in callRow.details) {
CallHistoryNew.deleteRecording(details.recording)
CallHistoryNew.deleteRecordingFiles(details.recording)
BaresipService.callHistory.removeAll {
it.startTime == details.startTime && it.stopTime == details.stopTime
}
}
CallHistoryNew.deleteRecording(callRow.recording)
CallHistoryNew.deleteRecordingFiles(callRow.recording)
val updatedList = callHistory.value.filterNot { it == callRow }
callHistory.value = updatedList
CallHistoryNew.save()

View File

@ -46,14 +46,13 @@ import java.io.IOException
import java.io.InputStream
import java.io.ObjectInputStream
import java.io.ObjectOutputStream
import java.io.RandomAccessFile
import java.io.Serializable
import java.lang.reflect.Method
import java.net.InetAddress
import java.net.NetworkInterface
import java.net.SocketException
import java.net.URL
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.security.KeyStore
import java.security.SecureRandom
import java.security.cert.CertificateException
@ -1148,108 +1147,135 @@ object Utils {
return bitmap
}
/**
* Merges two mono WAV files into a single stereo WAV file.
* @param file1 The first WAV file (Left channel)* @param file2 The second WAV file (Right channel)
* @param outFile The destination file
* @return True if successful, False otherwise
*/
fun mergeWavFiles(file1: File, file2: File, outFile: File): Boolean {
Log.d(TAG, "MergeWav: Input1 size=${file1.length()}, Input2 size=${file2.length()}")
fun mergeWavFiles(file1: File, file2: File, mergedFile: File): Boolean {
try {
val in1 = FileInputStream(file1)
val in2 = FileInputStream(file2)
val out = FileOutputStream(outFile)
val fis1 = FileInputStream(file1)
val fis2 = FileInputStream(file2)
val fos = FileOutputStream(mergedFile)
val header1 = ByteArray(44)
val header2 = ByteArray(44)
// Skip headers (assumed 44 bytes for standard WAV)
// NOTE: A robust implementation parses the header to find the 'data' chunk.
// For this quick fix, assuming 44 bytes is standard for Baresip output.
val headerSize = 44
val header1 = ByteArray(headerSize)
val header2 = ByteArray(headerSize)
if (in1.read(header1) != 44 || in2.read(header2) != 44) {
Log.e(TAG, "MergeWav: Failed to read headers")
if (fis1.read(header1) != headerSize || fis2.read(header2) != headerSize) {
Log.e(TAG, "MergeWav: Files too small")
return false
}
// Parse data sizes from the input headers (Little Endian, offset 40)
val dataSize1 = ByteBuffer.wrap(header1, 40, 4).order(ByteOrder.LITTLE_ENDIAN).int
val dataSize2 = ByteBuffer.wrap(header2, 40, 4).order(ByteOrder.LITTLE_ENDIAN).int
Log.d(TAG, "MergeWav: DataChunk1=$dataSize1, DataChunk2=$dataSize2")
// Since we are converting 2x Mono to 1x Stereo, the size doubles.
// If one file is shorter, we will pad it with silence (0s).
val maxDataSize = kotlin.math.max(dataSize1, dataSize2)
val totalDataSize = maxDataSize * 2
Log.d(TAG, "MergeWav: Calculated Target DataSize=$totalDataSize")
// Prepare new header based on header1
// Construct new header for stereo
// Copy header from file1 but update channels to 2 and block align
val newHeader = header1.clone()
newHeader[22] = 2 // Channels = Stereo
newHeader[32] = 4 // BlockAlign = 2 * 16bit / 8 = 4
val sampleRate = ByteBuffer.wrap(header1, 24, 4).order(ByteOrder.LITTLE_ENDIAN).int
val byteRate = sampleRate * 2 * 2 // SampleRate * Channels * Bits/8
// 1. Update File Size (Indices 4-7) - placeholder, fixed at end
// 2. Update Channels (Index 22) to 2 (Stereo)
newHeader[22] = 2
newHeader[23] = 0
ByteBuffer.wrap(newHeader, 28, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(byteRate)
ByteBuffer.wrap(newHeader, 40, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(totalDataSize)
ByteBuffer.wrap(newHeader, 4, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(totalDataSize + 36)
// 3. Update Block Align (Index 32) - usually 2*Channels (16bit) -> 4
newHeader[32] = 4
newHeader[33] = 0
out.write(newHeader)
// 4. Update Byte Rate (Index 28) - usually SampleRate * BlockAlign
// Assuming 8000Hz sample rate: 8000 * 4 = 32000
// You should calculate this dynamically based on the input header if possible.
// For now, copying the rest is usually "okay" if players are lenient,
// but setting channels to 2 is the critical part.
val buffer1 = ByteArray(2)
val buffer2 = ByteArray(2)
val silence = ByteArray(2) // Default 0s
fos.write(newHeader)
// MERGE LOOP with Buffering
val bufferSize = 4096 // 4KB buffer
val buffer1 = ByteArray(bufferSize)
val buffer2 = ByteArray(bufferSize)
val stereoBuffer = ByteArray(bufferSize * 2) // Output is twice as large
// Loop until the LONGEST file is finished
var bytesRead1: Int
var bytesRead2: Int
var totalBytesWritten = 0
var totalBytesData = 0
// We use a do-while or simpler loop structure to handle uneven lengths
while (true) {
bytesRead1 = in1.read(buffer1)
bytesRead2 = in2.read(buffer2)
bytesRead1 = fis1.read(buffer1)
bytesRead2 = fis2.read(buffer2)
// If both are done, stop
if (bytesRead1 == -1 && bytesRead2 == -1) break
// Write Left Channel (File 1)
if (bytesRead1 != -1) {
out.write(buffer1, 0, bytesRead1)
totalBytesWritten += bytesRead1
} else {
// File 1 ended, write silence
out.write(silence)
totalBytesWritten += 2
// Use the smaller read count to avoid out of bounds if files differ slightly
val limit = maxOf(bytesRead1, bytesRead2)
var outIndex = 0
// Interleave samples (Simple Left/Right merge)
// Assuming 16-bit audio (2 bytes per sample)
for (i in 0 until limit step 2) {
// Left Channel (File 1)
if (i + 1 < bytesRead1) {
stereoBuffer[outIndex++] = buffer1[i]
stereoBuffer[outIndex++] = buffer1[i+1]
} else {
// Padding if file1 ended
stereoBuffer[outIndex++] = 0
stereoBuffer[outIndex++] = 0
}
// Right Channel (File 2)
if (i + 1 < bytesRead2) {
stereoBuffer[outIndex++] = buffer2[i]
stereoBuffer[outIndex++] = buffer2[i+1]
} else {
// Padding if file2 ended
stereoBuffer[outIndex++] = 0
stereoBuffer[outIndex++] = 0
}
}
// Write Right Channel (File 2)
if (bytesRead2 != -1) {
out.write(buffer2, 0, bytesRead2)
totalBytesWritten += bytesRead2
} else {
// File 2 ended, write silence
out.write(silence)
totalBytesWritten += 2
}
fos.write(stereoBuffer, 0, outIndex)
totalBytesData += outIndex
}
Log.d(TAG, "MergeWav: Finished. Actual bytes written: $totalBytesWritten")
fis1.close()
fis2.close()
in1.close()
in2.close()
out.close()
// Fix Header Sizes
// ChunkSize (4-7) = TotalFileSize - 8
val totalFileSize = totalBytesData + 44 - 8
val rFile = RandomAccessFile(mergedFile, "rw")
rFile.seek(4)
rFile.write(intToLittleEndian(totalFileSize), 0, 4)
// Subchunk2Size (40-43) = DataSize
rFile.seek(40)
rFile.write(intToLittleEndian(totalBytesData), 0, 4)
rFile.close()
fos.close()
return true
} catch (e: Exception) {
Log.e(TAG, "MergeWav Failed: $e")
Log.e(TAG, "MergeWav error: $e")
return false
}
}
// Helper for header writing
private fun intToLittleEndian(value: Int): ByteArray {
return byteArrayOf(
(value and 0xff).toByte(),
(value shr 8 and 0xff).toByte(),
(value shr 16 and 0xff).toByte(),
(value shr 24 and 0xff).toByte()
)
}
fun createEmptyFile(path: String): File {
val file = File(path)
if (file.exists()) {
file.delete()
}
file.createNewFile()
return file
}
@Suppress("unused")
fun listFilesInDirectory(directoryPath: String): List<File> {
val directory = File(directoryPath)

View File

@ -272,6 +272,7 @@
<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>