600 lines
26 KiB
Kotlin
600 lines
26 KiB
Kotlin
package com.tutpro.baresip
|
|
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import androidx.activity.compose.BackHandler
|
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
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.automirrored.filled.CallMade
|
|
import androidx.compose.material.icons.automirrored.filled.CallReceived
|
|
import androidx.compose.material.icons.filled.AccountCircle
|
|
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.MaterialTheme
|
|
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.DisposableEffect
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.MutableState
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableIntStateOf
|
|
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.draw.scale
|
|
import androidx.compose.ui.layout.ContentScale
|
|
import androidx.compose.ui.platform.LocalContext
|
|
import androidx.compose.ui.res.colorResource
|
|
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.core.net.toUri
|
|
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
|
|
import androidx.navigation.compose.composable
|
|
import androidx.navigation.navArgument
|
|
import coil.compose.AsyncImage
|
|
import com.tutpro.baresip.BaresipService.Companion.circleGreen
|
|
import com.tutpro.baresip.BaresipService.Companion.colorblind
|
|
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 ua = UserAgent.ofAor(aor)!!
|
|
|
|
val callHistory: MutableState<List<CallRow>> = remember { mutableStateOf(emptyList()) }
|
|
var isHistoryLoaded by remember { mutableStateOf(false) }
|
|
|
|
var refreshTrigger by remember { mutableIntStateOf(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) {
|
|
ua.account.missedCalls = false
|
|
navController.navigateUp()
|
|
}
|
|
|
|
Scaffold(
|
|
modifier = Modifier.fillMaxSize().imePadding(),
|
|
containerColor = MaterialTheme.colorScheme.background,
|
|
topBar = {
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.background(MaterialTheme.colorScheme.background)
|
|
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding())
|
|
) {
|
|
TopAppBar(navController, ua, callHistory)
|
|
}
|
|
},
|
|
content = { contentPadding ->
|
|
if (isHistoryLoaded)
|
|
CallsContent(
|
|
LocalContext.current,
|
|
navController,
|
|
viewModel,
|
|
contentPadding,
|
|
ua,
|
|
callHistory
|
|
)
|
|
},
|
|
)
|
|
}
|
|
|
|
@OptIn(ExperimentalMaterial3Api::class)
|
|
@Composable
|
|
private fun TopAppBar(navController: NavController, ua: UserAgent, 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 blocked = stringResource(R.string.blocked)
|
|
|
|
val showDialog = remember { mutableStateOf(false) }
|
|
val lastAction = remember { mutableStateOf({}) }
|
|
|
|
val account = ua.account
|
|
|
|
AlertDialog(
|
|
showDialog = showDialog,
|
|
title = stringResource(R.string.confirmation),
|
|
message = String.format(stringResource(R.string.delete_history_alert), account.text()),
|
|
firstButtonText = stringResource(R.string.cancel),
|
|
lastButtonText = stringResource(R.string.delete),
|
|
onLastClicked = lastAction.value,
|
|
)
|
|
|
|
TopAppBar(
|
|
title = {
|
|
Text(
|
|
text = stringResource(R.string.call_history),
|
|
fontWeight = FontWeight.Bold
|
|
)
|
|
},
|
|
colors = TopAppBarDefaults.topAppBarColors(
|
|
containerColor = MaterialTheme.colorScheme.primary,
|
|
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
|
|
titleContentColor = MaterialTheme.colorScheme.onPrimary,
|
|
actionIconContentColor = MaterialTheme.colorScheme.onPrimary
|
|
),
|
|
windowInsets = WindowInsets(0, 0, 0, 0),
|
|
navigationIcon = {
|
|
IconButton(
|
|
onClick = {
|
|
account.missedCalls = false
|
|
navController.navigateUp()
|
|
}
|
|
) {
|
|
Icon(
|
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
|
contentDescription = "Back",
|
|
)
|
|
}
|
|
},
|
|
actions = {
|
|
IconButton(
|
|
onClick = { expanded = !expanded }
|
|
) {
|
|
Icon(
|
|
imageVector = Icons.Filled.Menu,
|
|
contentDescription = "Menu",
|
|
)
|
|
}
|
|
CustomElements.DropdownMenu(
|
|
expanded,
|
|
{ expanded = false },
|
|
if (account.callHistory) listOf(disable, delete, blocked) else listOf(enable),
|
|
onItemClick = { selectedItem ->
|
|
expanded = false
|
|
when (selectedItem) {
|
|
delete -> {
|
|
lastAction.value = {
|
|
CallHistoryNew.clear(account.aor)
|
|
callHistory.value = emptyList()
|
|
Blocked.clear(account.aor)
|
|
}
|
|
showDialog.value = true
|
|
}
|
|
disable, enable -> {
|
|
account.callHistory = !account.callHistory
|
|
Account.saveAccounts()
|
|
}
|
|
blocked -> {
|
|
navController.navigate("blocked/invite/${account.aor}")
|
|
}
|
|
}
|
|
}
|
|
)
|
|
}
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
private fun CallsContent(
|
|
ctx: Context,
|
|
navController: NavController,
|
|
viewModel: ViewModel,
|
|
contentPadding: PaddingValues,
|
|
ua: UserAgent,
|
|
callHistory: MutableState<List<CallRow>>
|
|
) {
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(contentPadding)
|
|
.padding(bottom = 16.dp),
|
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
|
) {
|
|
Account(ua.account)
|
|
Calls(ctx, navController, viewModel, ua, 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,
|
|
textAlign = TextAlign.Center
|
|
)
|
|
}
|
|
|
|
@OptIn(ExperimentalFoundationApi::class)
|
|
@Composable
|
|
private fun Calls(
|
|
ctx: Context,
|
|
navController: NavController,
|
|
viewModel: ViewModel,
|
|
ua: UserAgent,
|
|
callHistory: MutableState<List<CallRow>>
|
|
) {
|
|
|
|
val showDialog = remember { mutableStateOf(false) }
|
|
val message = remember { mutableStateOf("") }
|
|
val secondButtonText = remember { mutableStateOf("") }
|
|
val secondAction = remember { mutableStateOf({}) }
|
|
val thirdButtonText = remember { mutableStateOf("") }
|
|
val thirdAction = remember { mutableStateOf({}) }
|
|
val lastButtonText = remember { mutableStateOf("") }
|
|
val lastAction = remember { mutableStateOf({}) }
|
|
|
|
AlertDialog(
|
|
showDialog = showDialog,
|
|
title = stringResource(R.string.confirmation),
|
|
message = message.value,
|
|
firstButtonText = stringResource(R.string.cancel),
|
|
secondButtonText = secondButtonText.value,
|
|
onSecondClicked = secondAction.value,
|
|
thirdButtonText = thirdButtonText.value,
|
|
onThirdClicked = thirdAction.value,
|
|
lastButtonText = lastButtonText.value,
|
|
onLastClicked = lastAction.value,
|
|
)
|
|
|
|
val alertTitle = remember { mutableStateOf("") }
|
|
val alertMessage = remember { mutableStateOf("") }
|
|
val showAlert = remember { mutableStateOf(false) }
|
|
val unknown = stringResource(R.string.unknown)
|
|
|
|
AlertDialog(
|
|
showDialog = showAlert,
|
|
title = alertTitle.value,
|
|
message = alertMessage.value,
|
|
lastButtonText = stringResource(R.string.ok),
|
|
)
|
|
|
|
val lazyListState = rememberLazyListState()
|
|
LazyColumn(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(start = 16.dp, end = 4.dp)
|
|
.verticalScrollbar(state = lazyListState)
|
|
.background(MaterialTheme.colorScheme.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 = {
|
|
if (!peerUri.contains("anonymous") && peerUri != unknown) {
|
|
val intent = Intent(ctx, MainActivity::class.java)
|
|
intent.putExtra("uap", ua.uap)
|
|
intent.putExtra("peer", peerUri)
|
|
val peerNameWithLabel = Utils.friendlyUri(ctx, peerUri, ua.account)
|
|
val contact = Contact.findContact(peerUri)
|
|
if (contact is Contact.BaresipContact && contact.email.isNotEmpty())
|
|
message.value = String.format(
|
|
ctx.getString(R.string.contact_email_action_question),
|
|
peerNameWithLabel
|
|
)
|
|
else
|
|
message.value = String.format(
|
|
ctx.getString(R.string.contact_action_question),
|
|
peerNameWithLabel
|
|
)
|
|
secondButtonText.value = ctx.getString(R.string.call)
|
|
secondAction.value = {
|
|
if (ua.account.isMobile && ua.status != circleGreen.getValue(
|
|
colorblind
|
|
)
|
|
) {
|
|
alertTitle.value = ctx.getString(R.string.notice)
|
|
alertMessage.value =
|
|
ctx.getString(R.string.airplane_mode)
|
|
showAlert.value = true
|
|
} else {
|
|
handleIntent(ctx, viewModel, intent, "call")
|
|
navController.navigate("main") {
|
|
popUpTo("main")
|
|
launchSingleTop = true
|
|
}
|
|
}
|
|
}
|
|
thirdButtonText.value = ctx.getString(R.string.send_message)
|
|
thirdAction.value = {
|
|
if (ua.account.isMobile) {
|
|
if (ua.status != circleGreen.getValue(colorblind)) {
|
|
alertTitle.value = ctx.getString(R.string.notice)
|
|
alertMessage.value =
|
|
ctx.getString(R.string.airplane_mode)
|
|
showAlert.value = true
|
|
} else if (!Utils.isDefaultSmsApp(ctx)) {
|
|
alertTitle.value = ctx.getString(R.string.notice)
|
|
alertMessage.value =
|
|
ctx.getString(R.string.enable_default_messaging)
|
|
showAlert.value = true
|
|
} else {
|
|
handleIntent(ctx, viewModel, intent, "message")
|
|
navController.navigateUp()
|
|
}
|
|
} else {
|
|
handleIntent(ctx, viewModel, intent, "message")
|
|
navController.navigateUp()
|
|
}
|
|
}
|
|
if (contact is Contact.BaresipContact && contact.email.isNotEmpty()) {
|
|
lastButtonText.value = ctx.getString(R.string.send_email)
|
|
lastAction.value = {
|
|
val emailIntent = Intent(Intent.ACTION_SENDTO).apply {
|
|
data = "mailto:${contact.email}".toUri()
|
|
}
|
|
try {
|
|
ctx.startActivity(emailIntent)
|
|
} catch (e: Exception) {
|
|
Log.e(
|
|
TAG,
|
|
"Failed to start email activity: ${e.message}"
|
|
)
|
|
}
|
|
}
|
|
} else
|
|
lastButtonText.value = ""
|
|
showDialog.value = true
|
|
}
|
|
},
|
|
onLongClick = {
|
|
val peerName = Utils.friendlyUri(ctx, peerUri, ua.account, includeLabel = false)
|
|
val peerNameWithLabel = Utils.friendlyUri(ctx, peerUri, ua.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 || peerUri.contains("anonymous") || peerUri == unknown) {
|
|
message.value = String.format(
|
|
ctx.getString(R.string.calls_delete_question),
|
|
peerNameWithLabel, callText
|
|
)
|
|
secondButtonText.value = ""
|
|
thirdButtonText.value = ctx.getString(R.string.copy_uri)
|
|
thirdAction.value = {
|
|
Utils.copyToClipboard(ctx, peerUri)
|
|
}
|
|
lastButtonText.value = ctx.getString(R.string.delete)
|
|
lastAction.value = {
|
|
removeFromHistory(callHistory, callRow)
|
|
}
|
|
}
|
|
else {
|
|
message.value = String.format(
|
|
ctx.getString(R.string.calls_add_delete_question),
|
|
peerNameWithLabel,
|
|
callText
|
|
)
|
|
secondButtonText.value = ctx.getString(R.string.add_contact)
|
|
secondAction.value = {
|
|
val uri = Utils.sipToTel(peerUri)
|
|
navController.navigate("contact/$uri/new")
|
|
}
|
|
thirdButtonText.value = ctx.getString(R.string.copy_uri)
|
|
thirdAction.value = {
|
|
Utils.copyToClipboard(ctx, peerUri)
|
|
}
|
|
lastButtonText.value = ctx.getString(R.string.delete)
|
|
lastAction.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 -> {
|
|
Icon(
|
|
imageVector = Icons.Filled.AccountCircle,
|
|
contentDescription = "Avatar",
|
|
modifier = Modifier.size(36.dp).scale(1.2f),
|
|
tint = MaterialTheme.colorScheme.secondary
|
|
)
|
|
}
|
|
}
|
|
Spacer(modifier = Modifier.width(4.dp))
|
|
var count = 1
|
|
for (d in callRow.details) {
|
|
if (d.recording.isNotEmpty() && d.recording[0] != "")
|
|
recordings = true
|
|
if (count > 3)
|
|
continue
|
|
Icon(
|
|
imageVector = if (callUp(d.direction))
|
|
Icons.AutoMirrored.Filled.CallMade
|
|
else
|
|
Icons.AutoMirrored.Filled.CallReceived,
|
|
modifier = Modifier.size(20.dp),
|
|
tint = colorResource(id = callTint(d.direction)),
|
|
contentDescription = "Direction"
|
|
)
|
|
count++
|
|
}
|
|
if (count > 3)
|
|
Text("...", color = MaterialTheme.colorScheme.onBackground)
|
|
Text(text = Utils.friendlyUri(ctx, peerUri, ua.account),
|
|
modifier = Modifier.padding(start = 8.dp),
|
|
fontSize = 18.sp,
|
|
maxLines = 1,
|
|
overflow = TextOverflow.Ellipsis
|
|
)
|
|
}
|
|
}
|
|
Text(
|
|
text = Utils.relativeTime(ctx, callRow.stopTime),
|
|
fontSize = 12.sp,
|
|
minLines = 2, maxLines = 2,
|
|
lineHeight = 16.sp,
|
|
textAlign = TextAlign.End,
|
|
color = if (recordings)
|
|
MaterialTheme.colorScheme.error
|
|
else
|
|
MaterialTheme.colorScheme.onBackground,
|
|
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) CALL_DOWN_GREEN else CALL_DOWN_BLUE
|
|
}
|
|
else
|
|
if (h.rejected) CALL_DOWN_RED else CALL_MISSED_IN
|
|
}
|
|
else {
|
|
if (h.startTime != null)
|
|
CALL_UP_GREEN
|
|
else
|
|
if (h.rejected) CALL_UP_RED else CALL_MISSED_OUT
|
|
}
|
|
if (res.isNotEmpty() && res.last().peerUri == h.peerUri)
|
|
res.last().details.add(CallRow.Details(
|
|
direction, h.startTime,
|
|
h.stopTime, h.recording.toList()
|
|
))
|
|
else
|
|
res.add(CallRow(h.aor, h.peerUri, direction, h.startTime, h.stopTime, h.recording.toList()))
|
|
}
|
|
}
|
|
return res
|
|
}
|
|
|
|
private fun removeFromHistory(callHistory: MutableState<List<CallRow>>, callRow: CallRow) {
|
|
for (details in callRow.details) {
|
|
CallHistoryNew.deleteRecordingFiles(details.recording.toTypedArray())
|
|
BaresipService.callHistory.removeAll {
|
|
it.startTime == details.startTime && it.stopTime == details.stopTime
|
|
}
|
|
}
|
|
CallHistoryNew.deleteRecordingFiles(callRow.recording.toTypedArray())
|
|
val updatedList = callHistory.value.filterNot { it == callRow }
|
|
callHistory.value = updatedList
|
|
CallHistoryNew.save()
|
|
}
|
|
|
|
fun callUp(direction: Int): Boolean {
|
|
return when (direction) {
|
|
CALL_UP_GREEN, CALL_UP_RED, CALL_MISSED_OUT -> true
|
|
else -> false
|
|
}
|
|
}
|
|
|
|
fun callTint(direction: Int): Int {
|
|
return when (direction) {
|
|
CALL_UP_GREEN, CALL_DOWN_GREEN -> R.color.colorTrafficGreen
|
|
CALL_UP_RED, CALL_DOWN_RED -> R.color.colorTrafficRed
|
|
CALL_DOWN_BLUE -> R.color.colorPrimary
|
|
else -> R.color.colorTrafficYellow
|
|
}
|
|
}
|