From 4a98610b670f3bb928645be34e46a749927f5f47 Mon Sep 17 00:00:00 2001 From: Sylvain Berfini Date: Fri, 18 Aug 2023 10:28:56 +0200 Subject: [PATCH] Added contact image picker + favourite toggle --- app/build.gradle | 6 +- .../contacts/fragment/EditContactFragment.kt | 34 ++++++- .../contacts/fragment/NewContactFragment.kt | 37 ++++++++ .../main/contacts/model/ContactAvatarModel.kt | 4 +- .../viewmodel/ContactNewOrEditViewModel.kt | 10 ++ .../contacts/viewmodel/ContactViewModel.kt | 30 +++++- .../org/linphone/utils/DataBindingUtils.kt | 12 ++- .../main/java/org/linphone/utils/FileUtils.kt | 93 +++++++++++++++++++ app/src/main/res/layout/contact_fragment.xml | 4 +- .../layout/contact_new_or_edit_fragment.xml | 19 +++- 10 files changed, 233 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/org/linphone/utils/FileUtils.kt diff --git a/app/build.gradle b/app/build.gradle index 4af8dccac..3f30b37df 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -53,16 +53,16 @@ dependencies { implementation 'androidx.core:core-ktx:1.10.1' implementation 'androidx.appcompat:appcompat:1.7.0-alpha03' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' - implementation 'androidx.navigation:navigation-ui-ktx:2.6.0' + implementation 'androidx.navigation:navigation-ui-ktx:2.7.0' implementation 'androidx.gridlayout:gridlayout:1.0.0' implementation 'androidx.recyclerview:recyclerview:1.3.1' implementation "androidx.slidingpanelayout:slidingpanelayout:1.2.0" - def nav_version = "2.6.0" + def nav_version = "2.7.0" implementation "androidx.navigation:navigation-fragment-ktx:$nav_version" implementation "androidx.navigation:navigation-ui-ktx:$nav_version" - def emoji_version = "1.4.0-rc01" + def emoji_version = "1.4.0" implementation "androidx.emoji2:emoji2:$emoji_version" implementation "androidx.emoji2:emoji2-emojipicker:$emoji_version" diff --git a/app/src/main/java/org/linphone/ui/main/contacts/fragment/EditContactFragment.kt b/app/src/main/java/org/linphone/ui/main/contacts/fragment/EditContactFragment.kt index a33a05e94..a20f65965 100644 --- a/app/src/main/java/org/linphone/ui/main/contacts/fragment/EditContactFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/contacts/fragment/EditContactFragment.kt @@ -23,18 +23,23 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import androidx.navigation.navGraphViewModels +import kotlinx.coroutines.launch import org.linphone.R import org.linphone.core.tools.Log import org.linphone.databinding.ContactNewOrEditFragmentBinding import org.linphone.ui.main.contacts.viewmodel.ContactNewOrEditViewModel import org.linphone.ui.main.fragment.GenericFragment +import org.linphone.utils.FileUtils class EditContactFragment : GenericFragment() { companion object { - const val TAG = "[Contact Edit Fragment]" + const val TAG = "[Edit Contact Fragment]" } private lateinit var binding: ContactNewOrEditFragmentBinding @@ -45,6 +50,25 @@ class EditContactFragment : GenericFragment() { private val args: EditContactFragmentArgs by navArgs() + private val pickMedia = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> + if (uri != null) { + Log.i("$TAG Picture picked [$uri]") + // TODO FIXME: use a better file name + val localFileName = FileUtils.getFileStoragePath("temp", true) + lifecycleScope.launch { + if (FileUtils.copyFile(uri, localFileName)) { + viewModel.picturePath.postValue(localFileName.absolutePath) + } else { + Log.e( + "$TAG Failed to copy file from [$uri] to [${localFileName.absolutePath}]" + ) + } + } + } else { + Log.w("$TAG No picture picked") + } + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -74,6 +98,10 @@ class EditContactFragment : GenericFragment() { goBack() } + binding.setPickImageClickListener { + pickImage() + } + viewModel.saveChangesEvent.observe(viewLifecycleOwner) { it.consume { refKey -> if (refKey.isNotEmpty()) { @@ -91,4 +119,8 @@ class EditContactFragment : GenericFragment() { } } } + + private fun pickImage() { + pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + } } diff --git a/app/src/main/java/org/linphone/ui/main/contacts/fragment/NewContactFragment.kt b/app/src/main/java/org/linphone/ui/main/contacts/fragment/NewContactFragment.kt index 918ca1128..7ec7d4f11 100644 --- a/app/src/main/java/org/linphone/ui/main/contacts/fragment/NewContactFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/contacts/fragment/NewContactFragment.kt @@ -23,21 +23,50 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.navigation.navGraphViewModels +import kotlinx.coroutines.launch import org.linphone.R +import org.linphone.core.tools.Log import org.linphone.databinding.ContactNewOrEditFragmentBinding import org.linphone.ui.main.contacts.viewmodel.ContactNewOrEditViewModel import org.linphone.ui.main.fragment.GenericFragment import org.linphone.utils.Event +import org.linphone.utils.FileUtils class NewContactFragment : GenericFragment() { + companion object { + const val TAG = "[New Contact Fragment]" + } + private lateinit var binding: ContactNewOrEditFragmentBinding private val viewModel: ContactNewOrEditViewModel by navGraphViewModels( R.id.newContactFragment ) + private val pickMedia = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> + if (uri != null) { + Log.i("$TAG Picture picked [$uri]") + // TODO FIXME: use a better file name + val localFileName = FileUtils.getFileStoragePath("temp", true) + lifecycleScope.launch { + if (FileUtils.copyFile(uri, localFileName)) { + viewModel.picturePath.postValue(localFileName.absolutePath) + } else { + Log.e( + "$TAG Failed to copy file from [$uri] to [${localFileName.absolutePath}]" + ) + } + } + } else { + Log.w("$TAG No picture picked") + } + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -63,6 +92,10 @@ class NewContactFragment : GenericFragment() { goBack() } + binding.setPickImageClickListener { + pickImage() + } + viewModel.saveChangesEvent.observe(viewLifecycleOwner) { it.consume { refKey -> if (refKey.isNotEmpty()) { @@ -74,4 +107,8 @@ class NewContactFragment : GenericFragment() { } } } + + private fun pickImage() { + pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + } } diff --git a/app/src/main/java/org/linphone/ui/main/contacts/model/ContactAvatarModel.kt b/app/src/main/java/org/linphone/ui/main/contacts/model/ContactAvatarModel.kt index 756f6e984..006bd28fb 100644 --- a/app/src/main/java/org/linphone/ui/main/contacts/model/ContactAvatarModel.kt +++ b/app/src/main/java/org/linphone/ui/main/contacts/model/ContactAvatarModel.kt @@ -44,9 +44,7 @@ class ContactAvatarModel(val friend: Friend) { val name = MutableLiveData() - val firstLetter: String by lazy { - LinphoneUtils.getFirstLetter(friend.name.orEmpty()) - } + val firstLetter: String = LinphoneUtils.getFirstLetter(friend.name.orEmpty()) val showFirstLetter = MutableLiveData() diff --git a/app/src/main/java/org/linphone/ui/main/contacts/viewmodel/ContactNewOrEditViewModel.kt b/app/src/main/java/org/linphone/ui/main/contacts/viewmodel/ContactNewOrEditViewModel.kt index 18f47ec18..99fd99ad4 100644 --- a/app/src/main/java/org/linphone/ui/main/contacts/viewmodel/ContactNewOrEditViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/contacts/viewmodel/ContactNewOrEditViewModel.kt @@ -37,6 +37,8 @@ class ContactNewOrEditViewModel() : ViewModel() { val isEdit = MutableLiveData() + val picturePath = MutableLiveData() + val firstName = MutableLiveData() val lastName = MutableLiveData() @@ -79,6 +81,8 @@ class ContactNewOrEditViewModel() : ViewModel() { // TODO ? } + picturePath.postValue(friend.photo) + for (address in friend.addresses) { addresses.add( NewOrEditNumberOrAddressModel(address.asStringUriOnly(), true, { }, { model -> @@ -141,6 +145,12 @@ class ContactNewOrEditViewModel() : ViewModel() { if (vCard != null) { vCard.familyName = lastName.value vCard.givenName = firstName.value + + // TODO FIXME : doesn't work for newly created contact + val picture = picturePath.value.orEmpty() + if (picture.isNotEmpty()) { + friend.photo = picture + } } friend.organization = company.value.orEmpty() diff --git a/app/src/main/java/org/linphone/ui/main/contacts/viewmodel/ContactViewModel.kt b/app/src/main/java/org/linphone/ui/main/contacts/viewmodel/ContactViewModel.kt index 7a0001fc3..5634bf760 100644 --- a/app/src/main/java/org/linphone/ui/main/contacts/viewmodel/ContactViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/contacts/viewmodel/ContactViewModel.kt @@ -23,6 +23,7 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.core.Address +import org.linphone.core.Friend import org.linphone.ui.main.contacts.model.ContactAvatarModel import org.linphone.ui.main.contacts.model.ContactDeviceModel import org.linphone.ui.main.contacts.model.ContactNumberOrAddressClickListener @@ -40,6 +41,8 @@ class ContactViewModel : ViewModel() { val title = MutableLiveData() + val isFavourite = MutableLiveData() + val showBackButton = MutableLiveData() val showNumbersAndAddresses = MutableLiveData() @@ -64,7 +67,7 @@ class ContactViewModel : ViewModel() { MutableLiveData>() } - val listener = object : ContactNumberOrAddressClickListener { + private val listener = object : ContactNumberOrAddressClickListener { override fun onClicked(address: Address?) { // UI thread if (address != null) { @@ -80,6 +83,8 @@ class ContactViewModel : ViewModel() { } } + private lateinit var friend: Friend + init { showNumbersAndAddresses.value = true showDevicesTrust.value = false // TODO FIXME: set it to true when it will work for real @@ -90,6 +95,9 @@ class ContactViewModel : ViewModel() { coreContext.postOnCoreThread { core -> val friend = coreContext.contactsManager.findContactById(refKey) if (friend != null) { + this.friend = friend + isFavourite.postValue(friend.starred) + contact.postValue(ContactAvatarModel(friend)) val organization = friend.organization @@ -111,6 +119,8 @@ class ContactViewModel : ViewModel() { ) addressesAndNumbers.add(data) } + val indexOfLastSipAddress = addressesAndNumbers.count() + for (number in friend.phoneNumbersWithLabel) { val presenceModel = friend.getPresenceModelForUriOrTel(number.phoneNumber) if (presenceModel != null && !presenceModel.contact.isNullOrEmpty()) { @@ -120,15 +130,16 @@ class ContactViewModel : ViewModel() { it.displayedValue == contact } if (!contact.isNullOrEmpty() && found == null) { - val address = core.interpretUrl(contact, true) + val address = core.interpretUrl(contact, false) if (address != null) { + address.clean() // To remove ;user=phone val data = ContactNumberOrAddressModel( address, - contact, + address.asStringUriOnly(), listener, true ) - addressesAndNumbers.add(data) + addressesAndNumbers.add(indexOfLastSipAddress, data) } } } @@ -179,6 +190,17 @@ class ContactViewModel : ViewModel() { } } + fun toggleFavourite() { + // UI thread + coreContext.postOnCoreThread { + friend.edit() + friend.starred = !friend.starred + friend.done() + isFavourite.postValue(friend.starred) + coreContext.contactsManager.notifyContactsListChanged() + } + } + fun startAudioCall() { // UI thread val numbersAndAddresses = sipAddressesAndPhoneNumbers.value.orEmpty() diff --git a/app/src/main/java/org/linphone/utils/DataBindingUtils.kt b/app/src/main/java/org/linphone/utils/DataBindingUtils.kt index 17ba23741..38ae40b2e 100644 --- a/app/src/main/java/org/linphone/utils/DataBindingUtils.kt +++ b/app/src/main/java/org/linphone/utils/DataBindingUtils.kt @@ -132,6 +132,16 @@ fun AppCompatTextView.setDrawableTint(color: Int) { } } +@BindingAdapter("coil") +fun loadPictureWithCoil(imageView: ImageView, file: String?) { + // UI thread + if (file != null) { + imageView.load(file) { + transformations(CircleCropTransformation()) + } + } +} + @BindingAdapter("coilContact") fun loadContactPictureWithCoil2(imageView: ImageView, contact: ContactData?) { // UI thread @@ -158,7 +168,7 @@ fun ImageView.setPresenceIcon(presence: ConsolidatedPresence?) { } @BindingAdapter("contactAvatar") -fun AvatarView.loadContactPicture(contact: ContactAvatarModel?) { +fun AvatarView.loadContactAvatar(contact: ContactAvatarModel?) { // UI thread if (contact == null) { loadImage(R.drawable.contact_avatar) diff --git a/app/src/main/java/org/linphone/utils/FileUtils.kt b/app/src/main/java/org/linphone/utils/FileUtils.kt new file mode 100644 index 000000000..239105ddf --- /dev/null +++ b/app/src/main/java/org/linphone/utils/FileUtils.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2010-2023 Belledonne Communications SARL. + * + * This file is part of linphone-android + * (see https://www.linphone.org). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.linphone.utils + +import android.net.Uri +import android.os.Environment +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.linphone.LinphoneApplication.Companion.coreContext +import org.linphone.core.tools.Log + +class FileUtils { + companion object { + const val TAG = "[File Utils]" + + fun getFileStoragePath(fileName: String, isImage: Boolean = false): File { + val path = getFileStorageDir(isImage) + var file = File(path, fileName) + + var prefix = 1 + while (file.exists()) { + file = File(path, prefix.toString() + "_" + fileName) + Log.w("$TAG File with that name already exists, renamed to ${file.name}") + prefix += 1 + } + return file + } + + suspend fun copyFile(from: Uri, to: File): Boolean { + try { + withContext(Dispatchers.IO) { + FileOutputStream(to).use { outputStream -> + val inputStream = FileInputStream( + coreContext.context.contentResolver.openFileDescriptor(from, "r")?.fileDescriptor + ) + val buffer = ByteArray(4096) + var bytesRead: Int + while (inputStream.read(buffer).also { bytesRead = it } >= 0) { + outputStream.write(buffer, 0, bytesRead) + } + } + } + return true + } catch (e: IOException) { + Log.e("$TAG copyFile [$from] to [$to] exception: $e") + } + return false + } + + private fun getFileStorageDir(isPicture: Boolean = false): File { + var path: File? = null + if (Environment.getExternalStorageState() == Environment.MEDIA_MOUNTED) { + Log.w("$TAG External storage is mounted") + var directory = Environment.DIRECTORY_DOWNLOADS + if (isPicture) { + Log.w("$TAG Using pictures directory instead of downloads") + directory = Environment.DIRECTORY_PICTURES + } + path = coreContext.context.getExternalFilesDir(directory) + } + + val returnPath: File = path ?: coreContext.context.filesDir + if (path == null) { + Log.w( + "$TAG Couldn't get external storage path, using internal" + ) + } + + return returnPath + } + } +} diff --git a/app/src/main/res/layout/contact_fragment.xml b/app/src/main/res/layout/contact_fragment.xml index 113eca749..e76c06cdd 100644 --- a/app/src/main/res/layout/contact_fragment.xml +++ b/app/src/main/res/layout/contact_fragment.xml @@ -484,14 +484,16 @@ app:layout_constraintTop_toBottomOf="@+id/action_edit"/> diff --git a/app/src/main/res/layout/contact_new_or_edit_fragment.xml b/app/src/main/res/layout/contact_new_or_edit_fragment.xml index 6410b4a92..0e5799347 100644 --- a/app/src/main/res/layout/contact_new_or_edit_fragment.xml +++ b/app/src/main/res/layout/contact_new_or_edit_fragment.xml @@ -8,6 +8,9 @@ + @@ -76,18 +79,28 @@ android:layout_height="wrap_content"> + +