Migrated app to compose navigation

This commit is contained in:
Juha Heinanen
2025-05-27 14:30:06 +03:00
parent c35a1a5e88
commit 1f7d8fb509
45 changed files with 11381 additions and 11783 deletions

View File

@ -76,108 +76,6 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".AboutActivity"
android:configChanges="orientation|keyboardHidden|screenSize"
android:label="@string/about_title"
android:windowSoftInputMode="adjustResize"
android:parentActivityName=".MainActivity" >
</activity>
<activity
android:name=".AccountsActivity"
android:configChanges="orientation|keyboardHidden|screenSize"
android:windowSoftInputMode="adjustResize"
android:label="@string/accounts"
android:parentActivityName=".MainActivity" >
</activity>
<activity
android:name=".AccountActivity"
android:configChanges="orientation|keyboardHidden|screenSize"
android:windowSoftInputMode="adjustResize"
android:label="@string/account" >
</activity>
<activity
android:name=".CodecsActivity"
android:configChanges="orientation|keyboardHidden|screenSize"
android:label="@string/codecs"
android:windowSoftInputMode="adjustResize"
android:parentActivityName=".AccountActivity" >
</activity>
<activity
android:name=".ContactsActivity"
android:configChanges="orientation|keyboardHidden|screenSize"
android:label="@string/contacts"
android:windowSoftInputMode="adjustResize"
android:parentActivityName=".MainActivity" >
</activity>
<activity
android:name=".BaresipContactActivity"
android:configChanges="orientation|keyboardHidden|screenSize"
android:windowSoftInputMode="adjustResize"
android:label="@string/contact"
android:parentActivityName=".ContactsActivity" >
</activity>
<activity
android:name=".AndroidContactActivity"
android:configChanges="orientation|keyboardHidden|screenSize"
android:label="@string/contact"
android:windowSoftInputMode="adjustResize"
android:parentActivityName=".ContactsActivity" >
</activity>
<activity
android:name=".ConfigActivity"
android:configChanges="orientation|keyboardHidden|screenSize"
android:label="@string/configuration"
android:windowSoftInputMode="adjustResize"
android:parentActivityName=".MainActivity" >
</activity>
<activity
android:name=".AudioActivity"
android:configChanges="orientation|keyboardHidden|screenSize"
android:label="@string/audio_settings"
android:windowSoftInputMode="adjustResize"
android:parentActivityName=".ConfigActivity" >
</activity>
<activity
android:name=".CallsActivity"
android:configChanges="orientation|keyboardHidden|screenSize"
android:label="@string/call_history"
android:windowSoftInputMode="adjustResize"
android:parentActivityName=".MainActivity" >
</activity>
<activity
android:name=".CallDetailsActivity"
android:configChanges="orientation|keyboardHidden|screenSize"
android:label="@string/call_details"
android:windowSoftInputMode="adjustResize"
android:parentActivityName=".CallsActivity" >
</activity>
<activity
android:name=".ChatsActivity"
android:configChanges="orientation|keyboardHidden|screenSize"
android:windowSoftInputMode="adjustResize"
android:label="@string/chats"
android:parentActivityName=".MainActivity" >
</activity>
<activity
android:name=".ChatActivity"
android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|keyboardHidden|screenSize"
android:label="@string/chat" >
</activity>
<service <service
android:name=".BaresipService" android:name=".BaresipService"
android:enabled="true" android:enabled="true"

View File

@ -411,8 +411,10 @@ static int runLoggingThread()
dup2(pfd[1], 2); dup2(pfd[1], 2);
int ret = pthread_create(&loggingThread, NULL, loggingFunction, NULL); int ret = pthread_create(&loggingThread, NULL, loggingFunction, NULL);
if (ret != 0) if (ret != 0) {
LOGE("failed to create logging thread: %d", ret);
return ret; return ret;
}
pthread_detach(loggingThread); pthread_detach(loggingThread);
@ -456,8 +458,10 @@ JNIEXPORT void JNICALL Java_com_tutpro_baresip_BaresipService_baresipStart(
runLoggingThread(); runLoggingThread();
err = libre_init(); err = libre_init();
if (err) if (err) {
LOGE("failed to init libre");
goto out; goto out;
}
if (re_thread_check(true) == 0) { if (re_thread_check(true) == 0) {
LOGI("attaching to re thread\n"); LOGI("attaching to re thread\n");

View File

@ -1,151 +0,0 @@
package com.tutpro.baresip
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.window.OnBackInvokedCallback
import android.window.OnBackInvokedDispatcher
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
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.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.unit.dp
class AboutActivity : ComponentActivity() {
private var backInvokedCallback: OnBackInvokedCallback? = null
private lateinit var onBackPressedCallback: OnBackPressedCallback
@RequiresApi(33)
private fun registerBackInvokedCallback() {
backInvokedCallback = OnBackInvokedCallback { goBack() }
onBackInvokedDispatcher.registerOnBackInvokedCallback(
OnBackInvokedDispatcher.PRIORITY_DEFAULT,
backInvokedCallback!!
)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
if (Build.VERSION.SDK_INT >= 33)
registerBackInvokedCallback()
else {
onBackPressedCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
goBack()
}
}
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
}
Utils.addActivity("about")
val aboutTitle = getString(R.string.about_title)
val aboutText = String.format(getString(R.string.about_text),
BuildConfig.VERSION_NAME)
setContent {
AppTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
AboutContent(aboutTitle, aboutText) { goBack() }
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AboutContent(title: String, text: String, navigateBack: () -> Unit) {
Scaffold(
modifier = Modifier.safeDrawingPadding(),
containerColor = LocalCustomColors.current.background,
topBar = {
TopAppBar(
title = {
Text(text = title,
color = LocalCustomColors.current.light,
fontWeight = FontWeight.Bold
)
},
colors = TopAppBarDefaults.mediumTopAppBarColors(
containerColor = LocalCustomColors.current.primary
),
navigationIcon = {
IconButton(onClick = navigateBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Localized description",
tint = LocalCustomColors.current.light
)
}
},
)
}
) { contentPadding ->
Text(
text = AnnotatedString.Companion.fromHtml(
htmlString = text,
linkStyles = TextLinkStyles(
style = SpanStyle(color = LocalCustomColors.current.accent)
)
),
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 16.dp)
.padding(contentPadding)
.verticalScroll(rememberScrollState())
.fillMaxSize()
.background(LocalCustomColors.current.background)
)
}
}
override fun onDestroy() {
if (Build.VERSION.SDK_INT >= 33) {
if (backInvokedCallback != null)
onBackInvokedDispatcher.unregisterOnBackInvokedCallback(backInvokedCallback!!)
}
else
onBackPressedCallback.remove()
super.onDestroy()
}
private fun goBack() {
BaresipService.activities.remove("about")
setResult(RESULT_CANCELED, Intent())
finish()
}
}

View File

@ -0,0 +1,96 @@
package com.tutpro.baresip
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
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.statusBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material.icons.automirrored.filled.ArrowBack
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.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.fromHtml
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
fun NavGraphBuilder.aboutScreenRoute(navController: NavController) {
composable("about") {
AboutScreen(onBack = { navController.popBackStack() })
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AboutScreen(onBack: () -> Unit) {
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(
title = {
Text(
text = stringResource(R.string.about_title),
color = LocalCustomColors.current.light,
fontWeight = FontWeight.Bold
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = LocalCustomColors.current.light
)
}
},
colors = TopAppBarDefaults.mediumTopAppBarColors(
containerColor = LocalCustomColors.current.primary
),
windowInsets = WindowInsets(0, 0, 0, 0)
)
}
}
) { contentPadding ->
Text(
text = AnnotatedString.fromHtml(
htmlString = stringResource(R.string.about_text, BuildConfig.VERSION_NAME),
linkStyles = TextLinkStyles(SpanStyle(color = LocalCustomColors.current.accent))
),
color = LocalCustomColors.current.itemText,
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 16.dp)
.padding(contentPadding)
.verticalScroll(rememberScrollState())
.fillMaxSize()
)
}
}

View File

@ -249,6 +249,14 @@ class Account(val accp: Long) {
return res return res
} }
fun saveAccounts() {
var accounts = ""
for (a in accounts()) accounts = accounts + a.print() + "\n"
Utils.putFileContents(BaresipService.filesPath + "/accounts",
accounts.toByteArray(Charsets.UTF_8))
// Log.d(TAG, "Saved accounts '${accounts}' to '${BaresipService.filesPath}/accounts'")
}
fun ofAor(aor: String): Account? { fun ofAor(aor: String): Account? {
for (ua in BaresipService.uas.value) for (ua in BaresipService.uas.value)
if (ua.account.aor == aor) return ua.account if (ua.account.aor == aor) return ua.account

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,403 +0,0 @@
package com.tutpro.baresip
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.SystemClock
import android.window.OnBackInvokedCallback
import android.window.OnBackInvokedDispatcher
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.annotation.RequiresApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.outlined.Clear
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.Text
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.layout.ContentScale
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
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.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.tutpro.baresip.CustomElements.AlertDialog
import com.tutpro.baresip.CustomElements.LabelText
import com.tutpro.baresip.CustomElements.verticalScrollbar
class AccountsActivity : ComponentActivity() {
internal lateinit var aor: String
private lateinit var mediaEncMap: Map<String, String>
private lateinit var mediaNatMap: Map<String, String>
private var showAccounts = mutableStateOf(true)
private var lastClick: Long = 0
private val alertTitle = mutableStateOf("")
private val alertMessage = mutableStateOf("")
private val showAlert = mutableStateOf(false)
private var backInvokedCallback: OnBackInvokedCallback? = null
private lateinit var onBackPressedCallback: OnBackPressedCallback
@RequiresApi(33)
private fun registerBackInvokedCallback() {
backInvokedCallback = OnBackInvokedCallback { goBack() }
onBackInvokedDispatcher.registerOnBackInvokedCallback(
OnBackInvokedDispatcher.PRIORITY_DEFAULT,
backInvokedCallback!!
)
}
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
if (Build.VERSION.SDK_INT >= 33)
registerBackInvokedCallback()
else {
onBackPressedCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
goBack()
}
}
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
}
val title = getString(R.string.accounts)
aor = intent.getStringExtra("aor")!!
Utils.addActivity("accounts,$aor")
mediaEncMap = mapOf("zrtp" to "ZRTP", "dtls_srtp" to "DTLS-SRTPF",
"srtp-mand" to "SRTP-MAND", "srtp" to "SRTP", "" to "--")
mediaNatMap = mapOf("stun" to "STUN", "turn" to "TURN", "ice" to "ICE", "" to "--")
setContent {
AppTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = LocalCustomColors.current.background
) {
AccountsScreen(this, title) { goBack() }
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountsScreen(ctx: Context, title: String, navigateBack: () -> Unit) {
Scaffold(
modifier = Modifier.fillMaxHeight()
.imePadding()
.safeDrawingPadding(),
containerColor = LocalCustomColors.current.background,
topBar = {
TopAppBar(
title = {
Text(text = title,
color = LocalCustomColors.current.light,
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
)
}
},
)
},
bottomBar = { NewAccount(ctx) },
content = { contentPadding ->
AccountsContent(ctx, contentPadding)
},
)
}
@Composable
fun AccountsContent(ctx: Context, contentPadding: PaddingValues) {
val showDialog = remember { mutableStateOf(false) }
val message = remember { mutableStateOf("") }
val positiveAction = remember { mutableStateOf({}) }
AlertDialog(
showDialog = showDialog,
title = stringResource(R.string.confirmation),
message = message.value,
positiveButtonText = stringResource(R.string.delete),
onPositiveClicked = positiveAction.value,
negativeButtonText = stringResource(R.string.cancel),
)
if (showAlert.value) {
AlertDialog(
showDialog = showAlert,
title = alertTitle.value,
message = alertMessage.value,
positiveButtonText = stringResource(R.string.ok),
)
}
if (showAccounts.value && BaresipService.uas.value.isNotEmpty()) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.imePadding()
.fillMaxWidth()
.padding(contentPadding)
.padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp)
.verticalScrollbar(scrollState)
.verticalScroll(state = scrollState),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
for (ua in BaresipService.uas.value) {
val account = ua.account
val aor = account.aor
val text = if (account.nickName.value != "")
account.nickName.value
else
account.aor.substringAfter(":")
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = text,
fontSize = 20.sp,
color = LocalCustomColors.current.itemText,
modifier = Modifier.weight(1f).padding(start = 10.dp)
.clickable {
val i = Intent(ctx, AccountActivity::class.java)
val b = Bundle()
b.putString("aor", aor)
i.putExtras(b)
startActivity(i)
}
)
SmallFloatingActionButton(
onClick = {
if (SystemClock.elapsedRealtime() - lastClick > 1000) {
lastClick = SystemClock.elapsedRealtime()
message.value = String.format(
ctx.getString(R.string.delete_account),
text
)
positiveAction.value = {
CallHistoryNew.clear(aor)
Message.clearMessagesOfAor(aor)
ua.remove()
Api.ua_destroy(ua.uap)
saveAccounts()
showAccounts.value = false
showAccounts.value = true
}
showDialog.value = true
}
},
containerColor = LocalCustomColors.current.background,
contentColor = LocalCustomColors.current.secondary
) {
Icon(
Icons.Filled.Delete,
contentDescription = stringResource(R.string.delete)
)
}
}
}
}
}
}
@Composable
fun NewAccount(ctx: Context) {
var newAor by remember { mutableStateOf("") }
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
) {
OutlinedTextField(
value = newAor,
placeholder = { Text(text = stringResource(R.string.new_account)) },
onValueChange = { newAor = it },
modifier = Modifier
.weight(1f)
.padding(end = 8.dp)
.verticalScroll(rememberScrollState())
.clickable {
alertTitle.value = getString(R.string.new_account)
alertMessage.value = getString(R.string.accounts_help)
showAlert.value = true
},
singleLine = false,
trailingIcon = {
if (newAor.isNotEmpty()) {
Icon(
Icons.Outlined.Clear,
contentDescription = "Clear",
modifier = Modifier.clickable { newAor = "" }
)
}
},
label = { LabelText(stringResource(R.string.new_account)) },
textStyle = TextStyle(fontSize = 18.sp),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
)
)
Image(
painter = painterResource(id = R.drawable.plus),
contentDescription = "Add",
contentScale = ContentScale.Crop,
modifier = Modifier.size(48.dp)
.clickable(
onClick = {
val account = createNew(newAor.trim())
if (account != null) {
val i = Intent(ctx, AccountActivity::class.java)
val b = Bundle()
b.putString("aor", account.aor)
b.putString("kind", "new")
i.putExtras(b)
startActivity(i)
newAor = ""
focusManager.clearFocus()
showAccounts.value = false
}
}
),
)
}
}
private fun createNew(newAor: String): Account? {
val aor = if (newAor.startsWith("sip:"))
newAor
else
"sip:$newAor"
if (!Utils.checkAor(aor)) {
alertTitle.value = getString(R.string.notice)
alertMessage.value = String.format(getString(R.string.invalid_aor), aor.split(":")[1])
showAlert.value = true
return null
}
if (Account.ofAor(aor) != null) {
alertTitle.value = getString(R.string.notice)
alertMessage.value = String.format(getString(R.string.account_exists), aor.split(":")[1])
showAlert.value = true
return null
}
val ua = UserAgent.uaAlloc(
"<$aor>;stunserver=\"stun:stun.l.google.com:19302\";regq=0.5;pubint=0;regint=0;mwi=no"
)
if (ua == null) {
alertTitle.value = getString(R.string.notice)
alertMessage.value = getString(R.string.account_allocation_failure)
showAlert.value = true
return null
}
// Api.account_debug(ua.account.accp)
val acc = ua.account
Log.d(TAG, "Allocated UA ${ua.uap} with SIP URI ${acc.luri}")
saveAccounts()
return acc
}
private fun goBack() {
BaresipService.activities.remove("accounts,$aor")
setResult(RESULT_CANCELED, Intent())
finish()
}
override fun onPause() {
MainActivity.activityAor = aor
super.onPause()
}
override fun onResume() {
super.onResume()
showAccounts.value = true
}
override fun onDestroy() {
if (Build.VERSION.SDK_INT >= 33) {
if (backInvokedCallback != null)
onBackInvokedDispatcher.unregisterOnBackInvokedCallback(backInvokedCallback!!)
}
else
onBackPressedCallback.remove()
super.onDestroy()
}
companion object {
fun saveAccounts() {
var accounts = ""
for (a in Account.accounts()) accounts = accounts + a.print() + "\n"
Utils.putFileContents(BaresipService.filesPath + "/accounts",
accounts.toByteArray(Charsets.UTF_8))
// Log.d(TAG, "Saved accounts '${accounts}' to '${BaresipService.filesPath}/accounts'")
}
}
}

View File

@ -0,0 +1,306 @@
package com.tutpro.baresip
import android.content.Context
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
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.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.outlined.Clear
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.Text
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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
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.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.tutpro.baresip.CustomElements.AlertDialog
import com.tutpro.baresip.CustomElements.LabelText
import com.tutpro.baresip.CustomElements.verticalScrollbar
fun NavGraphBuilder.accountsScreenRoute(navController: NavController) {
composable("accounts") {
AccountsScreen(navController)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountsScreen(navController: NavController) {
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(
title = {
Text(
text = stringResource(R.string.accounts),
color = LocalCustomColors.current.light,
fontWeight = FontWeight.Bold
)
},
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = LocalCustomColors.current.light
)
}
},
colors = TopAppBarDefaults.mediumTopAppBarColors(
containerColor = LocalCustomColors.current.primary
),
windowInsets = WindowInsets(0, 0, 0, 0)
)
}
},
bottomBar = { NewAccount(navController) },
content = { contentPadding ->
AccountsContent(contentPadding, navController)
},
)
}
@Composable
fun AccountsContent(contentPadding: PaddingValues, navController: NavController) {
val ctx = LocalContext.current
val showDialog = remember { mutableStateOf(false) }
val message = remember { mutableStateOf("") }
val positiveAction = remember { mutableStateOf({}) }
AlertDialog(
showDialog = showDialog,
title = stringResource(R.string.confirmation),
message = message.value,
positiveButtonText = stringResource(R.string.delete),
onPositiveClicked = positiveAction.value,
negativeButtonText = stringResource(R.string.cancel),
)
val showAccounts = remember { mutableStateOf(true) }
if (showAccounts.value && BaresipService.uas.value.isNotEmpty()) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxWidth()
.padding(contentPadding)
.padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp)
.verticalScrollbar(scrollState)
.verticalScroll(state = scrollState),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
for (ua in BaresipService.uas.value) {
val account = ua.account
val aor = account.aor
val text = if (account.nickName.value != "")
account.nickName.value
else
account.aor.substringAfter(":")
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = text,
fontSize = 20.sp,
color = LocalCustomColors.current.itemText,
modifier = Modifier.weight(1f).padding(start = 10.dp)
.clickable {
navController.navigate("account/$aor/old")
}
)
SmallFloatingActionButton(
onClick = {
message.value = String.format(
ctx.getString(R.string.delete_account),
text
)
positiveAction.value = {
CallHistoryNew.clear(aor)
Message.clearMessagesOfAor(aor)
ua.remove()
Api.ua_destroy(ua.uap)
Account.saveAccounts()
showAccounts.value = false
showAccounts.value = true
}
showDialog.value = true
},
containerColor = LocalCustomColors.current.background,
contentColor = LocalCustomColors.current.secondary
) {
Icon(
Icons.Filled.Delete,
contentDescription = stringResource(R.string.delete)
)
}
}
}
}
}
}
@Composable
fun NewAccount(navController: NavController) {
val alertTitle = remember { mutableStateOf("") }
val alertMessage = remember { mutableStateOf("") }
val showAlert = remember { mutableStateOf(false) }
if (showAlert.value)
AlertDialog(
showDialog = showAlert,
title = alertTitle.value,
message = alertMessage.value,
positiveButtonText = stringResource(R.string.ok),
)
fun createNew(ctx: Context, newAor: String): Account? {
val aor = if (newAor.startsWith("sip:"))
newAor
else
"sip:$newAor"
if (!Utils.checkAor(aor)) {
alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value =
String.format(ctx.getString(R.string.invalid_aor), aor.split(":")[1])
showAlert.value = true
return null
}
if (Account.ofAor(aor) != null) {
alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value =
String.format(ctx.getString(R.string.account_exists), aor.split(":")[1])
showAlert.value = true
return null
}
val ua = UserAgent.uaAlloc(
"<$aor>;stunserver=\"stun:stun.l.google.com:19302\";regq=0.5;pubint=0;regint=0;mwi=no"
)
if (ua == null) {
alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value = ctx.getString(R.string.account_allocation_failure)
showAlert.value = true
return null
}
// Api.account_debug(ua.account.accp)
val acc = ua.account
Log.d(TAG, "Allocated UA ${ua.uap} with SIP URI ${acc.luri}")
Account.saveAccounts()
return acc
} // createNew
var newAor by remember { mutableStateOf("") }
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
) {
val ctx = LocalContext.current
OutlinedTextField(
value = newAor,
placeholder = { Text(text = stringResource(R.string.new_account)) },
onValueChange = { newAor = it },
modifier = Modifier
.weight(1f)
.padding(end = 8.dp)
.verticalScroll(rememberScrollState())
.clickable {
alertTitle.value = ctx.getString(R.string.new_account)
alertMessage.value = ctx.getString(R.string.accounts_help)
showAlert.value = true
},
singleLine = false,
trailingIcon = {
if (newAor.isNotEmpty()) {
Icon(
Icons.Outlined.Clear,
contentDescription = "Clear",
modifier = Modifier.clickable { newAor = "" }
)
}
},
label = { LabelText(stringResource(R.string.new_account)) },
textStyle = TextStyle(fontSize = 18.sp),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
)
)
Image(
painter = painterResource(id = R.drawable.plus),
contentDescription = "Add",
contentScale = ContentScale.Crop,
modifier = Modifier.size(48.dp)
.clickable(
onClick = {
val account = createNew(ctx, newAor.trim())
if (account != null) {
navController.navigate("account/${account.aor}/new")
newAor = ""
focusManager.clearFocus()
}
}
)
)
}
}

View File

@ -1,305 +0,0 @@
package com.tutpro.baresip
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.window.OnBackInvokedCallback
import android.window.OnBackInvokedDispatcher
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.annotation.RequiresApi
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.fillMaxHeight
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.safeDrawingPadding
import androidx.compose.foundation.layout.size
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.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
class AndroidContactActivity : ComponentActivity() {
private lateinit var aor: String
private lateinit var name: String
private var color = 0
private var backInvokedCallback: OnBackInvokedCallback? = null
private lateinit var onBackPressedCallback: OnBackPressedCallback
@RequiresApi(33)
private fun registerBackInvokedCallback() {
backInvokedCallback = OnBackInvokedCallback { goBack() }
onBackInvokedDispatcher.registerOnBackInvokedCallback(
OnBackInvokedDispatcher.PRIORITY_DEFAULT,
backInvokedCallback!!
)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
if (Build.VERSION.SDK_INT >= 33)
registerBackInvokedCallback()
else {
onBackPressedCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
goBack()
}
}
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
}
aor = intent.getStringExtra("aor")!!
name = intent.getStringExtra("name")!!
val title: String = name
val contact = Contact.androidContact(name)
if (contact == null) {
Log.e(TAG, "No Android contact found with name $name")
goBack()
}
Utils.addActivity("android contact, $name")
setContent {
AppTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = LocalCustomColors.current.background
) {
ContactScreen(this, title, contact!!) { goBack() }
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContactScreen(ctx: Context, title: String, contact: Contact.AndroidContact,
navigateBack: () -> Unit) {
Scaffold(
modifier = Modifier.fillMaxHeight().imePadding().safeDrawingPadding(),
containerColor = LocalCustomColors.current.background,
topBar = {
TopAppBar(
title = {
Text(
text = title, color = LocalCustomColors.current.grayLight,
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
)
}
},
)
},
content = { contentPadding ->
ContactContent(ctx, contentPadding, contact)
}
)
}
@Composable
fun ContactContent(ctx: Context, contentPadding: PaddingValues, contact: Contact.AndroidContact) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(LocalCustomColors.current.background)
.padding(contentPadding)
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 52.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Avatar(contact)
ContactName()
Uris(ctx, contact)
}
}
@Composable
fun TextAvatar(text: String, size: Int, color: Int) {
Box(
modifier = Modifier.size(size.dp),
contentAlignment = Alignment.Center
) {
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(SolidColor(Color(color)))
}
Text(text, fontSize = 72.sp, color = Color.White)
}
}
@Composable
fun ImageAvatar(uri: Uri, size: Int) {
AsyncImage(
model = uri,
contentDescription = stringResource(R.string.avatar_image),
contentScale = ContentScale.Crop,
modifier = Modifier.size(size.dp).clip(CircleShape)
)
}
@Composable
fun Avatar(contact: Contact.AndroidContact) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
color = contact.color
val thumbnailUri = contact.thumbnailUri
if (thumbnailUri != null)
ImageAvatar(thumbnailUri, 96)
else
TextAvatar(if (name == "") "" else name[0].toString(), 96, color)
}
}
@Composable
fun ContactName() {
Row(
Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
Text(name, fontSize = 24.sp, color = LocalCustomColors.current.itemText)
}
}
@Composable
fun Uris(ctx: Context, contact: Contact.AndroidContact) {
val lazyListState = rememberLazyListState()
LazyColumn(
modifier = Modifier
.imePadding()
.fillMaxWidth()
.padding(start = 16.dp, end = 4.dp)
.background(LocalCustomColors.current.background),
state = lazyListState,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(contact.uris) { uri ->
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = uri.substringAfter(":"),
modifier = Modifier.weight(1f),
fontSize = 18.sp,
color = LocalCustomColors.current.itemText,
)
Image(
painter = painterResource(R.drawable.message),
colorFilter = ColorFilter.tint(LocalCustomColors.current.itemText),
contentDescription = "Send Message",
modifier = Modifier.padding(end = 24.dp).clickable {
val i = Intent(ctx, MainActivity::class.java)
i.flags = Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
i.putExtra("action", "message")
val ua = UserAgent.ofAor(aor)
if (ua == null) {
Log.w(TAG, "message clickable did not find AoR $aor")
} else {
BaresipService.activities.clear()
i.putExtra("uap", ua.uap)
i.putExtra("peer", uri)
(ctx as Activity).startActivity(i)
}
}
)
Image(
painter = painterResource(R.drawable.call_small),
colorFilter = ColorFilter.tint(LocalCustomColors.current.itemText),
contentDescription = "Call",
modifier = Modifier.clickable {
val i = Intent(ctx, MainActivity::class.java)
i.flags = Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
i.putExtra("action", "call")
val ua = UserAgent.ofAor(aor)
if (ua == null) {
Log.w(TAG, "call clickable did not find AoR $aor")
} else {
BaresipService.activities.clear()
i.putExtra("uap", ua.uap)
i.putExtra("peer", uri)
(ctx as Activity).startActivity(i)
}
}
)
}
}
}
}
override fun onDestroy() {
if (Build.VERSION.SDK_INT >= 33) {
if (backInvokedCallback != null)
onBackInvokedDispatcher.unregisterOnBackInvokedCallback(backInvokedCallback!!)
}
else
onBackPressedCallback.remove()
super.onDestroy()
}
private fun goBack() {
BaresipService.activities.remove("android contact,$name")
setResult(RESULT_CANCELED, Intent(this, MainActivity::class.java))
finish()
}
}

View File

@ -0,0 +1,272 @@
package com.tutpro.baresip
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.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.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.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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.SolidColor
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.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
fun NavGraphBuilder.androidContactScreenRoute(navController: NavController, viewModel: ViewModel) {
composable(
route = "android_contact/{name}",
arguments = listOf(navArgument("name") { type = NavType.StringType })
) { backStackEntry ->
val ctx = LocalContext.current
val name = backStackEntry.arguments?.getString("name")!!
ContactScreen(
ctx = ctx,
viewModel = viewModel,
navController = navController,
name = name
)
}
}
@Composable
private fun ContactScreen(ctx: Context, viewModel: ViewModel, navController: NavController, name: String) {
val contact = Contact.androidContact(name)
if (contact == null) {
Log.e(TAG, "No Android contact found with name $name")
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(name, navController)
}
},
content = { contentPadding ->
ContactContent(ctx, viewModel, navController, contentPadding, contact!!)
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TopAppBar(title: String, navController: NavController) {
TopAppBar(
title = {
Text(
text = title,
color = LocalCustomColors.current.light,
fontWeight = FontWeight.Bold
)
},
colors = TopAppBarDefaults.mediumTopAppBarColors(
containerColor = LocalCustomColors.current.primary
),
windowInsets = WindowInsets(0, 0, 0, 0),
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = LocalCustomColors.current.light
)
}
}
)
}
@Composable
private fun ContactContent(
ctx: Context,
viewModel: ViewModel,
navController: NavController,
contentPadding: PaddingValues,
contact: Contact.AndroidContact
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(LocalCustomColors.current.background)
.padding(contentPadding)
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 52.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Avatar(contact)
ContactName(contact.name)
Uris(ctx, viewModel, navController, contact)
}
}
@Composable
private fun TextAvatar(text: String, color: Int) {
Box(
modifier = Modifier.size(avatarSize.dp),
contentAlignment = Alignment.Center
) {
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(SolidColor(Color(color)))
}
Text(text, fontSize = 72.sp, color = Color.White)
}
}
@Composable
private fun ImageAvatar(uri: Uri) {
AsyncImage(
model = uri,
contentDescription = stringResource(R.string.avatar_image),
contentScale = ContentScale.Crop,
modifier = Modifier.size(avatarSize.dp).clip(CircleShape)
)
}
@Composable
private fun Avatar(contact: Contact.AndroidContact) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
val color = contact.color
val name = contact.name
val thumbnailUri = contact.thumbnailUri
if (thumbnailUri != null)
ImageAvatar(thumbnailUri)
else
TextAvatar(if (name == "") "" else name[0].toString(), color)
}
}
@Composable
private fun ContactName(name: String) {
Row(
Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
Text(name, fontSize = 24.sp, color = LocalCustomColors.current.itemText)
}
}
@Composable
private fun Uris(
ctx: Context,
viewModel: ViewModel,
navController: NavController,
contact: Contact.AndroidContact
) {
val lazyListState = rememberLazyListState()
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 4.dp)
.background(LocalCustomColors.current.background),
state = lazyListState,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(contact.uris) { uri ->
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = uri.substringAfter(":"),
modifier = Modifier.weight(1f),
fontSize = 18.sp,
color = LocalCustomColors.current.itemText,
)
Image(
painter = painterResource(R.drawable.message),
colorFilter = ColorFilter.tint(LocalCustomColors.current.itemText),
contentDescription = "Send Message",
modifier = Modifier.padding(end = 24.dp).clickable {
val aor = viewModel.selectedAor.value
val ua = UserAgent.ofAor(aor)
if (ua == null)
Log.w(TAG, "message clickable did not find AoR $aor")
else {
val intent = Intent(ctx, MainActivity::class.java)
intent.putExtra("uap", ua.uap)
intent.putExtra("peer", uri)
handleIntent(ctx, viewModel, intent, "message")
navController.navigate("main") {
popUpTo("main") { inclusive = false }
launchSingleTop = true
}
}
}
)
Image(
painter = painterResource(R.drawable.call_small),
colorFilter = ColorFilter.tint(LocalCustomColors.current.itemText),
contentDescription = "Call",
modifier = Modifier.clickable {
val aor = viewModel.selectedAor.value
val ua = UserAgent.ofAor(aor)
if (ua == null)
Log.w(TAG, "message clickable did not find AoR $aor")
else {
val intent = Intent(ctx, MainActivity::class.java)
intent.putExtra("uap", ua.uap)
intent.putExtra("peer", uri)
handleIntent(ctx, viewModel, intent, "call")
navController.navigate("main") {
popUpTo("main") { inclusive = false }
launchSingleTop = true
}
}
}
)
}
}
}
}

