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)
- Prioritising Recovery key over Recovery passphrase (#1463)
- Room Settings: Name, Topic, Photo, Aliases, History Visibility (#1455)
- Update user avatar (#1054)
Bugfix 🐛:
- Fix dark theme issue on login screen (#1097)

View file

@ -17,6 +17,7 @@
package im.vector.matrix.android.api.session.profile
import android.net.Uri
import androidx.lifecycle.LiveData
import im.vector.matrix.android.api.MatrixCallback
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
/**
* 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.
* @param userId the userId param to look for

View file

@ -17,26 +17,44 @@
package im.vector.matrix.android.internal.session.profile
import android.net.Uri
import androidx.lifecycle.LiveData
import androidx.work.BackoffPolicy
import androidx.work.ExistingWorkPolicy
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.identity.ThreePid
import im.vector.matrix.android.api.session.profile.ProfileService
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.Optional
import im.vector.matrix.android.internal.database.model.UserThreePidEntity
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.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 kotlinx.coroutines.launch
import java.util.concurrent.TimeUnit
import javax.inject.Inject
private const val UPLOAD_AVATAR_WORK = "UPLOAD_AVATAR_WORK"
internal class DefaultProfileService @Inject constructor(private val taskExecutor: TaskExecutor,
@SessionDatabase private val monarchy: Monarchy,
@SessionId private val sessionId: String,
private val workManagerProvider: WorkManagerProvider,
private val coroutineDispatchers: MatrixCoroutineDispatchers,
private val refreshUserThreePidsTask: RefreshUserThreePidsTask,
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 {
val params = GetProfileInfoTask.Params(userId)
@ -64,6 +82,41 @@ internal class DefaultProfileService @Inject constructor(private val taskExecuto
.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 {
val params = GetProfileInfoTask.Params(userId)
return getProfileInfoTask

View file

@ -49,6 +49,12 @@ internal interface ProfileAPI {
@PUT(NetworkConstants.URI_API_PREFIX_PATH_R0 + "profile/{userId}/displayname")
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
* 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
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.content.Intent
import android.graphics.Color
import android.net.Uri
import android.text.Editable
import android.util.Patterns
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.core.view.isVisible
import androidx.preference.EditTextPreference
import androidx.preference.Preference
@ -36,6 +40,8 @@ import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.cache.DiskCache
import com.google.android.material.textfield.TextInputEditText
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.NoOpMatrixCallback
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.core.extensions.hideKeyboard
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.preference.UserAvatarPreference
import im.vector.riotx.core.preference.VectorPreference
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.TextUtils
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.getSizeOfFiles
import im.vector.riotx.core.utils.toast
import im.vector.riotx.features.MainActivity
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.workers.signout.SignOutUiWorker
import im.vector.riotx.multipicker.MultiPicker
import im.vector.riotx.multipicker.entity.MultiPickerImageType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
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 val preferenceXmlRes = R.xml.vector_settings_general
@ -72,6 +85,9 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
private var mDisplayedEmails = ArrayList<String>()
private var mDisplayedPhoneNumber = ArrayList<String>()
private lateinit var avatarSelector: AvatarSelectorView
private var avatarCameraUri: Uri? = null
private val mUserSettingsCategory by lazy {
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) {
if (allGranted(grantResults)) {
if (requestCode == PERMISSION_REQUEST_CODE_LAUNCH_CAMERA) {
changeAvatar()
onTypeSelected(AvatarSelectorView.Type.CAMERA)
}
}
}
@ -291,8 +307,27 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
if (resultCode == Activity.RESULT_OK) {
when (requestCode) {
REQUEST_NEW_PHONE_NUMBER -> refreshPhoneNumbersList()
REQUEST_PHONEBOOK_COUNTRY -> onPhonebookCountryUpdate(data)
REQUEST_NEW_PHONE_NUMBER -> refreshPhoneNumbersList()
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
VectorUtils.TAKE_IMAGE -> {
val thumbnailUri = VectorUtils.getThumbnailUriFromIntent(activity, data, session.mediaCache)
@ -370,26 +405,88 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
* Update the avatar.
*/
private fun onUpdateAvatarClick() {
notImplemented()
/* TODO
if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, this, PERMISSION_REQUEST_CODE_LAUNCH_CAMERA)) {
changeAvatar()
if (!::avatarSelector.isInitialized) {
avatarSelector = AvatarSelectorView(activity!!, activity!!.layoutInflater, this)
}
mUserAvatarPreference.mAvatarView?.let {
avatarSelector.show(it, false)
}
*/
}
private fun changeAvatar() {
/* TODO
val intent = Intent(activity, VectorMediaPickerActivity::class.java)
intent.putExtra(VectorMediaPickerActivity.EXTRA_AVATAR_MODE, true)
startActivityForResult(intent, VectorUtils.TAKE_IMAGE)
*/
override fun onTypeSelected(type: AvatarSelectorView.Type) {
when (type) {
AvatarSelectorView.Type.CAMERA -> {
if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, this, PERMISSION_REQUEST_CODE_LAUNCH_CAMERA)) {
avatarCameraUri = MultiPicker.get(MultiPicker.CAMERA).startWithExpectingFile(this)
}
}
AvatarSelectorView.Type.GALLERY -> {
MultiPicker.get(MultiPicker.IMAGE).single().startWith(this)
}
}
}
// ==============================================================================================================
// contacts management
// ==============================================================================================================
private fun onAvatarSelected(image: MultiPickerImageType) {
val destinationFile = File(requireContext().cacheDir, "${image.displayName}_edited_image_${System.currentTimeMillis()}")
val uri = image.contentUri
UCrop.of(uri, destinationFile.toUri())
.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)
}
})
}
// ==============================================================================================================
// contacts management
// ==============================================================================================================
private fun setContactsPreferences() {
/* TODO
@ -422,9 +519,9 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
*/
}
// ==============================================================================================================
// Phone number management
// ==============================================================================================================
// ==============================================================================================================
// Phone number management
// ==============================================================================================================
/**
* Refresh phone number list
@ -505,9 +602,9 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
} */
}
// ==============================================================================================================
// Email management
// ==============================================================================================================
// ==============================================================================================================
// Email management
// ==============================================================================================================
/**
* Refresh the emails list
@ -632,47 +729,47 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
*
* @param pid the used pid.
*/
/* TODO
private fun showEmailValidationDialog(pid: ThreePid) {
activity?.let {
AlertDialog.Builder(it)
.setTitle(R.string.account_email_validation_title)
.setMessage(R.string.account_email_validation_message)
.setPositiveButton(R.string._continue) { _, _ ->
session.myUser.add3Pid(pid, true, object : MatrixCallback<Unit> {
override fun onSuccess(info: Void?) {
/* TODO
private fun showEmailValidationDialog(pid: ThreePid) {
activity?.let {
AlertDialog.Builder(it)
.setTitle(R.string.account_email_validation_title)
.setMessage(R.string.account_email_validation_message)
.setPositiveButton(R.string._continue) { _, _ ->
session.myUser.add3Pid(pid, true, object : MatrixCallback<Unit> {
override fun onSuccess(info: Void?) {
it.runOnUiThread {
hideLoadingView()
refreshEmailsList()
}
}
override fun onNetworkError(e: Exception) {
onCommonDone(e.localizedMessage)
}
override fun onMatrixError(e: MatrixError) {
if (TextUtils.equals(e.errcode, MatrixError.THREEPID_AUTH_FAILED)) {
it.runOnUiThread {
hideLoadingView()
refreshEmailsList()
it.toast(R.string.account_email_validation_error)
}
}
override fun onNetworkError(e: Exception) {
} else {
onCommonDone(e.localizedMessage)
}
}
override fun onMatrixError(e: MatrixError) {
if (TextUtils.equals(e.errcode, MatrixError.THREEPID_AUTH_FAILED)) {
it.runOnUiThread {
hideLoadingView()
it.toast(R.string.account_email_validation_error)
}
} else {
onCommonDone(e.localizedMessage)
}
}
override fun onUnexpectedError(e: Exception) {
onCommonDone(e.localizedMessage)
}
})
}
.setNegativeButton(R.string.cancel) { _, _ ->
hideLoadingView()
}
.show()
}
} */
override fun onUnexpectedError(e: Exception) {
onCommonDone(e.localizedMessage)
}
})
}
.setNegativeButton(R.string.cancel) { _, _ ->
hideLoadingView()
}
.show()
}
} */
/**
* Display a dialog which asks confirmation for the deletion of a 3pid