Implementation of updating user avatar.

Fixes #1054
This commit is contained in:
onurays 2020-06-25 14:38:22 +03:00 committed by Benoit Marty
parent a03f69fb98
commit 16bd642ae8
8 changed files with 301 additions and 61 deletions

View file

@ -10,6 +10,7 @@ Improvements 🙌:
- Handle `/op`, `/deop`, and `/nick` commands (#12) - Handle `/op`, `/deop`, and `/nick` commands (#12)
- Prioritising Recovery key over Recovery passphrase (#1463) - Prioritising Recovery key over Recovery passphrase (#1463)
- Room Settings: Name, Topic, Photo, Aliases, History Visibility (#1455) - Room Settings: Name, Topic, Photo, Aliases, History Visibility (#1455)
- Update user avatar (#1054)
Bugfix 🐛: Bugfix 🐛:
- Fix dark theme issue on login screen (#1097) - Fix dark theme issue on login screen (#1097)

View file

@ -17,6 +17,7 @@
package im.vector.matrix.android.api.session.profile package im.vector.matrix.android.api.session.profile
import android.net.Uri
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.identity.ThreePid import im.vector.matrix.android.api.session.identity.ThreePid
@ -48,6 +49,14 @@ interface ProfileService {
*/ */
fun setDisplayName(userId: String, newDisplayName: String, matrixCallback: MatrixCallback<Unit>): Cancelable fun setDisplayName(userId: String, newDisplayName: String, matrixCallback: MatrixCallback<Unit>): Cancelable
/**
* Update the avatar for this user
* @param userId the userId to update the avatar of
* @param newAvatarUri the new avatar uri of the user
* @param fileName the fileName of selected image
*/
fun updateAvatar(userId: String, newAvatarUri: Uri, fileName: String, matrixCallback: MatrixCallback<Unit>): Cancelable
/** /**
* Return the current avatarUrl for this user. * Return the current avatarUrl for this user.
* @param userId the userId param to look for * @param userId the userId param to look for

View file

@ -17,26 +17,44 @@
package im.vector.matrix.android.internal.session.profile package im.vector.matrix.android.internal.session.profile
import android.net.Uri
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.work.BackoffPolicy
import androidx.work.ExistingWorkPolicy
import com.zhuinden.monarchy.Monarchy import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.identity.ThreePid import im.vector.matrix.android.api.session.identity.ThreePid
import im.vector.matrix.android.api.session.profile.ProfileService import im.vector.matrix.android.api.session.profile.ProfileService
import im.vector.matrix.android.api.util.Cancelable import im.vector.matrix.android.api.util.Cancelable
import im.vector.matrix.android.api.util.CancelableBag
import im.vector.matrix.android.api.util.JsonDict import im.vector.matrix.android.api.util.JsonDict
import im.vector.matrix.android.api.util.Optional import im.vector.matrix.android.api.util.Optional
import im.vector.matrix.android.internal.database.model.UserThreePidEntity import im.vector.matrix.android.internal.database.model.UserThreePidEntity
import im.vector.matrix.android.internal.di.SessionDatabase import im.vector.matrix.android.internal.di.SessionDatabase
import im.vector.matrix.android.internal.di.SessionId
import im.vector.matrix.android.internal.di.WorkManagerProvider
import im.vector.matrix.android.internal.session.content.UploadAvatarWorker
import im.vector.matrix.android.internal.task.TaskExecutor import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith import im.vector.matrix.android.internal.task.configureWith
import im.vector.matrix.android.internal.util.CancelableWork
import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers
import im.vector.matrix.android.internal.worker.WorkerParamsFactory
import io.realm.kotlin.where import io.realm.kotlin.where
import kotlinx.coroutines.launch
import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
private const val UPLOAD_AVATAR_WORK = "UPLOAD_AVATAR_WORK"
internal class DefaultProfileService @Inject constructor(private val taskExecutor: TaskExecutor, internal class DefaultProfileService @Inject constructor(private val taskExecutor: TaskExecutor,
@SessionDatabase private val monarchy: Monarchy, @SessionDatabase private val monarchy: Monarchy,
@SessionId private val sessionId: String,
private val workManagerProvider: WorkManagerProvider,
private val coroutineDispatchers: MatrixCoroutineDispatchers,
private val refreshUserThreePidsTask: RefreshUserThreePidsTask, private val refreshUserThreePidsTask: RefreshUserThreePidsTask,
private val getProfileInfoTask: GetProfileInfoTask, private val getProfileInfoTask: GetProfileInfoTask,
private val setDisplayNameTask: SetDisplayNameTask) : ProfileService { private val setDisplayNameTask: SetDisplayNameTask,
private val setAvatarUrlTask: SetAvatarUrlTask) : ProfileService {
override fun getDisplayName(userId: String, matrixCallback: MatrixCallback<Optional<String>>): Cancelable { override fun getDisplayName(userId: String, matrixCallback: MatrixCallback<Optional<String>>): Cancelable {
val params = GetProfileInfoTask.Params(userId) val params = GetProfileInfoTask.Params(userId)
@ -64,6 +82,41 @@ internal class DefaultProfileService @Inject constructor(private val taskExecuto
.executeBy(taskExecutor) .executeBy(taskExecutor)
} }
override fun updateAvatar(userId: String, newAvatarUri: Uri, fileName: String, matrixCallback: MatrixCallback<Unit>): Cancelable {
val cancelableBag = CancelableBag()
val workerParams = UploadAvatarWorker.Params(sessionId, newAvatarUri, fileName)
val workerData = WorkerParamsFactory.toData(workerParams)
val uploadAvatarWork = workManagerProvider.matrixOneTimeWorkRequestBuilder<UploadAvatarWorker>()
.setConstraints(WorkManagerProvider.workConstraints)
.setInputData(workerData)
.setBackoffCriteria(BackoffPolicy.LINEAR, WorkManagerProvider.BACKOFF_DELAY, TimeUnit.MILLISECONDS)
.build()
workManagerProvider.workManager
.beginUniqueWork("${userId}_$UPLOAD_AVATAR_WORK", ExistingWorkPolicy.REPLACE, uploadAvatarWork)
.enqueue()
cancelableBag.add(CancelableWork(workManagerProvider.workManager, uploadAvatarWork.id))
taskExecutor.executorScope.launch(coroutineDispatchers.main) {
workManagerProvider.workManager.getWorkInfoByIdLiveData(uploadAvatarWork.id)
.observeForever { info ->
if (info != null && info.state.isFinished) {
val result = WorkerParamsFactory.fromData<UploadAvatarWorker.OutputParams>(info.outputData)
cancelableBag.add(
setAvatarUrlTask
.configureWith(SetAvatarUrlTask.Params(userId = userId, newAvatarUrl = result?.imageUrl!!)) {
callback = matrixCallback
}
.executeBy(taskExecutor)
)
}
}
}
return cancelableBag
}
override fun getAvatarUrl(userId: String, matrixCallback: MatrixCallback<Optional<String>>): Cancelable { override fun getAvatarUrl(userId: String, matrixCallback: MatrixCallback<Optional<String>>): Cancelable {
val params = GetProfileInfoTask.Params(userId) val params = GetProfileInfoTask.Params(userId)
return getProfileInfoTask return getProfileInfoTask

View file

@ -49,6 +49,12 @@ internal interface ProfileAPI {
@PUT(NetworkConstants.URI_API_PREFIX_PATH_R0 + "profile/{userId}/displayname") @PUT(NetworkConstants.URI_API_PREFIX_PATH_R0 + "profile/{userId}/displayname")
fun setDisplayName(@Path("userId") userId: String, @Body body: SetDisplayNameBody): Call<Unit> fun setDisplayName(@Path("userId") userId: String, @Body body: SetDisplayNameBody): Call<Unit>
/**
* Change user avatar url.
*/
@PUT(NetworkConstants.URI_API_PREFIX_PATH_R0 + "profile/{userId}/avatar_url")
fun setAvatarUrl(@Path("userId") userId: String, @Body body: SetAvatarUrlBody): Call<Unit>
/** /**
* Bind a threePid * Bind a threePid
* Ref: https://matrix.org/docs/spec/client_server/latest#post-matrix-client-r0-account-3pid-bind * Ref: https://matrix.org/docs/spec/client_server/latest#post-matrix-client-r0-account-3pid-bind

View file

@ -54,4 +54,7 @@ internal abstract class ProfileModule {
@Binds @Binds
abstract fun bindSetDisplayNameTask(task: DefaultSetDisplayNameTask): SetDisplayNameTask abstract fun bindSetDisplayNameTask(task: DefaultSetDisplayNameTask): SetDisplayNameTask
@Binds
abstract fun bindSetAvatarUrlTask(task: DefaultSetAvatarUrlTask): SetAvatarUrlTask
} }

View file

@ -0,0 +1,28 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.matrix.android.internal.session.profile
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
internal data class SetAvatarUrlBody(
/**
* The new avatar url for this user.
*/
@Json(name = "avatar_url")
val avatarUrl: String
)

View file

@ -0,0 +1,43 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.matrix.android.internal.session.profile
import im.vector.matrix.android.internal.network.executeRequest
import im.vector.matrix.android.internal.task.Task
import org.greenrobot.eventbus.EventBus
import javax.inject.Inject
internal abstract class SetAvatarUrlTask : Task<SetAvatarUrlTask.Params, Unit> {
data class Params(
val userId: String,
val newAvatarUrl: String
)
}
internal class DefaultSetAvatarUrlTask @Inject constructor(
private val profileAPI: ProfileAPI,
private val eventBus: EventBus) : SetAvatarUrlTask() {
override suspend fun execute(params: Params) {
return executeRequest(eventBus) {
val body = SetAvatarUrlBody(
avatarUrl = params.newAvatarUrl
)
apiCall = profileAPI.setAvatarUrl(params.userId, body)
}
}
}

View file

@ -20,13 +20,17 @@ package im.vector.riotx.features.settings
import android.app.Activity import android.app.Activity
import android.content.Intent import android.content.Intent
import android.graphics.Color
import android.net.Uri
import android.text.Editable import android.text.Editable
import android.util.Patterns import android.util.Patterns
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.preference.EditTextPreference import androidx.preference.EditTextPreference
import androidx.preference.Preference import androidx.preference.Preference
@ -36,6 +40,8 @@ import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.cache.DiskCache import com.bumptech.glide.load.engine.cache.DiskCache
import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout import com.google.android.material.textfield.TextInputLayout
import com.yalantis.ucrop.UCrop
import com.yalantis.ucrop.UCropActivity
import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.NoOpMatrixCallback import im.vector.matrix.android.api.NoOpMatrixCallback
import im.vector.matrix.android.api.failure.isInvalidPassword import im.vector.matrix.android.api.failure.isInvalidPassword
@ -44,27 +50,34 @@ import im.vector.matrix.android.api.session.integrationmanager.IntegrationManage
import im.vector.riotx.R import im.vector.riotx.R
import im.vector.riotx.core.extensions.hideKeyboard import im.vector.riotx.core.extensions.hideKeyboard
import im.vector.riotx.core.extensions.showPassword import im.vector.riotx.core.extensions.showPassword
import im.vector.riotx.core.intent.getFilenameFromUri
import im.vector.riotx.core.platform.SimpleTextWatcher import im.vector.riotx.core.platform.SimpleTextWatcher
import im.vector.riotx.core.preference.UserAvatarPreference import im.vector.riotx.core.preference.UserAvatarPreference
import im.vector.riotx.core.preference.VectorPreference import im.vector.riotx.core.preference.VectorPreference
import im.vector.riotx.core.preference.VectorSwitchPreference import im.vector.riotx.core.preference.VectorSwitchPreference
import im.vector.riotx.core.utils.PERMISSIONS_FOR_TAKING_PHOTO
import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_LAUNCH_CAMERA import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_LAUNCH_CAMERA
import im.vector.riotx.core.utils.TextUtils import im.vector.riotx.core.utils.TextUtils
import im.vector.riotx.core.utils.allGranted import im.vector.riotx.core.utils.allGranted
import im.vector.riotx.core.utils.checkPermissions
import im.vector.riotx.core.utils.copyToClipboard import im.vector.riotx.core.utils.copyToClipboard
import im.vector.riotx.core.utils.getSizeOfFiles import im.vector.riotx.core.utils.getSizeOfFiles
import im.vector.riotx.core.utils.toast import im.vector.riotx.core.utils.toast
import im.vector.riotx.features.MainActivity import im.vector.riotx.features.MainActivity
import im.vector.riotx.features.MainActivityArgs import im.vector.riotx.features.MainActivityArgs
import im.vector.riotx.features.roomprofile.AvatarSelectorView
import im.vector.riotx.features.themes.ThemeUtils import im.vector.riotx.features.themes.ThemeUtils
import im.vector.riotx.features.workers.signout.SignOutUiWorker import im.vector.riotx.features.workers.signout.SignOutUiWorker
import im.vector.riotx.multipicker.MultiPicker
import im.vector.riotx.multipicker.entity.MultiPickerImageType
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.util.UUID
class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() { class VectorSettingsGeneralFragment : VectorSettingsBaseFragment(), AvatarSelectorView.Callback {
override var titleRes = R.string.settings_general_title override var titleRes = R.string.settings_general_title
override val preferenceXmlRes = R.xml.vector_settings_general override val preferenceXmlRes = R.xml.vector_settings_general
@ -72,6 +85,9 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
private var mDisplayedEmails = ArrayList<String>() private var mDisplayedEmails = ArrayList<String>()
private var mDisplayedPhoneNumber = ArrayList<String>() private var mDisplayedPhoneNumber = ArrayList<String>()
private lateinit var avatarSelector: AvatarSelectorView
private var avatarCameraUri: Uri? = null
private val mUserSettingsCategory by lazy { private val mUserSettingsCategory by lazy {
findPreference<PreferenceCategory>(VectorPreferences.SETTINGS_USER_SETTINGS_PREFERENCE_KEY)!! findPreference<PreferenceCategory>(VectorPreferences.SETTINGS_USER_SETTINGS_PREFERENCE_KEY)!!
} }
@ -281,7 +297,7 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) { override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
if (allGranted(grantResults)) { if (allGranted(grantResults)) {
if (requestCode == PERMISSION_REQUEST_CODE_LAUNCH_CAMERA) { if (requestCode == PERMISSION_REQUEST_CODE_LAUNCH_CAMERA) {
changeAvatar() onTypeSelected(AvatarSelectorView.Type.CAMERA)
} }
} }
} }
@ -293,6 +309,25 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
when (requestCode) { when (requestCode) {
REQUEST_NEW_PHONE_NUMBER -> refreshPhoneNumbersList() REQUEST_NEW_PHONE_NUMBER -> refreshPhoneNumbersList()
REQUEST_PHONEBOOK_COUNTRY -> onPhonebookCountryUpdate(data) REQUEST_PHONEBOOK_COUNTRY -> onPhonebookCountryUpdate(data)
MultiPicker.REQUEST_CODE_TAKE_PHOTO -> {
avatarCameraUri?.let { uri ->
MultiPicker.get(MultiPicker.CAMERA)
.getTakenPhoto(requireContext(), requestCode, resultCode, uri)
?.let {
onAvatarSelected(it)
}
}
}
MultiPicker.REQUEST_CODE_PICK_IMAGE -> {
MultiPicker
.get(MultiPicker.IMAGE)
.getSelectedFiles(requireContext(), requestCode, resultCode, data)
.firstOrNull()?.let {
// TODO. UCrop library cannot read from Gallery. For now, we will set avatar as it is.
onAvatarCropped(it.contentUri)
}
}
UCrop.REQUEST_CROP -> data?.let { onAvatarCropped(UCrop.getOutput(it)) }
/* TODO /* TODO
VectorUtils.TAKE_IMAGE -> { VectorUtils.TAKE_IMAGE -> {
val thumbnailUri = VectorUtils.getThumbnailUriFromIntent(activity, data, session.mediaCache) val thumbnailUri = VectorUtils.getThumbnailUriFromIntent(activity, data, session.mediaCache)
@ -370,21 +405,83 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
* Update the avatar. * Update the avatar.
*/ */
private fun onUpdateAvatarClick() { private fun onUpdateAvatarClick() {
notImplemented() if (!::avatarSelector.isInitialized) {
avatarSelector = AvatarSelectorView(activity!!, activity!!.layoutInflater, this)
}
mUserAvatarPreference.mAvatarView?.let {
avatarSelector.show(it, false)
}
}
/* TODO override fun onTypeSelected(type: AvatarSelectorView.Type) {
when (type) {
AvatarSelectorView.Type.CAMERA -> {
if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, this, PERMISSION_REQUEST_CODE_LAUNCH_CAMERA)) { if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, this, PERMISSION_REQUEST_CODE_LAUNCH_CAMERA)) {
changeAvatar() avatarCameraUri = MultiPicker.get(MultiPicker.CAMERA).startWithExpectingFile(this)
}
}
AvatarSelectorView.Type.GALLERY -> {
MultiPicker.get(MultiPicker.IMAGE).single().startWith(this)
}
} }
*/
} }
private fun changeAvatar() { private fun onAvatarSelected(image: MultiPickerImageType) {
/* TODO val destinationFile = File(requireContext().cacheDir, "${image.displayName}_edited_image_${System.currentTimeMillis()}")
val intent = Intent(activity, VectorMediaPickerActivity::class.java) val uri = image.contentUri
intent.putExtra(VectorMediaPickerActivity.EXTRA_AVATAR_MODE, true) UCrop.of(uri, destinationFile.toUri())
startActivityForResult(intent, VectorUtils.TAKE_IMAGE) .withOptions(
*/ UCrop.Options()
.apply {
setAllowedGestures(
/* tabScale = */ UCropActivity.SCALE,
/* tabRotate = */ UCropActivity.ALL,
/* tabAspectRatio = */ UCropActivity.SCALE
)
setToolbarTitle(image.displayName)
// Disable freestyle crop, usability was not easy
// setFreeStyleCropEnabled(true)
// Color used for toolbar icon and text
setToolbarColor(ThemeUtils.getColor(requireContext(), R.attr.riotx_background))
setToolbarWidgetColor(ThemeUtils.getColor(requireContext(), R.attr.vctr_toolbar_primary_text_color))
// Background
setRootViewBackgroundColor(ThemeUtils.getColor(requireContext(), R.attr.riotx_background))
// Status bar color (pb in dark mode, icon of the status bar are dark)
setStatusBarColor(ThemeUtils.getColor(requireContext(), R.attr.riotx_header_panel_background))
// Known issue: there is still orange color used by the lib
// https://github.com/Yalantis/uCrop/issues/602
setActiveControlsWidgetColor(ContextCompat.getColor(requireContext(), R.color.riotx_accent))
// Hide the logo (does not work)
setLogoColor(Color.TRANSPARENT)
}
)
.start(requireContext(), this)
}
private fun onAvatarCropped(uri: Uri?) {
if (uri != null) {
uploadAvatar(uri)
} else {
Toast.makeText(requireContext(), "Cannot retrieve cropped value", Toast.LENGTH_SHORT).show()
}
}
private fun uploadAvatar(uri: Uri) {
displayLoadingView()
session.updateAvatar(session.myUserId, uri, getFilenameFromUri(context, uri) ?: UUID.randomUUID().toString(), object : MatrixCallback<Unit> {
override fun onSuccess(data: Unit) {
if (!isAdded) return
mUserAvatarPreference.refreshAvatar()
onCommonDone(null)
}
override fun onFailure(failure: Throwable) {
if (!isAdded) return
onCommonDone(failure.localizedMessage)
}
})
} }
// ============================================================================================================== // ==============================================================================================================