View File

@ -2,6 +2,7 @@ package com.tutpro.baresip
import android.app.Activity import android.app.Activity
import android.os.Build import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicDarkColorScheme
@ -10,59 +11,61 @@ import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.core.graphics.ColorUtils
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
@Composable @Composable
fun AppTheme( fun AppTheme(
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val darkTheme = remember { BaresipService.darkTheme } val useDarkTheme by remember { BaresipService.darkTheme }
// "normal" palette, nothing change here
val colorScheme = when { val colorScheme = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (darkTheme.value) dynamicDarkColorScheme(context = LocalContext.current) if (useDarkTheme)
else dynamicLightColorScheme(context = LocalContext.current) dynamicDarkColorScheme(context = LocalContext.current)
else
if (isSystemInDarkTheme())
dynamicDarkColorScheme(context = LocalContext.current)
else
dynamicLightColorScheme(context = LocalContext.current)
} }
darkTheme.value -> darkColorScheme() useDarkTheme -> darkColorScheme()
else -> lightColorScheme() else ->
if (isSystemInDarkTheme())
darkColorScheme()
else
lightColorScheme()
} }
// logic for which custom palette to use
val customColorsPalette = val customColorsPalette =
if (darkTheme.value) DarkCustomColors if (useDarkTheme) DarkCustomColors
else LightCustomColors else LightCustomColors
val view = LocalView.current val view = LocalView.current
if (!view.isInEditMode) { if (!view.isInEditMode) {
SideEffect { SideEffect {
val window = (view.context as Activity).window val window = (view.context as Activity).window
val decorView = window.decorView val insetsController = WindowCompat.getInsetsController(window, view)
// Ensure insets are applied correctly // A common threshold for luminance is 0.5. Colors with luminance > 0.5 are considered light.
WindowCompat.setDecorFitsSystemWindows(window, false) val isBackgroundEffectivelyLight = ColorUtils.calculateLuminance(customColorsPalette.background.toArgb()) > 0.5
// Handle the status bar appearance insetsController.isAppearanceLightStatusBars = isBackgroundEffectivelyLight
val insetsController = WindowCompat.getInsetsController(window, decorView) insetsController.isAppearanceLightNavigationBars = isBackgroundEffectivelyLight
window.statusBarColor = customColorsPalette.background.toArgb()
window.navigationBarColor = customColorsPalette.background.toArgb()
insetsController.apply {
isAppearanceLightStatusBars = !darkTheme.value
isAppearanceLightNavigationBars = !darkTheme.value
}
} }
} }
// here is the important point, where you will expose custom objects
CompositionLocalProvider( CompositionLocalProvider(
LocalCustomColors provides customColorsPalette // our custom palette LocalCustomColors provides customColorsPalette
) { ) {
MaterialTheme( MaterialTheme(
colorScheme = colorScheme, // the MaterialTheme still uses the "normal" palette colorScheme = colorScheme, // MaterialTheme still uses the "normal" colorScheme
content = content content = content
) )
} }

View File

