diff --git a/app/src/main/kotlin/com/tutpro/baresip/CallDetailsScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/CallDetailsScreen.kt index 0b1f0da1..f34e5005 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/CallDetailsScreen.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/CallDetailsScreen.kt @@ -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 diff --git a/app/src/main/kotlin/com/tutpro/baresip/CallHistory.kt b/app/src/main/kotlin/com/tutpro/baresip/CallHistory.kt index 8c08fd07..f910290c 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/CallHistory.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/CallHistory.kt @@ -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) { + fun deleteRecordingFiles(recording: Array) { 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) diff --git a/app/src/main/kotlin/com/tutpro/baresip/CallsScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/CallsScreen.kt index d7191fdc..94ca0fa7 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/CallsScreen.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/CallsScreen.kt @@ -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> = 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 { private fun removeFromHistory(callHistory: MutableState>, 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() diff --git a/app/src/main/kotlin/com/tutpro/baresip/Utils.kt b/app/src/main/kotlin/com/tutpro/baresip/Utils.kt index 8409454b..0cd9e733 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/Utils.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/Utils.kt @@ -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 { val directory = File(directoryPath) diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 891942a4..ab14e437 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -272,6 +272,7 @@ Aika Kesto Tallenteen kuuntelu … + Lopeta Haluatko luoda uuden yhteystiedon \'%1$s\' tai poistaa %2$s puheluhistoriasta\?