Improved account avatar

This commit is contained in:
Sylvain Berfini 2023-08-31 15:49:59 +02:00
parent 157c233ab1
commit b93f75aade
6 changed files with 239 additions and 80 deletions

View file

@ -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<String>()
val avatar = MutableLiveData<String>()
@ -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

View file

@ -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 {

View file

@ -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()
}
}
}

View file

@ -54,6 +54,8 @@ class DrawerMenuViewModel @UiThread constructor() : ViewModel() {
coreContext.postOnCoreThread {
computeAccountsList()
}
// TODO FIXME: account avatar not refreshed...
}
@UiThread

View file

@ -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 = ""
}
)
}
}
}

View file

@ -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
}
}
}
}