@ -1,700 +0,0 @@
package com.tutpro.baresip
import android.os.Build
import android.os.Bundle
import android.window.OnBackInvokedCallback
import android.window.OnBackInvokedDispatcher
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.annotation.RequiresApi
import androidx.compose.foundation.clickable
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.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
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.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.tutpro.baresip.CustomElements.AlertDialog
import com.tutpro.baresip.CustomElements.LabelText
import com.tutpro.baresip.CustomElements.verticalScrollbar
class AudioActivity : ComponentActivity() {
private var save = false
private var restart = false
private var oldCallVolume = BaresipService.callVolume
private var newCallVolume = oldCallVolume
private var oldMicGain = ""
private var newMicGain = ""
private var newSpeakerPhone = BaresipService.speakerPhone
private val modules = Config.variables("module")
private var newAudioModules = mutableMapOf<String, Boolean>()
private var oldOpusBitrate = Config.variable("opus_bitrate")
private var newOpusBitrate = oldOpusBitrate
private var oldOpusPacketLoss = Config.variable("opus_packet_loss")
private var newOpusPacketLoss = oldOpusPacketLoss
private var newAudioDelay = BaresipService.audioDelay.toString()
private var newToneCountry = BaresipService.toneCountry
private val alertTitle = mutableStateOf("")
private val alertMessage = mutableStateOf("")
private val showAlert = mutableStateOf(false)
private var backInvokedCallback: OnBackInvokedCallback? = null
private lateinit var onBackPressedCallback: OnBackPressedCallback
@RequiresApi(33)
private fun registerBackInvokedCallback() {
backInvokedCallback = OnBackInvokedCallback { goBack() }
onBackInvokedDispatcher.registerOnBackInvokedCallback(
OnBackInvokedDispatcher.PRIORITY_DEFAULT,
backInvokedCallback!!
)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
if (Build.VERSION.SDK_INT >= 33)
registerBackInvokedCallback()
else {
onBackPressedCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
goBack()
}
}
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
}
Utils.addActivity("audio")
if (!BaresipService.agcAvailable)
oldMicGain = Config.variable("augain")
setContent {
AppTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = LocalCustomColors.current.background
) {
AudioScreen { goBack() }
}
}
}
}
@Composable
fun AudioScreen(navigateBack: () -> Unit) {
Scaffold(
modifier = Modifier
.fillMaxHeight()
.imePadding()
.safeDrawingPadding(),
containerColor = LocalCustomColors.current.background,
topBar = { TopAppBar(navigateBack) },
content = { contentPadding ->
AudioContent(contentPadding)
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TopAppBar(navigateBack: () -> Unit) {
androidx.compose.material3.TopAppBar(
title = {
Text(
text = stringResource(R.string.audio_settings),
color = LocalCustomColors.current.light,
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 = {
checkOnClick()
}) {
Icon(
imageVector = Icons.Filled.Check,
tint = LocalCustomColors.current.light,
contentDescription = "Check"
)
}
}
)
}
@Composable
fun AudioContent(contentPadding: PaddingValues) {
if (showAlert.value) {
AlertDialog(
showDialog = showAlert,
title = alertTitle.value,
message = alertMessage.value,
positiveButtonText = stringResource(R.string.ok),
)
}
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.imePadding()
.fillMaxWidth()
.padding(contentPadding)
.padding(top = 8.dp, bottom = 8.dp, start = 16.dp, end = 4.dp)
.verticalScrollbar(scrollState)
.verticalScroll(state = scrollState),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
CallVolume()
MicGain()
SpeakerPhone()
AudioModules()
OpusBitRate()
OpusPacketLoss()
AudioDelay()
ToneCountry()
}
}
@Composable
private fun CallVolume() {
Row(
Modifier.fillMaxWidth().padding(end=10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
Text(text = stringResource(R.string.default_call_volume),
modifier = Modifier
.weight(1f)
.clickable {
alertTitle.value = getString(R.string.default_call_volume)
alertMessage.value = getString(R.string.default_call_volume_help)
showAlert.value = true
},
color = LocalCustomColors.current.itemText,
fontSize = 18.sp)
val isDropDownExpanded = remember {
mutableStateOf(false)
}
val volNames = listOf("--", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10")
val volValues = listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
val itemPosition = remember {
mutableIntStateOf(volValues.indexOf(oldCallVolume))
}
Box {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable {
isDropDownExpanded.value = true
}
) {
Text(text = volNames[itemPosition.intValue],
color = LocalCustomColors.current.itemText)
CustomElements.DrawDrawable(R.drawable.arrow_drop_down,
tint = LocalCustomColors.current.itemText)
}
DropdownMenu(
expanded = isDropDownExpanded.value,
onDismissRequest = {
isDropDownExpanded.value = false
}) {
volNames.forEachIndexed { index, vol ->
DropdownMenuItem(text = {
Text(text = vol)
},
onClick = {
isDropDownExpanded.value = false
itemPosition.intValue = index
newCallVolume = volValues[index]
})
if (index < 10)
HorizontalDivider(
thickness = 1.dp,
color = LocalCustomColors.current.itemText
)
}
}
}
}
}
@Composable
private fun MicGain() {
if (!BaresipService.agcAvailable)
Row(
Modifier.fillMaxWidth().padding(end = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
var micGain by remember { mutableStateOf(oldMicGain) }
newMicGain = micGain
OutlinedTextField(
value = micGain,
placeholder = { Text(stringResource(R.string.microphone_gain)) },
onValueChange = {
micGain = it
newMicGain = micGain
},
modifier = Modifier
.fillMaxWidth()
.clickable {
alertTitle.value = getString(R.string.microphone_gain)
alertMessage.value = getString(R.string.microphone_gain_help)
showAlert.value = true
},
textStyle = androidx.compose.ui.text.TextStyle(
fontSize = 18.sp, color = LocalCustomColors.current.itemText
),
label = { LabelText(stringResource(R.string.microphone_gain)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text)
)
}
}
@Composable
private fun SpeakerPhone() {
Row(
Modifier.fillMaxWidth().padding(end=10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
Text(text = stringResource(R.string.speaker_phone),
modifier = Modifier
.weight(1f)
.clickable {
alertTitle.value = getString(R.string.speaker_phone)
alertMessage.value = getString(R.string.speaker_phone_help)
showAlert.value = true
},
color = LocalCustomColors.current.itemText,
fontSize = 18.sp)
var speakerPhone by remember { mutableStateOf(BaresipService.speakerPhone) }
Switch(
checked = speakerPhone,
onCheckedChange = {
speakerPhone = it
newSpeakerPhone = speakerPhone
}
)
}
}
@Composable
private fun AudioModules() {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
) {
Text(text = stringResource(R.string.audio_modules_title),
color = LocalCustomColors.current.itemText,
fontSize = 18.sp,
modifier = Modifier.clickable {
alertTitle.value = getString(R.string.audio_modules_title)
alertMessage.value = getString(R.string.audio_modules_help)
showAlert.value = true
})
for (module in audioModules) {
Row(horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 18.dp, end = 10.dp)
) {
Text(text = String.format(getString(R.string.bullet_item), module),
color = LocalCustomColors.current.itemText,
fontSize = 18.sp)
Spacer(modifier = Modifier.weight(1f))
var checked by remember { mutableStateOf(modules.contains("${module}.so")) }
Switch(
checked = checked,
onCheckedChange = {
checked = it
newAudioModules[module] = checked
}
)
}
}
}
}
@Composable
private fun OpusBitRate() {
Row(
Modifier.fillMaxWidth().padding(end = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
var opusBitrate by remember { mutableStateOf(oldOpusBitrate) }
newOpusBitrate = opusBitrate
OutlinedTextField(
value = opusBitrate,
placeholder = { Text(stringResource(R.string.opus_bit_rate)) },
onValueChange = {
opusBitrate = it
newOpusBitrate = opusBitrate
},
modifier = Modifier
.fillMaxWidth()
.clickable {
alertTitle.value = getString(R.string.opus_bit_rate)
alertMessage.value = getString(R.string.opus_bit_rate_help)
showAlert.value = true
},
textStyle = androidx.compose.ui.text.TextStyle(
fontSize = 18.sp, color = LocalCustomColors.current.itemText),
label = { LabelText(stringResource(R.string.opus_bit_rate)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text)
)
}
}
@Composable
private fun OpusPacketLoss() {
Row(
Modifier.fillMaxWidth().padding(end = 10.dp, top = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
var opusPacketLoss by remember { mutableStateOf(oldOpusPacketLoss) }
newOpusPacketLoss = opusPacketLoss
OutlinedTextField(
value = opusPacketLoss,
placeholder = { Text(stringResource(R.string.opus_packet_loss)) },
onValueChange = {
opusPacketLoss = it
newOpusPacketLoss = opusPacketLoss
},
modifier = Modifier
.fillMaxWidth()
.clickable {
alertTitle.value = getString(R.string.opus_packet_loss)
alertMessage.value = getString(R.string.opus_packet_loss_help)
showAlert.value = true
},
textStyle = androidx.compose.ui.text.TextStyle(
fontSize = 18.sp, color = LocalCustomColors.current.itemText),
label = { LabelText(stringResource(R.string.opus_packet_loss)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text)
)
}
}
@Composable
private fun AudioDelay() {
Row(
Modifier.fillMaxWidth().padding(end = 10.dp, top = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
var audioDelay by remember { mutableStateOf(BaresipService.audioDelay.toString()) }
newAudioDelay = audioDelay
OutlinedTextField(
value = audioDelay,
placeholder = { Text(getString(R.string.audio_delay)) },
onValueChange = {
audioDelay = it
newAudioDelay = audioDelay
},
modifier = Modifier
.fillMaxWidth()
.clickable {
alertTitle.value = getString(R.string.audio_delay)
alertMessage.value = getString(R.string.audio_delay_help)
showAlert.value = true
},
textStyle = androidx.compose.ui.text.TextStyle(
fontSize = 18.sp, color = LocalCustomColors.current.itemText),
label = { LabelText(stringResource(R.string.audio_delay)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text)
)
}
}
@Composable
private fun ToneCountry() {
Row(
Modifier.fillMaxWidth().padding(end=10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
Text(text = stringResource(R.string.tone_country),
modifier = Modifier
.weight(1f)
.clickable {
alertTitle.value = getString(R.string.tone_country)
alertMessage.value = getString(R.string.tone_country_help)
showAlert.value = true
},
color = LocalCustomColors.current.itemText,
fontSize = 18.sp)
val isDropDownExpanded = remember {
mutableStateOf(false)
}
val countryNames = arrayListOf("BG", "BR", "DE", "CZ", "ES", "FI", "FR", "GB", "JP", "NO", "NZ", "SE", "RU", "US")
val countryValues = arrayListOf("bg", "br", "de", "cz", "es", "fi", "fr", "uk", "jp", "no", "nz", "se", "ru", "us")
val itemPosition = remember {
mutableIntStateOf(countryValues.indexOf(BaresipService.toneCountry))
}
Box {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable {
isDropDownExpanded.value = true
}
) {
Text(text = countryNames[itemPosition.intValue],
color = LocalCustomColors.current.itemText)
CustomElements.DrawDrawable(R.drawable.arrow_drop_down,
tint = LocalCustomColors.current.itemText)
}
DropdownMenu(
expanded = isDropDownExpanded.value,
onDismissRequest = {
isDropDownExpanded.value = false
}) {
countryNames.forEachIndexed { index, name ->
DropdownMenuItem(text = {
Text(text = name)
},
onClick = {
isDropDownExpanded.value = false
itemPosition.intValue = index
newToneCountry = countryValues[index]
})
if (index < 10)
HorizontalDivider(
thickness = 1.dp,
color = LocalCustomColors.current.itemText
)
}
}
}
}
}
private fun checkOnClick() {
if (BaresipService.activities.indexOf("audio") == -1)
return
if (BaresipService.callVolume != newCallVolume) {
BaresipService.callVolume = newCallVolume
Config.replaceVariable("call_volume", newCallVolume.toString())
save = true
}
if (!BaresipService.agcAvailable) {
var gain = newMicGain.trim()
if (!gain.contains("."))
gain = "$gain.0"
if (gain != oldMicGain) {
if (!checkMicGain(gain)) {
alertTitle.value = getString(R.string.notice)
alertMessage.value = "${getString(R.string.invalid_microphone_gain)}: $gain."
showAlert.value = true
return
}
if (gain == "1.0") {
Api.module_unload("augain")
Config.removeVariableValue("module", "augain.so")
Config.replaceVariable("augain", "1.0")
} else {
if (oldMicGain == "1.0") {
if (Api.module_load("augain") != 0) {
alertTitle.value = getString(R.string.error)
alertMessage.value = getString(R.string.failed_to_load_module) + ": augain.so"
showAlert.value = true
return
}
Config.addVariable("module", "augain.so")
}
Config.replaceVariable("augain", gain)
Api.cmd_exec("augain $gain")
}
save = true
}
}
if (newSpeakerPhone != BaresipService.speakerPhone) {
BaresipService.speakerPhone = newSpeakerPhone
Config.replaceVariable("speaker_phone",
if (BaresipService.speakerPhone) "yes" else "no")
save = true
}
for (module in audioModules) {
if (newAudioModules[module] != null) {
if (newAudioModules[module]!!) {
if (!modules.contains("${module}.so")) {
if (Api.module_load("${module}.so") != 0) {
alertTitle.value = getString(R.string.error)
alertMessage.value = "${getString(R.string.failed_to_load_module)}: ${module}.so"
showAlert.value = true
return
}
Config.addVariable("module", "${module}.so")
save = true
}
} else if (modules.contains("${module}.so")) {
Api.module_unload("${module}.so")
Config.removeVariableValue("module", "${module}.so")
for (ua in BaresipService.uas.value)
ua.account.removeAudioCodecs(module)
AccountsActivity.saveAccounts()
save = true
}
}
}
if (newOpusBitrate != oldOpusBitrate) {
if (!checkOpusBitRate(newOpusBitrate)) {
alertTitle.value = getString(R.string.notice)
alertMessage.value = "${getString(R.string.invalid_opus_bitrate)}: $newOpusBitrate."
showAlert.value = true
return
}
Config.replaceVariable("opus_bitrate", newOpusBitrate)
restart = true
save = true
}
if (newOpusPacketLoss != oldOpusPacketLoss) {
if (!checkOpusPacketLoss(newOpusPacketLoss)) {
alertTitle.value = getString(R.string.notice)
alertMessage.value = "${getString(R.string.invalid_opus_packet_loss)}: $newOpusPacketLoss"
showAlert.value = true
return
}
Config.replaceVariable("opus_packet_loss", newOpusPacketLoss)
restart = true
save = true
}
val audioDelay = newAudioDelay.trim()
if (audioDelay != BaresipService.audioDelay.toString()) {
if (!checkAudioDelay(audioDelay)) {
alertTitle.value = getString(R.string.notice)
alertMessage.value = String.format(getString(R.string.invalid_audio_delay), audioDelay)
showAlert.value = true
return
}
Config.replaceVariable("audio_delay", audioDelay)
BaresipService.audioDelay = audioDelay.toLong()
save = true
}
if (BaresipService.toneCountry != newToneCountry) {
BaresipService.toneCountry = newToneCountry
Config.replaceVariable("tone_country", newToneCountry)
save = true
}
if (save)
Config.save()
setResult(if (restart) RESULT_OK else RESULT_CANCELED)
BaresipService.activities.remove("audio")
finish()
}
override fun onDestroy() {
if (Build.VERSION.SDK_INT >= 33) {
if (backInvokedCallback != null)
onBackInvokedDispatcher.unregisterOnBackInvokedCallback(backInvokedCallback!!)
}
else
onBackPressedCallback.remove()
super.onDestroy()
}
private fun goBack() {
BaresipService.activities.remove("audio")
finish()
}
private fun checkMicGain(micGain: String): Boolean {
val number =
try {
micGain.toDouble()
} catch (_: NumberFormatException) {
return false
}
return number >= 1.0
}
private fun checkOpusBitRate(opusBitRate: String): Boolean {
val number = opusBitRate.toIntOrNull() ?: return false
return (number >= 6000) && (number <= 510000)
}
private fun checkOpusPacketLoss(opusPacketLoss: String): Boolean {
val number = opusPacketLoss.toIntOrNull() ?: return false
return (number >= 0) && (number <= 100)
}
private fun checkAudioDelay(audioDelay: String): Boolean {
val number = audioDelay.toIntOrNull() ?: return false
return (number >= 100) && (number <= 3000)
}
companion object {
val audioModules = listOf("opus", "amr", "g722", "g7221", "g726", "g729", "codec2", "g711")
}
}

View File

@ -0,0 +1,664 @@
package com.tutpro.baresip
import android.content.Context
import androidx.activity.ComponentActivity
import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.statusBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.tutpro.baresip.CustomElements.AlertDialog
import com.tutpro.baresip.CustomElements.LabelText
import com.tutpro.baresip.CustomElements.verticalScrollbar
fun NavGraphBuilder.audioScreenRoute(
navController: NavController,
) {
composable("audio") {
val activity = LocalActivity.current
val viewModel: ViewModel = viewModel(activity as ComponentActivity)
val ctx = LocalContext.current
AudioScreen(
onBack = { navController.popBackStack() },
checkOnClick = {
viewModel.setAudioSettingsResult(checkOnClick(ctx))
navController.popBackStack()
},
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AudioScreen(
onBack: () -> Unit,
checkOnClick: () -> Unit,
) {
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(
title = {
Text(
text = stringResource(R.string.audio_settings),
color = LocalCustomColors.current.light,
fontWeight = FontWeight.Bold
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = LocalCustomColors.current.light
)
}
},
windowInsets = WindowInsets(0, 0, 0, 0),
actions = {
IconButton(onClick = checkOnClick) {
Icon(
imageVector = Icons.Filled.Check,
tint = LocalCustomColors.current.light,
contentDescription = "Check"
)
}
},
colors = TopAppBarDefaults.mediumTopAppBarColors(
containerColor = LocalCustomColors.current.primary
)
)
}
}
) { contentPadding ->
AudioContent(contentPadding)
}
}
private var oldCallVolume = BaresipService.callVolume
private var newCallVolume = oldCallVolume
private var oldMicGain = ""
private var newMicGain = ""
private var oldSpeakerPhone = BaresipService.speakerPhone
private var newSpeakerPhone = oldSpeakerPhone
private var oldAudioModules = ArrayList<String>()
private var newAudioModules = mutableMapOf<String, Boolean>()
private var oldOpusBitrate = ""
private var newOpusBitrate = oldOpusBitrate
private var oldOpusPacketLoss = ""
private var newOpusPacketLoss = oldOpusPacketLoss
private var newAudioDelay = BaresipService.audioDelay.toString()
private var newToneCountry = BaresipService.toneCountry
private var save = false
private val alertTitle = mutableStateOf("")
private val alertMessage = mutableStateOf("")
private val showAlert = mutableStateOf(false)
@Composable
private fun AudioContent(contentPadding: PaddingValues) {
oldAudioModules = Config.variables("module")
oldOpusBitrate = Config.variable("opus_bitrate")
oldOpusPacketLoss = Config.variable("opus_packet_loss")
if (!BaresipService.agcAvailable)
oldMicGain = Config.variable("augain")
if (showAlert.value) {
AlertDialog(
showDialog = showAlert,
title = alertTitle.value,
message = alertMessage.value,
positiveButtonText = stringResource(R.string.ok),
)
}
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxWidth()
.padding(contentPadding)
.padding(top = 8.dp, bottom = 8.dp, start = 16.dp, end = 4.dp)
.verticalScrollbar(scrollState)
.verticalScroll(state = scrollState),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
CallVolume()
MicGain()
SpeakerPhone()
AudioModules()
OpusBitRate()
OpusPacketLoss()
AudioDelay()
ToneCountry()
}
}
@Composable
private fun CallVolume() {
Row(
Modifier.fillMaxWidth().padding(end=10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
val ctx = LocalContext.current
Text(text = stringResource(R.string.default_call_volume),
modifier = Modifier
.weight(1f)
.clickable {
alertTitle.value = ctx.getString(R.string.default_call_volume)
alertMessage.value = ctx.getString(R.string.default_call_volume_help)
showAlert.value = true
},
color = LocalCustomColors.current.itemText,
fontSize = 18.sp)
val isDropDownExpanded = remember {
mutableStateOf(false)
}
val volNames = listOf("--", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10")
val volValues = listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
val itemPosition = remember {
mutableIntStateOf(volValues.indexOf(oldCallVolume))
}
Box {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable {
isDropDownExpanded.value = true
}
) {
Text(text = volNames[itemPosition.intValue],
color = LocalCustomColors.current.itemText)
CustomElements.DrawDrawable(R.drawable.arrow_drop_down,
tint = LocalCustomColors.current.itemText)
}
DropdownMenu(
expanded = isDropDownExpanded.value,
onDismissRequest = {
isDropDownExpanded.value = false
}) {
volNames.forEachIndexed { index, vol ->
DropdownMenuItem(text = {
Text(text = vol)
},
onClick = {
isDropDownExpanded.value = false
itemPosition.intValue = index
newCallVolume = volValues[index]
})
if (index < 10)
HorizontalDivider(
thickness = 1.dp,
color = LocalCustomColors.current.itemText
)
}
}
}
}
}
@Composable
private fun MicGain() {
if (!BaresipService.agcAvailable)
Row(
Modifier.fillMaxWidth().padding(end = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
val ctx = LocalContext.current
var micGain by remember { mutableStateOf(oldMicGain) }
newMicGain = micGain
OutlinedTextField(
value = micGain,
placeholder = { Text(stringResource(R.string.microphone_gain)) },
onValueChange = {
micGain = it
newMicGain = micGain
},
modifier = Modifier
.fillMaxWidth()
.clickable {
alertTitle.value = ctx.getString(R.string.microphone_gain)
alertMessage.value = ctx.getString(R.string.microphone_gain_help)
showAlert.value = true
},
textStyle = androidx.compose.ui.text.TextStyle(
fontSize = 18.sp, color = LocalCustomColors.current.itemText
),
label = { LabelText(stringResource(R.string.microphone_gain)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text)
)
}
}
@Composable
private fun SpeakerPhone() {
Row(
Modifier.fillMaxWidth().padding(end=10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
val ctx = LocalContext.current
Text(text = stringResource(R.string.speaker_phone),
modifier = Modifier
.weight(1f)
.clickable {
alertTitle.value = ctx.getString(R.string.speaker_phone)
alertMessage.value = ctx.getString(R.string.speaker_phone_help)
showAlert.value = true
},
color = LocalCustomColors.current.itemText,
fontSize = 18.sp)
var speakerPhone by remember { mutableStateOf(BaresipService.speakerPhone) }
Switch(
checked = speakerPhone,
onCheckedChange = {
speakerPhone = it
newSpeakerPhone = speakerPhone
}
)
}
}
@Composable
private fun AudioModules() {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
) {
val ctx = LocalContext.current
Text(text = stringResource(R.string.audio_modules_title),
color = LocalCustomColors.current.itemText,
fontSize = 18.sp,
modifier = Modifier.clickable {
alertTitle.value = ctx.getString(R.string.audio_modules_title)
alertMessage.value = ctx.getString(R.string.audio_modules_help)
showAlert.value = true
})
for (module in Config.audioModules) {
Row(horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 18.dp, end = 10.dp)
) {
Text(text = String.format(ctx.getString(R.string.bullet_item), module),
color = LocalCustomColors.current.itemText,
fontSize = 18.sp)
Spacer(modifier = Modifier.weight(1f))
var checked by remember { mutableStateOf(oldAudioModules.contains("${module}.so")) }
Switch(
checked = checked,
onCheckedChange = {
checked = it
newAudioModules[module] = checked
}
)
}
}
}
}
@Composable
private fun OpusBitRate() {
Row(
Modifier.fillMaxWidth().padding(end = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
val ctx = LocalContext.current
var opusBitrate by remember { mutableStateOf(oldOpusBitrate) }
newOpusBitrate = opusBitrate
OutlinedTextField(
value = opusBitrate,
placeholder = { Text(stringResource(R.string.opus_bit_rate)) },
onValueChange = {
opusBitrate = it
newOpusBitrate = opusBitrate
},
modifier = Modifier
.fillMaxWidth()
.clickable {
alertTitle.value = ctx.getString(R.string.opus_bit_rate)
alertMessage.value = ctx.getString(R.string.opus_bit_rate_help)
showAlert.value = true
},
textStyle = androidx.compose.ui.text.TextStyle(
fontSize = 18.sp, color = LocalCustomColors.current.itemText),
label = { LabelText(stringResource(R.string.opus_bit_rate)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text)
)
}
}
@Composable
private fun OpusPacketLoss() {
Row(
Modifier.fillMaxWidth().padding(end = 10.dp, top = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
val ctx = LocalContext.current
var opusPacketLoss by remember { mutableStateOf(oldOpusPacketLoss) }
newOpusPacketLoss = opusPacketLoss
OutlinedTextField(
value = opusPacketLoss,
placeholder = { Text(stringResource(R.string.opus_packet_loss)) },
onValueChange = {
opusPacketLoss = it
newOpusPacketLoss = opusPacketLoss
},
modifier = Modifier
.fillMaxWidth()
.clickable {
alertTitle.value = ctx.getString(R.string.opus_packet_loss)
alertMessage.value = ctx.getString(R.string.opus_packet_loss_help)
showAlert.value = true
},
textStyle = androidx.compose.ui.text.TextStyle(
fontSize = 18.sp, color = LocalCustomColors.current.itemText),
label = { LabelText(stringResource(R.string.opus_packet_loss)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text)
)
}
}
@Composable
private fun AudioDelay() {
Row(
Modifier.fillMaxWidth().padding(end = 10.dp, top = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
val ctx = LocalContext.current
var audioDelay by remember { mutableStateOf(BaresipService.audioDelay.toString()) }
newAudioDelay = audioDelay
OutlinedTextField(
value = audioDelay,
placeholder = { Text(stringResource(R.string.audio_delay)) },
onValueChange = {
audioDelay = it
newAudioDelay = audioDelay
},
modifier = Modifier
.fillMaxWidth()
.clickable {
alertTitle.value = ctx.getString(R.string.audio_delay)
alertMessage.value = ctx.getString(R.string.audio_delay_help)
showAlert.value = true
},
textStyle = androidx.compose.ui.text.TextStyle(
fontSize = 18.sp, color = LocalCustomColors.current.itemText),
label = { LabelText(stringResource(R.string.audio_delay)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text)
)
}
}
@Composable
private fun ToneCountry() {
Row(
Modifier.fillMaxWidth().padding(end=10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
val ctx = LocalContext.current
Text(text = stringResource(R.string.tone_country),
modifier = Modifier
.weight(1f)
.clickable {
alertTitle.value = ctx.getString(R.string.tone_country)
alertMessage.value = ctx.getString(R.string.tone_country_help)
showAlert.value = true
},
color = LocalCustomColors.current.itemText,
fontSize = 18.sp)
val isDropDownExpanded = remember {
mutableStateOf(false)
}
val countryNames = arrayListOf("BG", "BR", "DE", "CZ", "ES", "FI", "FR", "GB", "JP", "NO", "NZ", "SE", "RU", "US")
val countryValues = arrayListOf("bg", "br", "de", "cz", "es", "fi", "fr", "uk", "jp", "no", "nz", "se", "ru", "us")
val itemPosition = remember {
mutableIntStateOf(countryValues.indexOf(BaresipService.toneCountry))
}
Box {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable {
isDropDownExpanded.value = true
}
) {
Text(text = countryNames[itemPosition.intValue],
color = LocalCustomColors.current.itemText)
CustomElements.DrawDrawable(R.drawable.arrow_drop_down,
tint = LocalCustomColors.current.itemText)
}
DropdownMenu(
expanded = isDropDownExpanded.value,
onDismissRequest = {
isDropDownExpanded.value = false
}) {
countryNames.forEachIndexed { index, name ->
DropdownMenuItem(text = {
Text(text = name)
},
onClick = {
isDropDownExpanded.value = false
itemPosition.intValue = index
newToneCountry = countryValues[index]
})
if (index < 10)
HorizontalDivider(
thickness = 1.dp,
color = LocalCustomColors.current.itemText
)
}
}
}
}
}
private fun checkOnClick(ctx: Context): Boolean {
var restart = false
if (BaresipService.callVolume != newCallVolume) {
BaresipService.callVolume = newCallVolume
Config.replaceVariable("call_volume", newCallVolume.toString())
save = true
}
if (!BaresipService.agcAvailable) {
var gain = newMicGain.trim()
if (!gain.contains("."))
gain = "$gain.0"
if (gain != oldMicGain) {
if (!checkMicGain(gain)) {
alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value = "${ctx.getString(R.string.invalid_microphone_gain)}: $gain."
showAlert.value = true
return false
}
if (gain == "1.0") {
Api.module_unload("augain")
Config.removeVariableValue("module", "augain.so")
Config.replaceVariable("augain", "1.0")
} else {
if (oldMicGain == "1.0") {
if (Api.module_load("augain") != 0) {
alertTitle.value = ctx.getString(R.string.error)
alertMessage.value = ctx.getString(R.string.failed_to_load_module) + ": augain.so"
showAlert.value = true
return false
}
Config.addVariable("module", "augain.so")
}
Config.replaceVariable("augain", gain)
Api.cmd_exec("augain $gain")
}
save = true
}
}
if (newSpeakerPhone != BaresipService.speakerPhone) {
BaresipService.speakerPhone = newSpeakerPhone
Config.replaceVariable("speaker_phone",
if (BaresipService.speakerPhone) "yes" else "no")
save = true
}
for (module in Config.audioModules) {
if (newAudioModules[module] != null) {
if (newAudioModules[module]!!) {
if (!oldAudioModules.contains("${module}.so")) {
if (Api.module_load("${module}.so") != 0) {
alertTitle.value = ctx.getString(R.string.error)
alertMessage.value = "${ctx.getString(R.string.failed_to_load_module)}: ${module}.so"
showAlert.value = true
return false
}
Config.addVariable("module", "${module}.so")
save = true
}
} else if (oldAudioModules.contains("${module}.so")) {
Api.module_unload("${module}.so")
Config.removeVariableValue("module", "${module}.so")
for (ua in BaresipService.uas.value)
ua.account.removeAudioCodecs(module)
Account.saveAccounts()
save = true
}
}
}
if (newOpusBitrate != oldOpusBitrate) {
if (!checkOpusBitRate(newOpusBitrate)) {
alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value = "${ctx.getString(R.string.invalid_opus_bitrate)}: $newOpusBitrate."
showAlert.value = true
return false
}
Config.replaceVariable("opus_bitrate", newOpusBitrate)
restart = true
save = true
}
if (newOpusPacketLoss != oldOpusPacketLoss) {
if (!checkOpusPacketLoss(newOpusPacketLoss)) {
alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value = "${ctx.getString(R.string.invalid_opus_packet_loss)}: $newOpusPacketLoss"
showAlert.value = true
return false
}
Config.replaceVariable("opus_packet_loss", newOpusPacketLoss)
restart = true
save = true
}
val audioDelay = newAudioDelay.trim()
if (audioDelay != BaresipService.audioDelay.toString()) {
if (!checkAudioDelay(audioDelay)) {
alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value = String.format(ctx.getString(R.string.invalid_audio_delay), audioDelay)
showAlert.value = true
return false
}
Config.replaceVariable("audio_delay", audioDelay)
BaresipService.audioDelay = audioDelay.toLong()
save = true
}
if (BaresipService.toneCountry != newToneCountry) {
BaresipService.toneCountry = newToneCountry
Config.replaceVariable("tone_country", newToneCountry)
save = true
}
if (save) Config.save()
return restart
}
private fun checkMicGain(micGain: String): Boolean {
val number =
try {
micGain.toDouble()
} catch (_: NumberFormatException) {
return false
}
return number >= 1.0
}
private fun checkOpusBitRate(opusBitRate: String): Boolean {
val number = opusBitRate.toIntOrNull() ?: return false
return (number >= 6000) && (number <= 510000)
}
private fun checkOpusPacketLoss(opusPacketLoss: String): Boolean {
val number = opusPacketLoss.toIntOrNull() ?: return false
return (number >= 0) && (number <= 100)
}
private fun checkAudioDelay(audioDelay: String): Boolean {
val number = audioDelay.toIntOrNull() ?: return false
return (number >= 100) && (number <= 3000)
}

View File

@ -1,724 +0,0 @@
package com.tutpro.baresip
import android.content.ContentProviderOperation
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.database.Cursor
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds
import android.provider.ContactsContract.Contacts.Data
import android.window.OnBackInvokedCallback
import android.window.OnBackInvokedDispatcher
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.compose.foundation.Canvas
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.fillMaxHeight
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.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
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.Check
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.Surface
import androidx.compose.material3.Switch
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.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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.scale
import androidx.exifinterface.media.ExifInterface
import coil.compose.rememberAsyncImagePainter
import com.tutpro.baresip.CustomElements.AlertDialog
import java.io.ByteArrayOutputStream
import java.io.File
class BaresipContactActivity : ComponentActivity() {
private lateinit var name: String
private lateinit var uri: String
private var new = false
private var favorite = false
private var android = false
private var newName = ""
private var newUri = ""
private var newFavorite = false
private var newAndroid = false
private var uriOrName = ""
private var color = 0
private var id: Long = 0
private var newId: Long = 0
private var tmpFile: File? = null
private val alertTitle = mutableStateOf("")
private val alertMessage = mutableStateOf("")
private val showAlert = mutableStateOf(false)
private val textAvatarText = mutableStateOf("")
private val textAvatarColor = mutableIntStateOf(0)
private val imageAvatarUri = mutableStateOf("")
private var backInvokedCallback: OnBackInvokedCallback? = null
private lateinit var onBackPressedCallback: OnBackPressedCallback
@RequiresApi(33)
private fun registerBackInvokedCallback() {
backInvokedCallback = OnBackInvokedCallback { goBack() }
onBackInvokedDispatcher.registerOnBackInvokedCallback(
OnBackInvokedDispatcher.PRIORITY_DEFAULT,
backInvokedCallback!!
)
}
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
if (Build.VERSION.SDK_INT >= 33)
registerBackInvokedCallback()
else {
onBackPressedCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
goBack()
}
}
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
}
val title: String
new = intent.getBooleanExtra("new", false)
if (new) {
name = ""
uri = intent.getStringExtra("uri")!!
favorite = false
android = BaresipService.contactsMode == "android"
title = getString(R.string.new_contact)
uriOrName = uri
color = Utils.randomColor()
id = System.currentTimeMillis()
newId = id
}
else {
name = intent.getStringExtra("name")!!
val contact = Contact.baresipContact(name)!!
uri = contact.uri
favorite = contact.favorite
android = false
title = name
uriOrName = name
color = contact.color
id = contact.id
newId = id
if (contact.avatarImage != null) {
val avatarFile = File(BaresipService.filesPath, "${newId}.png")
if (avatarFile.exists())
imageAvatarUri.value = Uri.fromFile(avatarFile).toString()
}
}
Utils.addActivity("baresip contact,$new,$uriOrName")
setContent {
AppTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = LocalCustomColors.current.background
) {
ContactScreen(this, title) { goBack() }
}
}
}
}
@Composable
fun ContactScreen(ctx: Context, title: String, navigateBack: () -> Unit) {
Scaffold(
modifier = Modifier.fillMaxHeight().imePadding().safeDrawingPadding(),
containerColor = LocalCustomColors.current.background,
topBar = { TopAppBar(ctx, title, navigateBack) },
content = { contentPadding ->
ContactContent(contentPadding)
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TopAppBar(ctx: Context, title: String, navigateBack: () -> Unit) {
TopAppBar(
title = {
Text(
text = title,
color = LocalCustomColors.current.light,
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 = {
checkOnClick(ctx)
}) {
Icon(
imageVector = Icons.Filled.Check,
tint = LocalCustomColors.current.light,
contentDescription = "Check"
)
}
}
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ContactContent(contentPadding: PaddingValues) {
if (showAlert.value) {
AlertDialog(
showDialog = showAlert,
title = alertTitle.value,
message = alertMessage.value,
positiveButtonText = stringResource(R.string.ok),
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(LocalCustomColors.current.background)
.padding(contentPadding)
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 52.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Avatar()
ContactName()
ContactUri()
Favorite()
Android()
}
}
@Composable
fun TextAvatar(size: Int) {
Box(
modifier = Modifier.size(size.dp),
contentAlignment = Alignment.Center
) {
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(SolidColor(Color(textAvatarColor.intValue)))
}
Text(textAvatarText.value, fontSize = 72.sp, color = Color.White)
}
}
@Composable
fun ImageAvatar(size: Int) {
Image(
painter = rememberAsyncImagePainter(model = imageAvatarUri.value),
contentDescription = stringResource(R.string.avatar_image),
contentScale = ContentScale.Crop,
modifier = Modifier.size(size.dp).clip(CircleShape)
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun Avatar() {
val avatarRequest =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) {
if (it != null)
try {
val inputStream = baseContext.contentResolver.openInputStream(it)
val avatarBitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
val scaledBitmap = avatarBitmap.scale(192, 192)
val exif = ExifInterface(baseContext.contentResolver.openInputStream(it)!!)
val orientation = exif.getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL
)
val rotatedBitmap = rotateBitmap(scaledBitmap, orientation)
if (tmpFile != null && tmpFile!!.exists())
Utils.deleteFile(tmpFile!!)
newId = System.currentTimeMillis()
tmpFile = File(BaresipService.filesPath, "${newId}.png")
if (Utils.saveBitmap(rotatedBitmap, tmpFile!!)) {
imageAvatarUri.value = Uri.fromFile(tmpFile).toString()
}
} catch (e: Exception) {
Log.e(TAG, "Could not read avatar image: ${e.message}")
}
}
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(96.dp)
.combinedClickable(
onClick = {
avatarRequest.launch("image/*")
},
onLongClick = {
textAvatarColor.intValue = Utils.randomColor()
color = textAvatarColor.intValue
imageAvatarUri.value = ""
}
),
) {
if (imageAvatarUri.value == "") {
textAvatarText.value = if (new)
"?"
else
"${name[0]}"
textAvatarColor.intValue = color
TextAvatar(96)
} else
ImageAvatar(96)
}
}
}
@Composable
fun ContactName() {
val focusRequester = FocusRequester()
var contactName by remember { mutableStateOf(name) }
newName = contactName
OutlinedTextField(
value = contactName,
placeholder = { Text(stringResource(R.string.contact_name)) },
onValueChange = {
contactName = it
newName = contactName
},
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
textStyle = androidx.compose.ui.text.TextStyle(
fontSize = 18.sp, color = LocalCustomColors.current.itemText
),
label = { Text(stringResource(R.string.contact_name)) },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
capitalization = KeyboardCapitalization.Words
)
)
LaunchedEffect(new) {
if (new)
focusRequester.requestFocus()
}
}
@Composable
fun ContactUri() {
var contactUri by remember { mutableStateOf(uri) }
newUri = contactUri
OutlinedTextField(
value = contactUri,
placeholder = { Text(stringResource(R.string.user_domain_or_number)) },
onValueChange = {
contactUri = it
newUri = contactUri
},
modifier = Modifier.fillMaxWidth(),
textStyle = androidx.compose.ui.text.TextStyle(
fontSize = 18.sp, color = LocalCustomColors.current.itemText
),
label = { Text(stringResource(R.string.sip_or_tel_uri)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text)
)
}
@Composable
fun Favorite() {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
Text(
text = stringResource(R.string.favorite),
modifier = Modifier.weight(1f)
.clickable {
alertTitle.value = getString(R.string.favorite)
alertMessage.value = getString(R.string.favorite_help)
showAlert.value = true
},
color = LocalCustomColors.current.itemText,
)
var favoriteContact by remember { mutableStateOf(favorite) }
newFavorite = favoriteContact
Switch(
checked = favoriteContact,
onCheckedChange = {
favoriteContact = it
newFavorite = favoriteContact
}
)
}
}
@Composable
fun Android() {
if (new && BaresipService.contactsMode != "baresip")
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
Text(
text = stringResource(R.string.android),
modifier = Modifier.weight(1f),
color = LocalCustomColors.current.itemText
)
var androidContact by remember { mutableStateOf(android) }
newAndroid = androidContact
Switch(
checked = androidContact,
onCheckedChange = {
if (BaresipService.contactsMode != "android") {
androidContact = it
newAndroid = androidContact
}
}
)
}
}
private fun checkOnClick(ctx: Context) {
if (BaresipService.activities.indexOf("baresip contact,$new,$uriOrName") == -1)
return
newUri = newUri.filterNot{setOf('-', ' ', '(', ')').contains(it)}
if (!newUri.startsWith("sip:") && !newUri.startsWith("tel:"))
newUri = if (Utils.isTelNumber(newUri))
"tel:$newUri"
else
"sip:$newUri"
if (!Utils.checkUri(newUri)) {
alertTitle.value = getString(R.string.notice)
alertMessage.value = String.format(getString(R.string.invalid_sip_or_tel_uri), newUri)
showAlert.value = true
return
}
newName = newName.trim()
if (newName == "") newName = newUri.substringAfter(":")
if (!Utils.checkName(newName)) {
alertTitle.value = getString(R.string.notice)
alertMessage.value = String.format(getString(R.string.invalid_contact), newName)
showAlert.value = true
return
}
val alert: Boolean = if (new)
Contact.nameExists(newName, BaresipService.contacts,true)
else {
(uriOrName != newName) && Contact.nameExists(newName, BaresipService.contacts, false)
}
if (alert) {
alertTitle.value = getString(R.string.notice)
alertMessage.value = String.format(getString(R.string.contact_already_exists), newName)
showAlert.value = true
return
}
val contact: Contact.BaresipContact =
Contact.BaresipContact(newName, newUri, color, newId, newFavorite)
if (imageAvatarUri.value == "") {
if (contact.avatarImage != null) {
contact.avatarImage = null
Utils.deleteFile(File(BaresipService.filesPath, "${newId}.png"))
}
}
else {
val imageFilePath = BaresipService.filesPath + "/${newId}.png"
contact.avatarImage = BitmapFactory.decodeFile(imageFilePath)
}
if (newAndroid)
addOrUpdateAndroidContact(ctx, contact)
else {
if (new)
Contact.addBaresipContact(contact)
else
Contact.updateBaresipContact(id, contact)
}
BaresipService.activities.remove("baresip contact,$new,$uriOrName")
val i = Intent(ctx, MainActivity::class.java)
if (newAndroid && contact.favorite)
i.putExtra("name", newName)
setResult(RESULT_OK, i)
finish()
}
private fun rotateBitmap(bitmap: Bitmap, orientation: Int): Bitmap {
val matrix = Matrix()
when (orientation) {
ExifInterface.ORIENTATION_NORMAL -> return bitmap
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.setScale(-1f, 1f)
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.setRotate(180f)
ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
matrix.setRotate(180f)
matrix.postScale(-1f, 1f)
}
ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.setRotate(90f)
matrix.postScale(-1f, 1f)
}
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.setRotate(90f)
ExifInterface.ORIENTATION_TRANSVERSE -> {
matrix.setRotate(-90f)
matrix.postScale(-1f, 1f)
}
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.setRotate(-90f)
else -> return bitmap
}
val rotatedBitmap = Bitmap.createBitmap(bitmap, 0, 0,
bitmap.width, bitmap.height, matrix, true)
bitmap.recycle()
return rotatedBitmap
}
private fun addOrUpdateAndroidContact(ctx: Context, contact: Contact.BaresipContact) {
val projection = arrayOf(ContactsContract.Data.RAW_CONTACT_ID)
val selection = ContactsContract.Data.MIMETYPE + "='" +
CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE + "' AND " +
CommonDataKinds.StructuredName.DISPLAY_NAME + "='" + contact.name + "'"
val c: Cursor? = ctx.contentResolver.query(
ContactsContract.Data.CONTENT_URI, projection,
selection, null, null)
if (c != null && c.moveToFirst()) {
updateAndroidContact(c.getLong(0), contact)
} else {
addAndroidContact(ctx, contact)
}
c?.close()
}
private fun addAndroidContact(ctx: Context, contact: Contact.BaresipContact): Boolean {
val ops = ArrayList<ContentProviderOperation>()
ops.add(
ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI)
.withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, null)
.withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null).build())
ops.add(
ContentProviderOperation
.newInsert(ContactsContract.Data.CONTENT_URI)
.withValueBackReference(Data.RAW_CONTACT_ID, 0)
.withValue(Data.MIMETYPE, CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
.withValue(CommonDataKinds.StructuredName.DISPLAY_NAME, contact.name)
.build())
val mimeType = if (contact.uri.startsWith("sip:"))
CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE
else
CommonDataKinds.Phone.CONTENT_ITEM_TYPE
ops.add(
ContentProviderOperation
.newInsert(ContactsContract.Data.CONTENT_URI)
.withValueBackReference(Data.RAW_CONTACT_ID, 0)
.withValue(Data.MIMETYPE, mimeType)
.withValue(Data.DATA1, contact.uri.substringAfter(":"))
.build())
if (contact.avatarImage != null) {
val photoData: ByteArray? = bitmapToPNGByteArray(contact.avatarImage!!)
if (photoData != null) {
ops.add(
ContentProviderOperation
.newInsert(ContactsContract.Data.CONTENT_URI)
.withValueBackReference(Data.RAW_CONTACT_ID, 0)
.withValue(Data.MIMETYPE, CommonDataKinds.Photo.CONTENT_ITEM_TYPE)
.withValue(CommonDataKinds.Photo.PHOTO, photoData)
.build())
}
}
try {
ctx.contentResolver.applyBatch(ContactsContract.AUTHORITY, ops)
} catch (e: Exception) {
Log.e(TAG, "Adding of contact ${contact.name} failed: ${e.message}")
return false
}
return true
}
private fun updateAndroidContact(rawContactId: Long, contact: Contact.BaresipContact) {
if (updateAndroidUri(rawContactId, contact.uri) == 0)
addAndroidUri(rawContactId, contact.uri)
if (updateAndroidPhoto(rawContactId, contact.avatarImage) == 0)
if (contact.avatarImage != null)
addAndroidPhoto(rawContactId, contact.avatarImage!!)
}
private fun addAndroidUri(rawContactId: Long, uri: String) {
val mimeType = if (uri.startsWith("sip:"))
CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE
else
CommonDataKinds.Phone.CONTENT_ITEM_TYPE
val ops = ArrayList<ContentProviderOperation>()
ops.add(
ContentProviderOperation
.newInsert(ContactsContract.Data.CONTENT_URI)
.withValue(Data.RAW_CONTACT_ID, rawContactId)
.withValue(Data.MIMETYPE, mimeType)
.withValue(Data.DATA1, uri.substringAfter(":"))
.build())
try {
contentResolver.applyBatch(ContactsContract.AUTHORITY, ops)
} catch (e: Exception) {
Log.e(TAG, "Adding of SIP URI $uri failed: ${e.message}")
}
}
private fun updateAndroidUri(rawContactId: Long, uri: String): Int {
val mimeType = if (uri.startsWith("sip:"))
CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE
else
CommonDataKinds.Phone.CONTENT_ITEM_TYPE
val contentValues = ContentValues()
contentValues.put(ContactsContract.Data.DATA1, uri)
val where = "${ContactsContract.Data.RAW_CONTACT_ID}=$rawContactId and " +
"${ContactsContract.Data.MIMETYPE}='$mimeType'"
return try {
contentResolver.update(ContactsContract.Data.CONTENT_URI, contentValues, where, null)
} catch (e: Exception) {
Log.e(TAG, "Update of Android URI $uri failed: ${e.message}")
0
}
}
private fun addAndroidPhoto(rawContactId: Long, photoBits: Bitmap) {
val photoBytes = bitmapToPNGByteArray(photoBits)
if (photoBytes != null) {
val ops = ArrayList<ContentProviderOperation>()
ops.add(
ContentProviderOperation
.newInsert(ContactsContract.Data.CONTENT_URI)
.withValue(Data.RAW_CONTACT_ID, rawContactId)
.withValue(Data.MIMETYPE, CommonDataKinds.Photo.CONTENT_ITEM_TYPE)
.withValue(CommonDataKinds.Photo.PHOTO, photoBytes)
.build())
try {
contentResolver.applyBatch(ContactsContract.AUTHORITY, ops)
} catch (e: Exception) {
Log.e(TAG, "Adding of Android photo failed: ${e.message}")
}
}
}
private fun updateAndroidPhoto(rawContactId: Long, photoBits: Bitmap?): Int {
val photoBytes = if (photoBits == null)
null
else
bitmapToPNGByteArray(photoBits)
val contentValues = ContentValues()
contentValues.put(CommonDataKinds.Photo.PHOTO, photoBytes)
val where = "${ContactsContract.Data.RAW_CONTACT_ID}=$rawContactId and " +
"${ContactsContract.Data.MIMETYPE}='${CommonDataKinds.Photo.CONTENT_ITEM_TYPE}'"
return try {
contentResolver.update(ContactsContract.Data.CONTENT_URI, contentValues, where, null)
} catch (e: Exception) {
Log.e(TAG, "updateAndroidPhoto failed: ${e.message}")
0
}
}
private fun bitmapToPNGByteArray(bitmap: Bitmap): ByteArray? {
val size = bitmap.width * bitmap.height * 4
val out = ByteArrayOutputStream(size)
return try {
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
out.flush()
out.close()
out.toByteArray()
} catch (e: Exception) {
Log.w(TAG, "Unable to serialize photo: ${e.message}")
null
}
}
override fun onDestroy() {
if (Build.VERSION.SDK_INT >= 33) {
if (backInvokedCallback != null)
onBackInvokedDispatcher.unregisterOnBackInvokedCallback(backInvokedCallback!!)
}
else
onBackPressedCallback.remove()
super.onDestroy()
}
private fun goBack() {
BaresipService.activities.remove("baresip contact,$new,$uriOrName")
setResult(RESULT_CANCELED, Intent(this, MainActivity::class.java))
finish()
}
}

View File

@ -0,0 +1,819 @@
package com.tutpro.baresip
import android.content.ContentProviderOperation
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.net.Uri
import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds
import android.provider.ContactsContract.Contacts.Data
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas
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.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.shape.CircleShape
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.Check
import androidx.compose.material3.CircularProgressIndicator
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.Switch
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.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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.scale
import androidx.exifinterface.media.ExifInterface
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import coil.compose.rememberAsyncImagePainter
import com.tutpro.baresip.CustomElements.AlertDialog
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
fun NavGraphBuilder.baresipContactScreenRoute(navController: NavController) {
composable(
route = "baresip_contact/{uri_or_name}/{kind}",
arguments = listOf(
navArgument("uri_or_name") { type = NavType.StringType },
navArgument("kind") { type = NavType.StringType }
)
) { backStackEntry ->
val uriOrNameArg = backStackEntry.arguments?.getString("uri_or_name")!!
val kindArg = backStackEntry.arguments?.getString("kind")!!
ContactScreen(
navController = navController,
uriOrNameArg = uriOrNameArg,
kindArg = kindArg
)
}
}
private data class ScreenState(
val new: Boolean = false,
val favorite: Boolean = false,
val android: Boolean = false,
val id: Long = 0,
val newId: Long = 0,
val name: String = "",
val uri: String = "",
val color: Int = 0,
val avatarImageUri: String? = null,
val tmpAvatarFile: File? = null,
val isLoading: Boolean = true
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ContactScreen(
navController: NavController,
uriOrNameArg: String,
kindArg: String
) {
val ctx = LocalContext.current
var screenState by remember { mutableStateOf(ScreenState()) }
val title = if (screenState.new)
stringResource(R.string.new_contact)
else
uriOrNameArg
LaunchedEffect(key1 = uriOrNameArg, key2 = kindArg) {
val isNew = kindArg == "new"
if (isNew) {
val time = System.currentTimeMillis()
screenState = ScreenState(
new = true,
name = "",
uri = uriOrNameArg,
favorite = false,
android = BaresipService.contactsMode == "android",
color = Utils.randomColor(),
id = time,
newId = time,
isLoading = false
)
}
else {
val contact = Contact.baresipContact(uriOrNameArg)!!
val avatarFile = File(BaresipService.filesPath, "${contact.id}.png")
screenState = ScreenState(
new = false,
name = uriOrNameArg,
uri = contact.uri,
favorite = contact.favorite,
android = false,
color = contact.color,
id = contact.id,
newId = contact.id,
avatarImageUri = if (contact.avatarImage != null && avatarFile.exists())
Uri.fromFile(avatarFile).toString()
else
null,
isLoading = false
)
}
}
val onBack: () -> Unit = {
screenState.tmpAvatarFile?.let { tempFile ->
if (tempFile.exists()) {
Log.d(TAG, "Back pressed, deleting temp avatar: ${tempFile.name}")
Utils.deleteFile(tempFile)
}
}
navController.popBackStack()
}
val onCheck: () -> Unit = {
val result = checkOnClick(
ctx = ctx,
currentState = screenState,
uriOrNameArg = uriOrNameArg,
)
if (result)
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(title, onBack = onBack, onCheck = onCheck)
}
},
content = { contentPadding ->
if (!screenState.isLoading) {
ContactContent(
contentPadding = contentPadding,
screenState = screenState,
onStateChange = { newState -> screenState = newState }
)
} else {
// Optional: Show a loading indicator
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TopAppBar(title: String, onBack: () -> Unit, onCheck: () -> Unit) {
TopAppBar(
title = {
Text(
text = title,
color = LocalCustomColors.current.light,
fontWeight = FontWeight.Bold
)
},
colors = TopAppBarDefaults.mediumTopAppBarColors(
containerColor = LocalCustomColors.current.primary
),
windowInsets = WindowInsets(0, 0, 0, 0),
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = LocalCustomColors.current.light
)
}
},
actions = {
IconButton(onClick = onCheck) {
Icon(
imageVector = Icons.Filled.Check,
tint = LocalCustomColors.current.light,
contentDescription = "Check"
)
}
}
)
}
private val alertTitle = mutableStateOf("")
private val alertMessage = mutableStateOf("")
private val showAlert = mutableStateOf(false)
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ContactContent(
contentPadding: PaddingValues,
screenState: ScreenState,
onStateChange: (ScreenState) -> Unit
) {
val ctx = LocalContext.current
if (showAlert.value) {
AlertDialog(
showDialog = showAlert,
title = alertTitle.value,
message = alertMessage.value,
positiveButtonText = stringResource(R.string.ok),
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(LocalCustomColors.current.background)
.padding(contentPadding)
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 52.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Avatar(
ctx = ctx,
name = screenState.name,
color = screenState.color,
currentAvatarUri = screenState.avatarImageUri,
onNewAvatarChosen = { processedTmpFile, generatedNewId ->
onStateChange(
screenState.copy(
avatarImageUri = Uri.fromFile(processedTmpFile).toString(),
tmpAvatarFile = processedTmpFile,
newId = generatedNewId
)
)
},
onAvatarColorChange = { newRandomColor ->
// User long-clicked to change color, this means discarding any image.
// The old tempAvatarFile (if any) should be deleted.
screenState.tmpAvatarFile?.let {
if (it.exists())
Utils.deleteFile(it)
}
onStateChange(
screenState.copy(
color = newRandomColor,
avatarImageUri = null,
tmpAvatarFile = null
)
)
}
)
ContactName(
name = screenState.name,
new = screenState.new,
onNameChange = { newName -> onStateChange(screenState.copy(name = newName)) }
)
ContactUri(
uri = screenState.uri,
onUriChange = { newUri -> onStateChange(screenState.copy(uri = newUri)) }
)
Favorite(
ctx = ctx,
favorite = screenState.favorite,
onFavoriteChange = {
newFavorite -> onStateChange(screenState.copy(favorite = newFavorite))
}
)
if (screenState.new && BaresipService.contactsMode == "both")
Android(
android = screenState.android,
onAndroidChange = { newAndroid -> onStateChange(screenState.copy(android = newAndroid)) }
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun Avatar(
ctx: Context,
name: String,
color: Int,
currentAvatarUri: String?,
onNewAvatarChosen: (newImageFile: File, newImageId: Long) -> Unit,
onAvatarColorChange: (newColor: Int) -> Unit
) {
val avatarImagePicker =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
if (uri != null) {
try {
val inputStream = ctx.contentResolver.openInputStream(uri)
val avatarBitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
if (avatarBitmap == null) {
Log.e(TAG, "Failed to decode bitmap from URI: $uri")
return@rememberLauncherForActivityResult
}
val scaledBitmap = avatarBitmap.scale(192, 192) // Define desired scale
val orientationInputStream = ctx.contentResolver.openInputStream(uri)
val exif = if (orientationInputStream != null) ExifInterface(orientationInputStream) else null
orientationInputStream?.close()
val orientation = exif?.getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL
) ?: ExifInterface.ORIENTATION_NORMAL
val rotatedBitmap = rotateBitmap(scaledBitmap, orientation)
val newImageId = System.currentTimeMillis()
val tempNewImageFile = File(BaresipService.filesPath, "${newImageId}.png")
if (saveBitmap(rotatedBitmap, tempNewImageFile)) {
onNewAvatarChosen(tempNewImageFile, newImageId)
} else {
Log.e(TAG, "Failed to save processed avatar image to ${tempNewImageFile.absolutePath}")
if (tempNewImageFile.exists()) Utils.deleteFile(tempNewImageFile)
}
} catch (e: Exception) {
Log.e(TAG, "Could not process avatar image: ${e.message}")
}
}
}
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(avatarSize.dp)
.clip(CircleShape)
.background(if (currentAvatarUri == null) Color(color) else Color.Transparent)
.combinedClickable(
onClick = {
avatarImagePicker.launch("image/*")
},
onLongClick = {
onAvatarColorChange(Utils.randomColor())
}
)
) {
if (currentAvatarUri == null) {
Box(
modifier = Modifier.size(avatarSize.dp),
contentAlignment = Alignment.Center
) {
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(SolidColor(Color(color)))
}
val text = if (name.isNotBlank()) name.substring(0, 1).uppercase() else "?"
Text(text, fontSize = 72.sp, color = Color.White)
}
} else {
Image(
painter = rememberAsyncImagePainter(model = currentAvatarUri),
contentDescription = stringResource(R.string.avatar_image),
contentScale = ContentScale.Crop,
modifier = Modifier.size(avatarSize.dp).clip(CircleShape)
)
}
}
}
}
@Composable
private fun ContactName(name: String, new: Boolean, onNameChange: (String) -> Unit) {
val focusRequester = FocusRequester()
OutlinedTextField(
value = name,
placeholder = { Text(stringResource(R.string.contact_name)) },
onValueChange = onNameChange,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
textStyle = androidx.compose.ui.text.TextStyle(
fontSize = 18.sp, color = LocalCustomColors.current.itemText
),
label = { Text(stringResource(R.string.contact_name)) },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
capitalization = KeyboardCapitalization.Words
)
)
LaunchedEffect(new) {
if (new)
focusRequester.requestFocus()
}
}
@Composable
private fun ContactUri(uri: String, onUriChange: (String) -> Unit) {
OutlinedTextField(
value = uri,
placeholder = { Text(stringResource(R.string.user_domain_or_number)) },
onValueChange = onUriChange,
modifier = Modifier.fillMaxWidth(),
textStyle = androidx.compose.ui.text.TextStyle(
fontSize = 18.sp, color = LocalCustomColors.current.itemText
),
label = { Text(stringResource(R.string.sip_or_tel_uri)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text)
)
}
@Composable
private fun Favorite(ctx: Context, favorite: Boolean, onFavoriteChange: (Boolean) -> Unit) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
Text(
text = stringResource(R.string.favorite),
modifier = Modifier
.weight(1f)
.clickable {
alertTitle.value = ctx.getString(R.string.favorite)
alertMessage.value = ctx.getString(R.string.favorite_help)
showAlert.value = true
},
color = LocalCustomColors.current.itemText,
)
Switch(
checked = favorite,
onCheckedChange = onFavoriteChange
)
}
}
@Composable
private fun Android(android: Boolean, onAndroidChange: (Boolean) -> Unit) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
Text(
text = stringResource(R.string.android),
modifier = Modifier.weight(1f),
color = LocalCustomColors.current.itemText
)
Switch(
checked = android,
onCheckedChange = onAndroidChange
)
}
}
private fun checkOnClick(
ctx: Context,
currentState: ScreenState,
uriOrNameArg: String
): Boolean {
var newUri = currentState.uri.filterNot{setOf('-', ' ', '(', ')').contains(it)}
if (!newUri.startsWith("sip:") && !newUri.startsWith("tel:"))
newUri = if (Utils.isTelNumber(newUri))
"tel:$newUri"
else
"sip:$newUri"
if (!Utils.checkUri(newUri)) {
alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value = String.format(ctx.getString(R.string.invalid_sip_or_tel_uri), newUri)
showAlert.value = true
return false
}
var newName = currentState.name.trim()
if (newName == "") newName = newUri.substringAfter(":")
if (!Utils.checkName(newName)) {
alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value = String.format(ctx.getString(R.string.invalid_contact), newName)
showAlert.value = true
return false
}
val alert: Boolean = if (currentState.new)
Contact.nameExists(newName, BaresipService.contacts, true)
else {
(uriOrNameArg != newName) && Contact.nameExists(newName, BaresipService.contacts, false)
}
if (alert) {
alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value = String.format(ctx.getString(R.string.contact_already_exists), newName)
showAlert.value = true
return false
}
var idToUse = currentState.id
if (currentState.tmpAvatarFile != null && currentState.tmpAvatarFile.exists()) {
if (currentState.id != currentState.newId) { // Avatar changed, implying new ID
// Delete old avatar if it existed for currentState.contactId
val oldAvatar = File(BaresipService.filesPath, "${currentState.id}.png")
if (oldAvatar.exists()) Utils.deleteFile(oldAvatar)
idToUse = currentState.newId // Use the new ID for the contact
}
/*val avatarFile = File(BaresipService.filesPath, "$idToUse.png")
if (!Utils.moveFile(currentState.tempAvatarFile, avatarFile)) {
Log.e(TAG, "Failed to move tmp avatar file $idToUse.png")
return "Failed to save avatar"
}*/
} else if (currentState.avatarImageUri == null) { // Avatar was explicitly cleared
val avatarFile = File(BaresipService.filesPath, "$idToUse.png")
if (avatarFile.exists())
Utils.deleteFile(avatarFile)
}
val contact: Contact.BaresipContact =
Contact.BaresipContact(
newName,
newUri,
currentState.color,
idToUse,
currentState.favorite
)
if (currentState.avatarImageUri == null)
contact.avatarImage = null
else {
val imageFilePath = BaresipService.filesPath + "/${idToUse}.png"
contact.avatarImage = BitmapFactory.decodeFile(imageFilePath)
}
if (currentState.android) {
addOrUpdateAndroidContact(ctx, contact)
if (contact.favorite) {
val contentValues = ContentValues()
contentValues.put(ContactsContract.Contacts.STARRED, 1)
try {
ctx.contentResolver.update(
ContactsContract.RawContacts.CONTENT_URI, contentValues,
ContactsContract.Contacts.DISPLAY_NAME + "='" + newName + "'", null
)
} catch (e: Exception) {
Log.e(TAG, "Update of Android favorite failed: ${e.message}")
}
}
}
else {
if (currentState.new)
Contact.addBaresipContact(contact)
else
Contact.updateBaresipContact(currentState.id, contact)
}
return true
}
private fun rotateBitmap(bitmap: Bitmap, orientation: Int): Bitmap {
val matrix = Matrix()
when (orientation) {
ExifInterface.ORIENTATION_NORMAL -> return bitmap
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.setScale(-1f, 1f)
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.setRotate(180f)
ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
matrix.setRotate(180f)
matrix.postScale(-1f, 1f)
}
ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.setRotate(90f)
matrix.postScale(-1f, 1f)
}
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.setRotate(90f)
ExifInterface.ORIENTATION_TRANSVERSE -> {
matrix.setRotate(-90f)
matrix.postScale(-1f, 1f)
}
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.setRotate(-90f)
else -> return bitmap
}
val rotatedBitmap = Bitmap.createBitmap(bitmap, 0, 0,
bitmap.width, bitmap.height, matrix, true)
bitmap.recycle()
return rotatedBitmap
}
private fun saveBitmap(bitmap: Bitmap, file: File): Boolean {
if (file.exists()) file.delete()
try {
val out = FileOutputStream(file)
val scaledBitmap = bitmap.scale(avatarSize, avatarSize)
scaledBitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
out.flush()
out.close()
Log.d(TAG, "Saved bitmap to ${file.absolutePath} of length ${file.length()}")
} catch (e: Exception) {
Log.e(TAG, "Failed to save bitmap to ${file.absolutePath}: $e")
return false
}
return true
}
private fun addOrUpdateAndroidContact(ctx: Context, contact: Contact.BaresipContact) {
val projection = arrayOf(ContactsContract.Data.RAW_CONTACT_ID)
val selection = ContactsContract.Data.MIMETYPE + "='" +
CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE + "' AND " +
CommonDataKinds.StructuredName.DISPLAY_NAME + "='" + contact.name + "'"
val c: Cursor? = ctx.contentResolver.query(
ContactsContract.Data.CONTENT_URI, projection,
selection, null, null)
if (c != null && c.moveToFirst()) {
updateAndroidContact(ctx, c.getLong(0), contact)
} else {
addAndroidContact(ctx, contact)
}
c?.close()
}
private fun addAndroidContact(ctx: Context, contact: Contact.BaresipContact): Boolean {
val ops = ArrayList<ContentProviderOperation>()
ops.add(
ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI)
.withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, null)
.withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null).build())
ops.add(
ContentProviderOperation
.newInsert(ContactsContract.Data.CONTENT_URI)
.withValueBackReference(Data.RAW_CONTACT_ID, 0)
.withValue(Data.MIMETYPE, CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
.withValue(CommonDataKinds.StructuredName.DISPLAY_NAME, contact.name)
.build())
val mimeType = if (contact.uri.startsWith("sip:"))
CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE
else
CommonDataKinds.Phone.CONTENT_ITEM_TYPE
ops.add(
ContentProviderOperation
.newInsert(ContactsContract.Data.CONTENT_URI)
.withValueBackReference(Data.RAW_CONTACT_ID, 0)
.withValue(Data.MIMETYPE, mimeType)
.withValue(Data.DATA1, contact.uri.substringAfter(":"))
.build())
if (contact.avatarImage != null) {
val photoData: ByteArray? = bitmapToPNGByteArray(contact.avatarImage!!)
if (photoData != null) {
ops.add(
ContentProviderOperation
.newInsert(ContactsContract.Data.CONTENT_URI)
.withValueBackReference(Data.RAW_CONTACT_ID, 0)
.withValue(Data.MIMETYPE, CommonDataKinds.Photo.CONTENT_ITEM_TYPE)
.withValue(CommonDataKinds.Photo.PHOTO, photoData)
.build())
}
}
try {
ctx.contentResolver.applyBatch(ContactsContract.AUTHORITY, ops)
} catch (e: Exception) {
Log.e(TAG, "Adding of contact ${contact.name} failed: ${e.message}")
return false
}
return true
}
private fun updateAndroidContact(ctx: Context, rawContactId: Long, contact: Contact.BaresipContact) {
if (updateAndroidUri(ctx, rawContactId, contact.uri) == 0)
addAndroidUri(ctx, rawContactId, contact.uri)
if (updateAndroidPhoto(ctx, rawContactId, contact.avatarImage) == 0)
if (contact.avatarImage != null)
addAndroidPhoto(ctx, rawContactId, contact.avatarImage!!)
}
private fun addAndroidUri(ctx: Context, rawContactId: Long, uri: String) {
val mimeType = if (uri.startsWith("sip:"))
CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE
else
CommonDataKinds.Phone.CONTENT_ITEM_TYPE
val ops = ArrayList<ContentProviderOperation>()
ops.add(
ContentProviderOperation
.newInsert(ContactsContract.Data.CONTENT_URI)
.withValue(Data.RAW_CONTACT_ID, rawContactId)
.withValue(Data.MIMETYPE, mimeType)
.withValue(Data.DATA1, uri.substringAfter(":"))
.build())
try {
ctx.contentResolver.applyBatch(ContactsContract.AUTHORITY, ops)
} catch (e: Exception) {
Log.e(TAG, "Adding of SIP URI $uri failed: ${e.message}")
}
}
private fun updateAndroidUri(ctx: Context, rawContactId: Long, uri: String): Int {
val mimeType = if (uri.startsWith("sip:"))
CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE
else
CommonDataKinds.Phone.CONTENT_ITEM_TYPE
val contentValues = ContentValues()
contentValues.put(ContactsContract.Data.DATA1, uri)
val where = "${ContactsContract.Data.RAW_CONTACT_ID}=$rawContactId and " +
"${ContactsContract.Data.MIMETYPE}='$mimeType'"
return try {
ctx.contentResolver.update(ContactsContract.Data.CONTENT_URI, contentValues, where, null)
} catch (e: Exception) {
Log.e(TAG, "Update of Android URI $uri failed: ${e.message}")
0
}
}
private fun addAndroidPhoto(ctx: Context, rawContactId: Long, photoBits: Bitmap) {
val photoBytes = bitmapToPNGByteArray(photoBits)
if (photoBytes != null) {
val ops = ArrayList<ContentProviderOperation>()
ops.add(
ContentProviderOperation
.newInsert(ContactsContract.Data.CONTENT_URI)
.withValue(Data.RAW_CONTACT_ID, rawContactId)
.withValue(Data.MIMETYPE, CommonDataKinds.Photo.CONTENT_ITEM_TYPE)
.withValue(CommonDataKinds.Photo.PHOTO, photoBytes)
.build())
try {
ctx.contentResolver.applyBatch(ContactsContract.AUTHORITY, ops)
} catch (e: Exception) {
Log.e(TAG, "Adding of Android photo failed: ${e.message}")
}
}
}
private fun updateAndroidPhoto(ctx: Context, rawContactId: Long, photoBits: Bitmap?): Int {
val photoBytes = if (photoBits == null)
null
else
bitmapToPNGByteArray(photoBits)
val contentValues = ContentValues()
contentValues.put(CommonDataKinds.Photo.PHOTO, photoBytes)
val where = "${ContactsContract.Data.RAW_CONTACT_ID}=$rawContactId and " +
"${ContactsContract.Data.MIMETYPE}='${CommonDataKinds.Photo.CONTENT_ITEM_TYPE}'"
return try {
ctx.contentResolver.update(ContactsContract.Data.CONTENT_URI, contentValues, where, null)
} catch (e: Exception) {
Log.e(TAG, "updateAndroidPhoto failed: ${e.message}")
0
}
}
private fun bitmapToPNGByteArray(bitmap: Bitmap): ByteArray? {
val size = bitmap.width * bitmap.height * 4
val out = ByteArrayOutputStream(size)
return try {
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
out.flush()
out.close()
out.toByteArray()
} catch (e: Exception) {
Log.w(TAG, "Unable to serialize photo: ${e.message}")
null
}
}

