diff --git a/app/src/main/java/org/linphone/ui/main/model/AccountModel.kt b/app/src/main/java/org/linphone/ui/main/model/AccountModel.kt index 5d9e31494..c47a8aa40 100644 --- a/app/src/main/java/org/linphone/ui/main/model/AccountModel.kt +++ b/app/src/main/java/org/linphone/ui/main/model/AccountModel.kt @@ -27,13 +27,17 @@ import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.core.Account import org.linphone.core.AccountListenerStub import org.linphone.core.RegistrationState -import org.linphone.utils.FileUtils +import org.linphone.core.tools.Log import org.linphone.utils.LinphoneUtils class AccountModel @WorkerThread constructor( private val account: Account, private val onMenuClicked: ((view: View, account: Account) -> Unit)? = null ) { + companion object { + private const val TAG = "[Account Model]" + } + val displayName = MutableLiveData() val avatar = MutableLiveData() @@ -57,6 +61,9 @@ class AccountModel @WorkerThread constructor( state: RegistrationState?, message: String ) { + Log.i( + "$TAG Account [${account.params.identityAddress?.asStringUriOnly()}] registration state changed: [$state]($message)" + ) update() } } @@ -64,7 +71,6 @@ class AccountModel @WorkerThread constructor( init { account.addListener(accountListener) - avatar.postValue(account.getPicturePath()) showTrust.postValue(account.isInSecureMode()) update() @@ -97,10 +103,19 @@ class AccountModel @WorkerThread constructor( @WorkerThread private fun update() { + Log.i( + "$TAG Refreshing info for account [${account.params.identityAddress?.asStringUriOnly()}]" + ) + val name = LinphoneUtils.getDisplayName(account.params.identityAddress) displayName.postValue(name) + initials.postValue(LinphoneUtils.getInitials(name)) + val pictureUri = account.params.pictureUri.orEmpty() + avatar.postValue(pictureUri) + Log.i("$TAG Account picture URI is [$pictureUri]") + isDefault.postValue(coreContext.core.defaultAccount == account) val state = when (account.state) { @@ -118,15 +133,6 @@ class AccountModel @WorkerThread constructor( } } -fun Account.getPicturePath(): String { - // TODO FIXME: get image path from account - return FileUtils.getFileStoragePath( - "john.jpg", - isImage = true, - overrideExisting = true - ).absolutePath -} - fun Account.isInSecureMode(): Boolean { // TODO FIXME return true diff --git a/app/src/main/java/org/linphone/ui/main/settings/fragment/AccountProfileFragment.kt b/app/src/main/java/org/linphone/ui/main/settings/fragment/AccountProfileFragment.kt index 1c8a3d1fe..57a59e8bc 100644 --- a/app/src/main/java/org/linphone/ui/main/settings/fragment/AccountProfileFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/settings/fragment/AccountProfileFragment.kt @@ -12,9 +12,7 @@ import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import androidx.navigation.navGraphViewModels -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.linphone.R import org.linphone.core.tools.Log import org.linphone.databinding.AccountProfileFragmentBinding @@ -38,23 +36,15 @@ class AccountProfileFragment : GenericFragment() { private val pickMedia = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> if (uri != null) { - val identity = "john" // TODO FIXME: use account identity - val localFileName = FileUtils.getFileStoragePath( - "$identity.jpg", // TODO FIXME: use correct file extension - isImage = true, - overrideExisting = true - ) - Log.i("$TAG Picture picked [$uri], will be stored as [${localFileName.absolutePath}]") - + Log.i("$TAG Picture picked [$uri]") lifecycleScope.launch { - if (FileUtils.copyFile(uri, localFileName)) { - withContext(Dispatchers.Main) { - viewModel.setImage(localFileName) - } + val localFileName = FileUtils.getFilePath(requireContext(), uri, true) + if (localFileName != null) { + Log.i("$TAG Picture will be locally stored as [$localFileName]") + val path = FileUtils.getProperFilePath(localFileName) + viewModel.picturePath.postValue(path) } else { - Log.e( - "$TAG Failed to copy file from [$uri] to [${localFileName.absolutePath}]" - ) + Log.e("$TAG Failed to copy [$uri] to local storage") } } } else { diff --git a/app/src/main/java/org/linphone/ui/main/settings/viewmodel/AccountProfileViewModel.kt b/app/src/main/java/org/linphone/ui/main/settings/viewmodel/AccountProfileViewModel.kt index 56972246d..559d96298 100644 --- a/app/src/main/java/org/linphone/ui/main/settings/viewmodel/AccountProfileViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/settings/viewmodel/AccountProfileViewModel.kt @@ -3,13 +3,10 @@ package org.linphone.ui.main.settings.viewmodel import androidx.annotation.UiThread import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import java.io.File import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.core.Account import org.linphone.core.tools.Log -import org.linphone.ui.main.model.getPicturePath import org.linphone.utils.Event -import org.linphone.utils.FileUtils class AccountProfileViewModel @UiThread constructor() : ViewModel() { companion object { @@ -35,11 +32,11 @@ class AccountProfileViewModel @UiThread constructor() : ViewModel() { it.params.identityAddress?.asStringUriOnly() == identity } if (found != null) { - Log.i("$TAG Found matching local friend [$found]") + Log.i("$TAG Found matching account [$found]") account = found sipAddress.postValue(account.params.identityAddress?.asStringUriOnly()) displayName.postValue(account.params.identityAddress?.displayName) - picturePath.postValue(account.getPicturePath()) + picturePath.postValue(account.params.pictureUri) internationalPrefix.postValue(account.params.internationalPrefix) accountFoundEvent.postValue(Event(true)) @@ -58,6 +55,12 @@ class AccountProfileViewModel @UiThread constructor() : ViewModel() { copy.internationalPrefix = internationalPrefix.value.orEmpty() + val newPictureUri = picturePath.value + if (!newPictureUri.isNullOrEmpty() && newPictureUri != params.pictureUri) { + Log.i("$TAG New account profile picture [$newPictureUri]") + copy.pictureUri = newPictureUri + } + val address = params.identityAddress?.clone() if (address != null) { val newValue = displayName.value.orEmpty().trim() @@ -65,23 +68,13 @@ class AccountProfileViewModel @UiThread constructor() : ViewModel() { copy.identityAddress = address // This will trigger a REGISTER, so account display name will be updated by // CoreListener.onAccountRegistrationStateChanged everywhere in the app - account.params = copy Log.i( - "$TAG Updated account [$account] identity address display name [$newValue]" + "$TAG Updated account [${params.identityAddress?.asStringUriOnly()}] identity address display name [$newValue]" ) } - } - } - } - @UiThread - fun setImage(file: File) { - val path = FileUtils.getProperFilePath(file.absolutePath) - picturePath.value = path - - coreContext.postOnCoreThread { - if (::account.isInitialized) { - // TODO: save image path in account + account.params = copy + account.refreshRegister() } } } diff --git a/app/src/main/java/org/linphone/ui/main/viewmodel/DrawerMenuViewModel.kt b/app/src/main/java/org/linphone/ui/main/viewmodel/DrawerMenuViewModel.kt index 58097b9fd..a61b5f2a0 100644 --- a/app/src/main/java/org/linphone/ui/main/viewmodel/DrawerMenuViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/viewmodel/DrawerMenuViewModel.kt @@ -54,6 +54,8 @@ class DrawerMenuViewModel @UiThread constructor() : ViewModel() { coreContext.postOnCoreThread { computeAccountsList() } + + // TODO FIXME: account avatar not refreshed... } @UiThread diff --git a/app/src/main/java/org/linphone/utils/DataBindingUtils.kt b/app/src/main/java/org/linphone/utils/DataBindingUtils.kt index 110544c37..20ccb1a19 100644 --- a/app/src/main/java/org/linphone/utils/DataBindingUtils.kt +++ b/app/src/main/java/org/linphone/utils/DataBindingUtils.kt @@ -41,6 +41,7 @@ import androidx.databinding.BindingAdapter import androidx.databinding.DataBindingUtil import androidx.databinding.ViewDataBinding import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.findViewTreeLifecycleOwner import coil.load import coil.transform.CircleCropTransformation import io.getstream.avatarview.AvatarView @@ -170,6 +171,7 @@ fun AppCompatTextView.setDrawableTint(@ColorInt color: Int) { @UiThread @BindingAdapter("coil") fun loadPictureWithCoil(imageView: ImageView, file: String?) { + Log.i("[Data Binding Utils] Loading file [$file] with coil") if (file != null) { imageView.load(file) { transformations(CircleCropTransformation()) @@ -180,6 +182,7 @@ fun loadPictureWithCoil(imageView: ImageView, file: String?) { @UiThread @BindingAdapter("coilContact") fun loadContactPictureWithCoil2(imageView: ImageView, contact: ContactData?) { + Log.i("[Data Binding Utils] Loading contact picture [${contact?.avatar}] with coil") if (contact == null) { imageView.load(R.drawable.contact_avatar) } else { @@ -205,34 +208,61 @@ fun ImageView.setPresenceIcon(presence: ConsolidatedPresence?) { @UiThread @BindingAdapter("avatarInitials") fun AvatarView.loadInitials(initials: String?) { + Log.i("[Data Binding Utils] Displaying initials [$initials] on AvatarView") avatarInitials = initials.orEmpty() } @UiThread @BindingAdapter("accountAvatar") fun AvatarView.loadAccountAvatar(account: AccountModel?) { + Log.i("[Data Binding Utils] Loading account picture [${account?.avatar?.value}] with coil") if (account == null) { loadImage(R.drawable.contact_avatar) } else { - val uri = account.avatar.value - loadImage( - data = uri, - onStart = { - // Use initials as placeholder - avatarInitials = account.initials.value.orEmpty() + val lifecycleOwner = findViewTreeLifecycleOwner() + if (lifecycleOwner != null) { + account.avatar.observe(lifecycleOwner) { uri -> + loadImage( + data = uri, + onStart = { + // Use initials as placeholder + avatarInitials = account.initials.value.orEmpty() - if (account.showTrust.value == true) { - avatarBorderColor = resources.getColor(R.color.trusted_blue, context.theme) - avatarBorderWidth = AppUtils.getDimension(R.dimen.avatar_trust_border_width).toInt() - } else { - avatarBorderWidth = AppUtils.getDimension(R.dimen.zero).toInt() - } - }, - onSuccess = { _, _ -> - // If loading is successful, remove initials otherwise image won't be visible - avatarInitials = "" + if (account.showTrust.value == true) { + avatarBorderColor = + resources.getColor(R.color.trusted_blue, context.theme) + avatarBorderWidth = + AppUtils.getDimension(R.dimen.avatar_trust_border_width).toInt() + } else { + avatarBorderWidth = AppUtils.getDimension(R.dimen.zero).toInt() + } + }, + onSuccess = { _, _ -> + // If loading is successful, remove initials otherwise image won't be visible + avatarInitials = "" + } + ) } - ) + } else { + loadImage( + data = account.avatar.value, + onStart = { + // Use initials as placeholder + avatarInitials = account.initials.value.orEmpty() + + if (account.showTrust.value == true) { + avatarBorderColor = resources.getColor(R.color.trusted_blue, context.theme) + avatarBorderWidth = AppUtils.getDimension(R.dimen.avatar_trust_border_width).toInt() + } else { + avatarBorderWidth = AppUtils.getDimension(R.dimen.zero).toInt() + } + }, + onSuccess = { _, _ -> + // If loading is successful, remove initials otherwise image won't be visible + avatarInitials = "" + } + ) + } } } @@ -242,25 +272,53 @@ fun AvatarView.loadContactAvatar(contact: ContactAvatarModel?) { if (contact == null) { loadImage(R.drawable.contact_avatar) } else { - val uri = contact.avatar.value - loadImage( - data = uri, - onStart = { - // Use initials as placeholder - avatarInitials = contact.initials + val lifecycleOwner = findViewTreeLifecycleOwner() + if (lifecycleOwner != null) { + contact.avatar.observe(lifecycleOwner) { uri -> + loadImage( + data = uri, + onStart = { + // Use initials as placeholder + avatarInitials = contact.initials - if (contact.showTrust.value == true) { - avatarBorderColor = resources.getColor(R.color.trusted_blue, context.theme) - avatarBorderWidth = AppUtils.getDimension(R.dimen.avatar_trust_border_width).toInt() - } else { - avatarBorderWidth = AppUtils.getDimension(R.dimen.zero).toInt() - } - }, - onSuccess = { _, _ -> - // If loading is successful, remove initials otherwise image won't be visible - avatarInitials = "" + if (contact.showTrust.value == true) { + avatarBorderColor = + resources.getColor(R.color.trusted_blue, context.theme) + avatarBorderWidth = + AppUtils.getDimension(R.dimen.avatar_trust_border_width).toInt() + } else { + avatarBorderWidth = AppUtils.getDimension(R.dimen.zero).toInt() + } + }, + onSuccess = { _, _ -> + // If loading is successful, remove initials otherwise image won't be visible + avatarInitials = "" + } + ) } - ) + } else { + val uri = contact.avatar.value + loadImage( + data = uri, + onStart = { + // Use initials as placeholder + avatarInitials = contact.initials + + if (contact.showTrust.value == true) { + avatarBorderColor = + resources.getColor(R.color.trusted_blue, context.theme) + avatarBorderWidth = + AppUtils.getDimension(R.dimen.avatar_trust_border_width).toInt() + } else { + avatarBorderWidth = AppUtils.getDimension(R.dimen.zero).toInt() + } + }, + onSuccess = { _, _ -> + // If loading is successful, remove initials otherwise image won't be visible + avatarInitials = "" + } + ) + } } } diff --git a/app/src/main/java/org/linphone/utils/FileUtils.kt b/app/src/main/java/org/linphone/utils/FileUtils.kt index e8958857a..1b1a7fafd 100644 --- a/app/src/main/java/org/linphone/utils/FileUtils.kt +++ b/app/src/main/java/org/linphone/utils/FileUtils.kt @@ -19,19 +19,36 @@ */ package org.linphone.utils +import android.content.Context +import android.database.CursorIndexOutOfBoundsException import android.net.Uri import android.os.Environment +import android.os.ParcelFileDescriptor +import android.os.Process +import android.provider.OpenableColumns +import android.system.Os +import android.webkit.MimeTypeMap import androidx.annotation.AnyThread import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.io.IOException +import java.util.Locale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.core.tools.Log class FileUtils { + enum class MimeType { + PlainText, + Pdf, + Image, + Video, + Audio, + Unknown + } + companion object { private const val TAG = "[File Utils]" @@ -82,6 +99,38 @@ class FileUtils { return file } + suspend fun getFilePath(context: Context, uri: Uri, overrideExisting: Boolean): String? { + val name: String = getNameFromUri(uri, context) + try { + if (Os.fstat( + ParcelFileDescriptor.open( + File(uri.path), + ParcelFileDescriptor.MODE_READ_ONLY + ).fileDescriptor + ).st_uid != Process.myUid() + ) { + Log.e("$TAG File descriptor UID different from our, denying copy!") + return null + } + } catch (e: Exception) { + Log.e("$TAG Can't check file ownership: ", e) + } + + val extension = getExtensionFromFileName(name) + val type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) + val isImage = getMimeType(type) == MimeType.Image + + try { + val localFile: File = getFileStoragePath(name, isImage, overrideExisting) + copyFile(uri, localFile) + return localFile.absolutePath + } catch (e: Exception) { + Log.e("$TAG Can't copy file in local storage: ", e) + } + + return null + } + @AnyThread suspend fun copyFile(from: Uri, to: File): Boolean { try { @@ -146,5 +195,66 @@ class FileUtils { return returnPath } + + @AnyThread + private fun getNameFromUri(uri: Uri, context: Context): String { + var name = "" + if (uri.scheme == "content") { + val returnCursor = + context.contentResolver.query(uri, null, null, null, null) + if (returnCursor != null) { + returnCursor.moveToFirst() + val nameIndex = returnCursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex != -1) { + try { + val displayName = returnCursor.getString(nameIndex) + if (displayName != null) { + name = displayName + } else { + Log.e( + "$TAG Failed to get the display name for URI $uri, returned value is null" + ) + } + } catch (e: CursorIndexOutOfBoundsException) { + Log.e( + "$TAG Failed to get the display name for URI $uri, exception is $e" + ) + } + } else { + Log.e("$TAG Couldn't get DISPLAY_NAME column index for URI: $uri") + } + returnCursor.close() + } + } else if (uri.scheme == "file") { + name = uri.lastPathSegment ?: "" + } + return name + } + + @AnyThread + private fun getExtensionFromFileName(fileName: String): String { + var extension = MimeTypeMap.getFileExtensionFromUrl(fileName) + if (extension.isNullOrEmpty()) { + val i = fileName.lastIndexOf('.') + if (i > 0) { + extension = fileName.substring(i + 1) + } + } + + return extension.lowercase(Locale.getDefault()) + } + + @AnyThread + private fun getMimeType(type: String?): MimeType { + if (type.isNullOrEmpty()) return MimeType.Unknown + return when { + type.startsWith("image/") -> MimeType.Image + type.startsWith("text/plain") -> MimeType.PlainText + type.startsWith("video/") -> MimeType.Video + type.startsWith("audio/") -> MimeType.Audio + type.startsWith("application/pdf") -> MimeType.Pdf + else -> MimeType.Unknown + } + } } }