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
@@ -1116,7 +1116,14 @@ class BaresipService: Service() {
delay(500) delay(500)
val rxFile = File(call.dumpfiles[0]) val rxFile = File(call.dumpfiles[0])
val txFile = File(call.dumpfiles[1]) 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)) { if (Utils.mergeWavFiles(rxFile, txFile, mergedFile)) {
Log.d(TAG, "Automatic merge succeeded.") Log.d(TAG, "Automatic merge succeeded.")
history.recording = arrayOf(mergedFile.absolutePath, "") history.recording = arrayOf(mergedFile.absolutePath, "")
@@ -5,8 +5,12 @@ import android.media.AudioAttributes
import android.media.MediaPlayer import android.media.MediaPlayer
import android.text.format.DateUtils import android.text.format.DateUtils
import android.widget.Toast 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.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -50,6 +54,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -84,7 +90,7 @@ fun NavGraphBuilder.callDetailsScreenRoute(navController: NavController, viewMod
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun CallDetailsScreen(navController: NavController, callRow: CallRow) { private fun CallDetailsScreen(navController: NavController, callRow: CallRow) {
val detailsState = remember { callRow.details.toMutableStateList() }
Scaffold( Scaffold(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -122,13 +128,31 @@ private fun CallDetailsScreen(navController: NavController, callRow: CallRow) {
} }
}, },
content = { contentPadding -> 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 @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( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -137,7 +161,7 @@ private fun CallDetailsContent(ctx: Context, contentPadding: PaddingValues, call
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Peer(ctx, callRow) Peer(ctx, callRow)
Details(ctx, callRow.details) Details(ctx, details, onDelete)
} }
} }
@@ -155,7 +179,7 @@ private fun Peer(ctx: Context, callRow: CallRow) {
} }
@Composable @Composable
private fun Details(ctx: Context, details: ArrayList<Details>) { private fun Details(ctx: Context, details: SnapshotStateList<Details>, onDelete: (Details) -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = stringResource(R.string.direction), Text(text = stringResource(R.string.direction),
fontSize = 16.sp, fontSize = 16.sp,
@@ -216,7 +240,7 @@ private fun Details(ctx: Context, details: ArrayList<Details>) {
) )
} }
Spacer(modifier = Modifier.width(78.dp)) Spacer(modifier = Modifier.width(78.dp))
val durationText = startTime(detail) val durationText = startTime(detail, onDelete)
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
Duration(ctx, detail, durationText) Duration(ctx, detail, durationText)
} }
@@ -225,7 +249,8 @@ private fun Details(ctx: Context, details: ArrayList<Details>) {
} }
@Composable @Composable
private fun startTime(detail: Details): String {
private fun startTime(detail: Details, onDelete: (Details) -> Unit): String {
val startTime = detail.startTime val startTime = detail.startTime
val stopTime = detail.stopTime val stopTime = detail.stopTime
val startTimeText: String val startTimeText: String
@@ -257,32 +282,111 @@ private fun startTime(detail: Details): String {
durationText = DateUtils.formatElapsedTime(duration) 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 return durationText
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun Duration(ctx: Context, detail: Details, durationText: String) { 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 // 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()
// 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( PlaybackDialog(
showDialog = showDialog, showDialog = showPlaybackDialog,
mediaPlayer = mediaPlayer, mediaPlayer = mediaPlayer,
onStop = { onStop = {
if (mediaPlayer.isPlaying) { if (mediaPlayer.isPlaying) {
mediaPlayer.stop() mediaPlayer.stop()
} }
mediaPlayer.reset() 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] != "" val hasRecording = recording[0] != ""
if (hasRecording) { if (hasRecording) {
Text( Text(
@@ -290,111 +394,114 @@ private fun Duration(ctx: Context, detail: Details, durationText: String) {
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
modifier = Modifier modifier = Modifier
.padding(end = 12.dp) .padding(end = 12.dp)
.clickable(onClick = { .combinedClickable(
if (!mediaPlayer.isPlaying) { onLongClick = {
mediaPlayer.reset() showDownloadDialog.value = true
scope.launch(Dispatchers.IO) { },
var finalFile: File? = null onClick = {
// RE-EVALUATE STATE INSIDE THE CLICK LISTENER if (!mediaPlayer.isPlaying) {
val currentIsRaw = recording[0] != "" && recording[1] != "" mediaPlayer.reset()
val currentIsMerged = recording[0] != "" && recording[1] == "" 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) { if (currentIsRaw) {
val fileIn = File(recording[0]) val fileIn = File(recording[0])
val fileOut = File(recording[1]) val fileOut = File(recording[1])
// SAFETY CHECK: If the raw file is gone, the background service // SAFETY CHECK: If the raw file is gone, the background service
// likely finished merging just now. // likely finished merging just now.
if (!fileIn.exists()) { if (!fileIn.exists()) {
// Try to find the merged file based on naming convention as fallback // Try to find the merged file based on naming convention as fallback
val expectedMergedName = "merged_${fileIn.nameWithoutExtension}_${fileOut.nameWithoutExtension}.wav" val expectedMergedName = "merged_${fileIn.nameWithoutExtension}_${fileOut.nameWithoutExtension}.wav"
val fallbackMerged = File(BaresipService.filesPath + "/recordings", expectedMergedName) val fallbackMerged = File(BaresipService.filesPath + "/recordings", expectedMergedName)
if (fallbackMerged.exists()) { if (fallbackMerged.exists()) {
Log.d(TAG, "Raw file missing, found merged fallback: ${fallbackMerged.name}") Log.d(TAG, "Raw file missing, found merged fallback: ${fallbackMerged.name}")
finalFile = fallbackMerged finalFile = fallbackMerged
// Update state to match reality // Update state to match reality
recording[0] = fallbackMerged.absolutePath recording[0] = fallbackMerged.absolutePath
recording[1] = "" recording[1] = ""
} else {
Log.e(TAG, "Raw file missing and fallback not found: ${recording[0]}")
}
} else { } else {
Log.e(TAG, "Raw file missing and fallback not found: ${recording[0]}") // Normal Raw processing
} val mergedFileName = "merged_${fileIn.nameWithoutExtension}_${fileOut.nameWithoutExtension}.wav"
} else { val mergedFile = File(BaresipService.filesPath + "/recordings", mergedFileName)
// Normal Raw processing if (mergedFile.exists()) {
val mergedFileName = "merged_${fileIn.nameWithoutExtension}_${fileOut.nameWithoutExtension}.wav" Log.d(TAG, "Using already merged file: ${mergedFile.name}")
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 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()) { if (finalFile != null && finalFile.exists()) {
recording[0] = finalFile.absolutePath
recording[1] = ""
try { try {
if (fileIn.exists()) fileIn.delete() mediaPlayer.apply {
if (fileOut.exists()) fileOut.delete() setAudioAttributes(
CallHistoryNew.save() 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) { } 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 { } else {
Text(text = durationText, modifier = Modifier.padding(end = 12.dp)) Text(text = durationText, modifier = Modifier.padding(end = 12.dp))
@@ -410,8 +517,6 @@ private fun PlaybackDialog(
if (showDialog.value) { if (showDialog.value) {
// State to hold progress (0.0f to 1.0f) // State to hold progress (0.0f to 1.0f)
var currentProgress by remember { mutableFloatStateOf(0f) } var currentProgress by remember { mutableFloatStateOf(0f) }
// Formatted time strings
var currentPositionText by remember { mutableStateOf("00:00") } var currentPositionText by remember { mutableStateOf("00:00") }
var totalDurationText by remember { mutableStateOf("00:00") } var totalDurationText by remember { mutableStateOf("00:00") }
@@ -445,9 +550,7 @@ private fun PlaybackDialog(
) { ) {
LinearProgressIndicator( LinearProgressIndicator(
progress = { currentProgress }, progress = { currentProgress },
modifier = Modifier modifier = Modifier.fillMaxWidth().height(8.dp),
.fillMaxWidth()
.height(8.dp),
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Row( Row(
@@ -461,9 +564,7 @@ private fun PlaybackDialog(
}, },
confirmButton = { confirmButton = {
TextButton( TextButton(
onClick = { onClick = { onStop() }
onStop()
}
) { ) {
Text(stringResource(R.string.stop)) Text(stringResource(R.string.stop))
} }
@@ -50,6 +50,18 @@ class CallHistoryNew(val aor: String, val peerUri: String, val direction: String
save() 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() { fun save() {
Log.d(TAG, "Saving history of ${BaresipService.callHistory.size} calls") Log.d(TAG, "Saving history of ${BaresipService.callHistory.size} calls")
val file = File(BaresipService.filesPath + "/call_history") val file = File(BaresipService.filesPath + "/call_history")
+9 -4
View File
@@ -254,8 +254,6 @@
<string name="decrypt_password">Palauta salasanalla</string> <string name="decrypt_password">Palauta salasanalla</string>
<string name="delete_account">Haluatko poistaa tilin \'%1$s\'\?</string> <string name="delete_account">Haluatko poistaa tilin \'%1$s\'\?</string>
<!-- Baresip Service --> <!-- Baresip Service -->
<string name="reply">Vastaa</string>
<string name="save">Talleta</string>
<string name="is_calling">soittaa</string> <string name="is_calling">soittaa</string>
<string name="missed_call_from">Vastaamaton puhelu soittajalta</string> <string name="missed_call_from">Vastaamaton puhelu soittajalta</string>
<string name="missed_calls">Vastaamattomia puheluita</string> <string name="missed_calls">Vastaamattomia puheluita</string>
@@ -271,8 +269,6 @@
<string name="peer">Kumppani</string> <string name="peer">Kumppani</string>
<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="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>
@@ -284,6 +280,12 @@
<string name="call_answered_elsewhere">Puheluun vastattu muualla</string> <string name="call_answered_elsewhere">Puheluun vastattu muualla</string>
<string name="call_missed">Puheluun ei vastattu</string> <string name="call_missed">Puheluun ei vastattu</string>
<string name="call_rejected">Puhelu hylätty</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 --> <!-- Chat Activity -->
<string name="chat_with">Viestiketju %1$s</string> <string name="chat_with">Viestiketju %1$s</string>
<string name="new_message">Uusi viesti</string> <string name="new_message">Uusi viesti</string>
@@ -460,6 +462,9 @@
<string name="info">Tieto</string> <string name="info">Tieto</string>
<string name="notice">Huomio</string> <string name="notice">Huomio</string>
<string name="cancel">Peruuta</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="ok">OK</string>
<string name="yes">Kyllä</string> <string name="yes">Kyllä</string>
<string name="no">Ei</string> <string name="no">Ei</string>
+9 -4
View File
@@ -242,8 +242,6 @@
<string name="decrypt_password">Decrypt Password</string> <string name="decrypt_password">Decrypt Password</string>
<string name="delete_account">Do you want to delete account \'%1$s\'\?</string> <string name="delete_account">Do you want to delete account \'%1$s\'\?</string>
<!-- Baresip Service --> <!-- Baresip Service -->
<string name="reply">Reply</string>
<string name="save">Save</string>
<string name="is_calling">is calling</string> <string name="is_calling">is calling</string>
<string name="missed_call_from">Missed call from</string> <string name="missed_call_from">Missed call from</string>
<string name="missed_calls">Missed calls</string> <string name="missed_calls">Missed calls</string>
@@ -260,8 +258,6 @@
<string name="direction">Direction</string> <string name="direction">Direction</string>
<string name="time">Time</string> <string name="time">Time</string>
<string name="calls_duration">Duration</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 <string name="calls_add_delete_question">Do you want to add \'%1$s\' to contacts or delete
%2$s from call history\?</string> %2$s from call history\?</string>
<string name="calls_delete_question">Do you want to delete \'%1$s\' %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_answered_elsewhere">Call answered elsewhere</string>
<string name="call_missed">Call missed</string> <string name="call_missed">Call missed</string>
<string name="call_rejected">Call rejected</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 --> <!-- Chat Activity -->
<string name="chat_with">Chat with %1$s</string> <string name="chat_with">Chat with %1$s</string>
<string name="new_message">New message</string> <string name="new_message">New message</string>
@@ -438,6 +440,9 @@
<string name="info">Info</string> <string name="info">Info</string>
<string name="notice">Notice</string> <string name="notice">Notice</string>
<string name="cancel">Cancel</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="ok">OK</string>
<string name="yes">Yes</string> <string name="yes">Yes</string>
<string name="no">No</string> <string name="no">No</string>