View File

@ -49,21 +49,19 @@ import android.provider.ContactsContract
import android.provider.Settings import android.provider.Settings
import android.system.OsConstants import android.system.OsConstants
import android.telecom.TelecomManager import android.telecom.TelecomManager
import android.text.Spannable
import android.text.SpannableString
import android.text.style.ForegroundColorSpan
import android.view.View import android.view.View
import android.widget.RemoteViews import android.widget.RemoteViews
import android.widget.Toast import android.widget.Toast
import androidx.annotation.ColorRes
import androidx.annotation.Keep import androidx.annotation.Keep
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.MessagingStyle
import androidx.core.app.Person
import androidx.core.app.RemoteInput
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
@ -553,8 +551,13 @@ class BaresipService: Service() {
val ua = UserAgent.ofUap(uap) val ua = UserAgent.ofUap(uap)
if (ua == null) if (ua == null)
Log.w(TAG, "onStartCommand did not find UA $uap") Log.w(TAG, "onStartCommand did not find UA $uap")
else else {
Message.updateAorMessage(ua.account.aor, intent.getStringExtra("time")!!.toLong()) Message.updateAorMessage(
ua.account.aor,
intent.getStringExtra("time")!!.toLong()
)
ua.account.unreadMessages = Message.unreadMessages(ua.account.aor)
}
nm.cancel(MESSAGE_NOTIFICATION_ID) nm.cancel(MESSAGE_NOTIFICATION_ID)
} }
@ -563,9 +566,50 @@ class BaresipService: Service() {
val ua = UserAgent.ofUap(uap) val ua = UserAgent.ofUap(uap)
if (ua == null) if (ua == null)
Log.w(TAG, "onStartCommand did not find UA $uap") Log.w(TAG, "onStartCommand did not find UA $uap")
else else {
Message.deleteAorMessage(ua.account.aor, intent.getStringExtra("time")!!.toLong()) Message.deleteAorMessage(
ua.account.aor,
intent.getStringExtra("time")!!.toLong()
)
ua.account.unreadMessages = Message.unreadMessages(ua.account.aor)
}
nm.cancel(MESSAGE_NOTIFICATION_ID)
}
"Message Inline Reply" -> {
val remoteInputResults = RemoteInput.getResultsFromIntent(intent!!)
if (remoteInputResults != null) {
val replyText = remoteInputResults.getCharSequence(KEY_TEXT_REPLY)?.toString()
if (!replyText.isNullOrEmpty()) {
val uap = intent.getLongExtra("uap", -1L)
val ua = UserAgent.ofUap(uap)!!
val aor = ua.account.aor
var peerUri = intent.getStringExtra("peer")!!
val timeStamp = intent.getLongExtra("time", 0L)
if (Utils.isTelUri(peerUri)) {
if (ua.account.telProvider == "") {
Log.w(TAG, "No telephony provider for $aor")
peerUri = ""
} else
peerUri = Utils.telToSip(peerUri, ua.account)
}
if (peerUri != "") {
Log.d(TAG, "Direct Reply from $aor to $peerUri: $replyText")
Message.updateAorMessage(aor, timeStamp)
val time = System.currentTimeMillis()
val msg = Message(aor, peerUri, replyText, time, MESSAGE_UP_WAIT, 0, "", false)
msg.add()
if (Api.message_send(uap, peerUri, replyText, time.toString()) != 0) {
Log.w(TAG, "message_send failed")
msg.direction = MESSAGE_UP_FAIL
msg.responseReason = getString(R.string.message_failed)
}
else {
ua.account.unreadMessages = Message.unreadMessages(aor)
}
}
}
}
nm.cancel(MESSAGE_NOTIFICATION_ID) nm.cancel(MESSAGE_NOTIFICATION_ID)
} }
@ -844,15 +888,15 @@ class BaresipService: Service() {
if (!Utils.isVisible()) { if (!Utils.isVisible()) {
val piFlags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT val piFlags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
val intent = Intent(applicationContext, MainActivity::class.java) val intent = Intent(applicationContext, MainActivity::class.java)
.putExtra("action", "call show")
.putExtra("callp", callp)
intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_NEW_TASK Intent.FLAG_ACTIVITY_NEW_TASK
intent.putExtra("action", "call show") val pi = PendingIntent.getActivity(applicationContext, CALL_REQ_CODE, intent, piFlags)
.putExtra("callp", callp)
val pi = PendingIntent.getActivity(applicationContext, CALL_REQ_CODE, intent,
piFlags)
val nb = NotificationCompat.Builder(this, val nb = NotificationCompat.Builder(this,
if (shouldVibrate()) MEDIUM_CHANNEL_ID else HIGH_CHANNEL_ID) if (shouldVibrate()) MEDIUM_CHANNEL_ID else HIGH_CHANNEL_ID)
val caller = Utils.friendlyUri(this, peerUri, ua.account) val caller = Utils.friendlyUri(this, peerUri, ua.account)
val person = Person.Builder().setName(caller).build()
nb.setSmallIcon(R.drawable.ic_stat_call) nb.setSmallIcon(R.drawable.ic_stat_call)
.setColor(ContextCompat.getColor(this, R.color.colorBaresip)) .setColor(ContextCompat.getColor(this, R.color.colorBaresip))
.setContentIntent(pi) .setContentIntent(pi)
@ -867,19 +911,15 @@ class BaresipService: Service() {
.setPriority(NotificationCompat.PRIORITY_HIGH) .setPriority(NotificationCompat.PRIORITY_HIGH)
.setFullScreenIntent(pi, true) .setFullScreenIntent(pi, true)
val answerIntent = Intent(applicationContext, MainActivity::class.java) val answerIntent = Intent(applicationContext, MainActivity::class.java)
answerIntent.putExtra("action", "call answer") .putExtra("action", "call answer")
.putExtra("callp", callp) .putExtra("callp", callp)
val api = PendingIntent.getActivity(applicationContext, ANSWER_REQ_CODE, val api = PendingIntent.getActivity(applicationContext, ANSWER_REQ_CODE,
answerIntent, piFlags) answerIntent, piFlags)
val rejectIntent = Intent(this, BaresipService::class.java) val rejectIntent = Intent(this, BaresipService::class.java)
rejectIntent.action = "Call Reject" rejectIntent.action = "Call Reject"
rejectIntent.putExtra("callp", callp) rejectIntent.putExtra("callp", callp)
val rpi = PendingIntent.getService(this, REJECT_REQ_CODE, val rpi = PendingIntent.getService(this, REJECT_REQ_CODE, rejectIntent, piFlags)
rejectIntent, piFlags) nb.setStyle(NotificationCompat.CallStyle.forIncomingCall(person, rpi, api))
nb.addAction(R.drawable.ic_stat_call,
getActionText(R.string.answer, R.color.colorGreen), api)
nb.addAction(R.drawable.ic_stat_call_end,
getActionText(R.string.reject, R.color.colorRed), rpi)
nm.notify(CALL_NOTIFICATION_ID, nb.build()) nm.notify(CALL_NOTIFICATION_ID, nb.build())
return return
} }
@ -938,8 +978,7 @@ class BaresipService: Service() {
Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
intent.putExtra("action", "transfer show") intent.putExtra("action", "transfer show")
.putExtra("callp", callp).putExtra("uri", ev[1]) .putExtra("callp", callp).putExtra("uri", ev[1])
val pi = PendingIntent.getActivity(applicationContext, TRANSFER_REQ_CODE, val pi = PendingIntent.getActivity(applicationContext, TRANSFER_REQ_CODE, intent, piFlags)
intent, piFlags)
val nb = NotificationCompat.Builder(this, HIGH_CHANNEL_ID) val nb = NotificationCompat.Builder(this, HIGH_CHANNEL_ID)
val target = Utils.friendlyUri(this, ev[1], ua.account) val target = Utils.friendlyUri(this, ev[1], ua.account)
nb.setSmallIcon(R.drawable.ic_stat_call) nb.setSmallIcon(R.drawable.ic_stat_call)
@ -952,7 +991,7 @@ class BaresipService: Service() {
val acceptIntent = Intent(applicationContext, MainActivity::class.java) val acceptIntent = Intent(applicationContext, MainActivity::class.java)
acceptIntent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or acceptIntent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or
Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
acceptIntent.putExtra("action","transfer accept") acceptIntent.putExtra("action", "transfer accept")
.putExtra("callp", callp).putExtra("uri", ev[1]) .putExtra("callp", callp).putExtra("uri", ev[1])
val acceptPendingIntent = PendingIntent.getActivity(applicationContext, val acceptPendingIntent = PendingIntent.getActivity(applicationContext,
ACCEPT_REQ_CODE, acceptIntent, piFlags) ACCEPT_REQ_CODE, acceptIntent, piFlags)
@ -1078,16 +1117,6 @@ class BaresipService: Service() {
} }
private fun postServiceEvent(event: ServiceEvent) {
serviceEvents.add(event)
if (serviceEvents.size == 1) {
Log.d(TAG, "Posted service event ${event.event} at ${event.timeStamp}")
serviceEvent.postValue(Event(event.timeStamp))
} else {
Log.d(TAG, "Added service event ${event.event}")
}
}
@Suppress("unused") @Suppress("unused")
@SuppressLint("UnspecifiedImmutableFlag") @SuppressLint("UnspecifiedImmutableFlag")
@Keep @Keep
@ -1120,45 +1149,88 @@ class BaresipService: Service() {
ua.account.unreadMessages = true ua.account.unreadMessages = true
if (!Utils.isVisible()) { if (!Utils.isVisible()) {
// common flags
val piFlags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT val piFlags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
// message show
val intent = Intent(applicationContext, MainActivity::class.java) val intent = Intent(applicationContext, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_NEW_TASK Intent.FLAG_ACTIVITY_NEW_TASK
intent.putExtra("action", "message show").putExtra("uap", uap) intent.putExtra("action", "message show").putExtra("uap", uap).putExtra("peer", peerUri)
.putExtra("peer", peerUri)
val pi = PendingIntent.getActivity(applicationContext, MESSAGE_REQ_CODE, intent, piFlags) val pi = PendingIntent.getActivity(applicationContext, MESSAGE_REQ_CODE, intent, piFlags)
// message notification builder
val senderDisplayName = Utils.friendlyUri(this, peerUri, ua.account)
val senderPerson = Person.Builder()
.setName(senderDisplayName)
.setKey(peerUri)
.build()
val localUserPerson = Person.Builder()
.setName(getString(R.string.you))
.setKey(ua.account.aor)
.build()
val messagingStyle = MessagingStyle(localUserPerson)
.setConversationTitle(null)
.setGroupConversation(false)
.addMessage(text, timeStamp, senderPerson)
val nb = NotificationCompat.Builder(this, HIGH_CHANNEL_ID) val nb = NotificationCompat.Builder(this, HIGH_CHANNEL_ID)
val sender = Utils.friendlyUri(this, peerUri, ua.account) .setSmallIcon(R.drawable.ic_stat_message)
nb.setSmallIcon(R.drawable.ic_stat_message)
.setColor(ContextCompat.getColor(this, R.color.colorBaresip)) .setColor(ContextCompat.getColor(this, R.color.colorBaresip))
.setContentIntent(pi) .setContentIntent(pi)
.setSound(Settings.System.DEFAULT_NOTIFICATION_URI) .setSound(Settings.System.DEFAULT_NOTIFICATION_URI)
.setAutoCancel(true) .setAutoCancel(true)
.setContentTitle(getString(R.string.message_from) + " " + sender) .setStyle(messagingStyle)
.setContentText(text) .setCategory(NotificationCompat.CATEGORY_MESSAGE)
val replyIntent = Intent(applicationContext, MainActivity::class.java) .setPriority(NotificationCompat.PRIORITY_HIGH)
replyIntent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_NEW_TASK // messafe inline reply
replyIntent.putExtra("action", "message reply") val remoteInput = RemoteInput.Builder(KEY_TEXT_REPLY)
.putExtra("uap", uap).putExtra("peer", peerUri) .setLabel(getString(R.string.reply))
val rpi = PendingIntent.getActivity(applicationContext, REPLY_REQ_CODE, replyIntent, .build()
piFlags) val directReplyIntent = Intent(this, BaresipService::class.java)
directReplyIntent.action = "Message Inline Reply"
directReplyIntent.putExtra("uap", uap).putExtra("peer", peerUri).putExtra("time", timeStamp)
val directReplyPendingIntent = PendingIntent.getService(
this,
DIRECT_REPLY_REQ_CODE,
directReplyIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
val inlineReplyAction = NotificationCompat.Action.Builder(
R.drawable.ic_stat_reply,
getString(R.string.reply),
directReplyPendingIntent
).addRemoteInput(remoteInput)
.setAllowGeneratedReplies(true)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY).
build()
// message save
val saveIntent = Intent(this, BaresipService::class.java) val saveIntent = Intent(this, BaresipService::class.java)
saveIntent.action = "Message Save" saveIntent.action = "Message Save"
saveIntent.putExtra("uap", uap) saveIntent.putExtra("uap", uap).putExtra("time", timeStampString)
.putExtra("time", timeStampString) val savePendingIntent = PendingIntent.getService(this, SAVE_REQ_CODE, saveIntent, piFlags)
val savePendingIntent = PendingIntent.getService(this, SAVE_REQ_CODE, saveIntent, val saveAction = NotificationCompat.Action.Builder(
piFlags) R.drawable.ic_stat_save,
getString(R.string.save),
savePendingIntent
).build()
// message delete
val deleteIntent = Intent(this, BaresipService::class.java) val deleteIntent = Intent(this, BaresipService::class.java)
deleteIntent.action = "Message Delete" deleteIntent.action = "Message Delete"
deleteIntent.putExtra("uap", uap) deleteIntent.putExtra("uap", uap).putExtra("time", timeStampString)
.putExtra("time", timeStampString) val deletePendingIntent = PendingIntent.getService(this, DELETE_REQ_CODE, deleteIntent, piFlags)
val deletePendingIntent = PendingIntent.getService(this, DELETE_REQ_CODE, val deleteAction = NotificationCompat.Action.Builder(
deleteIntent, piFlags) R.drawable.ic_stat_delete,
nb.addAction(R.drawable.ic_stat_reply, "Reply", rpi) getString(R.string.delete),
nb.addAction(R.drawable.ic_stat_save, "Save", savePendingIntent) deletePendingIntent
nb.addAction(R.drawable.ic_stat_delete, "Delete", deletePendingIntent) ).build()
nb.addAction(inlineReplyAction).addAction(saveAction).addAction(deleteAction)
nm.notify(MESSAGE_NOTIFICATION_ID, nb.build()) nm.notify(MESSAGE_NOTIFICATION_ID, nb.build())
return return
} }
@ -1297,13 +1369,6 @@ class BaresipService: Service() {
} }
} }
private fun getActionText(@StringRes stringRes: Int, @ColorRes colorRes: Int): Spannable {
val spannable: Spannable = SpannableString(applicationContext.getText(stringRes))
spannable.setSpan(
ForegroundColorSpan(applicationContext.getColor(colorRes)), 0, spannable.length, 0)
return spannable
}
private fun startRinging() { private fun startRinging() {
am.mode = AudioManager.MODE_RINGTONE am.mode = AudioManager.MODE_RINGTONE
rt!!.isLooping = true rt!!.isLooping = true
@ -1638,12 +1703,9 @@ class BaresipService: Service() {
val baresipContacts = mutableStateOf(emptyList<Contact.BaresipContact>()) val baresipContacts = mutableStateOf(emptyList<Contact.BaresipContact>())
val androidContacts = mutableStateOf(emptyList<Contact.AndroidContact>()) val androidContacts = mutableStateOf(emptyList<Contact.AndroidContact>())
val contactNames = mutableStateOf(emptyList<String>()) val contactNames = mutableStateOf(emptyList<String>())
val contactUpdate = MutableLiveData<Long>()
val darkTheme = mutableStateOf(false) val darkTheme = mutableStateOf(false)
var messages by mutableStateOf(emptyList<Message>()) var messages by mutableStateOf(emptyList<Message>())
val messageUpdate = MutableLiveData<Long>() val messageUpdate = MutableLiveData<Long>()
val chatTexts: MutableMap<String, String> = mutableMapOf()
val activities = mutableListOf<String>()
val registrationUpdate = MutableLiveData<Long>() val registrationUpdate = MutableLiveData<Long>()
val serviceEvent = MutableLiveData<Event<Long>>() val serviceEvent = MutableLiveData<Event<Long>>()
val serviceEvents = mutableListOf<ServiceEvent>() val serviceEvents = mutableListOf<ServiceEvent>()
@ -1661,12 +1723,25 @@ class BaresipService: Service() {
private var aec: AcousticEchoCanceler? = null private var aec: AcousticEchoCanceler? = null
var agcAvailable = false var agcAvailable = false
var rt: Ringtone? = null var rt: Ringtone? = null
private var agc: AutomaticGainControl? = null private var agc: AutomaticGainControl? = null
private val nsAvailable = NoiseSuppressor.isAvailable() private val nsAvailable = NoiseSuppressor.isAvailable()
private var ns: NoiseSuppressor? = null private var ns: NoiseSuppressor? = null
private var btAdapter: BluetoothAdapter? = null private var btAdapter: BluetoothAdapter? = null
private var recorderSessionId = 0 private var recorderSessionId = 0
internal const val KEY_TEXT_REPLY = "key_text_reply_baresip"
fun postServiceEvent(event: ServiceEvent) {
serviceEvents.add(event)
if (serviceEvents.size == 1) {
Log.d(TAG, "Posted service event ${event.event} at ${event.timeStamp}")
serviceEvent.postValue(Event(event.timeStamp))
} else {
Log.d(TAG, "Added service event ${event.event}")
}
}
fun requestAudioFocus(ctx: Context): Boolean { fun requestAudioFocus(ctx: Context): Boolean {
Log.d(TAG, "Requesting audio focus") Log.d(TAG, "Requesting audio focus")
if (audioFocusRequest != null) { if (audioFocusRequest != null) {

View File

@ -1,404 +0,0 @@
package com.tutpro.baresip
import android.content.Context
import android.media.AudioAttributes
import android.media.MediaPlayer
import android.os.Build
import android.os.Bundle
import android.text.format.DateUtils
import android.window.OnBackInvokedCallback
import android.window.OnBackInvokedDispatcher
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.annotation.RequiresApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import com.tutpro.baresip.CallsActivity.Companion.uaHistory
import com.tutpro.baresip.CustomElements.AlertDialog
import com.tutpro.baresip.CustomElements.verticalScrollbar
import java.io.File
import java.io.IOException
import java.text.DateFormat
import java.util.GregorianCalendar
class CallDetailsActivity : ComponentActivity() {
private lateinit var aor: String
private lateinit var peer: String
private lateinit var account: Account
private lateinit var callRow: CallRow
private lateinit var details: ArrayList<CallRow. Details>
private val decPlayer = MediaPlayer()
private val encPlayer = MediaPlayer()
private var position = 0
private var backInvokedCallback: OnBackInvokedCallback? = null
private lateinit var onBackPressedCallback: OnBackPressedCallback
@RequiresApi(33)
private fun registerBackInvokedCallback() {
backInvokedCallback = OnBackInvokedCallback { goBack() }
onBackInvokedDispatcher.registerOnBackInvokedCallback(
OnBackInvokedDispatcher.PRIORITY_DEFAULT,
backInvokedCallback!!
)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
if (Build.VERSION.SDK_INT >= 33)
registerBackInvokedCallback()
else {
onBackPressedCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
goBack()
}
}
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
}
aor = intent.getStringExtra("aor")!!
account = Account.ofAor(aor)!!
peer = intent.getStringExtra("peer")!!
position = intent.getIntExtra("position", 0)
callRow = uaHistory.value[position]
details = callRow.details
Utils.addActivity("call_details,$aor,$peer,$position")
setContent {
AppTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = LocalCustomColors.current.background
) {
CallDetailsScreen(this, stringResource(R.string.call_details)) { goBack() }
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CallDetailsScreen(ctx: Context, title: String, navigateBack: () -> Unit) {
Scaffold(
modifier = Modifier
.fillMaxHeight()
.imePadding()
.safeDrawingPadding(),
containerColor = LocalCustomColors.current.background,
topBar = {
TopAppBar(
title = {
Text(
text = title,
color = LocalCustomColors.current.light,
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
)
}
},
)
},
content = { contentPadding ->
CallDetailsContent(ctx, contentPadding)
}
)
}
@Composable
fun CallDetailsContent(ctx: Context, contentPadding: PaddingValues) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(contentPadding)
.padding(top = 16.dp, start = 16.dp, end = 4.dp, bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Peer(ctx, peer, account)
Calls(ctx)
}
}
@Composable
fun Peer(ctx: Context, peer: String, account: Account) {
val headerText = stringResource(R.string.peer) + " " +
Utils.friendlyUri(ctx, peer, account)
Text(
text = headerText,
modifier = Modifier.fillMaxWidth(),
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
color = LocalCustomColors.current.itemText,
textAlign = TextAlign.Center
)
}
@Composable
fun Calls(ctx: Context) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = stringResource(R.string.direction),
color = LocalCustomColors.current.itemText,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.width(96.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(text = stringResource(R.string.time),
color = LocalCustomColors.current.itemText,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.weight(1f))
Text(text = stringResource(R.string.calls_duration),
modifier = Modifier.padding(end = 12.dp),
color = LocalCustomColors.current.itemText,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold
)
}
val lazyListState = rememberLazyListState()
LazyColumn(
modifier = Modifier
.imePadding()
.fillMaxWidth()
.verticalScrollbar(
state = lazyListState,
width = 4.dp,
color = LocalCustomColors.current.gray
)
.background(LocalCustomColors.current.background),
state = lazyListState,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(details) { detail ->
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
painter = painterResource(detail.direction),
contentDescription = "Direction",
modifier = Modifier.width(64.dp)
)
Spacer(modifier = Modifier.width(38.dp))
val durationText = startTime(detail)
Spacer(modifier = Modifier.weight(1f))
Duration(ctx, detail, durationText)
}
}
}
}
@Composable
fun startTime(detail: CallRow.Details): String {
val startTime = detail.startTime
val stopTime = detail.stopTime
val startTimeText: String
val durationText: String
val stopText = if (DateUtils.isToday(stopTime.timeInMillis)) {
val fmt = DateFormat.getTimeInstance(DateFormat.MEDIUM)
stringResource(R.string.today) + " " + fmt.format(stopTime.time)
} else {
val fmt = DateFormat.getDateTimeInstance()
fmt.format(stopTime.time)
}
if (startTime == GregorianCalendar(0, 0, 0)) {
startTimeText = stopText
durationText = "?"
} else {
if (startTime == null) {
startTimeText = stopText
durationText = ""
} else {
val startText = if (DateUtils.isToday(startTime.timeInMillis)) {
val fmt = DateFormat.getTimeInstance(DateFormat.MEDIUM)
stringResource(R.string.today) + " " + fmt.format(startTime.time)
} else {
val fmt = DateFormat.getDateTimeInstance()
fmt.format(startTime.time)
}
startTimeText = startText
val duration = (stopTime.time.time - startTime.time.time) / 1000
durationText = DateUtils.formatElapsedTime(duration)
}
}
Text(text = startTimeText, color = LocalCustomColors.current.itemText)
return durationText
}
@Composable
fun Duration(ctx: Context, detail: CallRow.Details, durationText: String) {
val showDialog = remember { mutableStateOf(false) }
val recording = detail.recording
AlertDialog(
showDialog = showDialog,
title = stringResource(R.string.playing_recording),
message = "",
)
if (recording[0] != "") {
Text(
text = durationText,
color = LocalCustomColors.current.accent,
modifier = Modifier.padding(end = 12.dp)
.clickable(onClick = {
if (!decPlayer.isPlaying && !encPlayer.isPlaying) {
decPlayer.reset()
encPlayer.reset()
Log.d(TAG, "Playing recordings ${recording[0]} and ${recording[1]}")
decPlayer.apply {
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.setUsage(AudioAttributes.USAGE_MEDIA)
.build()
)
setOnPreparedListener {
encPlayer.apply {
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.setUsage(AudioAttributes.USAGE_MEDIA)
.build()
)
setOnPreparedListener {
it.start()
decPlayer.start()
Log.d(TAG, "Started players")
showDialog.value = true
}
setOnCompletionListener {
Log.d(TAG, "Stopping encPlayer")
it.stop()
showDialog.value = false
}
try {
val file = recording[0]
val encFile = File(file)
.copyTo(File(BaresipService.filesPath +
"/tmp/encode.wav"), true)
val encUri = encFile.toUri()
setDataSource(ctx, encUri)
prepareAsync()
} catch (e: IllegalArgumentException) {
Log.e(TAG, "encPlayer IllegalArgumentException: $e")
} catch (e: IOException) {
Log.e(TAG, "encPlayer IOException: $e")
} catch (e: Exception) {
Log.e(TAG, "encPlayer Exception: $e")
}
}
}
setOnCompletionListener {
Log.d(TAG, "Stopping decPlayer")
it.stop()
showDialog.value = false
}
try {
val file = recording[1]
val decFile = File(file)
.copyTo(File(BaresipService.filesPath +
"/tmp/decode.wav"), true)
val decUri = decFile.toUri()
setDataSource(ctx, decUri)
prepareAsync()
} catch (e: IllegalArgumentException) {
Log.e(TAG, "decPlayer IllegalArgumentException: $e")
} catch (e: IOException) {
Log.e(TAG, "decPlayer IOException: $e")
} catch (e: Exception) {
Log.e(TAG, "decPlayer Exception: $e")
}
}
} else if (decPlayer.isPlaying && encPlayer.isPlaying) {
decPlayer.stop()
encPlayer.stop()
}
})
)
} else {
Text(text = durationText,
modifier = Modifier.padding(end = 12.dp),
color = LocalCustomColors.current.itemText)
}
}
override fun onPause() {
MainActivity.activityAor = aor
super.onPause()
}
override fun onDestroy() {
if (Build.VERSION.SDK_INT >= 33) {
if (backInvokedCallback != null)
onBackInvokedDispatcher.unregisterOnBackInvokedCallback(backInvokedCallback!!)
}
else
onBackPressedCallback.remove()
super.onDestroy()
}
private fun goBack() {
BaresipService.activities.remove("call_details,$aor,$peer,$position")
decPlayer.stop()
decPlayer.release()
encPlayer.stop()
encPlayer.release()
finish()
}
}

View File

@ -0,0 +1,326 @@
package com.tutpro.baresip
import android.content.Context
import android.media.AudioAttributes
import android.media.MediaPlayer
import android.text.format.DateUtils
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
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 java.io.File
import java.io.IOException
import java.text.DateFormat
import java.util.GregorianCalendar
fun NavGraphBuilder.callDetailsScreenRoute(navController: NavController, viewModel: ViewModel) {
composable("call_details") { backStackEntry ->
val callRow = remember { viewModel.consumeSelectedCallRow() }
CallDetailsScreen(navController, callRow!!)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CallDetailsScreen(navController: NavController, callRow: CallRow) {
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(
title = {
Text(
text = stringResource(R.string.call_details),
color = LocalCustomColors.current.light,
fontWeight = FontWeight.Bold
)
},
colors = TopAppBarDefaults.mediumTopAppBarColors(
containerColor = LocalCustomColors.current.primary
),
windowInsets = WindowInsets(0, 0, 0, 0),
navigationIcon = {
IconButton(onClick = navController::popBackStack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = LocalCustomColors.current.light
)
}
},
)
}
},
content = { contentPadding ->
CallDetailsContent(LocalContext.current, contentPadding, callRow)
},
)
}
@Composable
private fun CallDetailsContent(ctx: Context, contentPadding: PaddingValues, callRow: CallRow) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(contentPadding)
.padding(top = 16.dp, start = 16.dp, end = 4.dp, bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Peer(ctx, callRow)
Details(ctx, callRow.details)
}
}
@Composable
private fun Peer(ctx: Context, callRow: CallRow) {
val account = Account.ofAor(callRow.aor)!!
val headerText = stringResource(R.string.peer) + " " + Utils.friendlyUri(ctx, callRow.peerUri, account)
Text(
text = headerText,
modifier = Modifier.fillMaxWidth(),
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
color = LocalCustomColors.current.itemText,
textAlign = TextAlign.Center
)
}
@Composable
private fun Details(ctx: Context, details: ArrayList<Details>) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = stringResource(R.string.direction),
color = LocalCustomColors.current.itemText,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.width(96.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(text = stringResource(R.string.time),
color = LocalCustomColors.current.itemText,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.weight(1f))
Text(text = stringResource(R.string.calls_duration),
modifier = Modifier.padding(end = 12.dp),
color = LocalCustomColors.current.itemText,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold
)
}
val lazyListState = rememberLazyListState()
LazyColumn(
modifier = Modifier.fillMaxWidth()
.verticalScrollbar(
state = lazyListState,
width = 4.dp,
color = LocalCustomColors.current.gray
)
.background(LocalCustomColors.current.background),
state = lazyListState,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(details) { detail ->
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
painter = painterResource(detail.direction),
contentDescription = "Direction",
modifier = Modifier.width(64.dp)
)
Spacer(modifier = Modifier.width(38.dp))
val durationText = startTime(detail)
Spacer(modifier = Modifier.weight(1f))
Duration(ctx, detail, durationText)
}
}
}
}
@Composable
private fun startTime(detail: Details): String {
val startTime = detail.startTime
val stopTime = detail.stopTime
val startTimeText: String
val durationText: String
val stopText = if (DateUtils.isToday(stopTime.timeInMillis)) {
val fmt = DateFormat.getTimeInstance(DateFormat.MEDIUM)
stringResource(R.string.today) + " " + fmt.format(stopTime.time)
} else {
val fmt = DateFormat.getDateTimeInstance()
fmt.format(stopTime.time)
}
if (startTime == GregorianCalendar(0, 0, 0)) {
startTimeText = stopText
durationText = "?"
} else {
if (startTime == null) {
startTimeText = stopText
durationText = ""
} else {
val startText = if (DateUtils.isToday(startTime.timeInMillis)) {
val fmt = DateFormat.getTimeInstance(DateFormat.MEDIUM)
stringResource(R.string.today) + " " + fmt.format(startTime.time)
} else {
val fmt = DateFormat.getDateTimeInstance()
fmt.format(startTime.time)
}
startTimeText = startText
val duration = (stopTime.time.time - startTime.time.time) / 1000
durationText = DateUtils.formatElapsedTime(duration)
}
}
Text(text = startTimeText, color = LocalCustomColors.current.itemText)
return durationText
}
@Composable
private fun Duration(ctx: Context, detail: Details, durationText: String) {
val showDialog = remember { mutableStateOf(false) }
val recording = detail.recording
val decPlayer = MediaPlayer()
val encPlayer = MediaPlayer()
AlertDialog(
showDialog = showDialog,
title = stringResource(R.string.playing_recording),
message = "",
)
if (recording[0] != "") {
Text(
text = durationText,
color = LocalCustomColors.current.accent,
modifier = Modifier.padding(end = 12.dp)
.clickable(onClick = {
if (!decPlayer.isPlaying && !encPlayer.isPlaying) {
decPlayer.reset()
encPlayer.reset()
Log.d(TAG, "Playing recordings ${recording[0]} and ${recording[1]}")
decPlayer.apply {
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.setUsage(AudioAttributes.USAGE_MEDIA)
.build()
)
setOnPreparedListener {
encPlayer.apply {
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.setUsage(AudioAttributes.USAGE_MEDIA)
.build()
)
setOnPreparedListener {
it.start()
decPlayer.start()
Log.d(TAG, "Started players")
showDialog.value = true
}
setOnCompletionListener {
Log.d(TAG, "Stopping encPlayer")
it.stop()
showDialog.value = false
}
try {
val file = recording[0]
val encFile = File(file)
.copyTo(File(BaresipService.filesPath +
"/tmp/encode.wav"), true)
val encUri = encFile.toUri()
setDataSource(ctx, encUri)
prepareAsync()
} catch (e: IllegalArgumentException) {
Log.e(TAG, "encPlayer IllegalArgumentException: $e")
} catch (e: IOException) {
Log.e(TAG, "encPlayer IOException: $e")
} catch (e: Exception) {
Log.e(TAG, "encPlayer Exception: $e")
}
}
}
setOnCompletionListener {
Log.d(TAG, "Stopping decPlayer")
it.stop()
showDialog.value = false
}
try {
val file = recording[1]
val decFile = File(file)
.copyTo(File(BaresipService.filesPath +
"/tmp/decode.wav"), true)
val decUri = decFile.toUri()
setDataSource(ctx, decUri)
prepareAsync()
} catch (e: IllegalArgumentException) {
Log.e(TAG, "decPlayer IllegalArgumentException: $e")
} catch (e: IOException) {
Log.e(TAG, "decPlayer IOException: $e")
} catch (e: Exception) {
Log.e(TAG, "decPlayer Exception: $e")
}
}
} else if (decPlayer.isPlaying && encPlayer.isPlaying) {
decPlayer.stop()
encPlayer.stop()
}
})
)
} else {
Text(text = durationText,
modifier = Modifier.padding(end = 12.dp),
color = LocalCustomColors.current.itemText)
}
}

