diff --git a/app/src/main/kotlin/com/tutpro/baresip/BaresipContactScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/BaresipContactScreen.kt deleted file mode 100644 index 3f7681a4..00000000 --- a/app/src/main/kotlin/com/tutpro/baresip/BaresipContactScreen.kt +++ /dev/null @@ -1,951 +0,0 @@ -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.BackHandler -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.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -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.Add -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Remove -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalMinimumInteractiveComponentSize -import androidx.compose.material3.MaterialTheme -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.CompositionLocalProvider -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 uris: List = emptyList(), - val email: 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 = "", - uris = if (uriOrNameArg == "") emptyList() else listOf(uriOrNameArg), - email = "", - 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, - uris = contact.uris, - email = contact.email, - 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.navigateUp() - } - - val onCheck: () -> Unit = { - val result = checkOnClick( - ctx = ctx, - currentState = screenState, - uriOrNameArg = uriOrNameArg, - ) - if (result) { - navController.previousBackStackEntry?.savedStateHandle?.set("scrollToContact", screenState.name) - navController.navigateUp() - } - } - - BackHandler(enabled = true) { - onBack() - } - - 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(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, 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 = onBack) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - ) - } - }, - actions = { - IconButton(onClick = onCheck) { - Icon( - imageVector = Icons.Filled.Check, - 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, - lastButtonText = stringResource(R.string.ok), - ) - } - - val scrollState = rememberScrollState() - Column( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.background) - .padding(contentPadding) - .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 52.dp) - .verticalScroll(scrollState), - 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)) } - ) - ContactUris( - uris = screenState.uris, - onUrisChange = { newUris -> onStateChange(screenState.copy(uris = newUris)) } - ) - ContactEmail( - email = screenState.email, - onEmailChange = { newEmail -> onStateChange(screenState.copy(email = newEmail)) } - ) - Favorite( - ctx = ctx, - favorite = screenState.favorite, - onFavoriteChange = { - newFavorite -> onStateChange(screenState.copy(favorite = newFavorite)) - } - ) - if (screenState.new && BaresipService.contactsMode == "both") - Android( - ctx = ctx, - 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.take(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 = remember { 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), - label = { Text(stringResource(R.string.contact_name)) }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - capitalization = KeyboardCapitalization.Words - ) - ) - LaunchedEffect(new) { - if (new) - focusRequester.requestFocus() - } -} - -@Composable -private fun ContactUris(uris: List, onUrisChange: (List) -> Unit) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - uris.forEachIndexed { index, uri -> - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedTextField( - value = uri, - placeholder = { Text(stringResource(R.string.user_domain_or_number)) }, - onValueChange = { newUri -> - val newList = uris.toMutableList() - newList[index] = newUri - onUrisChange(newList.toList()) - }, - modifier = Modifier.weight(1f), - textStyle = androidx.compose.ui.text.TextStyle(fontSize = 18.sp), - label = { Text("${stringResource(R.string.sip_or_tel_uri)} ${index + 1}") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) - ) - if (uris.size > 1) { - CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) { - IconButton( - onClick = { - val newList = uris.toMutableList() - newList.removeAt(index) - onUrisChange(newList.toList()) - }, - modifier = Modifier.padding(start = 2.dp) - ) { - Icon( - imageVector = Icons.Filled.Remove, - contentDescription = "Remove", - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(32.dp) - ) - } - } - } - } - } - IconButton( - onClick = { - val newList = uris.toMutableList() - newList.add("") - onUrisChange(newList.toList()) - }, - modifier = Modifier.align(Alignment.Start) - ) { - Icon( - imageVector = Icons.Filled.Add, - contentDescription = "Add", - tint = MaterialTheme.colorScheme.primary - ) - } - } -} - -@Composable -private fun ContactEmail(email: String, onEmailChange: (String) -> Unit) { - OutlinedTextField( - value = email, - placeholder = { Text(stringResource(R.string.email)) }, - onValueChange = onEmailChange, - modifier = Modifier.fillMaxWidth(), - textStyle = androidx.compose.ui.text.TextStyle(fontSize = 18.sp), - label = { Text(stringResource(R.string.email)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email) - ) -} - -@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 - }, - ) - Switch( - checked = favorite, - onCheckedChange = onFavoriteChange - ) - } -} - -@Composable -private fun Android(ctx: Context, android: Boolean, onAndroidChange: (Boolean) -> Unit) { - Row( - Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text( - text = stringResource(R.string.android), - modifier = Modifier.weight(1f) - .clickable { - alertTitle.value = ctx.getString(R.string.android) - alertMessage.value = ctx.getString(R.string.android_contact_help) - showAlert.value = true - }, - ) - Switch( - checked = android, - onCheckedChange = onAndroidChange - ) - } -} - -private fun checkOnClick( - ctx: Context, - currentState: ScreenState, - uriOrNameArg: String -): Boolean { - - val newUris = ArrayList() - for (uri in currentState.uris) { - var u = uri.filterNot{setOf('-', ' ', '(', ')').contains(it)} - if (u == "") continue - if (!u.startsWith("sip:") && !u.startsWith("tel:")) - u = if (Utils.isTelNumber(u)) - "tel:$u" - else - "sip:$u" - - if (!Utils.checkUri(u)) { - alertTitle.value = ctx.getString(R.string.notice) - alertMessage.value = String.format(ctx.getString(R.string.invalid_sip_or_tel_uri), u) - showAlert.value = true - return false - } - newUris.add(u) - } - - if (newUris.isEmpty() && currentState.email.trim().isEmpty()) { - alertTitle.value = ctx.getString(R.string.notice) - alertMessage.value = ctx.getString(R.string.sip_or_tel_uri) - showAlert.value = true - return false - } - - var newName = currentState.name.trim() - if (newName == "") newName = newUris[0].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 - val oldAvatar = File(BaresipService.filesPath, "${currentState.id}.png") - if (oldAvatar.exists()) Utils.deleteFile(oldAvatar) - idToUse = currentState.newId - } - } 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, - newUris, - currentState.email.trim(), - 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() - 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()) - if (contact.email.isNotEmpty()) { - ops.add( - ContentProviderOperation - .newInsert(ContactsContract.Data.CONTENT_URI) - .withValueBackReference(Data.RAW_CONTACT_ID, 0) - .withValue(Data.MIMETYPE, CommonDataKinds.Email.CONTENT_ITEM_TYPE) - .withValue(CommonDataKinds.Email.ADDRESS, contact.email) - .withValue(CommonDataKinds.Email.TYPE, CommonDataKinds.Email.TYPE_HOME) - .build()) - } - for (uri in contact.uris) { - val mimeType = if (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, 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 (contact.email.isNotEmpty()) { - if (updateAndroidEmail(ctx, rawContactId, contact.email) == 0) - addAndroidEmail(ctx, rawContactId, contact.email) - } - for (uri in contact.uris) - if (updateAndroidUri(ctx, rawContactId, uri) == 0) - addAndroidUri(ctx, rawContactId, 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() - 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() - 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 addAndroidEmail(ctx: Context, rawContactId: Long, email: String) { - val ops = ArrayList() - ops.add( - ContentProviderOperation - .newInsert(ContactsContract.Data.CONTENT_URI) - .withValue(Data.RAW_CONTACT_ID, rawContactId) - .withValue(Data.MIMETYPE, CommonDataKinds.Email.CONTENT_ITEM_TYPE) - .withValue(CommonDataKinds.Email.ADDRESS, email) - .withValue(CommonDataKinds.Email.TYPE, CommonDataKinds.Email.TYPE_HOME) - .build()) - try { - ctx.contentResolver.applyBatch(ContactsContract.AUTHORITY, ops) - } catch (e: Exception) { - Log.e(TAG, "Adding of Android email failed: ${e.message}") - } -} - -private fun updateAndroidEmail(ctx: Context, rawContactId: Long, email: String): Int { - val contentValues = ContentValues() - contentValues.put(CommonDataKinds.Email.ADDRESS, email) - val where = "${ContactsContract.Data.RAW_CONTACT_ID}=$rawContactId and " + - "${ContactsContract.Data.MIMETYPE}='${CommonDataKinds.Email.CONTENT_ITEM_TYPE}'" - return try { - ctx.contentResolver.update(ContactsContract.Data.CONTENT_URI, contentValues, where, null) - } catch (e: Exception) { - Log.e(TAG, "updateAndroidEmail failed: ${e.message}") - 0 - } -} - -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 - } -} diff --git a/app/src/main/kotlin/com/tutpro/baresip/BlockedScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/BlockedScreen.kt index cb8204cf..c9279c3f 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/BlockedScreen.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/BlockedScreen.kt @@ -283,7 +283,7 @@ private fun Blocked(ctx: Context, navController: NavController, blocked: Mutable peerUri) lastButtonText.value = ctx.getString(R.string.add_contact) lastAction.value = { - navController.navigate("baresip_contact/$peerUri/new") + navController.navigate("contact/$peerUri/new") } showDialog.value = true }) diff --git a/app/src/main/kotlin/com/tutpro/baresip/CallsScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/CallsScreen.kt index 90eab376..a094207f 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/CallsScreen.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/CallsScreen.kt @@ -436,7 +436,7 @@ private fun Calls( secondButtonText.value = ctx.getString(R.string.add_contact) secondAction.value = { val uri = Utils.sipToTel(peerUri) - navController.navigate("baresip_contact/$uri/new") + navController.navigate("contact/$uri/new") } lastButtonText.value = ctx.getString(R.string.delete) lastAction.value = { diff --git a/app/src/main/kotlin/com/tutpro/baresip/ChatScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/ChatScreen.kt index 479e4f3b..c7aa3bcc 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/ChatScreen.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/ChatScreen.kt @@ -418,7 +418,7 @@ private fun Messages( ) secondButtonText.value = ctx.getString(R.string.add_contact) secondAction.value = { - navController.navigate("baresip_contact/$peerUri/new") + navController.navigate("contact/$peerUri/new") } lastButtonText.value = ctx.getString(R.string.delete) lastAction.value = { diff --git a/app/src/main/kotlin/com/tutpro/baresip/ChatsScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/ChatsScreen.kt index fc7866eb..4015430d 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/ChatsScreen.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/ChatsScreen.kt @@ -369,7 +369,7 @@ private fun Chats( } lastButtonText.value = ctx.getString(R.string.add_contact) lastAction.value = { - navController.navigate("baresip_contact/${message.peerUri}/new") + navController.navigate("contact/${message.peerUri}/new") } } showDialog.value = true diff --git a/app/src/main/kotlin/com/tutpro/baresip/ContactScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/ContactScreen.kt index ee05e15d..e48b4978 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/ContactScreen.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/ContactScreen.kt @@ -1,17 +1,35 @@ package com.tutpro.baresip +import android.Manifest +import android.content.ContentProviderOperation +import android.content.ContentValues import android.content.Context import android.content.Intent +import android.content.pm.PackageManager +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 android.widget.Toast +import androidx.activity.compose.BackHandler +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.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -22,24 +40,41 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +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.automirrored.filled.Chat +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Remove import androidx.compose.material.icons.outlined.Call import androidx.compose.material.icons.outlined.Email +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.MaterialTheme +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.CompositionLocalProvider +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.graphics.asImageBitmap @@ -47,43 +82,200 @@ 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.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import androidx.core.graphics.scale +import androidx.core.net.toUri +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.AsyncImage -import androidx.core.net.toUri +import coil.compose.rememberAsyncImagePainter +import com.tutpro.baresip.CustomElements.AlertDialog +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileOutputStream fun NavGraphBuilder.contactScreenRoute(navController: NavController, viewModel: ViewModel) { composable( - route = "contact/{name}", - arguments = listOf(navArgument("name") { type = NavType.StringType }) + route = "contact/{uri_or_name}/{kind}", + arguments = listOf( + navArgument("uri_or_name") { type = NavType.StringType }, + navArgument("kind") { type = NavType.StringType } + ) ) { backStackEntry -> - val ctx = LocalContext.current - val name = backStackEntry.arguments?.getString("name")!! + val uriOrNameArg = backStackEntry.arguments?.getString("uri_or_name")!! + val kindArg = backStackEntry.arguments?.getString("kind")!! ContactScreen( - ctx = ctx, viewModel = viewModel, navController = navController, - name = name + uriOrNameArg = uriOrNameArg, + kindArg = kindArg ) } } +private data class ScreenState( + val new: Boolean = false, + val isEditing: Boolean = false, + val favorite: Boolean = false, + val android: Boolean = false, + val id: Long = 0, + val newId: Long = 0, + val name: String = "", + val uris: List = emptyList(), + val email: String = "", + val color: Int = 0, + val avatarImageUri: String? = null, + val tmpAvatarFile: File? = null, + val isLoading: Boolean = true, + val isBaresipContact: Boolean = false +) + +@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun ContactScreen(ctx: Context, viewModel: ViewModel, navController: NavController, name: String) { - val contact = Contact.baresipContact(name) ?: Contact.androidContact(name) - if (contact == null) { - Log.e(TAG, "No contact found with name $name") - navController.navigateUp() - return +private fun ContactScreen( + viewModel: ViewModel, + navController: NavController, + uriOrNameArg: String, + kindArg: String +) { + val ctx = LocalContext.current + var screenState by remember { mutableStateOf(ScreenState()) } + + val title = when { + 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, + isEditing = true, + name = "", + uris = if (uriOrNameArg == "") emptyList() else listOf(uriOrNameArg), + email = "", + favorite = false, + android = BaresipService.contactsMode == "android", + color = Utils.randomColor(), + id = time, + newId = time, + isLoading = false, + isBaresipContact = true + ) + } else { + val baresipContact = Contact.baresipContact(uriOrNameArg) + val androidContact = Contact.androidContact(uriOrNameArg) + val contact = baresipContact ?: androidContact + + if (contact == null) { + Log.e(TAG, "No contact found with name $uriOrNameArg") + navController.navigateUp() + return@LaunchedEffect + } + + val avatarFile = File(BaresipService.filesPath, "${contact.id()}.png") + screenState = ScreenState( + new = false, + isEditing = false, + name = contact.name(), + uris = contact.uris(), + email = contact.email(), + favorite = contact.favorite(), + android = false, + color = contact.colorInt(), + id = contact.id(), + newId = contact.id(), + avatarImageUri = if (contact is Contact.BaresipContact && contact.avatarImage != null && avatarFile.exists()) + Uri.fromFile(avatarFile).toString() + else if (contact is Contact.AndroidContact && contact.thumbnailUri != null) + contact.thumbnailUri.toString() + else + null, + isLoading = false, + isBaresipContact = contact is Contact.BaresipContact + ) + } + } + + val onBack: () -> Unit = { + if (screenState.isEditing && !screenState.new) { + screenState = screenState.copy(isEditing = false) + // Reload original state + val contact = Contact.baresipContact(uriOrNameArg)!! + val avatarFile = File(BaresipService.filesPath, "${contact.id}.png") + screenState = screenState.copy( + name = contact.name, + uris = contact.uris, + email = contact.email, + favorite = contact.favorite, + color = contact.color, + avatarImageUri = if (contact.avatarImage != null && avatarFile.exists()) + Uri.fromFile(avatarFile).toString() + else + null, + tmpAvatarFile = null + ) + } else { + screenState.tmpAvatarFile?.let { tempFile -> + if (tempFile.exists()) { + Log.d(TAG, "Back pressed, deleting temp avatar: ${tempFile.name}") + Utils.deleteFile(tempFile) + } + } + navController.navigateUp() + } + } + + val onCheck: () -> Unit = { + val result = checkOnClick( + ctx = ctx, + currentState = screenState, + uriOrNameArg = uriOrNameArg, + ) + if (result) { + if (screenState.new) { + navController.previousBackStackEntry?.savedStateHandle?.set("scrollToContact", screenState.name) + navController.navigateUp() + } else { + screenState = screenState.copy(isEditing = false, tmpAvatarFile = null) + // Update UI state with saved values + val contact = Contact.baresipContact(screenState.name)!! + val avatarFile = File(BaresipService.filesPath, "${contact.id}.png") + screenState = screenState.copy( + id = contact.id, + newId = contact.id, + avatarImageUri = if (contact.avatarImage != null && avatarFile.exists()) + Uri.fromFile(avatarFile).toString() + else + null + ) + } + } + } + + val onEdit: () -> Unit = { + screenState = screenState.copy(isEditing = true) + } + + BackHandler(enabled = true) { + onBack() + } + Scaffold( - modifier = Modifier.fillMaxSize().imePadding(), + modifier = Modifier + .fillMaxSize() + .imePadding(), containerColor = MaterialTheme.colorScheme.background, topBar = { Column( @@ -92,45 +284,105 @@ private fun ContactScreen(ctx: Context, viewModel: ViewModel, navController: Nav .background(MaterialTheme.colorScheme.background) .padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()) ) { - TopAppBar(name, navController) + TopAppBar( + title = title, + isEditing = screenState.isEditing, + isBaresipContact = screenState.isBaresipContact, + onBack = onBack, + onCheck = onCheck, + onEdit = onEdit + ) } }, content = { contentPadding -> - ContactContent(ctx, viewModel, navController, contentPadding, contact) + if (!screenState.isLoading) { + ContactContent( + ctx = ctx, + viewModel = viewModel, + navController = navController, + contentPadding = contentPadding, + screenState = screenState, + onStateChange = { newState -> screenState = newState } + ) + } else { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } } ) } @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun TopAppBar(title: String, navController: NavController) { +private fun TopAppBar( + title: String, + isEditing: Boolean, + isBaresipContact: Boolean, + onBack: () -> Unit, + onCheck: () -> Unit, + onEdit: () -> Unit +) { TopAppBar( title = { Text(text = title, 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.navigateUp() }) { + IconButton(onClick = onBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", ) } + }, + actions = { + if (isEditing) { + IconButton(onClick = onCheck) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = "Save" + ) + } + } else if (isBaresipContact) { + IconButton(onClick = onEdit) { + Icon( + imageVector = Icons.Filled.Edit, + contentDescription = "Edit" + ) + } + } } ) } +private val alertTitle = mutableStateOf("") +private val alertMessage = mutableStateOf("") +private val showAlert = mutableStateOf(false) + +@OptIn(ExperimentalFoundationApi::class) @Composable private fun ContactContent( ctx: Context, viewModel: ViewModel, navController: NavController, contentPadding: PaddingValues, - contact: Contact + screenState: ScreenState, + onStateChange: (ScreenState) -> Unit ) { + if (showAlert.value) { + AlertDialog( + showDialog = showAlert, + title = alertTitle.value, + message = alertMessage.value, + lastButtonText = stringResource(R.string.ok), + ) + } + val scrollState = rememberScrollState() Column( modifier = Modifier @@ -141,212 +393,742 @@ private fun ContactContent( .verticalScroll(scrollState), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Avatar(contact) - ContactName(contact.name()) - Uris(ctx, viewModel, navController, contact) - if (contact.email().isNotEmpty()) - Email(ctx, contact.email()) - } -} + AvatarSection( + ctx = ctx, + isEditing = screenState.isEditing, + 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 -> + screenState.tmpAvatarFile?.let { + if (it.exists()) + Utils.deleteFile(it) + } + onStateChange( + screenState.copy( + color = newRandomColor, + avatarImageUri = null, + tmpAvatarFile = null + ) + ) + } + ) -@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))) + ContactNameSection( + name = screenState.name, + isEditing = screenState.isEditing, + new = screenState.new, + onNameChange = { newName -> onStateChange(screenState.copy(name = newName)) } + ) + + UrisSection( + ctx = ctx, + viewModel = viewModel, + navController = navController, + uris = screenState.uris, + isEditing = screenState.isEditing, + onUrisChange = { newUris -> onStateChange(screenState.copy(uris = newUris)) } + ) + + EmailSection( + ctx = ctx, + email = screenState.email, + isEditing = screenState.isEditing, + onEmailChange = { newEmail -> onStateChange(screenState.copy(email = newEmail)) } + ) + + if (screenState.isEditing) { + FavoriteSection( + ctx = ctx, + favorite = screenState.favorite, + onFavoriteChange = { newFavorite -> + onStateChange(screenState.copy(favorite = newFavorite)) + } + ) + if (screenState.new && BaresipService.contactsMode == "both") + AndroidSection( + ctx = ctx, + android = screenState.android, + onAndroidChange = { newAndroid -> + onStateChange(screenState.copy(android = newAndroid)) + } + ) } - Text(text, fontSize = 72.sp, color = Color.White) } } +@OptIn(ExperimentalFoundationApi::class) @Composable -private fun ImageAvatar(bitmap: Bitmap) { - Image( - bitmap = bitmap.asImageBitmap(), - contentDescription = "Avatar", - contentScale = ContentScale.Crop, - modifier = Modifier - .size(avatarSize.dp) - .clip(CircleShape) - ) -} +private fun AvatarSection( + ctx: Context, + isEditing: Boolean, + 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() -@Composable -private fun AsyncImageAvatar(uri: Uri) { - AsyncImage( - model = uri, - contentDescription = stringResource(R.string.avatar_image), - contentScale = ContentScale.Crop, - modifier = Modifier.size(avatarSize.dp).clip(CircleShape) - ) -} + if (avatarBitmap == null) { + Log.e(TAG, "Failed to decode bitmap from URI: $uri") + return@rememberLauncherForActivityResult + } + + val scaledBitmap = avatarBitmap.scale(192, 192) + + 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") + if (tempNewImageFile.exists()) Utils.deleteFile(tempNewImageFile) + } + } catch (e: Exception) { + Log.e(TAG, "Could not process avatar image: ${e.message}") + } + } + } -@Composable -private fun Avatar(contact: Contact) { Row( Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { - val color = contact.colorInt() - val name = contact.name() - when (contact) { - is Contact.BaresipContact -> { - if (contact.avatarImage != null) - ImageAvatar(contact.avatarImage!!) - else - TextAvatar(if (name == "") "" else name[0].toString(), color) - } - is Contact.AndroidContact -> { - if (contact.thumbnailUri != null) - AsyncImageAvatar(contact.thumbnailUri!!) - else - TextAvatar(if (name == "") "" else name[0].toString(), color) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(avatarSize.dp) + .clip(CircleShape) + .background(if (currentAvatarUri == null) Color(color) else Color.Transparent) + .let { modifier -> + if (isEditing) { + modifier.combinedClickable( + onClick = { avatarImagePicker.launch("image/*") }, + onLongClick = { onAvatarColorChange(Utils.randomColor()) } + ) + } else { + modifier + } + } + ) { + 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.take(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) { - Row( - Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - Text( - text = name, - fontSize = 24.sp, - color = MaterialTheme.colorScheme.onBackground, - textAlign = TextAlign.Center +private fun ContactNameSection(name: String, isEditing: Boolean, new: Boolean, onNameChange: (String) -> Unit) { + if (isEditing) { + val focusRequester = remember { 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), + label = { Text(stringResource(R.string.contact_name)) }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + capitalization = KeyboardCapitalization.Words + ) ) - } -} - -@Composable -private fun Email(ctx: Context, email: String) { - Row( - Modifier.fillMaxWidth().padding(bottom = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text( - text = stringResource(R.string.bullet_item, email), - modifier = Modifier.weight(1f), - fontSize = 18.sp, - color = MaterialTheme.colorScheme.onBackground, - ) - IconButton( - onClick = { - val emailIntent = Intent(Intent.ACTION_SENDTO).apply { - data = "mailto:$email".toUri() - } - try { - ctx.startActivity(emailIntent) - } catch (e: Exception) { - Log.e(TAG, "Failed to start email activity: ${e.message}") - } - } + LaunchedEffect(new) { + if (new) focusRequester.requestFocus() + } + } else { + Row( + Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center ) { - Icon( - imageVector = Icons.Outlined.Email, - contentDescription = "Send Email", - tint = MaterialTheme.colorScheme.onBackground + Text( + text = name, + fontSize = 24.sp, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center ) } } } @Composable -private fun Uris( +private fun UrisSection( ctx: Context, viewModel: ViewModel, navController: NavController, - contact: Contact + uris: List, + isEditing: Boolean, + onUrisChange: (List) -> Unit ) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - for (uri in contact.uris()) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.bullet_item, uri.substringAfter(":")), - modifier = Modifier.weight(1f), - fontSize = 18.sp, - color = MaterialTheme.colorScheme.onBackground, - ) - - // Chat Button - IconButton( - onClick = { - val aor = viewModel.selectedAor.value - val ua = UserAgent.ofAor(aor) - if (ua == null) - Log.w(TAG, "Message clickable did not find AoR $aor") - else { - if (ua.account.isMobile && Utils.isAirplaneModeOn(ctx)) { - handleDialog(ctx, ctx.getString(R.string.notice), - ctx.getString(R.string.no_airplane_mode)) - } else if (ua.account.isMobile && !Utils.isDefaultSmsApp(ctx)) { - handleDialog(ctx, ctx.getString(R.string.notice), - ctx.getString(R.string.enable_default_messaging)) - } 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 - } + if (isEditing) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + uris.forEachIndexed { index, uri -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = uri, + placeholder = { Text(stringResource(R.string.user_domain_or_number)) }, + onValueChange = { newUri -> + val newList = uris.toMutableList() + newList[index] = newUri + onUrisChange(newList.toList()) + }, + modifier = Modifier.weight(1f), + textStyle = androidx.compose.ui.text.TextStyle(fontSize = 18.sp), + label = { Text("${stringResource(R.string.sip_or_tel_uri)} ${index + 1}") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + if (uris.size > 1) { + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) { + IconButton( + onClick = { + val newList = uris.toMutableList() + newList.removeAt(index) + onUrisChange(newList.toList()) + }, + modifier = Modifier.padding(start = 2.dp) + ) { + Icon( + imageVector = Icons.Filled.Remove, + contentDescription = "Remove", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(32.dp) + ) } } } - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Chat, - contentDescription = "Send Message", - tint = MaterialTheme.colorScheme.onBackground - ) } + } + IconButton( + onClick = { + val newList = uris.toMutableList() + newList.add("") + onUrisChange(newList.toList()) + }, + modifier = Modifier.align(Alignment.Start) + ) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = "Add", + tint = MaterialTheme.colorScheme.primary + ) + } + } + } else { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + for (uri in uris) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.bullet_item, uri.substringAfter(":")), + modifier = Modifier.weight(1f), + fontSize = 18.sp, + color = MaterialTheme.colorScheme.onBackground, + ) - // Call Button - IconButton( - onClick = { - val aor = viewModel.selectedAor.value - val ua = UserAgent.ofAor(aor) - if (ua == null) - Log.w(TAG, "Call clickable did not find AoR $aor") - else { - if (ua.account.isMobile && Utils.isAirplaneModeOn(ctx)) { - handleDialog(ctx, ctx.getString(R.string.notice), - ctx.getString(R.string.no_airplane_mode)) - } 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 + // Chat Button + IconButton( + onClick = { + val aor = viewModel.selectedAor.value + val ua = UserAgent.ofAor(aor) + if (ua == null) + Log.w(TAG, "Message clickable did not find AoR $aor") + else { + if (ua.account.isMobile && Utils.isAirplaneModeOn(ctx)) { + handleDialog(ctx, ctx.getString(R.string.notice), + ctx.getString(R.string.no_airplane_mode)) + } else if (ua.account.isMobile && !Utils.isDefaultSmsApp(ctx)) { + handleDialog(ctx, ctx.getString(R.string.notice), + ctx.getString(R.string.enable_default_messaging)) + } 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 + } } } } + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Chat, + contentDescription = "Send Message", + tint = MaterialTheme.colorScheme.onBackground + ) + } + + // Call Button + IconButton( + onClick = { + val aor = viewModel.selectedAor.value + val ua = UserAgent.ofAor(aor) + if (ua == null) + Log.w(TAG, "Call clickable did not find AoR $aor") + else { + if (ua.account.isMobile && Utils.isAirplaneModeOn(ctx)) { + handleDialog(ctx, ctx.getString(R.string.notice), + ctx.getString(R.string.no_airplane_mode)) + } 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 + } + } + } + } + ) { + Icon( + imageVector = Icons.Outlined.Call, + contentDescription = "Call", + tint = MaterialTheme.colorScheme.onBackground + ) } - ) { - Icon( - imageVector = Icons.Outlined.Call, - contentDescription = "Call", - tint = MaterialTheme.colorScheme.onBackground - ) } } } } } + +@Composable +private fun EmailSection(ctx: Context, email: String, isEditing: Boolean, onEmailChange: (String) -> Unit) { + if (isEditing) { + OutlinedTextField( + value = email, + placeholder = { Text(stringResource(R.string.email)) }, + onValueChange = onEmailChange, + modifier = Modifier.fillMaxWidth(), + textStyle = androidx.compose.ui.text.TextStyle(fontSize = 18.sp), + label = { Text(stringResource(R.string.email)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email) + ) + } else if (email.isNotEmpty()) { + Row( + Modifier.fillMaxWidth().padding(bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text( + text = stringResource(R.string.bullet_item, email), + modifier = Modifier.weight(1f), + fontSize = 18.sp, + color = MaterialTheme.colorScheme.onBackground, + ) + IconButton( + onClick = { + val emailIntent = Intent(Intent.ACTION_SENDTO).apply { + data = "mailto:$email".toUri() + } + try { + ctx.startActivity(emailIntent) + } catch (e: Exception) { + Log.e(TAG, "Failed to start email activity: ${e.message}") + } + } + ) { + Icon( + imageVector = Icons.Outlined.Email, + contentDescription = "Send Email", + tint = MaterialTheme.colorScheme.onBackground + ) + } + } + } +} + +@Composable +private fun FavoriteSection(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 + }, + ) + Switch( + checked = favorite, + onCheckedChange = onFavoriteChange + ) + } +} + +@Composable +private fun AndroidSection(ctx: Context, android: Boolean, onAndroidChange: (Boolean) -> Unit) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text( + text = stringResource(R.string.android), + modifier = Modifier.weight(1f) + .clickable { + alertTitle.value = ctx.getString(R.string.android) + alertMessage.value = ctx.getString(R.string.android_contact_help) + showAlert.value = true + }, + ) + Switch( + checked = android, + onCheckedChange = onAndroidChange + ) + } +} + +private fun checkOnClick( + ctx: Context, + currentState: ScreenState, + uriOrNameArg: String +): Boolean { + val newUris = ArrayList() + for (uri in currentState.uris) { + var u = uri.filterNot { setOf('-', ' ', '(', ')').contains(it) } + if (u == "") continue + if (!u.startsWith("sip:") && !u.startsWith("tel:")) + u = if (Utils.isTelNumber(u)) "tel:$u" else "sip:$u" + + if (!Utils.checkUri(u)) { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = String.format(ctx.getString(R.string.invalid_sip_or_tel_uri), u) + showAlert.value = true + return false + } + newUris.add(u) + } + + if (newUris.isEmpty() && currentState.email.trim().isEmpty()) { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = ctx.getString(R.string.sip_or_tel_uri) + showAlert.value = true + return false + } + + var newName = currentState.name.trim() + if (newName == "") newName = if (newUris.isNotEmpty()) newUris[0].substringAfter(":") else currentState.email + 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) { + val oldAvatar = File(BaresipService.filesPath, "${currentState.id}.png") + if (oldAvatar.exists()) Utils.deleteFile(oldAvatar) + idToUse = currentState.newId + } + } else if (currentState.avatarImageUri == null) { + val avatarFile = File(BaresipService.filesPath, "$idToUse.png") + if (avatarFile.exists()) Utils.deleteFile(avatarFile) + } + + val contact: Contact.BaresipContact = Contact.BaresipContact( + newName, newUris, currentState.email.trim(), currentState.color, idToUse, currentState.favorite + ) + + if (currentState.avatarImageUri != null && (currentState.tmpAvatarFile != null || !currentState.new)) { + 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() + } catch (e: Exception) { + Log.e(TAG, "Failed to save bitmap: $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() + 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()) + if (contact.email.isNotEmpty()) { + ops.add(ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI) + .withValueBackReference(Data.RAW_CONTACT_ID, 0) + .withValue(Data.MIMETYPE, CommonDataKinds.Email.CONTENT_ITEM_TYPE) + .withValue(CommonDataKinds.Email.ADDRESS, contact.email) + .withValue(CommonDataKinds.Email.TYPE, CommonDataKinds.Email.TYPE_HOME).build()) + } + for (uri in contact.uris) { + val mimeType = if (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, uri.substringAfter(":")).build()) + } + if (contact.avatarImage != null) { + val photoData = 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 (contact.email.isNotEmpty()) { + if (updateAndroidEmail(ctx, rawContactId, contact.email) == 0) addAndroidEmail(ctx, rawContactId, contact.email) + } + for (uri in contact.uris) if (updateAndroidUri(ctx, rawContactId, uri) == 0) addAndroidUri(ctx, rawContactId, 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() + 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 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 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() + 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 photo failed: ${e.message}") + } + } +} + +private fun addAndroidEmail(ctx: Context, rawContactId: Long, email: String) { + val ops = ArrayList() + ops.add(ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI) + .withValue(Data.RAW_CONTACT_ID, rawContactId) + .withValue(Data.MIMETYPE, CommonDataKinds.Email.CONTENT_ITEM_TYPE) + .withValue(CommonDataKinds.Email.ADDRESS, email) + .withValue(CommonDataKinds.Email.TYPE, CommonDataKinds.Email.TYPE_HOME).build()) + try { + ctx.contentResolver.applyBatch(ContactsContract.AUTHORITY, ops) + } catch (e: Exception) { + Log.e(TAG, "Adding of email failed: ${e.message}") + } +} + +private fun updateAndroidEmail(ctx: Context, rawContactId: Long, email: String): Int { + val contentValues = ContentValues() + contentValues.put(CommonDataKinds.Email.ADDRESS, email) + val where = "${ContactsContract.Data.RAW_CONTACT_ID}=$rawContactId and ${ContactsContract.Data.MIMETYPE}='${CommonDataKinds.Email.CONTENT_ITEM_TYPE}'" + return try { + ctx.contentResolver.update(ContactsContract.Data.CONTENT_URI, contentValues, where, null) + } catch (e: Exception) { + 0 + } +} + +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) { + 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) { + null + } +} diff --git a/app/src/main/kotlin/com/tutpro/baresip/ContactsScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/ContactsScreen.kt index b6dda2dd..c7c25fc9 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/ContactsScreen.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/ContactsScreen.kt @@ -473,7 +473,7 @@ private fun BottomBar( ) ) SmallFloatingActionButton( - onClick = { navController.navigate("baresip_contact//new") }, + onClick = { navController.navigate("contact//new") }, containerColor = MaterialTheme.colorScheme.secondary, contentColor = MaterialTheme.colorScheme.onSecondary ) { @@ -621,7 +621,7 @@ private fun ContactsContent( .padding(start = 10.dp) .combinedClickable( onClick = { - navController.navigate("contact/${contact.name()}") + navController.navigate("contact/${contact.name()}/old") }, onLongClick = { confirmationDialogMessage.value = String.format( @@ -650,18 +650,6 @@ private fun ContactsContent( } ) ) - SmallFloatingActionButton( - modifier = Modifier.padding(end = 10.dp), - onClick = { navController.navigate("baresip_contact/${contact.name()}/old") }, - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - contentColor = MaterialTheme.colorScheme.onTertiaryContainer - ) { - Icon( - imageVector = Icons.Filled.Edit, - modifier = Modifier.size(28.dp), - contentDescription = stringResource(R.string.edit) - ) - } } is Contact.AndroidContact -> { @@ -673,7 +661,7 @@ private fun ContactsContent( .padding(start = 10.dp, top = 4.dp, bottom = 4.dp) .combinedClickable( onClick = { - navController.navigate("contact/${contact.name()}") + navController.navigate("contact/${contact.name()}/old") }, onLongClick = { confirmationDialogMessage.value = String.format( diff --git a/app/src/main/kotlin/com/tutpro/baresip/MainActivity.kt b/app/src/main/kotlin/com/tutpro/baresip/MainActivity.kt index 542a7e1d..9151f5cd 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/MainActivity.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/MainActivity.kt @@ -286,7 +286,6 @@ class MainActivity : ComponentActivity() { accountScreenRoute(navController) codecsScreenRoute(navController) contactsScreenRoute(navController) - baresipContactScreenRoute(navController) contactScreenRoute(navController, viewModel) callsScreenRoute(navController, viewModel) callDetailsScreenRoute(navController, viewModel)