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
@@ -64,7 +64,6 @@ import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import com.tutpro.baresip.CallRow.Details import com.tutpro.baresip.CallRow.Details
import com.tutpro.baresip.CustomElements.AlertDialog
import com.tutpro.baresip.CustomElements.verticalScrollbar import com.tutpro.baresip.CustomElements.verticalScrollbar
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -266,6 +265,8 @@ private fun startTime(detail: Details): String {
private fun Duration(ctx: Context, detail: Details, durationText: String) { private fun Duration(ctx: Context, detail: Details, durationText: String) {
val showDialog = remember { mutableStateOf(false) } 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 recording = detail.recording
val mediaPlayer = remember { MediaPlayer() } val mediaPlayer = remember { MediaPlayer() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -282,10 +283,8 @@ private fun Duration(ctx: Context, detail: Details, durationText: String) {
} }
) )
val isRawState = recording[0] != "" && recording[1] != "" val hasRecording = recording[0] != ""
val isMergedState = recording[0] != "" && recording[1] == "" if (hasRecording) {
if (isRawState || isMergedState) {
Text( Text(
text = durationText, text = durationText,
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
@@ -296,49 +295,65 @@ private fun Duration(ctx: Context, detail: Details, durationText: String) {
mediaPlayer.reset() mediaPlayer.reset()
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
var finalFile: File? = null 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 fileIn = File(recording[0])
val fileOut = File(recording[1]) val fileOut = File(recording[1])
val mergedFileName = // SAFETY CHECK: If the raw file is gone, the background service
"merged_${fileIn.nameWithoutExtension}_${fileOut.nameWithoutExtension}.wav" // likely finished merging just now.
val mergedFile = File(BaresipService.filesPath + "/tmp", mergedFileName) 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()) { if (mergedFile.exists()) {
Log.d(TAG, "Using already merged file: ${mergedFile.name}") Log.d(TAG, "Using already merged file: ${mergedFile.name}")
finalFile = mergedFile
}
else {
File(BaresipService.filesPath + "/tmp").mkdirs()
if (Utils.mergeWavFiles(fileIn, fileOut, mergedFile)) {
finalFile = mergedFile 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) {
// If merge successful, update state and delete originals // We are in merged state
if (finalFile != null && finalFile.exists()) { val f = File(recording[0])
// Update the object state if (f.exists()) {
// We put the merged path in [0] and clear [1] Log.d(TAG, "Using already merged file: ${recording[0]}")
recording[0] = finalFile.absolutePath finalFile = f
recording[1] = "" } else {
Log.e(TAG, "Merged file record exists but file is missing: ${recording[0]}")
// 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 {
// We are already in merged state
Log.d(TAG, "Using already merged file: ${recording[0]}")
finalFile = File(recording[0])
}
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (finalFile != null && finalFile.exists()) { if (finalFile != null && finalFile.exists()) {
@@ -369,14 +384,12 @@ private fun Duration(ctx: Context, detail: Details, durationText: String) {
Log.e(TAG, "Playback failed: $e") Log.e(TAG, "Playback failed: $e")
Toast.makeText(ctx, "Playback error", Toast.LENGTH_SHORT).show() Toast.makeText(ctx, "Playback error", Toast.LENGTH_SHORT).show()
} }
} } else {
else {
Toast.makeText(ctx, "Failed to process audio file", Toast.LENGTH_SHORT).show() Toast.makeText(ctx, "Failed to process audio file", Toast.LENGTH_SHORT).show()
} }
} }
} }
} } else {
else {
mediaPlayer.stop() mediaPlayer.stop()
mediaPlayer.reset() mediaPlayer.reset()
showDialog.value = false showDialog.value = false
@@ -15,14 +15,14 @@ class CallHistoryNew(val aor: String, val peerUri: String, val direction: String
var startTime: GregorianCalendar? = null var startTime: GregorianCalendar? = null
var stopTime = GregorianCalendar() // Set to time when call is closed var stopTime = GregorianCalendar() // Set to time when call is closed
var rejected = false 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() { fun add() {
BaresipService.callHistory.add(this) BaresipService.callHistory.add(this)
val aorSpecificHistory = BaresipService.callHistory.filter { it.aor == this.aor } val aorSpecificHistory = BaresipService.callHistory.filter { it.aor == this.aor }
if (aorSpecificHistory.size > CALL_HISTORY_SIZE) { if (aorSpecificHistory.size > CALL_HISTORY_SIZE) {
val oldestToRemove = aorSpecificHistory.first() val oldestToRemove = aorSpecificHistory.first()
deleteRecording(oldestToRemove.recording) deleteRecordingFiles(oldestToRemove.recording)
BaresipService.callHistory.remove(oldestToRemove) BaresipService.callHistory.remove(oldestToRemove)
} }
save() save()
@@ -43,7 +43,7 @@ class CallHistoryNew(val aor: String, val peerUri: String, val direction: String
for (i in BaresipService.callHistory.indices.reversed()) { for (i in BaresipService.callHistory.indices.reversed()) {
val h = BaresipService.callHistory[i] val h = BaresipService.callHistory[i]
if (h.aor == aor) { if (h.aor == aor) {
deleteRecording(h.recording) deleteRecordingFiles(h.recording)
BaresipService.callHistory.removeAt(i) 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[0]))
Utils.deleteFile(File(recording[1])) Utils.deleteFile(File(recording[1]))
} }
fun clearRecordings() {
for (h in BaresipService.callHistory) {
h.recording = arrayOf("", "")
}
}
@Suppress("UNUSED") @Suppress("UNUSED")
fun print() { fun print() {
for (h in BaresipService.callHistory) for (h in BaresipService.callHistory)
@@ -41,6 +41,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.NavController
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType import androidx.navigation.NavType
@@ -86,13 +90,28 @@ private fun CallsScreen(navController: NavController, viewModel: ViewModel, aor:
val account = Account.ofAor(aor)!! val account = Account.ofAor(aor)!!
val callHistory: MutableState<List<CallRow>> = remember { mutableStateOf(emptyList()) } val callHistory: MutableState<List<CallRow>> = remember { mutableStateOf(emptyList()) }
var isHistoryLoaded by remember { mutableStateOf(false) } 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) callHistory.value = loadCallHistory(aor)
isHistoryLoaded = true 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) { BackHandler(enabled = true) {
account.missedCalls = false account.missedCalls = false
navController.popBackStack() navController.popBackStack()
@@ -199,7 +218,6 @@ private fun TopAppBar(navController: NavController, account: Account, callHistor
} }
showDialog.value = true showDialog.value = true
} }
disable, enable -> { disable, enable -> {
account.callHistory = !account.callHistory account.callHistory = !account.callHistory
Account.saveAccounts() Account.saveAccounts()
@@ -478,12 +496,12 @@ private fun loadCallHistory(aor: String): MutableList<CallRow> {
private fun removeFromHistory(callHistory: MutableState<List<CallRow>>, callRow: CallRow) { private fun removeFromHistory(callHistory: MutableState<List<CallRow>>, callRow: CallRow) {
for (details in callRow.details) { for (details in callRow.details) {
CallHistoryNew.deleteRecording(details.recording) CallHistoryNew.deleteRecordingFiles(details.recording)
BaresipService.callHistory.removeAll { BaresipService.callHistory.removeAll {
it.startTime == details.startTime && it.stopTime == details.stopTime it.startTime == details.startTime && it.stopTime == details.stopTime
} }
} }
CallHistoryNew.deleteRecording(callRow.recording) CallHistoryNew.deleteRecordingFiles(callRow.recording)
val updatedList = callHistory.value.filterNot { it == callRow } val updatedList = callHistory.value.filterNot { it == callRow }
callHistory.value = updatedList callHistory.value = updatedList
CallHistoryNew.save() CallHistoryNew.save()
+99 -73
View File
@@ -46,14 +46,13 @@ import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.io.ObjectInputStream import java.io.ObjectInputStream
import java.io.ObjectOutputStream import java.io.ObjectOutputStream
import java.io.RandomAccessFile
import java.io.Serializable import java.io.Serializable
import java.lang.reflect.Method import java.lang.reflect.Method
import java.net.InetAddress import java.net.InetAddress
import java.net.NetworkInterface import java.net.NetworkInterface
import java.net.SocketException import java.net.SocketException
import java.net.URL import java.net.URL
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.security.KeyStore import java.security.KeyStore
import java.security.SecureRandom import java.security.SecureRandom
import java.security.cert.CertificateException import java.security.cert.CertificateException
@@ -1148,108 +1147,135 @@ object Utils {
return bitmap return bitmap
} }
/** fun mergeWavFiles(file1: File, file2: File, mergedFile: File): Boolean {
* 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()}")
try { try {
val in1 = FileInputStream(file1) val fis1 = FileInputStream(file1)
val in2 = FileInputStream(file2) val fis2 = FileInputStream(file2)
val out = FileOutputStream(outFile) val fos = FileOutputStream(mergedFile)
val header1 = ByteArray(44) // Skip headers (assumed 44 bytes for standard WAV)
val header2 = ByteArray(44) // 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) { if (fis1.read(header1) != headerSize || fis2.read(header2) != headerSize) {
Log.e(TAG, "MergeWav: Failed to read headers") Log.e(TAG, "MergeWav: Files too small")
return false return false
} }
// Parse data sizes from the input headers (Little Endian, offset 40) // Construct new header for stereo
val dataSize1 = ByteBuffer.wrap(header1, 40, 4).order(ByteOrder.LITTLE_ENDIAN).int // Copy header from file1 but update channels to 2 and block align
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
val newHeader = header1.clone() 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 // 1. Update File Size (Indices 4-7) - placeholder, fixed at end
val byteRate = sampleRate * 2 * 2 // SampleRate * Channels * Bits/8 // 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) // 3. Update Block Align (Index 32) - usually 2*Channels (16bit) -> 4
ByteBuffer.wrap(newHeader, 40, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(totalDataSize) newHeader[32] = 4
ByteBuffer.wrap(newHeader, 4, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(totalDataSize + 36) 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) fos.write(newHeader)
val buffer2 = ByteArray(2)
val silence = ByteArray(2) // Default 0s // 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 bytesRead1: Int
var bytesRead2: 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) { while (true) {
bytesRead1 = in1.read(buffer1) bytesRead1 = fis1.read(buffer1)
bytesRead2 = in2.read(buffer2) bytesRead2 = fis2.read(buffer2)
// If both are done, stop
if (bytesRead1 == -1 && bytesRead2 == -1) break if (bytesRead1 == -1 && bytesRead2 == -1) break
// Write Left Channel (File 1) // Use the smaller read count to avoid out of bounds if files differ slightly
if (bytesRead1 != -1) { val limit = maxOf(bytesRead1, bytesRead2)
out.write(buffer1, 0, bytesRead1) var outIndex = 0
totalBytesWritten += bytesRead1
} else { // Interleave samples (Simple Left/Right merge)
// File 1 ended, write silence // Assuming 16-bit audio (2 bytes per sample)
out.write(silence) for (i in 0 until limit step 2) {
totalBytesWritten += 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) fos.write(stereoBuffer, 0, outIndex)
if (bytesRead2 != -1) { totalBytesData += outIndex
out.write(buffer2, 0, bytesRead2)
totalBytesWritten += bytesRead2
} else {
// File 2 ended, write silence
out.write(silence)
totalBytesWritten += 2
}
} }
Log.d(TAG, "MergeWav: Finished. Actual bytes written: $totalBytesWritten") fis1.close()
fis2.close()
in1.close() // Fix Header Sizes
in2.close() // ChunkSize (4-7) = TotalFileSize - 8
out.close() 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 return true
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "MergeWav Failed: $e") Log.e(TAG, "MergeWav error: $e")
return false 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") @Suppress("unused")
fun listFilesInDirectory(directoryPath: String): List<File> { fun listFilesInDirectory(directoryPath: String): List<File> {
val directory = File(directoryPath) val directory = File(directoryPath)
+1
View File
@@ -272,6 +272,7 @@
<string name="time">Aika</string> <string name="time">Aika</string>
<string name="calls_duration">Kesto</string> <string name="calls_duration">Kesto</string>
<string name="playing_recording">Tallenteen kuuntelu …</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 <string name="calls_add_delete_question">Haluatko luoda uuden yhteystiedon \'%1$s\' tai poistaa
%2$s puheluhistoriasta\? %2$s puheluhistoriasta\?
</string> </string>