View File

@ -5,9 +5,7 @@ import java.util.*
class CallRow( class CallRow(
val aor: String, val peerUri: String, val direction: Int, startTime: GregorianCalendar?, val aor: String, val peerUri: String, val direction: Int, startTime: GregorianCalendar?,
val stopTime: GregorianCalendar, val recording: Array<String> val stopTime: GregorianCalendar, val recording: Array<String>
) ) {
{
class Details( class Details(
val direction: Int, val startTime: GregorianCalendar?, val direction: Int, val startTime: GregorianCalendar?,
val stopTime: GregorianCalendar, val recording: Array<String> val stopTime: GregorianCalendar, val recording: Array<String>

View File

@ -1,545 +0,0 @@
package com.tutpro.baresip
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.os.Build
import android.os.Bundle
import android.window.OnBackInvokedCallback
import android.window.OnBackInvokedDispatcher
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.annotation.RequiresApi
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.fillMaxHeight
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.safeDrawingPadding
import androidx.compose.foundation.layout.size
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.Surface
import androidx.compose.material3.Text
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.layout.ContentScale
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 coil.compose.AsyncImage
import com.tutpro.baresip.CustomElements.AlertDialog
import com.tutpro.baresip.CustomElements.ImageAvatar
import com.tutpro.baresip.CustomElements.TextAvatar
import com.tutpro.baresip.CustomElements.verticalScrollbar
class CallsActivity : ComponentActivity() {
private lateinit var account: Account
private var aor = ""
private var backInvokedCallback: OnBackInvokedCallback? = null
private lateinit var onBackPressedCallback: OnBackPressedCallback
@RequiresApi(33)
private fun registerBackInvokedCallback() {
backInvokedCallback = OnBackInvokedCallback { goBack() }
onBackInvokedDispatcher.registerOnBackInvokedCallback(
OnBackInvokedDispatcher.PRIORITY_DEFAULT,
backInvokedCallback!!
)
}
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
if (Build.VERSION.SDK_INT >= 33)
registerBackInvokedCallback()
else {
onBackPressedCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
goBack()
}
}
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
}
val title = getString(R.string.call_history)
aor = intent.getStringExtra("aor")!!
Utils.addActivity("calls,$aor")
val ua = UserAgent.ofAor(aor)!!
account = ua.account
aorGenerateHistory(aor)
setContent {
AppTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = LocalCustomColors.current.background
) {
CallsScreen(this, title) { goBack() }
}
}
}
account.missedCalls = false
}
@Composable
fun CallsScreen(ctx: Context, title: String, navigateBack: () -> Unit) {
Scaffold(
modifier = Modifier
.fillMaxHeight()
.imePadding()
.safeDrawingPadding(),
containerColor = LocalCustomColors.current.background,
topBar = { TopAppBar(title, navigateBack) },
content = { contentPadding ->
CallsContent(ctx, contentPadding)
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TopAppBar(title: String, navigateBack: () -> Unit) {
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), aor.substringAfter(":")),
positiveButtonText = stringResource(R.string.delete),
negativeButtonText = stringResource(R.string.cancel),
onPositiveClicked = positiveAction.value,
)
TopAppBar(
title = {
Text(
text = title,
color = LocalCustomColors.current.light,
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 -> {
positiveAction.value = {
CallHistoryNew.clear(aor)
uaHistory.value = emptyList()
}
showDialog.value = true
}
disable, enable -> {
account.callHistory = !account.callHistory
AccountsActivity.saveAccounts()
}
}
}
)
}
)
}
@Composable
fun CallsContent(ctx: Context, contentPadding: PaddingValues) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(contentPadding)
.padding(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Account(account)
Calls(ctx, account)
}
}
@Composable
fun Account(account: Account) {
val headerText = stringResource(R.string.account) + " " +
if (account.nickName.value != "")
account.nickName.value
else
aor.split(":")[1]
Text(
text = headerText,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
color = LocalCustomColors.current.itemText,
textAlign = TextAlign.Center
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun Calls(ctx: Context, account: Account) {
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
.imePadding()
.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 = uaHistory.value) { callRow ->
var recordings = false
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Box(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.combinedClickable(
onClick = {
val peerUri = callRow.peerUri
val peerName = Utils.friendlyUri(ctx, peerUri, account)
message.value = String.format(getString(R.string.contact_action_question), peerName)
positiveButtonText.value = getString(R.string.call)
positiveAction.value = {
BaresipService.activities.remove("calls,$aor")
MainActivity.activityAor = aor
returnResult()
val i = Intent(this@CallsActivity, MainActivity::class.java)
i.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
i.putExtra("action", "call")
i.putExtra("uap", UserAgent.ofAor(aor)!!.uap)
i.putExtra("peer", peerUri)
startActivity(i)
}
neutralButtonText.value = getString(R.string.send_message)
neutralAction.value = {
BaresipService.activities.remove("calls,$aor")
MainActivity.activityAor = aor
returnResult()
val i = Intent(this@CallsActivity, MainActivity::class.java)
i.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
i.putExtra("action", "message")
i.putExtra("uap", UserAgent.ofAor(aor)!!.uap)
i.putExtra("peer", peerUri)
startActivity(i)
}
showDialog.value = true
},
onLongClick = {
val peerUri = callRow.peerUri
val peerName = Utils.friendlyUri(ctx, peerUri, account)
val callText: String = if (callRow.details.size > 1)
getString(R.string.calls_calls)
else
getString(R.string.calls_call)
val contactExists = Contact.nameExists(peerName, BaresipService.contacts, false)
if (contactExists) {
message.value = String.format(
getString(R.string.calls_delete_question),
peerName, callText
)
positiveButtonText.value = getString(R.string.delete)
positiveAction.value = {
removeFromHistory(callRow)
}
neutralButtonText.value = ""
}
else {
message.value = String.format(
getString(R.string.calls_add_delete_question),
peerName, callText
)
positiveButtonText.value = getString(R.string.add_contact)
positiveAction.value = {
val i = Intent(ctx, BaresipContactActivity::class.java)
val b = Bundle()
b.putBoolean("new", true)
b.putString("uri", callRow.peerUri)
i.putExtras(b)
ctx.startActivity(i)
}
neutralButtonText.value = getString(R.string.delete)
neutralAction.value = {
removeFromHistory(callRow)
}
}
showDialog.value = true
}
)
) {
val uri = callRow.peerUri
when (val contact = Contact.findContact(uri)) {
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(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, callRow.peerUri, account),
modifier = Modifier.padding(start = 8.dp),
fontSize = 18.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = LocalCustomColors.current.itemText
)
}
}
Box(modifier = Modifier.width(56.dp)) {
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)
.width(64.dp)
.clickable(onClick = {
val i = Intent(ctx, CallDetailsActivity::class.java)
val b = Bundle()
b.putString("aor", account.aor)
b.putString("peer", callRow.peerUri)
b.putInt("position", uaHistory.value.indexOf(callRow))
i.putExtras(b)
ctx.startActivity(i)
})
)
}
}
}
}
}
private fun goBack() {
BaresipService.activities.remove("calls,$aor")
returnResult()
}
private fun returnResult() {
setResult(RESULT_CANCELED, Intent())
finish()
}
override fun onPause() {
MainActivity.activityAor = aor
super.onPause()
}
override fun onDestroy() {
if (Build.VERSION.SDK_INT >= 33) {
if (backInvokedCallback != null)
onBackInvokedDispatcher.unregisterOnBackInvokedCallback(backInvokedCallback!!)
}
else
onBackPressedCallback.remove()
super.onDestroy()
}
private fun aorGenerateHistory(aor: String) {
uaHistory.value = emptyList()
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) {
R.drawable.call_down_green
} 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 (uaHistory.value.isNotEmpty() && (uaHistory.value.last().peerUri == h.peerUri))
uaHistory.value.last().details.add(CallRow.Details(
direction, h.startTime,
h.stopTime, h.recording
))
else
addToUaHistory(
CallRow(h.aor, h.peerUri, direction, h.startTime, h.stopTime, h.recording)
)
}
}
}
private fun removeFromHistory(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)
deleteFromUaHistory(callRow)
CallHistoryNew.save()
}
private fun addToUaHistory(callRow: CallRow) {
val updatedList = uaHistory.value.toMutableList()
updatedList.add(callRow)
uaHistory.value = updatedList
}
private fun deleteFromUaHistory(callRow: CallRow) {
val updatedList = uaHistory.value.toMutableList()
updatedList.remove(callRow)
uaHistory.value = updatedList
}
@SuppressLint("MutableCollectionMutableState")
companion object {
val uaHistory = mutableStateOf(emptyList<CallRow>())
}
}

View File

@ -0,0 +1,483 @@
package com.tutpro.baresip
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
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<CallRow>()) }
var isHistoryLoaded by remember { mutableStateOf(false) }
LaunchedEffect(aor) {
callHistory.value = loadCallHistory(aor)
isHistoryLoaded = true
}
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 = {
navController.popBackStack()
account.missedCalls = false
}
) {
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) {
val headerText = stringResource(R.string.account) + " " +
if (account.nickName.value != "")
account.nickName.value
else
account.aor.split(":")[1]
Text(
text = headerText,
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 ->
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 peerUri = callRow.peerUri
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 peerUri = callRow.peerUri
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/${callRow.peerUri}/new")
}
neutralButtonText.value = ctx.getString(R.string.delete)
neutralAction.value = {
removeFromHistory(callHistory, callRow)
}
}
showDialog.value = true
}
)
) {
val uri = callRow.peerUri
when (val contact = Contact.findContact(uri)) {
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, callRow.peerUri, account),
modifier = Modifier.padding(start = 8.dp),
fontSize = 18.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = LocalCustomColors.current.itemText
)
}
}
Box(modifier = Modifier.width(56.dp)) {
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)
.width(64.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) {
R.drawable.call_down_green
} 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()
}

View File

