Call history/recording improvements
This commit is contained in:
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user