diff --git a/app/src/main/kotlin/com/tutpro/baresip/Blocked.kt b/app/src/main/kotlin/com/tutpro/baresip/Blocked.kt new file mode 100644 index 00000000..1d3ddc80 --- /dev/null +++ b/app/src/main/kotlin/com/tutpro/baresip/Blocked.kt @@ -0,0 +1,61 @@ +package com.tutpro.baresip + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +import java.io.File + +@Serializable +class Blocked ( + val aor: String, + val peerUri: String, + val request: String, + val timeStamp: Long +) { + private val blockedSize = 3 + + fun add() { + BaresipService.blocked.add(this) + val aorBlocked = BaresipService.blocked.filter { it.aor == this.aor } + if (aorBlocked.size > blockedSize) { + val oldestToRemove = aorBlocked.first() + BaresipService.blocked.remove(oldestToRemove) + } + save() + } + + companion object { + + fun clear(aor: String) { + val updatedBlockedList = BaresipService.blocked.filter { it.aor != aor } + BaresipService.blocked = ArrayList(updatedBlockedList) + save() + } + + fun save() { + Log.d(TAG, "Saving ${BaresipService.blocked.size} blocked calls and messages") + val file = File(BaresipService.filesPath + "/blocked.json") + try { + val jsonString = Json.encodeToString(BaresipService.blocked) + file.writeText(jsonString) + } catch (e: Exception) { + Log.e(TAG, "Serialization exception: $e") + e.printStackTrace() + } + } + + fun restore() { + val file = File(BaresipService.filesPath + "/blocked.json") + if (file.exists()) { + try { + val jsonString = file.readText() + val blockedList = Json.decodeFromString>(jsonString) + BaresipService.blocked = ArrayList(blockedList) + Log.d(TAG, "Restored ${BaresipService.blocked.size} blocked calls and messages") + } catch (e: Exception) { + Log.e(TAG, "Deserialization exception: - $e") + } + } + } + } +} diff --git a/app/src/main/kotlin/com/tutpro/baresip/BlockedScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/BlockedScreen.kt new file mode 100644 index 00000000..89c803bf --- /dev/null +++ b/app/src/main/kotlin/com/tutpro/baresip/BlockedScreen.kt @@ -0,0 +1,328 @@ +package com.tutpro.baresip + +import android.content.Context +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.ExperimentalFoundationApi +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.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.material.icons.filled.Menu +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +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.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 com.tutpro.baresip.CustomElements.AlertDialog +import com.tutpro.baresip.CustomElements.verticalScrollbar +import java.util.GregorianCalendar + +fun NavGraphBuilder.blockedScreenRoute(navController: NavController) { + composable( + route = "blocked/{aor}", + arguments = listOf(navArgument("aor") { type = NavType.StringType }) + ) { backStackEntry -> + val aor = backStackEntry.arguments?.getString("aor")!! + BlockedScreen(navController, aor) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun BlockedScreen(navController: NavController, aor: String) { + + val account = Account.ofAor(aor)!! + + val blocked: MutableState> = remember { mutableStateOf(emptyList()) } + var isBlockedLoaded by remember { mutableStateOf(false) } + + var refreshTrigger by remember { mutableIntStateOf(0) } + val lifecycleOwner = LocalLifecycleOwner.current + + LaunchedEffect(aor, refreshTrigger) { + blocked.value = loadBlocked(aor) + isBlockedLoaded = true + } + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) + refreshTrigger++ + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + BackHandler(enabled = true) { + navController.popBackStack() + } + + Scaffold( + modifier = Modifier + .fillMaxSize() + .imePadding(), + containerColor = MaterialTheme.colorScheme.background, + topBar = { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.background) + .padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()) + ) { + TopAppBar(navController, account) + } + }, + content = { contentPadding -> + if (isBlockedLoaded) + BlockedContent( + LocalContext.current, + navController, + contentPadding, + account, + blocked + ) + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopAppBar(navController: NavController, account: Account) { + + 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.blocked_delete_alert), account.text()), + positiveButtonText = stringResource(R.string.delete), + negativeButtonText = stringResource(R.string.cancel), + onPositiveClicked = positiveAction.value, + ) + + TopAppBar( + title = { + Text( + text = stringResource(R.string.blocked_calls), + fontWeight = FontWeight.Bold + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + navigationIconContentColor = MaterialTheme.colorScheme.onPrimary, + titleContentColor = MaterialTheme.colorScheme.onPrimary, + actionIconContentColor = MaterialTheme.colorScheme.onPrimary + ), + windowInsets = WindowInsets(0, 0, 0, 0), + navigationIcon = { + IconButton( + onClick = { + navController.popBackStack() + } + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + }, + actions = { + IconButton( + onClick = { expanded = !expanded } + ) { + Icon( + imageVector = Icons.Filled.Menu, + contentDescription = "Menu", + ) + } + CustomElements.DropdownMenu( + expanded, + { expanded = false }, + listOf(delete), + onItemClick = { selectedItem -> + expanded = false + when (selectedItem) { + delete -> { + positiveAction.value = { + Blocked.clear(account.aor) + } + showDialog.value = true + } + } + } + ) + } + ) +} + +@Composable +private fun BlockedContent( + ctx: Context, + navController: NavController, + contentPadding: PaddingValues, + account: Account, + blocked: MutableState> +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(contentPadding) + .padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Account(account) + Blocked(ctx, navController, blocked) + } +} + +@Composable +private fun Account(account: Account) { + Text( + text = stringResource(R.string.account) + " " + account.text(), + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun Blocked(ctx: Context, navController: NavController, blocked: MutableState>) +{ + 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 = MaterialTheme.colorScheme.outlineVariant + ) + .background(MaterialTheme.colorScheme.background), + state = lazyListState, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(items = blocked.value, key = { blocked -> blocked.timeStamp }) { blocked -> + val peerUri = blocked.peerUri + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + .clickable(onClick = { + message.value = String.format(ctx.getString(R.string.blocked_contact_question), + peerUri) + positiveButtonText.value = ctx.getString(R.string.add_contact) + positiveAction.value = { + navController.navigate("baresip_contact/$peerUri/new") + } + showDialog.value = true + }) + ) { + Text(text = peerUri, + modifier = Modifier.padding(start = 8.dp), + fontSize = 18.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.weight(1f)) + + val calendar = GregorianCalendar() + calendar.timeInMillis = blocked.timeStamp + Text( + text = Utils.relativeTime(ctx, calendar), + fontSize = 12.sp, + minLines = 2, maxLines = 2, + lineHeight = 16.sp, + textAlign = TextAlign.End, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(end = 16.dp) + ) + } + } + } +} + +private fun loadBlocked(aor: String): MutableList { + val res = mutableListOf() + for (i in BaresipService.blocked.indices.reversed()) { + val b = BaresipService.blocked[i] + if (b.aor == aor && b.request == "invite") { + res.add(Blocked("", b.peerUri, "", b.timeStamp)) + } + } + Log.d(TAG, "Loaded ${res.size} blocked calls") + return res +}