@ -1,637 +0,0 @@
package com.tutpro.baresip
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.SystemClock
import android.text.format.DateUtils.isToday
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import android.window.OnBackInvokedCallback
import android.window.OnBackInvokedDispatcher
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.annotation.RequiresApi
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.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.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
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.Surface
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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.SoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.Observer
import com.tutpro.baresip.CustomElements.AlertDialog
import com.tutpro.baresip.CustomElements.EventListener
import com.tutpro.baresip.CustomElements.LabelText
import com.tutpro.baresip.CustomElements.verticalScrollbar
import kotlinx.coroutines.launch
import java.text.DateFormat
import java.util.GregorianCalendar
class ChatActivity : ComponentActivity() {
private lateinit var imm: InputMethodManager
private lateinit var aor: String
private lateinit var account: Account
private lateinit var peerUri: String
private lateinit var ua: UserAgent
private var _chatMessages = mutableStateOf<List<Message>>(emptyList())
private var chatMessages : List<Message> by _chatMessages
private var focus = false
private var lastCall: Long = 0
private var keyboardController: SoftwareKeyboardController? = null
private val chatPeer = mutableStateOf("")
private var backInvokedCallback: OnBackInvokedCallback? = null
private lateinit var onBackPressedCallback: OnBackPressedCallback
@RequiresApi(33)
private fun registerBackInvokedCallback() {
backInvokedCallback = OnBackInvokedCallback { goBack() }
onBackInvokedDispatcher.registerOnBackInvokedCallback(
OnBackInvokedDispatcher.PRIORITY_DEFAULT,
backInvokedCallback!!
)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
if (Build.VERSION.SDK_INT >= 33)
registerBackInvokedCallback()
else {
onBackPressedCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
goBack()
}
}
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
}
aor = intent.getStringExtra("aor")!!
peerUri = intent.getStringExtra("peer")!!
account = UserAgent.ofAor(aor)!!.account
focus = intent.getBooleanExtra("focus", false)
if (BaresipService.activities.first().startsWith("chat,$aor,$peerUri")) {
returnResult(RESULT_CANCELED)
return
} else {
Utils.addActivity("chat,$aor,$peerUri,$focus")
}
val userAgent = UserAgent.ofAor(aor)
if (userAgent == null) {
Log.w(TAG, "ChatActivity did not find ua of $aor")
MainActivity.activityAor = aor
returnResult(RESULT_CANCELED)
return
} else {
ua = userAgent
}
chatPeer.value = Utils.friendlyUri(this, peerUri, userAgent.account, true)
imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
val messagesObserver = Observer<Long> {
_chatMessages.value = listOf()
_chatMessages.value = uaPeerMessages(aor, peerUri)
}
BaresipService.messageUpdate.observe(this, messagesObserver)
ua.account.unreadMessages = false
setContent {
AppTheme {
keyboardController = LocalSoftwareKeyboardController.current
Surface(
modifier = Modifier.fillMaxSize(),
) {
ChatScreen(this) { goBack() }
}
}
}
}
@Composable
fun ChatScreen(ctx: Context, navigateBack: () -> Unit) {
Scaffold(
modifier = Modifier
.fillMaxHeight()
.imePadding()
.safeDrawingPadding(),
containerColor = LocalCustomColors.current.background,
topBar = { TopAppBar(ctx, navigateBack) },
bottomBar = { NewMessage(ctx, peerUri) },
content = { contentPadding ->
ChatContent(ctx, contentPadding)
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TopAppBar(ctx: Context, navigateBack: () -> Unit) {
TopAppBar(
title = {
Text(
text = String.format(getString(R.string.chat_with), chatPeer.value),
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 = {
if (SystemClock.elapsedRealtime() - lastCall > 1000) {
lastCall = SystemClock.elapsedRealtime()
val intent = Intent(ctx, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
intent.putExtra("action", "call")
intent.putExtra("uap", ua.uap)
intent.putExtra("peer", peerUri)
startActivity(intent)
}
}
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.call_small),
contentDescription = "Call",
tint = LocalCustomColors.current.light
)
}
}
)
}
@Composable
fun ChatContent(ctx: Context, contentPadding: PaddingValues) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(contentPadding),
verticalArrangement = Arrangement.Bottom
) {
Account(account)
Spacer(modifier = Modifier.weight(1f))
Messages(ctx)
}
}
@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 Messages(ctx: Context) {
val showDialog = remember { mutableStateOf(false) }
val dialogMessage = 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 = dialogMessage.value,
positiveButtonText = positiveButtonText.value,
onPositiveClicked = positiveAction.value,
neutralButtonText = neutralButtonText.value,
onNeutralClicked = neutralAction.value,
negativeButtonText = stringResource(R.string.cancel)
)
val lazyListState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(Unit) {
_chatMessages.value = uaPeerMessages(aor, peerUri)
}
LaunchedEffect(chatMessages) {
// Scroll to the bottom when new messages are added
if (chatMessages.isNotEmpty()) {
coroutineScope.launch {
lazyListState.scrollToItem(0)
}
}
}
LazyColumn(
modifier = Modifier
.imePadding()
.fillMaxWidth()
.padding(start = 16.dp, end = 2.dp)
.verticalScrollbar(
state = lazyListState,
width = 4.dp,
color = LocalCustomColors.current.gray
),
reverseLayout = true,
state = lazyListState,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
items(items = chatMessages, key = { message -> message.timeStamp }) { message ->
val down = message.direction == MESSAGE_DOWN
val peer: String = if (down) {
if (chatPeer.value.startsWith("sip:") &&
(Utils.uriHostPart(message.peerUri) == Utils.uriHostPart(message.aor)))
Utils.uriUserPart(message.peerUri)
else
chatPeer.value
}
else
stringResource(R.string.you)
var info: String
val cal = GregorianCalendar()
cal.timeInMillis = message.timeStamp
val fmt: DateFormat = if (isToday(message.timeStamp))
DateFormat.getTimeInstance(DateFormat.SHORT)
else
DateFormat.getDateInstance(DateFormat.SHORT)
info = fmt.format(cal.time)
if (info.length < 6) info = "${stringResource(R.string.today)} $info"
if (message.direction == MESSAGE_UP_FAIL) {
info = if (message.responseCode != 0)
"$info - ${stringResource(R.string.message_failed)}: " + "${message.responseCode} ${message.responseReason}"
else
"$info - ${stringResource(R.string.sending_failed)}"
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(end = 12.dp)
) {
Button(
onClick = {
if (Contact.findContact(peerUri) == null) {
dialogMessage.value = String.format(getString(R.string.long_message_question),
chatPeer.value
)
positiveButtonText.value = getString(R.string.add_contact)
positiveAction.value = {
val i = Intent(ctx, BaresipContactActivity::class.java)
val b = Bundle()
b.putBoolean("new", true)
b.putString("uri", peerUri)
i.putExtras(b)
ctx.startActivity(i)
}
neutralButtonText.value = getString(R.string.delete)
neutralAction.value = {
message.delete()
_chatMessages.value = uaPeerMessages(aor, peerUri)
}
}
else {
dialogMessage.value = getString(R.string.short_message_question)
positiveButtonText.value = getString(R.string.delete)
positiveAction.value = {
message.delete()
_chatMessages.value = uaPeerMessages(aor, peerUri)
}
neutralButtonText.value = ""
}
showDialog.value = true
},
shape = if (message.direction == MESSAGE_DOWN)
RoundedCornerShape(50.dp, 20.dp, 20.dp, 10.dp)
else
RoundedCornerShape(20.dp, 10.dp, 50.dp, 20.dp),
colors = ButtonDefaults.buttonColors(containerColor =
if (message.direction == MESSAGE_DOWN) {
if (BaresipService.darkTheme.value)
LocalCustomColors.current.secondaryDark
else
LocalCustomColors.current.secondaryLight
}
else {
if (BaresipService.darkTheme.value)
LocalCustomColors.current.primaryDark
else
LocalCustomColors.current.primaryLight
}),
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(
start = if (message.direction == MESSAGE_DOWN) 0.dp else 24.dp,
end = if (message.direction == MESSAGE_DOWN) 24.dp else 0.dp
)
) {
Column {
Row {
val textColor =
if (message.direction == MESSAGE_DOWN) {
if (BaresipService.darkTheme.value)
LocalCustomColors.current.secondaryLight
else
LocalCustomColors.current.secondaryDark
}
else {
if (BaresipService.darkTheme.value)
LocalCustomColors.current.primaryLight
else
LocalCustomColors.current.primaryDark
}
Text(text = peer, fontSize = 12.sp, color = textColor)
Spacer(modifier = Modifier.weight(1f))
Text(text = info, fontSize = 12.sp, color = textColor)
}
Row {
SelectionContainer {
Text(
text = message.message,
color = LocalCustomColors.current.itemText,
fontWeight = if (message.direction == MESSAGE_DOWN && message.new)
FontWeight.Bold else FontWeight.Normal
)
}
}
}
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun NewMessage(ctx: Context, peerUri: String) {
var newMessage = rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue(""))
}
var textFieldLoaded by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
EventListener {
when (it) {
Lifecycle.Event.ON_RESUME -> {
val chatText = BaresipService.chatTexts["$aor::$peerUri"]
if (chatText != null) {
Log.d(TAG, "Restoring newMessage '$chatText' for $aor::$peerUri")
newMessage.value = TextFieldValue(
text = chatText,
selection = TextRange(chatText.length)
)
BaresipService.chatTexts.remove("$aor::$peerUri")
}
}
Lifecycle.Event.ON_PAUSE -> {
if (newMessage.value != TextFieldValue("")) {
Log.d(TAG, "Saving newMessage '${newMessage.value.text}' for $aor::$peerUri")
BaresipService.chatTexts["$aor::$peerUri"] = newMessage.value.text
}
}
else -> {}
}
}
val showDialog = remember { mutableStateOf(false) }
val dialogMessage = remember { mutableStateOf("") }
AlertDialog(
showDialog = showDialog,
title = stringResource(R.string.notice),
message = dialogMessage.value,
positiveButtonText = stringResource(R.string.ok),
)
Row(modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(start = 16.dp, end = 8.dp, top = 10.dp, bottom = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
val keyboardController = LocalSoftwareKeyboardController.current
OutlinedTextField(
value = newMessage.value,
placeholder = { Text(stringResource(R.string.new_message)) },
onValueChange = { newMessage.value = it },
modifier = Modifier
.weight(1f)
.padding(end = 8.dp)
.verticalScroll(rememberScrollState())
.focusRequester(focusRequester)
.onGloballyPositioned {
if (!textFieldLoaded)
textFieldLoaded = true
},
singleLine = false,
trailingIcon = {
if (newMessage.value.text.isNotEmpty()) {
Icon(
Icons.Outlined.Clear,
contentDescription = "Clear",
modifier = Modifier.clickable { newMessage.value = TextFieldValue("") }
)
} },
label = { LabelText(stringResource(R.string.new_message)) },
textStyle = TextStyle(fontSize = 18.sp),
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Sentences,
keyboardType = KeyboardType.Text,
autoCorrectEnabled = true
)
)
LaunchedEffect(Unit) {
if (newMessage.value.text.isNotEmpty())
focusRequester.requestFocus()
}
Image(
painter = painterResource(id = R.drawable.send),
contentDescription = "Send",
contentScale = ContentScale.Crop,
modifier = Modifier
.size(36.dp)
.clickable {
val msgText = newMessage.value.text
if (msgText.isNotEmpty()) {
keyboardController?.hide()
val time = System.currentTimeMillis()
val msg =
Message(aor, peerUri, msgText, time, MESSAGE_UP_WAIT, 0, "", true)
msg.add()
var msgUri = ""
_chatMessages.value += msg
if (Utils.isTelUri(peerUri))
if (ua.account.telProvider == "") {
dialogMessage.value = String.format(
getString(R.string.no_telephony_provider),
Utils.plainAor(aor)
)
showDialog.value = true
} else {
msgUri = Utils.telToSip(peerUri, ua.account)
}
else
msgUri = peerUri
if (msgUri != "")
if (Api.message_send(ua.uap, msgUri, msgText, time.toString()) != 0
) {
Toast.makeText(
ctx, "${getString(R.string.message_failed)}!",
Toast.LENGTH_SHORT
).show()
msg.direction = MESSAGE_UP_FAIL
msg.responseReason = getString(R.string.message_failed)
} else {
newMessage.value = TextFieldValue("")
keyboardController?.hide()
BaresipService.chatTexts.remove("$aor::$peerUri")
}
}
}
)
}
}
override fun onPause() {
super.onPause()
MainActivity.activityAor = aor
}
override fun onResume() {
super.onResume()
_chatMessages.value = uaPeerMessages(aor, peerUri)
chatPeer.value = Utils.friendlyUri(this, peerUri, ua.account, true)
}
override fun onDestroy() {
if (Build.VERSION.SDK_INT >= 33) {
if (backInvokedCallback != null)
onBackInvokedDispatcher.unregisterOnBackInvokedCallback(backInvokedCallback!!)
}
else
onBackPressedCallback.remove()
super.onDestroy()
}
private fun goBack() {
var save = false
for (m in chatMessages) {
if (m.new) {
m.new = false
save = true
}
}
if (save) Message.save()
keyboardController?.hide()
BaresipService.activities.remove("chat,$aor,$peerUri,false")
BaresipService.activities.remove("chat,$aor,$peerUri,true")
returnResult(RESULT_OK)
}
private fun returnResult(code: Int) {
setResult(code, Intent())
finish()
}
private fun uaPeerMessages(aor: String, peerUri: String): List<Message> {
val res = mutableListOf<Message>()
for (m in BaresipService.messages.reversed())
if ((m.aor == aor) && (m.peerUri == peerUri)) res.add(m)
return res
}
}

View File

@ -0,0 +1,580 @@
package com.tutpro.baresip
import android.content.Context
import android.content.Intent
import android.text.format.DateUtils.isToday
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
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.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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.Observer
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 com.tutpro.baresip.CustomElements.AlertDialog
import com.tutpro.baresip.CustomElements.LabelText
import com.tutpro.baresip.CustomElements.verticalScrollbar
import kotlinx.coroutines.launch
import java.lang.String.format
import java.text.DateFormat
import java.util.GregorianCalendar
fun NavGraphBuilder.chatScreenRoute(navController: NavController, viewModel: ViewModel) {
composable(
route = "chat/{aor}/{peer}",
arguments = listOf(
navArgument("aor") { type = NavType.StringType },
navArgument("peer") { type = NavType.StringType }
)
) { backStackEntry ->
val aor = backStackEntry.arguments?.getString("aor")!!
val peerUri = backStackEntry.arguments?.getString("peer")!!
ChatScreen(
ctx = LocalContext.current,
navController = navController,
viewModel = viewModel,
account = Account.ofAor(aor)!!,
peerUri = peerUri
)
}
}
@Composable
private fun ChatScreen(
ctx: Context,
navController: NavController,
viewModel: ViewModel,
account: Account,
peerUri: String
) {
val lifecycleOwner = LocalLifecycleOwner.current
val aor = account.aor
var chatMessages by remember(aor, peerUri) { mutableStateOf<List<Message>>(emptyList()) }
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
Log.d(TAG, "Resumed to ChatScreen for AOR: $aor peer $peerUri")
chatMessages = loadPeerMessages(aor, peerUri)
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
var areMessagesLoaded by remember(aor, peerUri) {
mutableStateOf(false)
}
val reloadMessages = {
Log.d(TAG, "Reloading messages for $aor peer $peerUri")
chatMessages = loadPeerMessages(aor, peerUri)
if (!areMessagesLoaded)
areMessagesLoaded = true
}
val addMessage = { newMessage: Message ->
chatMessages = chatMessages + newMessage
}
DisposableEffect(key1 = lifecycleOwner, key2 = account.aor, key3 = peerUri) {
val messagesObserver = Observer<Long> { timestamp ->
Log.d(TAG, "Message update received via LiveData for $peerUri, timestamp: $timestamp")
reloadMessages()
}
reloadMessages() // Initial load
Log.d(TAG, "Observing message updates for $peerUri")
BaresipService.messageUpdate.observe(lifecycleOwner, messagesObserver)
onDispose {
Log.d(TAG, "Removing message observer for $peerUri")
BaresipService.messageUpdate.removeObserver(messagesObserver)
}
}
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(ctx, navController, viewModel, account, peerUri)
}
},
bottomBar = { NewMessage(ctx, account, peerUri, addMessage) },
content = { contentPadding ->
if (areMessagesLoaded)
ChatContent(ctx, navController, contentPadding, account, peerUri, chatMessages, reloadMessages)
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TopAppBar(
ctx: Context,
navController: NavController,
viewModel: ViewModel,
account: Account,
peerUri: String
) {
val aor = account.aor
TopAppBar(
title = {
Text(
text = format(ctx.getString(R.string.chat_with), Utils.friendlyUri(ctx, peerUri, account)),
color = LocalCustomColors.current.light,
fontSize = 22.sp,
fontWeight = FontWeight.Bold
)
},
colors = TopAppBarDefaults.mediumTopAppBarColors(
containerColor = LocalCustomColors.current.primary
),
navigationIcon = {
IconButton(onClick = {
Message.updateMessagesFromPearRead(aor, peerUri)
account.unreadMessages = Message.unreadMessages(aor)
navController.popBackStack()
}) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = LocalCustomColors.current.light
)
}
},
windowInsets = WindowInsets(0, 0, 0, 0),
actions = {
IconButton(
onClick = {
val ua = UserAgent.ofAor(account.aor)
if (ua != null) {
val intent = Intent(ctx, MainActivity::class.java)
intent.putExtra("uap", ua.uap)
intent.putExtra("peer", peerUri)
handleIntent(ctx, viewModel, intent, "call")
navController.navigate("main") {
popUpTo("main")
launchSingleTop = true
}
}
else
Log.w(TAG, "onClickListener did not find UA for $aor")
}
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.call_small),
contentDescription = "Call",
tint = LocalCustomColors.current.light
)
}
}
)
}
@Composable
private fun ChatContent(
ctx: Context,
navController: NavController,
contentPadding: PaddingValues,
account: Account,
peerUri: String,
messages: List<Message>,
onMessageDeleted: () -> Unit
) {
Column(
modifier = Modifier.fillMaxWidth().padding(contentPadding),
verticalArrangement = Arrangement.Bottom
) {
Account(ctx, account)
Spacer(modifier = Modifier.weight(1f))
Messages(ctx, navController, account, peerUri, messages, onMessageDeleted)
}
}
@Composable
private fun Account(ctx: Context, account: Account) {
val headerText = ctx.getString(R.string.account) + " " +
if (account.nickName.value != "")
account.nickName.value
else
account.aor.substringAfter(":")
Text(
text = headerText,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 8.dp),
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center
)
}
@Composable
private fun Messages(
ctx: Context,
navController: NavController,
account: Account,
peerUri: String,
messages: List<Message>,
onMessageDeleted: () -> Unit
) {
val peerName = Utils.friendlyUri(ctx, peerUri, account)
val showDialog = remember { mutableStateOf(false) }
val dialogMessage = 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 = dialogMessage.value,
positiveButtonText = positiveButtonText.value,
onPositiveClicked = positiveAction.value,
neutralButtonText = neutralButtonText.value,
onNeutralClicked = neutralAction.value,
negativeButtonText = stringResource(R.string.cancel)
)
val lazyListState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(messages) {
// Scroll to the bottom when new messages are added
if (messages.isNotEmpty()) {
coroutineScope.launch {
lazyListState.scrollToItem(0)
}
}
}
LazyColumn(
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 2.dp)
.verticalScrollbar(
state = lazyListState,
width = 4.dp,
color = LocalCustomColors.current.gray
),
reverseLayout = true,
state = lazyListState,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
items(items = messages, key = { message -> message.timeStamp }) { message ->
val down = message.direction == MESSAGE_DOWN
val sender: String = if (down)
peerName
else if (BaresipService.uas.value.size == 1)
stringResource(R.string.you)
else
account.text()
var info: String
val cal = GregorianCalendar()
cal.timeInMillis = message.timeStamp
val fmt: DateFormat = if (isToday(message.timeStamp))
DateFormat.getTimeInstance(DateFormat.SHORT)
else
DateFormat.getDateInstance(DateFormat.SHORT)
info = fmt.format(cal.time)
if (info.length < 6) info = "${stringResource(R.string.today)} $info"
if (message.direction == MESSAGE_UP_FAIL) {
info = if (message.responseCode != 0)
"$info - ${stringResource(R.string.message_failed)}: " + "${message.responseCode} ${message.responseReason}"
else
"$info - ${stringResource(R.string.sending_failed)}"
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(end = 12.dp)
) {
Button(
onClick = {
if (Contact.findContact(peerUri) == null) {
dialogMessage.value = String.format(
ctx.getString(R.string.long_message_question),
peerUri
)
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 = {
message.delete()
onMessageDeleted
}
} else {
dialogMessage.value = ctx.getString(R.string.short_message_question)
positiveButtonText.value = ctx.getString(R.string.delete)
positiveAction.value = {
message.delete()
onMessageDeleted()
}
neutralButtonText.value = ""
}
showDialog.value = true
},
shape = if (message.direction == MESSAGE_DOWN)
RoundedCornerShape(50.dp, 20.dp, 20.dp, 10.dp)
else
RoundedCornerShape(20.dp, 10.dp, 50.dp, 20.dp),
colors = ButtonDefaults.buttonColors(
containerColor =
if (message.direction == MESSAGE_DOWN) {
if (BaresipService.darkTheme.value)
LocalCustomColors.current.secondaryDark
else
LocalCustomColors.current.secondaryLight
} else {
if (BaresipService.darkTheme.value)
LocalCustomColors.current.primaryDark
else
LocalCustomColors.current.primaryLight
}
),
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(
start = if (message.direction == MESSAGE_DOWN) 0.dp else 24.dp,
end = if (message.direction == MESSAGE_DOWN) 24.dp else 0.dp
)
) {
Column {
Row {
val textColor =
if (message.direction == MESSAGE_DOWN) {
if (BaresipService.darkTheme.value)
LocalCustomColors.current.secondaryLight
else
LocalCustomColors.current.secondaryDark
} else {
if (BaresipService.darkTheme.value)
LocalCustomColors.current.primaryLight
else
LocalCustomColors.current.primaryDark
}
Text(text = sender, fontSize = 12.sp, color = textColor)
Spacer(modifier = Modifier.weight(1f))
Text(text = info, fontSize = 12.sp, color = textColor)
}
Row {
SelectionContainer {
Text(
text = message.message,
color = LocalCustomColors.current.itemText,
fontWeight = if (message.direction == MESSAGE_DOWN && message.new)
FontWeight.Bold else FontWeight.Normal
)
}
}
}
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun NewMessage(
ctx: Context,
account: Account,
peerUri: String,
addMessage: (message: Message) -> Unit
) {
val aor = account.aor
val ua = UserAgent.ofAor(aor)!!
var newMessage = rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue(""))
}
var textFieldLoaded by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
val showDialog = remember { mutableStateOf(false) }
val dialogMessage = remember { mutableStateOf("") }
AlertDialog(
showDialog = showDialog,
title = stringResource(R.string.notice),
message = dialogMessage.value,
positiveButtonText = stringResource(R.string.ok),
)
Row(modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(start = 16.dp, end = 8.dp, top = 10.dp, bottom = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
val keyboardController = LocalSoftwareKeyboardController.current
OutlinedTextField(
value = newMessage.value,
placeholder = { Text(stringResource(R.string.new_message)) },
onValueChange = { newMessage.value = it },
modifier = Modifier
.weight(1f)
.padding(end = 8.dp)
.verticalScroll(rememberScrollState())
.focusRequester(focusRequester)
.onGloballyPositioned {
if (!textFieldLoaded)
textFieldLoaded = true
},
singleLine = false,
trailingIcon = {
if (newMessage.value.text.isNotEmpty()) {
Icon(
Icons.Outlined.Clear,
contentDescription = "Clear",
modifier = Modifier.clickable { newMessage.value = TextFieldValue("") }
)
} },
label = { LabelText(stringResource(R.string.new_message)) },
textStyle = TextStyle(fontSize = 18.sp),
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Sentences,
keyboardType = KeyboardType.Text,
autoCorrectEnabled = true
)
)
LaunchedEffect(Unit) {
if (newMessage.value.text.isNotEmpty())
focusRequester.requestFocus()
}
Image(
painter = painterResource(id = R.drawable.send),
contentDescription = "Send",
contentScale = ContentScale.Crop,
modifier = Modifier
.size(36.dp)
.clickable {
val msgText = newMessage.value.text
if (msgText.isNotEmpty()) {
keyboardController?.hide()
val time = System.currentTimeMillis()
val msg =
Message(aor, peerUri, msgText, time, MESSAGE_UP_WAIT, 0, "", true)
msg.add()
var msgUri = ""
addMessage(msg)
if (Utils.isTelUri(peerUri))
if (ua.account.telProvider == "") {
dialogMessage.value = String.format(
ctx.getString(R.string.no_telephony_provider),
Utils.plainAor(aor)
)
showDialog.value = true
} else {
msgUri = Utils.telToSip(peerUri, ua.account)
}
else
msgUri = peerUri
if (msgUri != "")
if (Api.message_send(ua.uap, msgUri, msgText, time.toString()) != 0
) {
Toast.makeText(
ctx, "${ctx.getString(R.string.message_failed)}!",
Toast.LENGTH_SHORT
).show()
msg.direction = MESSAGE_UP_FAIL
msg.responseReason = ctx.getString(R.string.message_failed)
} else {
newMessage.value = TextFieldValue("")
keyboardController?.hide()
}
}
}
)
}
}
private fun loadPeerMessages(aor: String, peerUri: String): List<Message> {
val res = mutableListOf<Message>()
for (m in BaresipService.messages.reversed())
if ((m.aor == aor) && (m.peerUri == peerUri)) res.add(m)
return res
}

View File

@ -1,728 +0,0 @@
package com.tutpro.baresip
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.os.Build
import android.os.Bundle
import android.text.format.DateUtils.isToday
import android.window.OnBackInvokedCallback
import android.window.OnBackInvokedDispatcher
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.annotation.RequiresApi
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.BasicText
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.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.Text
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.tutpro.baresip.BaresipService.Companion.contactNames
import com.tutpro.baresip.CustomElements.AlertDialog
import com.tutpro.baresip.CustomElements.ImageAvatar
import com.tutpro.baresip.CustomElements.LabelText
import com.tutpro.baresip.CustomElements.TextAvatar
import com.tutpro.baresip.CustomElements.verticalScrollbar
import com.tutpro.baresip.CustomElements.SelectableAlertDialog
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 alertTitle = mutableStateOf("")
private val alertMessage = mutableStateOf("")
private val showAlert = mutableStateOf(false)
private var backInvokedCallback: OnBackInvokedCallback? = null
private lateinit var onBackPressedCallback: OnBackPressedCallback
@RequiresApi(33)
private fun registerBackInvokedCallback() {
backInvokedCallback = OnBackInvokedCallback { goBack() }
onBackInvokedDispatcher.registerOnBackInvokedCallback(
OnBackInvokedDispatcher.PRIORITY_DEFAULT,
backInvokedCallback!!
)
}
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
if (Build.VERSION.SDK_INT >= 33)
registerBackInvokedCallback()
else {
onBackPressedCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
goBack()
}
}
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
}
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() }
}
}
}
}
@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 = stringResource(R.string.delete)
val showDialog = remember { mutableStateOf(false) }
val positiveAction = remember { mutableStateOf({}) }
AlertDialog(
showDialog = showDialog,
title = stringResource(R.string.confirmation),
message = String.format(
stringResource(R.string.delete_chats_alert),
aor.substringAfter(":")
),
positiveButtonText = stringResource(R.string.delete),
onPositiveClicked = positiveAction.value,
negativeButtonText = stringResource(R.string.cancel),
)
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),
onItemClick = { selectedItem ->
expanded = false
when (selectedItem) {
delete -> {
positiveAction.value = {
Message.clearMessagesOfAor(aor)
Message.save()
_uaMessages.value = listOf()
account.unreadMessages = false
}
showDialog.value = true
}
}
}
)
}
)
}
@Composable
fun ChatsContent(ctx: Context, contentPadding: PaddingValues) {
if (showAlert.value) {
AlertDialog(
showDialog = showAlert,
title = alertTitle.value,
message = alertMessage.value,
positiveButtonText = stringResource(R.string.ok),
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(contentPadding),
verticalArrangement = Arrangement.Top
) {
Account(account)
Chats(ctx, account)
}
}
@Composable
fun Account(account: Account) {
val headerText = stringResource(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),
color = LocalCustomColors.current.itemText,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center
)
}
@Composable
fun Chats(ctx: Context, account: Account) {
val showDialog = remember { mutableStateOf(false) }
val dialogMessage = remember { mutableStateOf("") }
val positiveButtonText = remember { mutableStateOf("") }
val positiveAction = remember { mutableStateOf({}) }
val neutralButtonText = remember { mutableStateOf("") }
val neutralAction = remember { mutableStateOf({}) }
if (showDialog.value)
AlertDialog(
showDialog = showDialog,
title = stringResource(R.string.confirmation),
message = dialogMessage.value,
positiveButtonText = positiveButtonText.value,
onPositiveClicked = positiveAction.value,
neutralButtonText = neutralButtonText.value,
onNeutralClicked = neutralAction.value,
negativeButtonText = stringResource(R.string.cancel)
)
val lazyListState = rememberLazyListState()
LazyColumn(
modifier = Modifier
.imePadding()
.fillMaxWidth()
.padding(start = 8.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) {
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))
CustomElements.Button(
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 peer = Utils.friendlyUri(ctx, message.peerUri, account)
val contactExists =
Contact.nameExists(peer, BaresipService.contacts, false)
if (contactExists) {
dialogMessage.value = String.format(
getString(R.string.short_chat_question),
peer
)
positiveButtonText.value = getString(R.string.delete)
positiveAction.value = {
Message.deleteAorPeerMessages(aor, message.peerUri)
_uaMessages.value = uaMessages(aor)
}
neutralButtonText.value = ""
} else {
dialogMessage.value = String.format(
getString(R.string.long_chat_question),
peer
)
positiveButtonText.value = getString(R.string.add_contact)
positiveAction.value = {
val i = Intent(ctx, BaresipContactActivity::class.java)
val b = Bundle()
b.putBoolean("new", true)
b.putString("uri", message.peerUri)
i.putExtras(b)
startActivity(i)
}
neutralButtonText.value = getString(R.string.delete)
neutralAction.value = {
Message.deleteAorPeerMessages(aor, message.peerUri)
}
}
showDialog.value = true
},
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(end = 6.dp),
shape = if (message.direction == MESSAGE_DOWN)
RoundedCornerShape(50.dp, 20.dp, 20.dp, 10.dp)
else
RoundedCornerShape(20.dp, 10.dp, 50.dp, 20.dp),
color =
if (message.direction == MESSAGE_DOWN) {
if (BaresipService.darkTheme.value)
LocalCustomColors.current.secondaryDark
else
LocalCustomColors.current.secondaryLight
} else {
if (BaresipService.darkTheme.value)
LocalCustomColors.current.primaryDark
else
LocalCustomColors.current.primaryLight
},
) {
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 {
val textColor =
if (message.direction == MESSAGE_DOWN) {
if (BaresipService.darkTheme.value)
LocalCustomColors.current.secondaryLight
else
LocalCustomColors.current.secondaryDark
} else {
if (BaresipService.darkTheme.value)
LocalCustomColors.current.primaryLight
else
LocalCustomColors.current.primaryDark
}
Text(text = peer, color = textColor, fontSize = 12.sp)
Spacer(modifier = Modifier.weight(1f))
Text(text = info, color = textColor, fontSize = 12.sp)
}
Row {
BasicText(
text = message.message,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = TextStyle(
color = LocalCustomColors.current.itemText,
fontWeight = if (message.direction == MESSAGE_DOWN && message.new)
FontWeight.Bold else FontWeight.Normal,
fontSize = 16.sp
)
)
}
}
}
}
}
}
}
@Composable
fun NewChatPeer() {
val showDialog = remember { mutableStateOf(false) }
val items = remember { mutableStateOf(listOf<String>()) }
val itemAction = remember { mutableStateOf<(Int) -> Unit>({ _ -> run {} }) }
SelectableAlertDialog(
openDialog = showDialog,
title = stringResource(R.string.choose_destination_uri),
items = items.value,
onItemClicked = itemAction.value,
neutralButtonText = stringResource(R.string.cancel),
onNeutralClicked = {}
)
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()
.heightIn(max = 150.dp)
) {
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(stringResource(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 {
if (showSuggestions)
showSuggestions = false
else
newPeer = ""
}
)
},
label = {
LabelText(stringResource(R.string.new_chat_peer))
},
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
val peerText = newPeer.trim()
if (peerText.isNotEmpty()) {
val uris = Contact.contactUris(peerText)
if (uris.isEmpty())
makeChat(peerText)
else if (uris.size == 1)
makeChat(uris[0])
else {
items.value = uris
itemAction.value = { index ->
makeChat(uris[index])
}
showDialog.value = 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(chatPeer: String) {
val peerUri = if (Utils.isTelNumber(chatPeer))
"tel:$chatPeer"
else
chatPeer
val uri = if (Utils.isTelUri(peerUri)) {
if (account.telProvider == "") {
alertTitle.value = getString(R.string.notice)
alertMessage.value =
String.format(getString(R.string.no_telephony_provider), account.aor)
showAlert.value = true
""
} else
Utils.telToSip(peerUri, account)
} else
Utils.uriComplete(peerUri, aor)
if (alertMessage.value.isEmpty()) {
if (!Utils.checkUri(uri)) {
alertTitle.value = getString(R.string.notice)
alertMessage.value = String.format(getString(R.string.invalid_sip_or_tel_uri), uri)
showAlert.value = true
}
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)
}
}
}
override fun onPause() {
MainActivity.activityAor = aor
super.onPause()
}
override fun onResume() {
super.onResume()
_uaMessages.value = uaMessages(aor)
}
override fun onDestroy() {
if (Build.VERSION.SDK_INT >= 33) {
if (backInvokedCallback != null)
onBackInvokedDispatcher.unregisterOnBackInvokedCallback(backInvokedCallback!!)
}
else
onBackPressedCallback.remove()
super.onDestroy()
}
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.reversed()) {
if (m.aor != aor) continue
var found = false
for (r in res)
if (r.peerUri == m.peerUri) {
found = true
break
}
if (!found) {
res.add(0, m)
if (m.new)
account.unreadMessages = true
}
}
return res.toList()
}
}

View File

