Files
2025-03-29 19:20:43 +02:00

684 lines
28 KiB
Kotlin

package com.tutpro.baresip
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.graphics.BitmapFactory
import android.os.Bundle
import android.text.format.DateUtils.isToday
import android.view.Menu
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.animateContentSize
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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
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.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.outlined.Clear
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.Surface
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
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.draw.shadow
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.tutpro.baresip.BaresipService.Companion.contactNames
import com.tutpro.baresip.CustomElements.ImageAvatar
import com.tutpro.baresip.CustomElements.Text
import com.tutpro.baresip.CustomElements.TextAvatar
import com.tutpro.baresip.CustomElements.verticalScrollbar
import java.text.DateFormat
import java.util.GregorianCalendar
class ChatsActivity: ComponentActivity() {
internal lateinit var aor: String
internal lateinit var account: Account
private lateinit var chatRequest: ActivityResultLauncher<Intent>
private var _uaMessages = mutableStateOf<List<Message>>(emptyList())
private var uaMessages : List<Message> by _uaMessages
private val onBackPressedCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
goBack()
}
}
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
aor = intent.extras!!.getString("aor")!!
Utils.addActivity("chats,$aor")
account = UserAgent.ofAor(aor)!!.account
val title = getString(R.string.chats)
_uaMessages.value = uaMessages(aor)
chatRequest =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK)
_uaMessages.value = uaMessages(aor)
}
setContent {
AppTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = LocalCustomColors.current.background
) {
ChatsScreen(this, title) { goBack() }
}
}
}
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
}
@Composable
fun ChatsScreen(ctx: Context, title: String, navigateBack: () -> Unit) {
Scaffold(
modifier = Modifier
.fillMaxHeight()
.imePadding()
.safeDrawingPadding(),
containerColor = LocalCustomColors.current.background,
topBar = { TopAppBar(title, navigateBack) },
bottomBar = { NewChatPeer() },
content = { contentPadding ->
ChatsContent(ctx, contentPadding)
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TopAppBar(title: String, navigateBack: () -> Unit) {
var expanded by remember { mutableStateOf(false) }
val delete = String.format(getString(R.string.delete))
val disable = String.format(getString(R.string.disable_history))
val enable = String.format(getString(R.string.enable_history))
TopAppBar(
title = {
Text(
text = title,
color = LocalCustomColors.current.light,
fontSize = 22.sp,
fontWeight = FontWeight.Bold
)
},
colors = TopAppBarDefaults.mediumTopAppBarColors(
containerColor = LocalCustomColors.current.primary
),
navigationIcon = {
IconButton(onClick = navigateBack) {
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 ->
with(
MaterialAlertDialogBuilder(
this@ChatsActivity,
R.style.AlertDialogTheme
)
) {
setTitle(R.string.confirmation)
setMessage(String.format(getString(R.string.delete_chats_alert),
aor.substringAfter(":")))
setPositiveButton(delete) { dialog, _ ->
Message.clearMessagesOfAor(aor)
Message.save()
_uaMessages.value = listOf()
account.unreadMessages = false
dialog.dismiss()
}
setNeutralButton(getText(R.string.cancel)) { dialog, _ ->
dialog.dismiss()
}
show()
}
}
}
)
}
)
}
@Composable
fun ChatsContent(ctx: Context, contentPadding: PaddingValues) {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.background(LocalCustomColors.current.background)
.padding(contentPadding),
verticalArrangement = Arrangement.Bottom
) {
Account(account)
Spacer(modifier = Modifier.weight(1f))
Chats(ctx, account)
}
}
@Composable
fun Account(account: Account) {
val headerText = getString(R.string.account) + " " +
if (account.nickName.value != "")
account.nickName.value
else
aor.split(":")[1]
Text(
text = headerText, modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 8.dp),
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center
)
}
@Composable
fun Chats(ctx: Context, account: Account) {
val lazyListState = rememberLazyListState()
LazyColumn(
modifier = Modifier
.imePadding()
.fillMaxWidth()
.padding(start = 16.dp, end = 4.dp)
.verticalScrollbar(
state = lazyListState,
width = 4.dp,
color = LocalCustomColors.current.gray
)
.background(LocalCustomColors.current.background),
reverseLayout = true,
state = lazyListState,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
items(items = uaMessages, key = { message -> message.timeStamp }) { message ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.combinedClickable(
onClick = {
val i = Intent(ctx, ChatActivity::class.java)
val b = Bundle()
b.putString("aor", aor)
b.putString("peer", message.peerUri)
i.putExtras(b)
chatRequest.launch(i)
},
onLongClick = {
val dialogClickListener =
DialogInterface.OnClickListener { _, which ->
when (which) {
DialogInterface.BUTTON_POSITIVE -> {
val i =
Intent(ctx, BaresipContactActivity::class.java)
val b = Bundle()
b.putBoolean("new", true)
b.putString("uri", message.peerUri)
i.putExtras(b)
startActivity(i)
}
DialogInterface.BUTTON_NEGATIVE -> {
Message.deleteAorPeerMessages(aor, message.peerUri)
}
DialogInterface.BUTTON_NEUTRAL -> {
}
}
}
val builder = MaterialAlertDialogBuilder(
this@ChatsActivity,
R.style.AlertDialogTheme
)
val peer = Utils.friendlyUri(ctx, message.peerUri, account)
if (!Contact.nameExists(peer, BaresipService.contacts, false))
with(builder) {
setTitle(R.string.confirmation)
setMessage(
String.format(
getString(R.string.long_chat_question),
peer
)
)
setNeutralButton(
getText(R.string.cancel),
dialogClickListener
)
setNegativeButton(
getText(R.string.delete),
dialogClickListener
)
setPositiveButton(
getText(R.string.add_contact),
dialogClickListener
)
show()
}
else
with(builder) {
setTitle(R.string.confirmation)
setMessage(
String.format(
getString(R.string.short_chat_question),
peer
)
)
setNeutralButton(
getText(R.string.cancel),
dialogClickListener
)
setNegativeButton(
getText(R.string.delete),
dialogClickListener
)
show()
}
}
)) {
when (val contact = Contact.findContact(message.peerUri)) {
is Contact.BaresipContact -> {
val avatarImage = contact.avatarImage
if (avatarImage != null)
ImageAvatar(avatarImage)
else
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
TextAvatar(contact.name, contact.color)
}
null -> {
val avatarImage = BitmapFactory
.decodeResource(ctx.resources, R.drawable.person_image)
ImageAvatar(avatarImage)
}
}
Spacer(modifier = Modifier.width(6.dp))
Button(
onClick = {
val i = Intent(this@ChatsActivity, ChatActivity::class.java)
val b = Bundle()
b.putString("aor", aor)
b.putString("peer", message.peerUri)
i.putExtras(b)
chatRequest.launch(i) },
shape = if (message.direction == MESSAGE_DOWN)
RoundedCornerShape(50.dp, 20.dp, 20.dp, 10.dp)
else
RoundedCornerShape(20.dp, 50.dp, 20.dp, 10.dp),
colors = ButtonDefaults.buttonColors(containerColor =
if (message.direction == MESSAGE_DOWN)
LocalCustomColors.current.secondaryLight
else
LocalCustomColors.current.primaryLight),
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(start = 8.dp, end = 16.dp)
) {
val peer = Utils.friendlyUri(ctx, message.peerUri, account)
val cal = GregorianCalendar()
cal.timeInMillis = message.timeStamp
val fmt: DateFormat = if (isToday(message.timeStamp))
DateFormat.getTimeInstance(DateFormat.SHORT)
else
DateFormat.getDateInstance(DateFormat.SHORT)
val info = fmt.format(cal.time)
Column {
Row {
Text(
peer, color = LocalCustomColors.current.dark,
fontSize = 12.sp
)
Spacer(modifier = Modifier.weight(1f))
Text(
info, color = LocalCustomColors.current.dark,
fontSize = 12.sp
)
}
Row {
Text(
text = message.message, color = LocalCustomColors.current.dark,
maxLines = 1
)
}
}
}
}
}
}
}
@Composable
fun NewChatPeer() {
val suggestions by remember { contactNames }
var filteredSuggestions by remember { mutableStateOf(suggestions) }
var showSuggestions by remember { mutableStateOf(false) }
val lazyListState = rememberLazyListState()
val focusManager = LocalFocusManager.current
Row(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(start = 16.dp, end = 8.dp, top = 10.dp, bottom = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
var newPeer by remember { mutableStateOf("") }
Column(
horizontalAlignment = Alignment.Start,
modifier = Modifier.weight(1f)
) {
if (showSuggestions && filteredSuggestions.isNotEmpty()) {
Column(
modifier = Modifier
.shadow(8.dp, RoundedCornerShape(8.dp))
.background(LocalCustomColors.current.grayLight,
shape = RoundedCornerShape(8.dp))
.animateContentSize()
) {
Box(modifier = Modifier.fillMaxWidth()) {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 180.dp)
.verticalScrollbar(
state = lazyListState,
color = LocalCustomColors.current.gray
),
horizontalAlignment = Alignment.Start,
state = lazyListState
) {
items(
items = filteredSuggestions,
key = { suggestion -> suggestion }
) { suggestion ->
Box(
modifier = Modifier
.fillMaxWidth()
.clickable {
newPeer = suggestion
showSuggestions = false
}
.padding(12.dp)
) {
Text(
text = suggestion,
modifier = Modifier.fillMaxWidth(),
color = LocalCustomColors.current.grayDark,
fontSize = 18.sp
)
}
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
}
OutlinedTextField(
value = newPeer,
placeholder = {
Text(text = getString(R.string.new_chat_peer))
},
onValueChange = {
newPeer = it
showSuggestions = newPeer.length > 2
filteredSuggestions = if (it.isEmpty()) {
suggestions
} else {
suggestions.filter { suggestion ->
newPeer.length > 2 && suggestion.startsWith(
newPeer,
ignoreCase = true
)
}
}
},
modifier = Modifier
.padding(end = 6.dp)
.fillMaxWidth(),
singleLine = true,
trailingIcon = {
if (newPeer.isNotEmpty())
Icon(
Icons.Outlined.Clear,
contentDescription = null,
modifier = Modifier
.clickable {
newPeer = ""
showSuggestions = false
}
)
},
label = {
Text(
text = getString(R.string.new_chat_peer),
fontSize = 18.sp
)
},
textStyle = TextStyle(
fontSize = 18.sp,
color = LocalCustomColors.current.itemText
),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done)
)
}
Spacer(Modifier.width(4.dp))
SmallFloatingActionButton(
modifier = Modifier.padding(end = 4.dp),
onClick = {
showSuggestions = false
if (makeChat(newPeer, true)) {
newPeer = ""
focusManager.clearFocus()
}
},
containerColor = LocalCustomColors.current.accent,
contentColor = LocalCustomColors.current.background
) {
Icon(
imageVector = Icons.Filled.Add,
modifier = Modifier.size(36.dp),
contentDescription = stringResource(R.string.add)
)
}
}
}
private fun makeChat(peer: String, lookForContact: Boolean = true): Boolean {
var peerUri = peer.trim()
if (peerUri.isNotEmpty()) {
if (lookForContact) {
val uris = Contact.contactUris(peerUri)
if (uris.size == 1)
peerUri = uris[0]
else if (uris.size > 1) {
val builder = MaterialAlertDialogBuilder(this, R.style.AlertDialogTheme)
with(builder) {
setTitle("Choose Destination")
setItems(uris.toTypedArray()) { _, which ->
peerUri = uris[which]
makeChat(peerUri, false)
}
setNeutralButton(getString(R.string.cancel)) { _: DialogInterface, _: Int -> }
show()
}
return true
}
}
if (Utils.isTelNumber(peerUri))
peerUri = "tel:$peerUri"
val uri = if (Utils.isTelUri(peerUri)) {
if (account.telProvider == "") {
Utils.alertView(this, getString(R.string.notice),
String.format(getString(R.string.no_telephony_provider), account.aor))
return false
}
Utils.telToSip(peerUri, account)
} else {
Utils.uriComplete(peerUri, aor)
}
if (!Utils.checkUri(uri)) {
Utils.alertView(this, getString(R.string.notice),
String.format(getString(R.string.invalid_sip_or_tel_uri), uri))
return false
} else {
val i = Intent(this@ChatsActivity, ChatActivity::class.java)
val b = Bundle()
b.putString("aor", aor)
b.putString("peer", uri)
i.putExtras(b)
chatRequest.launch(i)
return true
}
}
return false
}
override fun onPause() {
MainActivity.activityAor = aor
super.onPause()
}
override fun onResume() {
super.onResume()
_uaMessages.value = uaMessages(aor)
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.chats_menu, menu)
return true
}
private fun goBack() {
BaresipService.activities.remove("chats,$aor")
returnResult()
}
private fun returnResult() {
setResult(RESULT_CANCELED, Intent())
finish()
}
private fun uaMessages(aor: String) : List<Message> {
val res = mutableListOf<Message>()
account.unreadMessages = false
for (m in BaresipService.messages) {
if (m.aor != aor) continue
var found = false
for (r in res)
if (r.peerUri == m.peerUri) {
found = true
break
}
if (!found) {
res.add(m)
if (m.new)
account.unreadMessages = true
}
}
return res.toList()
}
}