diff --git a/CHANGES.md b/CHANGES.md index 337e61afb4..06c1424404 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/profile/ProfileService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/profile/ProfileService.kt index 3d084336e3..d7569bbc18 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/profile/ProfileService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/profile/ProfileService.kt @@ -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): 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): Cancelable + /** * Return the current avatarUrl for this user. * @param userId the userId param to look for diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/DefaultProfileService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/DefaultProfileService.kt index 459d53607b..aae2df5379 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/DefaultProfileService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/DefaultProfileService.kt @@ -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>): 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): Cancelable { + val cancelableBag = CancelableBag() + val workerParams = UploadAvatarWorker.Params(sessionId, newAvatarUri, fileName) + val workerData = WorkerParamsFactory.toData(workerParams) + + val uploadAvatarWork = workManagerProvider.matrixOneTimeWorkRequestBuilder() + .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(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>): Cancelable { val params = GetProfileInfoTask.Params(userId) return getProfileInfoTask diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/ProfileAPI.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/ProfileAPI.kt index b3b726a315..7dc4763403 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/ProfileAPI.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/ProfileAPI.kt @@ -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 + /** + * 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 + /** * Bind a threePid * Ref: https://matrix.org/docs/spec/client_server/latest#post-matrix-client-r0-account-3pid-bind diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/ProfileModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/ProfileModule.kt index d83c305c10..b86d0ee07a 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/ProfileModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/ProfileModule.kt @@ -54,4 +54,7 @@ internal abstract class ProfileModule { @Binds abstract fun bindSetDisplayNameTask(task: DefaultSetDisplayNameTask): SetDisplayNameTask + + @Binds + abstract fun bindSetAvatarUrlTask(task: DefaultSetAvatarUrlTask): SetAvatarUrlTask } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/SetAvatarUrlBody.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/SetAvatarUrlBody.kt new file mode 100644 index 0000000000..0288853e28 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/SetAvatarUrlBody.kt @@ -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 +) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/SetAvatarUrlTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/SetAvatarUrlTask.kt new file mode 100644 index 0000000000..263922ca78 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/SetAvatarUrlTask.kt @@ -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 { + 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) + } + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsGeneralFragment.kt b/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsGeneralFragment.kt index 5ff521400f..791b0e20f5 100644 --- a/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsGeneralFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsGeneralFragment.kt @@ -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() private var mDisplayedPhoneNumber = ArrayList() + private lateinit var avatarSelector: AvatarSelectorView + private var avatarCameraUri: Uri? = null + private val mUserSettingsCategory by lazy { findPreference(VectorPreferences.SETTINGS_USER_SETTINGS_PREFERENCE_KEY)!! } @@ -281,7 +297,7 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() { override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, 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 { + 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 { - 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 { + 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