@ -0,0 +1,684 @@
package com.tutpro.baresip
import android.content.Context
import android.graphics.BitmapFactory
import android.text.format.DateUtils.isToday
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.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.size
import androidx.compose.foundation.layout.statusBars
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.BasicText
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.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.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.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.LocalContext
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.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
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import coil.compose.AsyncImage
import com.tutpro.baresip.BaresipService.Companion.contactNames
import com.tutpro.baresip.CustomElements.AlertDialog
import com.tutpro.baresip.CustomElements.DropdownMenu
import com.tutpro.baresip.CustomElements.LabelText
import com.tutpro.baresip.CustomElements.SelectableAlertDialog
import com.tutpro.baresip.CustomElements.verticalScrollbar
import java.text.DateFormat
import java.util.GregorianCalendar
fun NavGraphBuilder.chatsScreenRoute(navController: NavController) {
composable(
route = "chats/{aor}",
arguments = listOf(navArgument("aor") { type = NavType.StringType })
) { backStackEntry ->
val aor = backStackEntry.arguments?.getString("aor")!!
ChatsScreen(navController, aor)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ChatsScreen(navController: NavController, aor: String) {
val account = Account.ofAor(aor)!!
val ctx = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val uaMessages: MutableState<List<Message>> = remember { mutableStateOf(emptyList<Message>()) }
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
Log.d(TAG, "Resumed to ChatsScreen for AOR: $aor")
uaMessages.value = loadMessages(account)
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
var areMessagesLoaded by remember { mutableStateOf(false) }
LaunchedEffect(aor) {
uaMessages.value = loadMessages(account)
areMessagesLoaded = true
}
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, uaMessages)
}
},
bottomBar = { NewChatPeer(ctx, navController, account) },
content = { contentPadding ->
if (areMessagesLoaded)
ChatsContent(LocalContext.current, navController, contentPadding, account, uaMessages)
},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TopAppBar(
navController: NavController,
account: Account,
uaMessages: MutableState<List<Message>>
) {
var menuExpanded by remember { mutableStateOf(false) }
val delete = stringResource(R.string.delete)
val showDialog = remember { mutableStateOf(false) }
val positiveAction = remember { mutableStateOf({}) }
AlertDialog(
showDialog = showDialog,
title = stringResource(R.string.confirmation),
message = String.format(
stringResource(R.string.delete_chats_alert),
if (account.nickName.value != "")
account.nickName.value
else
account.aor.substringAfter(":")
),
positiveButtonText = stringResource(R.string.delete),
onPositiveClicked = positiveAction.value,
negativeButtonText = stringResource(R.string.cancel),
)
TopAppBar(
title = {
Text(
text = stringResource(R.string.chats),
color = LocalCustomColors.current.light,
fontWeight = FontWeight.Bold
)
},
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = LocalCustomColors.current.light
)
}
},
windowInsets = WindowInsets(0, 0, 0, 0),
actions = {
IconButton(
onClick = { menuExpanded = !menuExpanded }
) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "Menu",
tint = LocalCustomColors.current.light
)
}
DropdownMenu (
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false },
items = listOf(delete),
onItemClick = { selectedItem ->
menuExpanded = false
when (selectedItem) {
delete -> {
positiveAction.value = {
Message.clearMessagesOfAor(account.aor)
Message.save()
uaMessages.value = listOf()
account.unreadMessages = false
}
showDialog.value = true
}
}
}
)
},
colors = TopAppBarDefaults.mediumTopAppBarColors(
containerColor = LocalCustomColors.current.primary
)
)
}
@Composable
private fun ChatsContent(
ctx: Context,
navController: NavController,
contentPadding: PaddingValues,
account: Account,
uaMessages: MutableState<List<Message>>
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(contentPadding),
verticalArrangement = Arrangement.Top
) {
Account(account)
Chats(ctx, navController, account, uaMessages)
}
}
@Composable
private fun Account(account: Account) {
val headerText = stringResource(R.string.account) + " " +
if (account.nickName.value != "")
account.nickName.value
else
account.aor.substringAfter(":")
Text(
text = headerText,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 8.dp),
color = LocalCustomColors.current.itemText,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center
)
}
@Composable
private fun Chats(
ctx: Context,
navController: NavController,
account: Account,
uaMessages: MutableState<List<Message>>
) {
val aor = account.aor
val showDialog = remember { mutableStateOf(false) }
val dialogMessage = remember { mutableStateOf("") }
val positiveButtonText = remember { mutableStateOf("") }
val positiveAction = remember { mutableStateOf({}) }
val neutralButtonText = remember { mutableStateOf("") }
val neutralAction = remember { mutableStateOf({}) }
if (showDialog.value)
AlertDialog(
showDialog = showDialog,
title = stringResource(R.string.confirmation),
message = dialogMessage.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 = 8.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.value, key = { message -> message.timeStamp }) { message ->
Row(verticalAlignment = Alignment.CenterVertically) {
when (val contact = Contact.findContact(message.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(6.dp))
val buttonShape = if (message.direction == MESSAGE_DOWN) {
RoundedCornerShape(50.dp, 20.dp, 20.dp, 10.dp)
} else {
RoundedCornerShape(20.dp, 10.dp, 50.dp, 20.dp)
}
val borderStroke = if (account.unreadMessages && Message.unreadMessagesFromPeer(aor, message.peerUri)) {
BorderStroke(width = 2.dp, color = LocalCustomColors.current.alert)
} else {
null
}
CustomElements.Button(
onClick = {
navController.navigate("chat/${aor}/${message.peerUri}")
},
onLongClick = {
val peer = Utils.friendlyUri(ctx, message.peerUri, account)
val contactExists =
Contact.nameExists(peer, BaresipService.contacts, false)
if (contactExists) {
dialogMessage.value = String.format(
ctx.getString(R.string.short_chat_question),
peer
)
positiveButtonText.value = ctx.getString(R.string.delete)
positiveAction.value = {
Message.deleteAorPeerMessages(aor, message.peerUri)
uaMessages.value = loadMessages(account)
}
neutralButtonText.value = ""
} else {
dialogMessage.value =
String.format(ctx.getString(R.string.long_chat_question), peer)
positiveButtonText.value = ctx.getString(R.string.add_contact)
positiveAction.value = {
navController.navigate("baresip_contact/${message.peerUri}/new")
}
neutralButtonText.value = ctx.getString(R.string.delete)
neutralAction.value = {
Message.deleteAorPeerMessages(aor, message.peerUri)
}
}
showDialog.value = true
},
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(end = 6.dp),
shape = buttonShape,
border = borderStroke,
color =
if (message.direction == MESSAGE_DOWN) {
if (BaresipService.darkTheme.value)
LocalCustomColors.current.secondaryDark
else
LocalCustomColors.current.secondaryLight
} else {
if (BaresipService.darkTheme.value)
LocalCustomColors.current.primaryDark
else
LocalCustomColors.current.primaryLight
},
) {
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 {
val textColor =
if (message.direction == MESSAGE_DOWN) {
if (BaresipService.darkTheme.value)
LocalCustomColors.current.secondaryLight
else
LocalCustomColors.current.secondaryDark
} else {
if (BaresipService.darkTheme.value)
LocalCustomColors.current.primaryLight
else
LocalCustomColors.current.primaryDark
}
Text(text = peer, color = textColor, fontSize = 12.sp)
Spacer(modifier = Modifier.weight(1f))
Text(text = info, color = textColor, fontSize = 12.sp)
}
Row {
BasicText(
text = message.message,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = TextStyle(
color = LocalCustomColors.current.itemText,
fontWeight = if (message.direction == MESSAGE_DOWN && message.new)
FontWeight.Bold else FontWeight.Normal,
fontSize = 16.sp
)
)
}
}
}
}
}
}
}
@Composable
private fun NewChatPeer(ctx: Context, navController: NavController, account: Account) {
val alertTitle = remember { mutableStateOf("") }
val alertMessage = remember { mutableStateOf("") }
val showAlert = remember { mutableStateOf(false) }
fun makeChat(ctx: Context, navController: NavController, account: Account, chatPeer: String) {
val peerUri = if (Utils.isTelNumber(chatPeer))
"tel:$chatPeer"
else
chatPeer
val uri = if (Utils.isTelUri(peerUri)) {
if (account.telProvider == "") {
alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value =
String.format(ctx.getString(R.string.no_telephony_provider), account.aor)
showAlert.value = true
""
} else
Utils.telToSip(peerUri, account)
} else
Utils.uriComplete(peerUri, account.aor)
if (alertMessage.value.isEmpty()) {
if (!Utils.checkUri(uri)) {
alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value = String.format(ctx.getString(R.string.invalid_sip_or_tel_uri), uri)
showAlert.value = true
}
else
navController.navigate("chat/${account.aor}/${uri}")
}
}
if (showAlert.value) {
AlertDialog(
showDialog = showAlert,
title = alertTitle.value,
message = alertMessage.value,
positiveButtonText = stringResource(R.string.ok),
)
}
val showDialog = remember { mutableStateOf(false) }
val items = remember { mutableStateOf(listOf<String>()) }
val itemAction = remember { mutableStateOf<(Int) -> Unit>({ _ -> run {} }) }
SelectableAlertDialog(
openDialog = showDialog,
title = stringResource(R.string.choose_destination_uri),
items = items.value,
onItemClicked = itemAction.value,
neutralButtonText = stringResource(R.string.cancel),
onNeutralClicked = {}
)
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()
.heightIn(max = 150.dp)
) {
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(stringResource(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 {
if (showSuggestions)
showSuggestions = false
else
newPeer = ""
}
)
},
label = {
LabelText(stringResource(R.string.new_chat_peer))
},
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
val peerText = newPeer.trim()
if (peerText.isNotEmpty()) {
val uris = Contact.contactUris(peerText)
if (uris.isEmpty())
makeChat(ctx, navController, account, peerText)
else if (uris.size == 1)
makeChat(ctx, navController, account, uris[0])
else {
items.value = uris
itemAction.value = { index ->
makeChat(ctx, navController, account, uris[index])
}
showDialog.value = 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 loadMessages(account: Account) : List<Message> {
val res = mutableListOf<Message>()
account.unreadMessages = false
for (m in BaresipService.messages.reversed()) {
if (m.aor != account.aor) continue
var found = false
for (r in res)
if (r.peerUri == m.peerUri) {
found = true
break
}
if (!found) {
res.add(0, m)
if (m.new)
account.unreadMessages = true
}
}
return res.toList()
}

View File

@ -1,384 +0,0 @@
package com.tutpro.baresip
import android.os.Build
import android.os.Bundle
import android.window.OnBackInvokedCallback
import android.window.OnBackInvokedDispatcher
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.annotation.RequiresApi
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.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
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.safeDrawingPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.tutpro.baresip.CustomElements.AlertDialog
import com.tutpro.baresip.CustomElements.verticalScrollbar
class CodecsActivity : ComponentActivity() {
private lateinit var acc: Account
private lateinit var ua: UserAgent
private lateinit var allCodecs: List<String>
private lateinit var accCodecs: List<String>
private lateinit var codecs: SnapshotStateList<Codec>
private var aor = ""
private var media = ""
private var title = ""
private val alertTitle = mutableStateOf("")
private val alertMessage = mutableStateOf("")
private val showAlert = mutableStateOf(false)
private var backInvokedCallback: OnBackInvokedCallback? = null
private lateinit var onBackPressedCallback: OnBackPressedCallback
@RequiresApi(33)
private fun registerBackInvokedCallback() {
backInvokedCallback = OnBackInvokedCallback { goBack() }
onBackInvokedDispatcher.registerOnBackInvokedCallback(
OnBackInvokedDispatcher.PRIORITY_DEFAULT,
backInvokedCallback!!
)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
if (Build.VERSION.SDK_INT >= 33)
registerBackInvokedCallback()
else {
onBackPressedCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
goBack()
}
}
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
}
aor = intent.getStringExtra("aor")!!
media = intent.getStringExtra("media")!!
Utils.addActivity("codecs,$aor,$media")
ua = UserAgent.ofAor(aor)!!
acc = ua.account
if (media == "audio") {
title = getString(R.string.audio_codecs)
allCodecs = ArrayList(Api.audio_codecs().split(","))
accCodecs = acc.audioCodec
} else {
title = getString(R.string.video_codecs)
allCodecs = ArrayList(Api.video_codecs().split(",").distinct())
accCodecs = acc.videoCodec
}
val currentCodecs = mutableListOf<Codec>()
for (codec in accCodecs)
currentCodecs.add(Codec(codec, mutableStateOf(true)))
for (codec in allCodecs)
if (codec !in accCodecs)
currentCodecs.add(Codec(codec, mutableStateOf(false)))
codecs = mutableStateListOf<Codec>().apply { addAll(currentCodecs) }
setContent {
AppTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = LocalCustomColors.current.background
) {
CodecsScreen { goBack() }
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CodecsScreen(navigateBack: () -> Unit) {
Scaffold(
modifier = Modifier
.fillMaxHeight()
.imePadding()
.safeDrawingPadding(),
containerColor = LocalCustomColors.current.background,
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.codecs),
color = LocalCustomColors.current.light,
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 = {
updateCodecs()
}) {
Icon(
imageVector = Icons.Filled.Check,
tint = LocalCustomColors.current.light,
contentDescription = "Check"
)
}
}
)
},
content = { contentPadding ->
CodecsContent(contentPadding)
},
)
}
@Composable
fun CodecsContent(contentPadding: PaddingValues) {
if (showAlert.value) {
AlertDialog(
showDialog = showAlert,
title = alertTitle.value,
message = alertMessage.value,
positiveButtonText = stringResource(R.string.ok),
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(LocalCustomColors.current.background)
.padding(contentPadding)
.padding(bottom = 16.dp),
) {
Title()
Codecs(codecs = codecs, onCodecsChange = { updatedCodecs ->
onCodecsChange(updatedCodecs)
})
}
}
private fun onCodecsChange(updatedCodecs: List<Codec>) {
codecs.clear()
codecs.addAll(updatedCodecs)
}
@Composable
fun Title() {
Text(
text = title,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
.clickable {
if (media == "audio") {
alertTitle.value = getString(R.string.audio_codecs)
alertMessage.value = getString(R.string.audio_codecs_help)
}
else {
alertTitle.value = getString(R.string.video_codecs)
alertMessage.value = getString(R.string.video_codecs_help)
}
showAlert.value = true
},
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
color = LocalCustomColors.current.itemText,
textAlign = TextAlign.Center
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun Codecs(codecs: SnapshotStateList<Codec>, onCodecsChange: (List<Codec>) -> Unit) {
val draggableState = rememberDraggableListState(
onMove = { fromIndex, toIndex ->
codecs.add(toIndex, codecs.removeAt(fromIndex))
onCodecsChange(codecs.toList())
}
)
LazyColumn(
modifier = Modifier
.padding(end = 4.dp)
.verticalScrollbar(
state = draggableState.listState,
width = 4.dp,
color = LocalCustomColors.current.gray
),
state = draggableState.listState,
contentPadding = PaddingValues(start = 12.dp, end = 12.dp),
//verticalArrangement = Arrangement.spacedBy(8.dp)
) {
draggableItems(
state = draggableState,
items = codecs,
key = { item -> item.name }
) { item, isDragging ->
ListItem(
colors = ListItemDefaults.colors(
containerColor = if (isDragging)
LocalCustomColors.current.grayLight
else
LocalCustomColors.current.background
),
headlineContent = {
Text(text = item.name,
modifier = Modifier.fillMaxWidth()
.alpha(if (item.enabled.value) 1.0f else 0.5f)
.padding(start = 6.dp)
.combinedClickable(
onClick = {},
onLongClick = {
item.enabled.value = !item.enabled.value
if (item.enabled.value) {
val index = codecs.indexOf(item)
codecs.removeAt(index)
codecs.add(0, item)
}
else {
val index = codecs.indexOf(item)
codecs.removeAt(index)
codecs.add(item)
}
onCodecsChange(codecs.toList())
}
)
)
},
trailingContent = {
Icon(
modifier = Modifier.dragHandle(
state = draggableState,
key = item.name
),
imageVector =ImageVector.vectorResource(R.drawable.reorder),
contentDescription = null
)
},
)
if (codecs.indexOf(item) > 0)
HorizontalDivider(
color = LocalCustomColors.current.gray,
modifier = Modifier.padding(horizontal = 12.dp),
thickness = 1.dp
)
}
}
}
private fun updateCodecs() {
var save = false
val newCodecs = ArrayList<String>()
for (codec in codecs)
if (codec.enabled.value)
newCodecs.add(codec.name)
val codecList = Utils.implode(newCodecs, ",")
if (media == "audio")
if (newCodecs != acc.audioCodec) {
if (Api.account_set_audio_codecs(acc.accp, codecList) == 0) {
Log.d(TAG, "New audio codecs '$codecList'")
acc.audioCodec = newCodecs
save = true
} else {
Log.e(TAG, "Setting of audio codecs '$codecList' failed")
}
}
if (media == "video")
if (newCodecs != acc.videoCodec) {
if (Api.account_set_video_codecs(acc.accp, codecList) == 0) {
Log.d(TAG, "New video codecs '$codecs'")
acc.videoCodec = newCodecs
save = true
} else {
Log.e(TAG, "Setting of video codecs '$codecs' failed")
}
}
if (save)
AccountsActivity.saveAccounts()
BaresipService.activities.remove("codecs,$aor,$media")
finish()
}
private fun goBack() {
BaresipService.activities.remove("codecs,$aor,$media")
finish()
}
override fun onPause() {
MainActivity.activityAor = aor
super.onPause()
}
override fun onDestroy() {
if (Build.VERSION.SDK_INT >= 33) {
if (backInvokedCallback != null)
onBackInvokedDispatcher.unregisterOnBackInvokedCallback(backInvokedCallback!!)
}
else
onBackPressedCallback.remove()
super.onDestroy()
}
}

View File

@ -0,0 +1,301 @@
package com.tutpro.baresip
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.statusBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
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.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.tutpro.baresip.CustomElements.AlertDialog
import com.tutpro.baresip.CustomElements.verticalScrollbar
fun NavGraphBuilder.codecsScreenRoute(navController: NavController) {
composable(
route = "codecs/{aor}/{media}",
arguments = listOf(
navArgument("aor") { type = NavType.StringType },
navArgument("media") { type = NavType.StringType })
) { backStackEntry ->
val aor = backStackEntry.arguments?.getString("aor")!!
val media = backStackEntry.arguments?.getString("media")!!
val account = UserAgent.ofAor(aor)?.account!!
CodecsScreen(
onBack = { navController.popBackStack() },
checkOnClick = { updatedCodecs ->
val enabledCodecNames = updatedCodecs.filter { it.enabled.value }.map { it.name }
val codecList = Utils.implode(enabledCodecNames, ",")
Log.d(TAG, "Saving codecs for ${account.aor} (${media}): $codecList")
val success = if (media == "audio")
Api.account_set_audio_codecs(account.accp, codecList)
else
Api.account_set_video_codecs(account.accp, codecList)
if (success == 0) {
if (media == "audio")
account.audioCodec = enabledCodecNames as ArrayList<String>
else
account.videoCodec = enabledCodecNames as ArrayList<String>
Account.saveAccounts()
Log.d("CodecsSave", "Codecs saved successfully.")
}
else
Log.e(TAG, "Failed to set $aor codecs.")
navController.popBackStack()
},
aor = aor,
media = media
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CodecsScreen(
onBack: () -> Unit,
checkOnClick: (List<Codec>) -> Unit,
aor: String,
media: String
) {
val ua = UserAgent.ofAor(aor)!!
val acc = ua.account
var currentCodecsState by remember { mutableStateOf<List<Codec>>(emptyList()) }
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(
title = {
Text(
text = if (media == "audio")
stringResource(R.string.audio_codecs)
else
stringResource(R.string.video_codecs),
color = LocalCustomColors.current.light,
fontWeight = FontWeight.Bold
)
},
colors = TopAppBarDefaults.mediumTopAppBarColors(
containerColor = LocalCustomColors.current.primary
),
windowInsets = WindowInsets(0, 0, 0, 0),
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = LocalCustomColors.current.light
)
}
},
actions = {
IconButton(onClick = {
checkOnClick(currentCodecsState)
}) {
Icon(
imageVector = Icons.Filled.Check,
tint = LocalCustomColors.current.light,
contentDescription = "Check"
)
}
}
)
}
},
content = { contentPadding ->
CodecsContent(
contentPadding,
acc,
media,
onUpdateCodecs = { updatedCodecs -> currentCodecsState = updatedCodecs }
)
},
)
}
private val alertTitle = mutableStateOf("")
private val alertMessage = mutableStateOf("")
private val showAlert = mutableStateOf(false)
@Composable
private fun CodecsContent(
contentPadding: PaddingValues,
acc: Account,
media: String,
onUpdateCodecs: (List<Codec>) -> Unit
) {
val codecs = remember { mutableStateListOf<Codec>() }
LaunchedEffect(acc, media) {
val allCodecs: List<String> = if (media == "audio") {
Api.audio_codecs().split(",")
} else {
Api.video_codecs().split(",").distinct()
}
val accCodecs: List<String> = if (media == "audio") {
acc.audioCodec
} else {
acc.videoCodec
}
val currentCodecs = mutableListOf<Codec>()
for (codec in accCodecs)
currentCodecs.add(Codec(codec, mutableStateOf(true)))
for (codec in allCodecs)
if (codec !in accCodecs)
currentCodecs.add(Codec(codec, mutableStateOf(false)))
codecs.clear()
codecs.addAll(currentCodecs)
}
if (showAlert.value) {
AlertDialog(
showDialog = showAlert,
title = alertTitle.value,
message = alertMessage.value,
positiveButtonText = stringResource(R.string.ok),
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(LocalCustomColors.current.background)
.padding(contentPadding)
.padding(bottom = 16.dp),
) {
Codecs(
codecs = codecs,
onUpdateCodecs = onUpdateCodecs
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun Codecs(codecs: SnapshotStateList<Codec>, onUpdateCodecs: (List<Codec>) -> Unit) {
val draggableState = rememberDraggableListState(
onMove = { fromIndex, toIndex ->
codecs.add(toIndex, codecs.removeAt(fromIndex))
onUpdateCodecs(codecs.toList())
}
)
LazyColumn(
modifier = Modifier
.padding(end = 4.dp)
.verticalScrollbar(
state = draggableState.listState,
width = 4.dp,
color = LocalCustomColors.current.gray
),
state = draggableState.listState,
contentPadding = PaddingValues(start = 12.dp, end = 12.dp),
//verticalArrangement = Arrangement.spacedBy(8.dp)
) {
draggableItems(
state = draggableState,
items = codecs,
key = { item -> item.name }
) { item, isDragging ->
ListItem(
colors = ListItemDefaults.colors(
containerColor = if (isDragging)
LocalCustomColors.current.grayLight
else
LocalCustomColors.current.background
),
headlineContent = {
Text(text = item.name,
modifier = Modifier
.fillMaxWidth()
.alpha(if (item.enabled.value) 1.0f else 0.5f)
.padding(start = 6.dp)
.combinedClickable(
onClick = {},
onLongClick = {
item.enabled.value = !item.enabled.value
if (item.enabled.value) {
val index = codecs.indexOf(item)
codecs.removeAt(index)
codecs.add(0, item)
} else {
val index = codecs.indexOf(item)
codecs.removeAt(index)
codecs.add(item)
}
onUpdateCodecs(codecs.toList())
}
)
)
},
trailingContent = {
Icon(
modifier = Modifier.dragHandle(
state = draggableState,
key = item.name
),
imageVector =ImageVector.vectorResource(R.drawable.reorder),
contentDescription = null
)
},
)
if (codecs.indexOf(item) > 0)
HorizontalDivider(
color = LocalCustomColors.current.gray,
modifier = Modifier.padding(horizontal = 12.dp),
thickness = 1.dp
)
}
}
}

View File

@ -10,6 +10,7 @@ import java.nio.charset.StandardCharsets
object Config { object Config {
private val configPath = BaresipService.filesPath + "/config" private val configPath = BaresipService.filesPath + "/config"
val audioModules = listOf("opus", "amr", "g722", "g7221", "g726", "g729", "codec2", "g711")
private lateinit var config: String private lateinit var config: String
private lateinit var previousConfig: String private lateinit var previousConfig: String
private lateinit var previousLines: List<String> private lateinit var previousLines: List<String>
@ -18,7 +19,7 @@ object Config {
config = ctx.assets.open("config.static").bufferedReader().use { it.readText() } config = ctx.assets.open("config.static").bufferedReader().use { it.readText() }
if (!File(configPath).exists()) { if (!File(configPath).exists()) {
for (module in AudioActivity.audioModules) for (module in audioModules)
config = "${config}module ${module}.so\n" config = "${config}module ${module}.so\n"
previousConfig = config previousConfig = config
} else { } else {
@ -149,7 +150,7 @@ object Config {
} }
val previousModules = previousVariables("module") val previousModules = previousVariables("module")
for (module in AudioActivity.audioModules) for (module in audioModules)
if ("${module}.so" in previousModules) if ("${module}.so" in previousModules)
config = "${config}module ${module}.so\n" config = "${config}module ${module}.so\n"

File diff suppressed because it is too large Load Diff

View File

@ -23,6 +23,7 @@ const val MESSAGE_REQ_CODE = 8
const val REPLY_REQ_CODE = 9 const val REPLY_REQ_CODE = 9
const val SAVE_REQ_CODE = 10 const val SAVE_REQ_CODE = 10
const val DELETE_REQ_CODE = 11 const val DELETE_REQ_CODE = 11
const val DIRECT_REPLY_REQ_CODE = 12
const val REGISTRATION_INTERVAL = 900 const val REGISTRATION_INTERVAL = 900
const val NO_AUTH_PASS = "t%Qa?~?J8,~6" const val NO_AUTH_PASS = "t%Qa?~?J8,~6"
@ -32,6 +33,10 @@ const val MESSAGE_UP = 2131165306
const val MESSAGE_UP_FAIL = 2131165307 const val MESSAGE_UP_FAIL = 2131165307
const val MESSAGE_UP_WAIT = 2131165308 const val MESSAGE_UP_WAIT = 2131165308
val mediaEncMap = mapOf("zrtp" to "ZRTP", "dtls_srtp" to "DTLS-SRTPF", "srtp-mand" to "SRTP-MAND",
"srtp" to "SRTP", "" to "--")
val mediaNatMap = mapOf("stun" to "STUN", "turn" to "TURN", "ice" to "ICE", "" to "--")

View File

@ -1,458 +0,0 @@
package com.tutpro.baresip
import android.app.Activity
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.SystemClock
import android.provider.ContactsContract
import android.window.OnBackInvokedCallback
import android.window.OnBackInvokedDispatcher
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
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.safeDrawingPadding
import androidx.compose.foundation.layout.size
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.Add
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Observer
import coil.compose.AsyncImage
import com.tutpro.baresip.CustomElements.AlertDialog
import com.tutpro.baresip.CustomElements.TextAvatar
import com.tutpro.baresip.CustomElements.verticalScrollbar
import java.io.File
import java.io.IOException
class ContactsActivity : ComponentActivity() {
private lateinit var aor: String
private var newAndroidName: String? = null
private var lastClick: Long = 0
private var backInvokedCallback: OnBackInvokedCallback? = null
private lateinit var onBackPressedCallback: OnBackPressedCallback
@RequiresApi(33)
private fun registerBackInvokedCallback() {
backInvokedCallback = OnBackInvokedCallback { goBack() }
onBackInvokedDispatcher.registerOnBackInvokedCallback(
OnBackInvokedDispatcher.PRIORITY_DEFAULT,
backInvokedCallback!!
)
}
private val contactRequest =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
if (it.data != null && it.data!!.hasExtra("name"))
newAndroidName = it.data!!.getStringExtra("name")
}
}
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
if (Build.VERSION.SDK_INT >= 33)
registerBackInvokedCallback()
else {
onBackPressedCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
goBack()
}
}
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
}
val title = getString(R.string.contacts)
aor = intent.getStringExtra("aor")!!
Utils.addActivity("contacts,$aor")
val androidContactsObserver = Observer<Long> {
if (newAndroidName != null) {
val contentValues = ContentValues()
contentValues.put(ContactsContract.Contacts.STARRED, 1)
try {
this.contentResolver.update(
ContactsContract.RawContacts.CONTENT_URI, contentValues,
ContactsContract.Contacts.DISPLAY_NAME + "='" + newAndroidName + "'", null
)
} catch (e: Exception) {
Log.e(TAG, "Update of Android favorite failed: ${e.message}")
}
newAndroidName = null
}
//Contact.contactsUpdate()
}
BaresipService.contactUpdate.observe(this, androidContactsObserver)
setContent {
AppTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = LocalCustomColors.current.background
) {
ContactsScreen(LocalContext.current, title) {
goBack()
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContactsScreen(ctx: Context, title: String, navigateBack: () -> Unit) {
Scaffold(
modifier = Modifier
.fillMaxHeight()
.imePadding()
.safeDrawingPadding(),
containerColor = LocalCustomColors.current.background,
topBar = {
TopAppBar(
title = {
Text(
text = title,
color = LocalCustomColors.current.light,
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
)
}
},
)
},
floatingActionButton = {
SmallFloatingActionButton(onClick = {
if (SystemClock.elapsedRealtime() - lastClick > 1000) {
lastClick = SystemClock.elapsedRealtime()
val intent = Intent(ctx, BaresipContactActivity::class.java)
val b = Bundle()
b.putBoolean("new", true)
b.putString("uri", "")
intent.putExtras(b)
contactRequest.launch(intent)
}},
containerColor = LocalCustomColors.current.accent,
contentColor = LocalCustomColors.current.background
) {
Icon(imageVector = Icons.Filled.Add,
modifier = Modifier.size(36.dp),
contentDescription = stringResource(R.string.add)
)
}
},
content = { contentPadding ->
ContactsContent(ctx, contentPadding)
}
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ContactsContent(ctx: Context, contentPadding: PaddingValues) {
val showDialog = remember { mutableStateOf(false) }
val dialogMessage = remember { mutableStateOf("") }
val positiveText = remember { mutableStateOf("") }
val positiveAction = remember { mutableStateOf({}) }
val neutralText = remember { mutableStateOf("") }
val neutralAction = remember { mutableStateOf({}) }
if (showDialog.value)
AlertDialog(
showDialog = showDialog,
title = stringResource(R.string.confirmation),
message = dialogMessage.value,
positiveButtonText = positiveText.value,
onPositiveClicked = positiveAction.value,
neutralButtonText = neutralText.value,
onNeutralClicked = neutralAction.value,
negativeButtonText = stringResource(R.string.cancel)
)
val lazyListState = rememberLazyListState()
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.background(LocalCustomColors.current.background)
.padding(contentPadding)
.padding(start = 16.dp, end = 4.dp, top = 16.dp, bottom = 64.dp)
.verticalScrollbar(
state = lazyListState,
width = 4.dp,
color = LocalCustomColors.current.gray
),
state = lazyListState,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(BaresipService.contacts, key = { it.id() }) { contact ->
val name = contact.name()
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
when(contact) {
is Contact.BaresipContact -> {
val avatarImage = contact.avatarImage
if (avatarImage != null)
Image(
bitmap = avatarImage.asImageBitmap(),
contentDescription = "Avatar",
contentScale = ContentScale.Crop,
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
)
else
TextAvatar(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(name, contact.color)
}
}
when(contact) {
is Contact.BaresipContact -> {
Text(text = name,
fontSize = 20.sp,
fontStyle = if (contact.favorite()) FontStyle.Italic else FontStyle.Normal,
color = LocalCustomColors.current.itemText,
modifier = Modifier
.weight(1f)
.padding(start = 10.dp)
.combinedClickable(
onClick = {
dialogMessage.value = String.format(
getString(R.string.contact_action_question),
name
)
positiveText.value = getString(R.string.call)
positiveAction.value = {
val i = Intent(ctx, MainActivity::class.java)
i.flags =
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
i.putExtra("action", "call")
val ua = UserAgent.ofAor(aor)
if (ua == null)
Log.w(TAG, "onClickListener did not find AoR $aor")
else {
BaresipService.activities.clear()
i.putExtra("uap", ua.uap)
i.putExtra("peer", contact.uri)
(ctx as Activity).startActivity(i)
}
}
neutralText.value = getString(R.string.send_message)
neutralAction.value = {
val i = Intent(
ctx,
MainActivity::class.java
)
i.flags =
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
i.putExtra("action", "message")
val ua = UserAgent.ofAor(aor)
if (ua == null)
Log.w(TAG, "onClickListener did not find AoR $aor")
else {
BaresipService.activities.clear()
i.putExtra("uap", ua.uap)
i.putExtra("peer", contact.uri)
(ctx as Activity).startActivity(i)
}
}
showDialog.value = true
},
onLongClick = {
dialogMessage.value = String.format(
getString(R.string.contact_delete_question),
name
)
positiveText.value = getString(R.string.delete)
positiveAction.value = {
val id = contact.id
val avatarFile = File(
BaresipService.filesPath,
"$id.png"
)
if (avatarFile.exists()) {
try {
avatarFile.delete()
} catch (e: IOException) {
Log.e(
TAG,
"Could not delete file $id.png: ${e.message}"
)
}
}
Contact.removeBaresipContact(contact)
}
showDialog.value = true
}
)
)
SmallFloatingActionButton(
modifier = Modifier.padding(end = 10.dp),
onClick = {
if (SystemClock.elapsedRealtime() - lastClick > 1000) {
lastClick = SystemClock.elapsedRealtime()
val intent = Intent(ctx, BaresipContactActivity::class.java)
val b = Bundle()
b.putBoolean("new", false)
b.putString("name", name)
intent.putExtras(b)
contactRequest.launch(intent)
}
},
containerColor = LocalCustomColors.current.background,
contentColor = LocalCustomColors.current.secondary
) {
Icon(
imageVector = Icons.Filled.Edit,
modifier = Modifier.size(28.dp),
contentDescription = stringResource(R.string.edit)
)
}
}
is Contact.AndroidContact -> {
Text(text = name,
fontSize = 20.sp,
fontStyle = if (contact.favorite()) FontStyle.Italic else FontStyle.Normal,
color = LocalCustomColors.current.itemText,
modifier = Modifier
.weight(1f)
.padding(start = 10.dp, top = 4.dp, bottom = 4.dp)
.combinedClickable(
onClick = {
if (SystemClock.elapsedRealtime() - lastClick > 1000) {
lastClick = SystemClock.elapsedRealtime()
val i =
Intent(ctx, AndroidContactActivity::class.java)
val b = Bundle()
b.putString("aor", aor)
b.putString("name", name)
i.putExtras(b)
ctx.startActivity(i, null)
}
},
onLongClick = {
dialogMessage.value = String.format(
getString(R.string.contact_delete_question),
name
)
positiveText.value = getString(R.string.delete)
positiveAction.value = {
ctx.contentResolver.delete(
ContactsContract.RawContacts.CONTENT_URI,
ContactsContract.Contacts.DISPLAY_NAME + "='" + name + "'",
null
)
}
showDialog.value = true
}
)
)
}
}
}
}
}
}
override fun onDestroy() {
if (Build.VERSION.SDK_INT >= 33) {
if (backInvokedCallback != null)
onBackInvokedDispatcher.unregisterOnBackInvokedCallback(backInvokedCallback!!)
}
else
onBackPressedCallback.remove()
super.onDestroy()
}
private fun goBack() {
BaresipService.activities.remove("contacts,$aor")
setResult(RESULT_OK, Intent())
finish()
}
}

View File

@ -0,0 +1,332 @@
package com.tutpro.baresip
import android.content.Context
import android.content.Intent
import android.provider.ContactsContract
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
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.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.Add
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import coil.compose.AsyncImage
import com.tutpro.baresip.CustomElements.AlertDialog
import com.tutpro.baresip.CustomElements.TextAvatar
import com.tutpro.baresip.CustomElements.verticalScrollbar
import java.io.File
import java.io.IOException
const val avatarSize: Int = 96
fun NavGraphBuilder.contactsScreenRoute(
navController: NavController,
viewModel: ViewModel
) {
composable("contacts") { backStackEntry ->
ContactsScreen(navController = navController, viewModel = viewModel)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ContactsScreen(navController: NavController, viewModel: ViewModel) {
val ctx = LocalContext.current
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(
title = {
Text(
text = stringResource(R.string.contacts),
color = LocalCustomColors.current.light,
fontWeight = FontWeight.Bold
)
},
colors = TopAppBarDefaults.mediumTopAppBarColors(
containerColor = LocalCustomColors.current.primary
),
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = LocalCustomColors.current.light
)
}
},
windowInsets = WindowInsets(0, 0, 0, 0),
)
}
},
floatingActionButton = {
SmallFloatingActionButton(onClick = { navController.navigate("baresip_contact//new") },
containerColor = LocalCustomColors.current.accent,
contentColor = LocalCustomColors.current.background
) {
Icon(imageVector = Icons.Filled.Add,
modifier = Modifier.size(36.dp),
contentDescription = stringResource(R.string.add)
)
}
},
content = { contentPadding ->
ContactsContent(ctx, viewModel, navController, contentPadding)
}
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ContactsContent(
ctx: Context,
viewModel: ViewModel,
navController: NavController,
contentPadding: PaddingValues
) {
val showDialog = remember { mutableStateOf(false) }
val dialogMessage = remember { mutableStateOf("") }
val positiveText = remember { mutableStateOf("") }
val positiveAction = remember { mutableStateOf({}) }
val neutralText = remember { mutableStateOf("") }
val neutralAction = remember { mutableStateOf({}) }
if (showDialog.value)
AlertDialog(
showDialog = showDialog,
title = stringResource(R.string.confirmation),
message = dialogMessage.value,
positiveButtonText = positiveText.value,
onPositiveClicked = positiveAction.value,
neutralButtonText = neutralText.value,
onNeutralClicked = neutralAction.value,
negativeButtonText = stringResource(R.string.cancel)
)
val lazyListState = rememberLazyListState()
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.background(LocalCustomColors.current.background)
.padding(contentPadding)
.padding(start = 16.dp, end = 4.dp, top = 16.dp, bottom = 64.dp)
.verticalScrollbar(
state = lazyListState,
width = 4.dp,
color = LocalCustomColors.current.gray
),
state = lazyListState,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(BaresipService.contacts, key = { it.id() }) { contact ->
val name = contact.name()
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
when(contact) {
is Contact.BaresipContact -> {
val avatarImage = contact.avatarImage
if (avatarImage != null)
Image(
bitmap = avatarImage.asImageBitmap(),
contentDescription = "Avatar",
contentScale = ContentScale.Crop,
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
)
else
TextAvatar(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(name, contact.color)
}
}
when(contact) {
is Contact.BaresipContact -> {
Text(text = name,
fontSize = 20.sp,
fontStyle = if (contact.favorite()) FontStyle.Italic else FontStyle.Normal,
color = LocalCustomColors.current.itemText,
modifier = Modifier
.weight(1f)
.padding(start = 10.dp)
.combinedClickable(
onClick = {
val aor = viewModel.selectedAor.value
val ua = UserAgent.ofAor(aor)
val intent = Intent(ctx, MainActivity::class.java)
if (ua != null) {
intent.putExtra("uap", ua.uap)
intent.putExtra("peer", contact.uri)
}
else
Log.w(TAG, "onClickListener did not find UA for $aor")
dialogMessage.value = String.format(
ctx.getString(R.string.contact_action_question),
name
)
positiveText.value = ctx.getString(R.string.call)
positiveAction.value = {
if (ua != null) {
handleIntent(ctx, viewModel, intent, "call")
navController.popBackStack()
}
}
neutralText.value = ctx.getString(R.string.send_message)
neutralAction.value = {
if (ua != null) {
handleIntent(ctx, viewModel, intent, "message")
navController.popBackStack()
}
}
showDialog.value = true
},
onLongClick = {
dialogMessage.value = String.format(
ctx.getString(R.string.contact_delete_question),
name
)
positiveText.value = ctx.getString(R.string.delete)
positiveAction.value = {
val id = contact.id
val avatarFile = File(
BaresipService.filesPath,
"$id.png"
)
if (avatarFile.exists()) {
try {
avatarFile.delete()
} catch (e: IOException) {
Log.e(
TAG,
"Could not delete file $id.png: ${e.message}"
)
}
}
Contact.removeBaresipContact(contact)
}
showDialog.value = true
}
)
)
SmallFloatingActionButton(
modifier = Modifier.padding(end = 10.dp),
onClick = { navController.navigate("baresip_contact/${name}/old") },
containerColor = LocalCustomColors.current.background,
contentColor = LocalCustomColors.current.secondary
) {
Icon(
imageVector = Icons.Filled.Edit,
modifier = Modifier.size(28.dp),
contentDescription = stringResource(R.string.edit)
)
}
}
is Contact.AndroidContact -> {
Text(text = name,
fontSize = 20.sp,
fontStyle = if (contact.favorite()) FontStyle.Italic else FontStyle.Normal,
color = LocalCustomColors.current.itemText,
modifier = Modifier
.weight(1f)
.padding(start = 10.dp, top = 4.dp, bottom = 4.dp)
.combinedClickable(
onClick = { navController.navigate("android_contact/${name}") },
onLongClick = {
dialogMessage.value = String.format(
ctx.getString(R.string.contact_delete_question),
name
)
positiveText.value = ctx.getString(R.string.delete)
positiveAction.value = {
ctx.contentResolver.delete(
ContactsContract.RawContacts.CONTENT_URI,
ContactsContract.Contacts.DISPLAY_NAME + "='" + name + "'",
null
)
}
neutralText.value = ""
showDialog.value = true
}
)
)
}
}
}
}
}
}

View File

@ -5,6 +5,7 @@ import android.graphics.Bitmap
import android.widget.Toast import android.widget.Toast
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.ScrollState import androidx.compose.foundation.ScrollState
@ -117,12 +118,14 @@ object CustomElements {
onLongClick: () -> Unit, onLongClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
shape: Shape, shape: Shape,
border: BorderStroke? = null,
color: Color, color: Color,
content: @Composable RowScope.() -> Unit content: @Composable RowScope.() -> Unit
) { ) {
Surface( Surface(
shape = shape, shape = shape,
color = color, color = color,
border = border,
modifier = modifier modifier = modifier
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures( detectTapGestures(
@ -491,7 +494,7 @@ object CustomElements {
BasicAlertDialog( BasicAlertDialog(
properties = DialogProperties( properties = DialogProperties(
dismissOnBackPress = false, dismissOnBackPress = false,
dismissOnClickOutside = false dismissOnClickOutside = false,
), ),
onDismissRequest = { onDismissRequest = {
keyboardController?.hide() keyboardController?.hide()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -52,7 +52,7 @@ class Message(val aor: String, val peerUri: String, val message: String, val tim
fun deleteAorMessage(aor: String, time: Long) { fun deleteAorMessage(aor: String, time: Long) {
val updatedMessages = BaresipService.messages.toMutableList() val updatedMessages = BaresipService.messages.toMutableList()
for (message in updatedMessages) for (message in updatedMessages.reversed())
if (message.aor == aor && message.timeStamp == time) { if (message.aor == aor && message.timeStamp == time) {
updatedMessages.remove(message) updatedMessages.remove(message)
BaresipService.messages = updatedMessages.toList() BaresipService.messages = updatedMessages.toList()
@ -72,7 +72,7 @@ class Message(val aor: String, val peerUri: String, val message: String, val tim
fun updateAorMessage(aor: String, time: Long) { fun updateAorMessage(aor: String, time: Long) {
val updatedMessages = BaresipService.messages.toMutableList() val updatedMessages = BaresipService.messages.toMutableList()
for (message in updatedMessages) for (message in updatedMessages.reversed())
if (message.aor == aor && message.timeStamp == time) { if (message.aor == aor && message.timeStamp == time) {
message.new = false message.new = false
BaresipService.messages = updatedMessages.toList() BaresipService.messages = updatedMessages.toList()
@ -81,6 +81,35 @@ class Message(val aor: String, val peerUri: String, val message: String, val tim
} }
} }
fun unreadMessages(aor: String): Boolean {
for (message in BaresipService.messages.reversed())
if (message.aor == aor && message.new)
return true
return false
}
fun unreadMessagesFromPeer(aor: String, peerUri: String): Boolean {
for (message in BaresipService.messages.reversed())
if (message.aor == aor && message.peerUri == peerUri && message.new)
return true
return false
}
fun updateMessagesFromPearRead(aor: String, peerUri: String): Boolean {
val updatedMessages = BaresipService.messages.toMutableList()
var updated = false
for (message in updatedMessages)
if (message.aor == aor && message.peerUri == peerUri && message.new) {
message.new = false
updated = true
}
if (updated) {
BaresipService.messages = updatedMessages.toList()
save()
}
return updated
}
fun save() { fun save() {
val file = File(BaresipService.filesPath, "messages") val file = File(BaresipService.filesPath, "messages")
try { try {

File diff suppressed because it is too large Load Diff

View File

@ -31,13 +31,13 @@ class TaskReceiver : BroadcastReceiver() {
Api.account_set_regint(acc.accp, REGISTRATION_INTERVAL) Api.account_set_regint(acc.accp, REGISTRATION_INTERVAL)
Api.ua_register(ua.uap) Api.ua_register(ua.uap)
acc.regint = Api.account_regint(acc.accp) acc.regint = Api.account_regint(acc.accp)
AccountsActivity.saveAccounts() Account.saveAccounts()
} else { } else {
Log.d(TAG, "TaskReceiver: un-registering $aor") Log.d(TAG, "TaskReceiver: un-registering $aor")
Api.account_set_regint(acc.accp, 0) Api.account_set_regint(acc.accp, 0)
Api.ua_unregister(ua.uap) Api.ua_unregister(ua.uap)
acc.regint = Api.account_regint(acc.accp) acc.regint = Api.account_regint(acc.accp)
AccountsActivity.saveAccounts() Account.saveAccounts()
} }
} }

View File

@ -45,6 +45,23 @@ class UserAgent(val uap: Long) {
return null return null
} }
fun reRegister() {
this.status = R.drawable.circle_yellow
if (this.account.regint == 0)
Api.ua_unregister(this.uap)
else
Api.ua_register(this.uap)
}
fun makeDefault() {
val index = uas.value.indexOf(this)
val updatedUas = uas.value.toMutableList()
updatedUas.removeAt(index)
updatedUas.add(0, this)
uas.value = updatedUas.toList()
uasStatus.value = statusMap()
}
companion object { companion object {
fun ofAor(aor: String): UserAgent? { fun ofAor(aor: String): UserAgent? {

View File

@ -7,7 +7,6 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.res.Configuration import android.content.res.Configuration
import android.graphics.Bitmap
import android.media.AudioAttributes import android.media.AudioAttributes
import android.media.AudioDeviceInfo import android.media.AudioDeviceInfo
import android.media.AudioManager import android.media.AudioManager
@ -62,7 +61,6 @@ import javax.crypto.SecretKeyFactory
import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.PBEKeySpec import javax.crypto.spec.PBEKeySpec
import javax.crypto.spec.SecretKeySpec import javax.crypto.spec.SecretKeySpec
import androidx.core.graphics.scale
object Utils { object Utils {
@ -231,7 +229,7 @@ object Utils {
fun checkUriUser(user: String): Boolean { fun checkUriUser(user: String): Boolean {
val escaped = """%(\d|A|B|C|D|E|F|a|b|c|d|e|f){2}""".toRegex() val escaped = """%(\d|A|B|C|D|E|F|a|b|c|d|e|f){2}""".toRegex()
escaped.replace(user, "").forEach { escaped.replace(user, "").forEach {
if (!(it.isLetterOrDigit() || "-_.!~*\'()&=+\$,;?/".contains(it))) return false } if (!(it.isLetterOrDigit() || "-_.!~*\'()&=+$,;?/".contains(it))) return false }
return user.isNotEmpty() && !checkIpV4(user) && !checkIpV6(user) return user.isNotEmpty() && !checkIpV4(user) && !checkIpV6(user)
} }
@ -239,7 +237,7 @@ object Utils {
val parts = domain.split(".") val parts = domain.split(".")
for (p in parts) { for (p in parts) {
if (p.endsWith("-") || p.startsWith("-") || if (p.endsWith("-") || p.startsWith("-") ||
!Regex("^[-a-zA-Z0-9]+\$").matches(p)) !Regex("^[-a-zA-Z0-9]+$").matches(p))
return false return false
} }
return true return true
@ -333,7 +331,7 @@ object Utils {
} }
fun isTelNumber(no: String): Boolean { fun isTelNumber(no: String): Boolean {
return no.isNotEmpty() && Regex("^([+][1-9])?[0-9- (),*#]{0,24}\$").matches(no) return no.isNotEmpty() && Regex("^([+][1-9])?[0-9- (),*#]{0,24}$").matches(no)
} }
fun isTelUri(uri: String): Boolean { fun isTelUri(uri: String): Boolean {
@ -383,7 +381,7 @@ object Utils {
} }
private fun checkToken(token: String): Boolean { private fun checkToken(token: String): Boolean {
return Regex("^[-a-zA-Z0-9.!%*_+`'~]+\$").matches(token) return Regex("^[-a-zA-Z0-9.!%*_+`'~]+$").matches(token)
} }
@Suppress("unused") @Suppress("unused")
@ -585,22 +583,6 @@ object Utils {
name name
} }
fun saveBitmap(bitmap: Bitmap, file: File): Boolean {
if (file.exists()) file.delete()
try {
val out = FileOutputStream(file)
val scaledBitmap = bitmap.scale(96, 96)
scaledBitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
out.flush()
out.close()
Log.d(TAG, "Saved bitmap to ${file.absolutePath} of length ${file.length()}")
} catch (e: Exception) {
Log.e(TAG, "Failed to save bitmap to ${file.absolutePath}: $e")
return false
}
return true
}
class Crypto(val salt: ByteArray, val iter: Int, val iv: ByteArray, val data: ByteArray): Serializable { class Crypto(val salt: ByteArray, val iter: Int, val iv: ByteArray, val data: ByteArray): Serializable {
companion object { companion object {
private const val serialVersionUID: Long = -29238082928391L private const val serialVersionUID: Long = -29238082928391L
@ -805,11 +787,6 @@ object Utils {
rnd.nextInt(256)) rnd.nextInt(256))
} }
fun addActivity(activity: String) {
if ((BaresipService.activities.isEmpty()) || (BaresipService.activities[0] != activity))
BaresipService.activities.add(0, activity)
}
fun requestDismissKeyguard(activity: Activity) { fun requestDismissKeyguard(activity: Activity) {
val kgm = activity.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager val kgm = activity.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
kgm.requestDismissKeyguard(activity, null) kgm.requestDismissKeyguard(activity, null)
@ -1018,4 +995,39 @@ object Utils {
Api.AAudio_close_stream() Api.AAudio_close_stream()
} }
/*fun listFilesInDirectory(directoryPath: String): List<File> {
val directory = File(directoryPath)
if (!directory.exists()) {
Log.w(TAG, "Directory does not exist: $directoryPath")
return emptyList()
}
if (!directory.isDirectory) {
Log.w(TAG, "Path is not a directory: $directoryPath")
return emptyList()
}
val files = directory.listFiles()
if (files == null) {
Log.e(
TAG,
"Failed to list files in directory (listFiles returned null): $directoryPath"
)
return emptyList()
}
return files.filter { it.isFile }
}*/
/*@SuppressLint("RestrictedApi")
fun printBackStack(navController: NavController) {
Log.e(TAG, "---- Current Navigation Back Stack ----")
navController.currentBackStack.value.forEachIndexed { index, navBackStackEntry ->
val route = navBackStackEntry.destination.route
val arguments = navBackStackEntry.arguments?.let { bundle ->
bundle.keySet().joinToString(", ") { key -> "$key=${bundle.get(key)}" }
} ?: "null"
Log.e(TAG, "$index: Route='${route}', Args=[$arguments], ID=${navBackStackEntry.id}")
}
Log.e(TAG, "--------------------------------------")
}*/
} }

View File

@ -1,10 +1,23 @@
package com.tutpro.baresip package com.tutpro.baresip
import android.app.Application import android.app.Application
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import androidx.compose.runtime.State
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
sealed class NavigationCommand {
data class NavigateToChat(val aor: String, val peer: String?) : NavigationCommand()
data class NavigateToCalls(val aor: String) : NavigationCommand()
object NavigateToHome: NavigationCommand()
// Add other navigation commands as needed
}
class ViewModel(application: Application) : AndroidViewModel(application) { class ViewModel(application: Application) : AndroidViewModel(application) {
@ -15,4 +28,96 @@ class ViewModel(application: Application) : AndroidViewModel(application) {
_selectedAor.value = newValue _selectedAor.value = newValue
} }
private val _speakerIcon = MutableStateFlow(R.drawable.speaker_off)
val speakerIcon: StateFlow<Int> = _speakerIcon.asStateFlow()
fun updateSpeakerIcon(iconResId: Int) {
_speakerIcon.value = iconResId
}
private val _micIcon = MutableStateFlow(R.drawable.mic_on)
val micIcon: StateFlow<Int> = _micIcon.asStateFlow()
fun updateMicIcon(iconResId: Int) {
_micIcon.value = iconResId
}
private val _vmIcon = MutableStateFlow(R.drawable.voicemail)
val vmIcon: StateFlow<Int> = _vmIcon.asStateFlow()
fun updateVmIcon(newIcon: Int) {
_vmIcon.value = newIcon
}
private val _showVmIcon = MutableStateFlow(false)
val showVmIcon: StateFlow<Boolean> = _showVmIcon.asStateFlow()
fun updateShowVmIcon(show: Boolean) {
_showVmIcon.value = show
}
private val _messagesIcon = MutableStateFlow(R.drawable.messages)
val messagesIcon: StateFlow<Int> = _messagesIcon.asStateFlow()
fun updateMessagesIcon(newIcon: Int) {
_messagesIcon.value = newIcon
}
private val _callsIcon = MutableStateFlow(R.drawable.calls)
val callsIcon: StateFlow<Int> = _callsIcon.asStateFlow()
fun updateCallsIcon(newIcon: Int) {
_callsIcon.value = newIcon
}
private val _dialpadIcon = MutableStateFlow(R.drawable.dialpad_off)
val dialpadIcon: StateFlow<Int> = _dialpadIcon.asStateFlow()
fun updateDialpadIcon(newIcon: Int) {
_dialpadIcon.value = newIcon
}
private val _audioSettingsResult = mutableStateOf<Boolean?>(null)
val audioSettingsResult: State<Boolean?> get() = _audioSettingsResult
fun setAudioSettingsResult(result: Boolean) {
_audioSettingsResult.value = result
}
fun clearAudioSettingsResult() {
_audioSettingsResult.value = null
}
private val _selectedCallRow = MutableStateFlow<CallRow?>(null)
fun selectCallRow(callRow: CallRow) {
_selectedCallRow.value = callRow
}
fun consumeSelectedCallRow(): CallRow? {
val callRow = _selectedCallRow.value
_selectedCallRow.value = null
return callRow
}
private val _navigationCommand = MutableSharedFlow<NavigationCommand>()
val navigationCommand = _navigationCommand.asSharedFlow()
fun onNewMessageReceived(aor: String, peer: String) {
viewModelScope.launch {
_navigationCommand.emit(NavigationCommand.NavigateToChat(aor, peer))
}
}
fun navigateToCalls(aor: String) {
viewModelScope.launch {
_navigationCommand.emit(NavigationCommand.NavigateToCalls(aor))
}
}
fun navigateToHome() {
viewModelScope.launch {
_navigationCommand.emit(NavigationCommand.NavigateToHome)
}
}
} }

View File

@ -26,6 +26,7 @@
<li>Voit lisätä puheluiden ja viestien kohteet yhteystietoihin pitkällä kosketuksella.</li> <li>Voit lisätä puheluiden ja viestien kohteet yhteystietoihin pitkällä kosketuksella.</li>
<li>Pitkillä kosketuksilla voit myös poistaa puheluita, viestiketjuja, viestejä ja yhteystietoja.</li> <li>Pitkillä kosketuksilla voit myös poistaa puheluita, viestiketjuja, viestejä ja yhteystietoja.</li>
<li>Voit lisätä/poistaa yhteystiedon avatar-kuvan koskettamalla yhteystiedon ikonia lyhyesti/pitkästi.</li> <li>Voit lisätä/poistaa yhteystiedon avatar-kuvan koskettamalla yhteystiedon ikonia lyhyesti/pitkästi.</li>
<li>Audio-koodekin saa pitkällä kosketuksella käytöön/pois käytöstä.</li>
<li>Katso lisätietoja <a href=https://github.com/juha-h/baresip-studio/wiki>Wiki</a>-sivulta.</li> <li>Katso lisätietoja <a href=https://github.com/juha-h/baresip-studio/wiki>Wiki</a>-sivulta.</li>
</ul> </ul>
<h2>Tietosuoja</h2> <h2>Tietosuoja</h2>
@ -69,6 +70,7 @@
<li>Voit lisätä puheluiden ja viestien kohteet yhteystietoihin pitkällä kosketuksella.</li> <li>Voit lisätä puheluiden ja viestien kohteet yhteystietoihin pitkällä kosketuksella.</li>
<li>Pitkillä kosketuksilla voit myös poistaa puheluita, viestiketjuja, viestejä ja yhteystietoja.</li> <li>Pitkillä kosketuksilla voit myös poistaa puheluita, viestiketjuja, viestejä ja yhteystietoja.</li>
<li>Voit lisätä/poistaa yhteystiedon avatar-kuvan koskettamalla yhteystiedon ikonia lyhyesti/pitkästi.</li> <li>Voit lisätä/poistaa yhteystiedon avatar-kuvan koskettamalla yhteystiedon ikonia lyhyesti/pitkästi.</li>
<li>Audio- ja video-koodekin saa pitkällä kosketuksella käytöön/pois käytöstä.</li>
<li>Katso lisätietoja <a href=https://github.com/juha-h/baresip-studio/wiki>Wiki</a>-sivulta.</li> <li>Katso lisätietoja <a href=https://github.com/juha-h/baresip-studio/wiki>Wiki</a>-sivulta.</li>
</ul> </ul>
<h2>Tunnetut ongelmat</h2> <h2>Tunnetut ongelmat</h2>
@ -255,6 +257,8 @@
<!-- Baresip Service --> <!-- Baresip Service -->
<string name="answer">Vastaa</string> <string name="answer">Vastaa</string>
<string name="reject">Hylkää</string> <string name="reject">Hylkää</string>
<string name="reply">Vastaa</string>
<string name="save">Talleta</string>
<string name="incoming_call_from">Puhelu soittajalta</string> <string name="incoming_call_from">Puhelu soittajalta</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>
@ -593,8 +597,13 @@
<string name="no_android_contacts">Et voi käyttää Androidin yhteystietoja ilman Yhteystiedot-lupaa.</string> <string name="no_android_contacts">Et voi käyttää Androidin yhteystietoja ilman Yhteystiedot-lupaa.</string>
<string name="audio_focus_denied">Audiofokus on evätty!</string> <string name="audio_focus_denied">Audiofokus on evätty!</string>
<string name="permissions_rationale">Tarvittavat luvat</string> <string name="permissions_rationale">Tarvittavat luvat</string>
<string name="audio_permissions">baresip tarvitsee Mikrofoni-luvan puheluita varten ja Lähellä olevat laitteet -luvan Bluetooth-mikrofonin/kaiuttimen havaitsemista varten ja Ilmoitukset-luvan ilmoitusten lähettämistä varten.</string> <string name="audio_permissions">baresip tarvitsee Mikrofoni-luvan puheluita varten, Lähellä olevat laitteet -luvan
<string name="audio_and_video_permissions">baresip+ tarvitsee Mikrofoni-luvan puheluita varten, Kamera-luvan videopuheluita varten ja Lähellä olevat laitteet -luvan Bluetooth-mikrofonin/kaiuttimen havaitsemista varten ja Ilmoitukset-luvan ilmoitusten näyttämistä varten.</string> Bluetooth-mikrofonin/kaiuttimen havaitsemista varten, Ilmoitukset-luvan ilmoitusten lähettämistä varten
ja Android versiossa 9 Tallennustila-luvan Talleta/Palauta-toimintoja varten.</string>
<string name="audio_and_video_permissions">baresip+ tarvitsee Mikrofoni-luvan puheluita varten,
Kamera-luvan videopuheluita varten, Lähellä olevat laitteet -luvan Bluetooth-mikrofonin/kaiuttimen
havaitsemista varten, Ilmoitukset-luvan ilmoitusten näyttämistä varten ja Android versiossa 9
Tallennustila-luvan Talleta/Palauta-toimintoja varten.</string>
<string name="call_recording_title">Puheluiden talletus</string> <string name="call_recording_title">Puheluiden talletus</string>
<string name="call_recording_tip">Jos aktivoitu, uudet soitetut ja vastatut puhelut talletetaan. <string name="call_recording_tip">Jos aktivoitu, uudet soitetut ja vastatut puhelut talletetaan.
Tallennukset voi kuunnella Puhelutiedot-sivulla.</string> Tallennukset voi kuunnella Puhelutiedot-sivulla.</string>

View File

@ -29,6 +29,7 @@
<li>Peers of calls and messages can be added to contacts by long touches.</li> <li>Peers of calls and messages can be added to contacts by long touches.</li>
<li>Long touches can also be used to remove calls, chats, messages, and contacts.</li> <li>Long touches can also be used to remove calls, chats, messages, and contacts.</li>
<li>Touch/long touch on contact icon can be used to install/remove image avatar.</li> <li>Touch/long touch on contact icon can be used to install/remove image avatar.</li>
<li>Long touch on an audio codec can be used to enable/disable the codec.</li>
<li>See <a href="https://github.com/juha-h/baresip-studio/wiki">Wiki</a> for more information.</li> <li>See <a href="https://github.com/juha-h/baresip-studio/wiki">Wiki</a> for more information.</li>
</ul> </ul>
<h2>Privacy Policy</h2> <h2>Privacy Policy</h2>
@ -72,6 +73,7 @@
<li>Peers of calls and messages can be added to contacts by long touches.</li> <li>Peers of calls and messages can be added to contacts by long touches.</li>
<li>Long touches can also be used to remove calls, chats, messages, and contacts.</li> <li>Long touches can also be used to remove calls, chats, messages, and contacts.</li>
<li>Touch/long touch of contact icon can be used to install/remove image avatar.</li> <li>Touch/long touch of contact icon can be used to install/remove image avatar.</li>
<li>Long touch on an audio or video codec can be used to enable/disable the codec.</li>
<li>See <a href="https://github.com/juha-h/baresip-studio/wiki">Wiki</a> for more <li>See <a href="https://github.com/juha-h/baresip-studio/wiki">Wiki</a> for more
information.</li> information.</li>
</ul> </ul>
@ -240,6 +242,8 @@
<!-- Baresip Service --> <!-- Baresip Service -->
<string name="answer">Answer</string> <string name="answer">Answer</string>
<string name="reject">Reject</string> <string name="reject">Reject</string>
<string name="reply">Reply</string>
<string name="save">Save</string>
<string name="incoming_call_from">Incoming call from</string> <string name="incoming_call_from">Incoming call from</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>
@ -575,11 +579,13 @@
<string name="audio_focus_denied">Audio focus denied!</string> <string name="audio_focus_denied">Audio focus denied!</string>
<string name="permissions_rationale">Permissions rationale</string> <string name="permissions_rationale">Permissions rationale</string>
<string name="audio_permissions">baresip needs \"Microphone\" permission for voice calls, <string name="audio_permissions">baresip needs \"Microphone\" permission for voice calls,
\"Nearby devices\" permission for Bluetooth microphone/speaker detection, and \"Nearby devices\" permission for Bluetooth microphone/speaker detection,
\"Notifications\" permission for posting notifications.</string> \"Notifications\" permission for posting notifications, and in Android 9
<string name="audio_and_video_permissions">baresip+ needs \"Microphone\" permission for voice calls, \"Storage\" permission for Backup/Restore operations.</string>
\"Camera\" permission for video calls, \"Nearby devices\" permission for Bluetooth <string name="audio_and_video_permissions">baresip+ needs \"Microphone\" permission
microphone/speaker detection, and \"Notifications\" permission for posting notifications.</string> for voice calls, \"Camera\" permission for video calls, \"Nearby devices\" permission
for Bluetooth microphone/speaker detection, \"Notifications\" permission for posting
notifications, and in Android 9 \"Storage\" permissions for Backup/Restore operations.</string>
<string name="call_recording_title">Call Recording</string> <string name="call_recording_title">Call Recording</string>
<string name="call_recording_tip">If activated, new incoming and outgoing calls will be recorded. <string name="call_recording_tip">If activated, new incoming and outgoing calls will be recorded.
Recordings can be played on Call Details page</string> Recordings can be played on Call Details page</string>
@ -587,4 +593,4 @@
<string name="microphone_tip">If activated during call, microphone is muted.</string> <string name="microphone_tip">If activated during call, microphone is muted.</string>
<string name="speakerphone_title">Speakerphone</string> <string name="speakerphone_title">Speakerphone</string>
<string name="speakerphone_tip">If activated, audio is played via device speakerphone.</string> <string name="speakerphone_tip">If activated, audio is played via device speakerphone.</string>
</resources> </resources>