Files
2025-08-29 14:19:21 +03:00

483 lines
20 KiB
Kotlin

package com.tutpro.baresip
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
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.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import coil.compose.AsyncImage
import com.tutpro.baresip.CustomElements.AlertDialog
import com.tutpro.baresip.CustomElements.verticalScrollbar
fun NavGraphBuilder.callsScreenRoute(navController: NavController, viewModel: ViewModel) {
composable(
route = "calls/{aor}",
arguments = listOf(navArgument("aor") { type = NavType.StringType })
) { backStackEntry ->
val aor = backStackEntry.arguments?.getString("aor")!!
CallsScreen(navController, viewModel, aor)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CallsScreen(navController: NavController, viewModel: ViewModel, aor: String) {
val account = Account.ofAor(aor)!!
val callHistory: MutableState<List<CallRow>> = remember { mutableStateOf(emptyList()) }
var isHistoryLoaded by remember { mutableStateOf(false) }
LaunchedEffect(aor) {
callHistory.value = loadCallHistory(aor)
isHistoryLoaded = true
}
BackHandler(enabled = true) {
account.missedCalls = false
navController.popBackStack()
}
Scaffold(
modifier = Modifier.fillMaxSize().imePadding(),
containerColor = LocalCustomColors.current.background,
topBar = {
Column(
modifier = Modifier
.fillMaxWidth()
.background(LocalCustomColors.current.background)
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding())
) {
TopAppBar(navController, account, callHistory)
}
},
content = { contentPadding ->
if (isHistoryLoaded)
CallsContent(
LocalContext.current,
navController,
viewModel,
contentPadding,
account,
callHistory
)
},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TopAppBar(navController: NavController, account: Account, callHistory: MutableState<List<CallRow>>) {
var expanded by remember { mutableStateOf(false) }
val delete = stringResource(R.string.delete)
val disable = stringResource(R.string.disable_history)
val enable = stringResource(R.string.enable_history)
val showDialog = remember { mutableStateOf(false) }
val positiveAction = remember { mutableStateOf({}) }
AlertDialog(
showDialog = showDialog,
title = stringResource(R.string.confirmation),
message = String.format(stringResource(R.string.delete_history_alert), account.text()),
positiveButtonText = stringResource(R.string.delete),
negativeButtonText = stringResource(R.string.cancel),
onPositiveClicked = positiveAction.value,
)
TopAppBar(
title = {
Text(
text = stringResource(R.string.call_history),
color = LocalCustomColors.current.light,
fontWeight = FontWeight.Bold
)
},
colors = TopAppBarDefaults.mediumTopAppBarColors(
containerColor = LocalCustomColors.current.primary
),
windowInsets = WindowInsets(0, 0, 0, 0),
navigationIcon = {
IconButton(
onClick = {
account.missedCalls = false
navController.popBackStack()
}
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = LocalCustomColors.current.light
)
}
},
actions = {
IconButton(
onClick = { expanded = !expanded }
) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "Menu",
tint = LocalCustomColors.current.light
)
}
CustomElements.DropdownMenu(
expanded,
{ expanded = false },
listOf(delete, if (account.callHistory) disable else enable),
onItemClick = { selectedItem ->
expanded = false
when (selectedItem) {
delete -> {
positiveAction.value = {
CallHistoryNew.clear(account.aor)
callHistory.value = emptyList()
}
showDialog.value = true
}
disable, enable -> {
account.callHistory = !account.callHistory
Account.saveAccounts()
}
}
}
)
}
)
}
@Composable
private fun CallsContent(
ctx: Context,
navController: NavController,
viewModel: ViewModel,
contentPadding: PaddingValues,
account: Account,
callHistory: MutableState<List<CallRow>>
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(contentPadding)
.padding(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Account(account)
Calls(ctx, navController, viewModel, account, callHistory)
}
}
@Composable
private fun Account(account: Account) {
Text(
text = stringResource(R.string.account) + " " + account.text(),
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
color = LocalCustomColors.current.itemText,
textAlign = TextAlign.Center
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun Calls(
ctx: Context,
navController: NavController,
viewModel: ViewModel,
account: Account,
callHistory: MutableState<List<CallRow>>
) {
val showDialog = remember { mutableStateOf(false) }
val message = remember { mutableStateOf("") }
val positiveButtonText = remember { mutableStateOf("") }
val positiveAction = remember { mutableStateOf({}) }
val neutralButtonText = remember { mutableStateOf("") }
val neutralAction = remember { mutableStateOf({}) }
AlertDialog(
showDialog = showDialog,
title = stringResource(R.string.confirmation),
message = message.value,
positiveButtonText = positiveButtonText.value,
onPositiveClicked = positiveAction.value,
neutralButtonText = neutralButtonText.value,
onNeutralClicked = neutralAction.value,
negativeButtonText = stringResource(R.string.cancel)
)
val lazyListState = rememberLazyListState()
LazyColumn(
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp)
.verticalScrollbar(
state = lazyListState,
width = 4.dp,
color = LocalCustomColors.current.gray
)
.background(LocalCustomColors.current.background),
state = lazyListState,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(items = callHistory.value, key = { callRow -> callRow.stopTime }) { callRow ->
val peerUri = callRow.peerUri
var recordings = false
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Box(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.combinedClickable(
onClick = {
val aor = account.aor
val ua = UserAgent.ofAor(aor)
val intent = Intent(ctx, MainActivity::class.java)
if (ua != null) {
intent.putExtra("uap", ua.uap)
intent.putExtra("peer", peerUri)
}
else
Log.w(TAG, "onClickListener did not find UA for $aor")
val peerName = Utils.friendlyUri(ctx, peerUri, account)
message.value = String.format(ctx.getString(R.string.contact_action_question), peerName)
positiveButtonText.value = ctx.getString(R.string.call)
positiveAction.value = {
if (ua != null) {
handleIntent(ctx, viewModel, intent, "call")
navController.popBackStack()
}
}
neutralButtonText.value = ctx.getString(R.string.send_message)
neutralAction.value = {
if (ua != null) {
handleIntent(ctx, viewModel, intent, "message")
navController.popBackStack()
}
}
showDialog.value = true
},
onLongClick = {
val peerName = Utils.friendlyUri(ctx, peerUri, account)
val callText: String = if (callRow.details.size > 1)
ctx.getString(R.string.calls_calls)
else
ctx.getString(R.string.calls_call)
val contactExists = Contact.nameExists(peerName, BaresipService.contacts, false)
if (contactExists) {
message.value = String.format(
ctx.getString(R.string.calls_delete_question),
peerName, callText
)
positiveButtonText.value = ctx.getString(R.string.delete)
positiveAction.value = {
removeFromHistory(callHistory, callRow)
}
neutralButtonText.value = ""
}
else {
message.value = String.format(
ctx.getString(R.string.calls_add_delete_question),
peerName, callText
)
positiveButtonText.value = ctx.getString(R.string.add_contact)
positiveAction.value = {
navController.navigate("baresip_contact/$peerUri/new")
}
neutralButtonText.value = ctx.getString(R.string.delete)
neutralAction.value = {
removeFromHistory(callHistory, callRow)
}
}
showDialog.value = true
}
)
) {
when (val contact = Contact.findContact(peerUri)) {
is Contact.BaresipContact -> {
val avatarImage = contact.avatarImage
if (avatarImage != null)
CustomElements.ImageAvatar(avatarImage)
else
CustomElements.TextAvatar(contact.name, contact.color)
}
is Contact.AndroidContact -> {
val thumbNailUri = contact.thumbnailUri
if (thumbNailUri != null)
AsyncImage(
model = thumbNailUri,
contentDescription = "Avatar",
contentScale = ContentScale.Crop,
modifier = Modifier
.size(36.dp)
.clip(CircleShape),
)
else
CustomElements.TextAvatar(contact.name, contact.color)
}
null -> {
val avatarImage = BitmapFactory
.decodeResource(ctx.resources, R.drawable.person_image)
CustomElements.ImageAvatar(avatarImage)
}
}
Spacer(modifier = Modifier.width(4.dp))
var count = 1
for (d in callRow.details) {
if (d.recording[0] != "")
recordings = true
if (count > 3)
continue
Image(painterResource(d.direction), "Direction")
count++
}
if (count > 3)
Text("...", color = LocalCustomColors.current.itemText)
Text(text = Utils.friendlyUri(ctx, peerUri, account),
modifier = Modifier.padding(start = 8.dp),
fontSize = 18.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = LocalCustomColors.current.itemText
)
}
}
Text(
text = Utils.relativeTime(ctx, callRow.stopTime),
fontSize = 12.sp,
minLines = 2, maxLines = 2,
lineHeight = 16.sp,
textAlign = TextAlign.End,
color = if (recordings)
LocalCustomColors.current.accent
else
LocalCustomColors.current.itemText,
modifier = Modifier
.padding(end = 16.dp)
.clickable(onClick = {
viewModel.selectCallRow(callRow)
navController.navigate("call_details")
})
)
}
}
}
}
private fun loadCallHistory(aor: String): MutableList<CallRow> {
val res = mutableListOf<CallRow>()
for (i in BaresipService.callHistory.indices.reversed()) {
val h = BaresipService.callHistory[i]
if (h.aor == aor) {
val direction: Int = if (h.direction == "in") {
if (h.startTime != null) {
if (h.startTime != h.stopTime)
R.drawable.call_down_green
else
R.drawable.call_down_blue
} else {
if (h.rejected)
R.drawable.call_down_red
else
R.drawable.call_missed_in
}
} else {
if (h.startTime != null) {
R.drawable.call_up_green
} else {
if (h.rejected)
R.drawable.call_up_red
else
R.drawable.call_missed_out
}
}
if (res.isNotEmpty() && res.last().peerUri == h.peerUri)
res.last().details.add(CallRow.Details(
direction, h.startTime,
h.stopTime, h.recording
))
else
res.add(CallRow(h.aor, h.peerUri, direction, h.startTime, h.stopTime, h.recording))
}
}
return res
}
private fun removeFromHistory(callHistory: MutableState<List<CallRow>>, callRow: CallRow) {
for (details in callRow.details) {
if (details.recording[0] != "")
CallHistoryNew.deleteRecording(details.recording)
BaresipService.callHistory.removeAll {
it.startTime == details.startTime && it.stopTime == details.stopTime
}
}
CallHistoryNew.deleteRecording(callRow.recording)
val updatedList = callHistory.value.filterNot { it == callRow }
callHistory.value = updatedList
CallHistoryNew.save()
}