From a6335e6bcdc1ddfd8b3221231c6a6c8dbae82dac Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Wed, 2 Mar 2022 20:39:56 +0100 Subject: [PATCH] update Android Image Cropper and get rid of deprecated onActivityResult (#2351) * update Android Image Cropper and get rid of deprecated onActivityResult * add comment why skipping caches is necessary * inject application into EditProfileViewModel instead of passing it everytime --- app/build.gradle | 2 +- .../tusky/EditProfileActivity.kt | 279 +++++------------- .../tusky/viewmodel/EditProfileViewModel.kt | 121 ++------ .../main/res/layout/activity_edit_profile.xml | 25 +- 4 files changed, 104 insertions(+), 323 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 67a1ecbe4..43a078aae 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -168,7 +168,7 @@ dependencies { implementation "com.mikepenz:materialdrawer-iconics:$materialdrawerVersion" implementation 'com.mikepenz:google-material-typeface:4.0.0.2-kotlin@aar' - implementation "com.github.CanHub:Android-Image-Cropper:3.1.0" + implementation "com.github.CanHub:Android-Image-Cropper:4.1.0" implementation "de.c1710:filemojicompat:1.0.18" diff --git a/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt index ef0e3e983..b749fe937 100644 --- a/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt @@ -15,28 +15,26 @@ package com.keylesspalace.tusky -import android.Manifest -import android.app.Activity import android.content.Intent -import android.content.pm.PackageManager import android.graphics.Bitmap import android.graphics.Color import android.net.Uri import android.os.Bundle +import android.util.Log import android.view.Menu import android.view.MenuItem import android.view.View import android.widget.ImageView import androidx.activity.viewModels -import androidx.core.app.ActivityCompat -import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.lifecycle.LiveData import androidx.recyclerview.widget.LinearLayoutManager import com.bumptech.glide.Glide +import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.resource.bitmap.FitCenter import com.bumptech.glide.load.resource.bitmap.RoundedCorners -import com.canhub.cropper.CropImage +import com.canhub.cropper.CropImageContract +import com.canhub.cropper.options import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.adapter.AccountFieldEditAdapter import com.keylesspalace.tusky.databinding.ActivityEditProfileBinding @@ -44,9 +42,7 @@ import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.util.Error import com.keylesspalace.tusky.util.Loading -import com.keylesspalace.tusky.util.Resource import com.keylesspalace.tusky.util.Success -import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewmodel.EditProfileViewModel @@ -63,12 +59,7 @@ class EditProfileActivity : BaseActivity(), Injectable { const val HEADER_WIDTH = 1500 const val HEADER_HEIGHT = 500 - private const val AVATAR_PICK_RESULT = 1 - private const val HEADER_PICK_RESULT = 2 - private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1 private const val MAX_ACCOUNT_FIELDS = 4 - - private const val BUNDLE_CURRENTLY_PICKING = "BUNDLE_CURRENTLY_PICKING" } @Inject @@ -78,23 +69,28 @@ class EditProfileActivity : BaseActivity(), Injectable { private val binding by viewBinding(ActivityEditProfileBinding::inflate) - private var currentlyPicking: PickType = PickType.NOTHING - private val accountFieldEditAdapter = AccountFieldEditAdapter() private enum class PickType { - NOTHING, AVATAR, HEADER } + private val cropImage = registerForActivityResult(CropImageContract()) { result -> + if (result.isSuccessful) { + if (result.uriContent == viewModel.getAvatarUri()) { + viewModel.newAvatarPicked() + } else { + viewModel.newHeaderPicked() + } + } else { + onPickFailure(result.error) + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - savedInstanceState?.getString(BUNDLE_CURRENTLY_PICKING)?.let { - currentlyPicking = PickType.valueOf(it) - } - setContentView(binding.root) setSupportActionBar(binding.includedToolbar.toolbar) @@ -104,8 +100,8 @@ class EditProfileActivity : BaseActivity(), Injectable { setDisplayShowHomeEnabled(true) } - binding.avatarButton.setOnClickListener { onMediaPick(PickType.AVATAR) } - binding.headerButton.setOnClickListener { onMediaPick(PickType.HEADER) } + binding.avatarButton.setOnClickListener { pickMedia(PickType.AVATAR) } + binding.headerButton.setOnClickListener { pickMedia(PickType.HEADER) } binding.fieldList.layoutManager = LinearLayoutManager(this) binding.fieldList.adapter = accountFieldEditAdapter @@ -159,11 +155,11 @@ class EditProfileActivity : BaseActivity(), Injectable { } } is Error -> { - val snackbar = Snackbar.make(binding.avatarButton, R.string.error_generic, Snackbar.LENGTH_LONG) - snackbar.setAction(R.string.action_retry) { - viewModel.obtainProfile() - } - snackbar.show() + Snackbar.make(binding.avatarButton, R.string.error_generic, Snackbar.LENGTH_LONG) + .setAction(R.string.action_retry) { + viewModel.obtainProfile() + } + .show() } is Loading -> { } } @@ -179,30 +175,24 @@ class EditProfileActivity : BaseActivity(), Injectable { } } - observeImage(viewModel.avatarData, binding.avatarPreview, binding.avatarProgressBar, true) - observeImage(viewModel.headerData, binding.headerPreview, binding.headerProgressBar, false) + observeImage(viewModel.avatarData, binding.avatarPreview, true) + observeImage(viewModel.headerData, binding.headerPreview, false) viewModel.saveData.observe( - this, - { - when (it) { - is Success -> { - finish() - } - is Loading -> { - binding.saveProgressBar.visibility = View.VISIBLE - } - is Error -> { - onSaveFailure(it.errorMessage) - } + this + ) { + when (it) { + is Success -> { + finish() + } + is Loading -> { + binding.saveProgressBar.visibility = View.VISIBLE + } + is Error -> { + onSaveFailure(it.errorMessage) } } - ) - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - outState.putString(BUNDLE_CURRENTLY_PICKING, currentlyPicking.toString()) + } } override fun onStop() { @@ -218,90 +208,60 @@ class EditProfileActivity : BaseActivity(), Injectable { } private fun observeImage( - liveData: LiveData>, + liveData: LiveData, imageView: ImageView, - progressBar: View, roundedCorners: Boolean ) { liveData.observe( - this, - { + this + ) { imageUri -> - when (it) { - is Success -> { - val glide = Glide.with(imageView) - .load(it.data) + // skipping all caches so we can always reuse the same uri + val glide = Glide.with(imageView) + .load(imageUri) + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) - if (roundedCorners) { - glide.transform( - FitCenter(), - RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp)) - ) - } - - glide.into(imageView) - - imageView.show() - progressBar.hide() - } - is Loading -> { - progressBar.show() - } - is Error -> { - progressBar.hide() - if (!it.consumed) { - onResizeFailure() - it.consumed = true - } - } - } + if (roundedCorners) { + glide.transform( + FitCenter(), + RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp)) + ).into(imageView) + } else { + glide.into(imageView) } - ) - } - private fun onMediaPick(pickType: PickType) { - if (currentlyPicking != PickType.NOTHING) { - // Ignore inputs if another pick operation is still occurring. - return - } - currentlyPicking = pickType - if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { - ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) - } else { - initiateMediaPicking() + imageView.show() } } - override fun onRequestPermissionsResult( - requestCode: Int, - permissions: Array, - grantResults: IntArray - ) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults) - when (requestCode) { - PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE -> { - if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - initiateMediaPicking() - } else { - endMediaPicking() - Snackbar.make(binding.avatarButton, R.string.error_media_upload_permission, Snackbar.LENGTH_LONG).show() - } - } - } - } - - private fun initiateMediaPicking() { + private fun pickMedia(pickType: PickType) { val intent = Intent(Intent.ACTION_GET_CONTENT) intent.addCategory(Intent.CATEGORY_OPENABLE) intent.type = "image/*" - when (currentlyPicking) { + when (pickType) { PickType.AVATAR -> { - startActivityForResult(intent, AVATAR_PICK_RESULT) + cropImage.launch( + options { + setRequestedSize(AVATAR_SIZE, AVATAR_SIZE) + setAspectRatio(AVATAR_SIZE, AVATAR_SIZE) + setImageSource(includeGallery = true, includeCamera = false) + setOutputUri(viewModel.getAvatarUri()) + setOutputCompressFormat(Bitmap.CompressFormat.PNG) + } + ) } PickType.HEADER -> { - startActivityForResult(intent, HEADER_PICK_RESULT) + cropImage.launch( + options { + setRequestedSize(HEADER_WIDTH, HEADER_HEIGHT) + setAspectRatio(HEADER_WIDTH, HEADER_HEIGHT) + setImageSource(includeGallery = true, includeCamera = false) + setOutputUri(viewModel.getHeaderUri()) + setOutputCompressFormat(Bitmap.CompressFormat.PNG) + } + ) } - PickType.NOTHING -> { /* do nothing */ } } } @@ -321,16 +281,11 @@ class EditProfileActivity : BaseActivity(), Injectable { } private fun save() { - if (currentlyPicking != PickType.NOTHING) { - return - } - viewModel.save( binding.displayNameEditText.text.toString(), binding.noteEditText.text.toString(), binding.lockedCheckBox.isChecked, - accountFieldEditAdapter.getFieldData(), - this + accountFieldEditAdapter.getFieldData() ) } @@ -340,90 +295,8 @@ class EditProfileActivity : BaseActivity(), Injectable { binding.saveProgressBar.visibility = View.GONE } - private fun beginMediaPicking() { - when (currentlyPicking) { - PickType.AVATAR -> { - binding.avatarProgressBar.visibility = View.VISIBLE - binding.avatarPreview.visibility = View.INVISIBLE - binding.avatarButton.setImageDrawable(null) - } - PickType.HEADER -> { - binding.headerProgressBar.visibility = View.VISIBLE - binding.headerPreview.visibility = View.INVISIBLE - binding.headerButton.setImageDrawable(null) - } - PickType.NOTHING -> { /* do nothing */ } - } - } - - private fun endMediaPicking() { - binding.avatarProgressBar.visibility = View.GONE - binding.headerProgressBar.visibility = View.GONE - - currentlyPicking = PickType.NOTHING - } - - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - when (requestCode) { - AVATAR_PICK_RESULT -> { - if (resultCode == Activity.RESULT_OK && data != null) { - CropImage.activity(data.data) - .setInitialCropWindowPaddingRatio(0f) - .setOutputCompressFormat(Bitmap.CompressFormat.PNG) - .setAspectRatio(AVATAR_SIZE, AVATAR_SIZE) - .start(this) - } else { - endMediaPicking() - } - } - HEADER_PICK_RESULT -> { - if (resultCode == Activity.RESULT_OK && data != null) { - CropImage.activity(data.data) - .setInitialCropWindowPaddingRatio(0f) - .setOutputCompressFormat(Bitmap.CompressFormat.PNG) - .setAspectRatio(HEADER_WIDTH, HEADER_HEIGHT) - .start(this) - } else { - endMediaPicking() - } - } - CropImage.CROP_IMAGE_ACTIVITY_REQUEST_CODE -> { - val result = CropImage.getActivityResult(data) - when (resultCode) { - Activity.RESULT_OK -> beginResize(result?.uriContent) - CropImage.CROP_IMAGE_ACTIVITY_RESULT_ERROR_CODE -> onResizeFailure() - else -> endMediaPicking() - } - } - } - } - - private fun beginResize(uri: Uri?) { - if (uri == null) { - currentlyPicking = PickType.NOTHING - return - } - - beginMediaPicking() - - when (currentlyPicking) { - PickType.AVATAR -> { - viewModel.newAvatar(uri, this) - } - PickType.HEADER -> { - viewModel.newHeader(uri, this) - } - else -> { - throw AssertionError("PickType not set.") - } - } - - currentlyPicking = PickType.NOTHING - } - - private fun onResizeFailure() { + private fun onPickFailure(throwable: Throwable?) { + Log.w("EditProfileActivity", "failed to pick media", throwable) Snackbar.make(binding.avatarButton, R.string.error_media_upload_sending, Snackbar.LENGTH_LONG).show() - endMediaPicking() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt index a51fea0de..f3539f8dd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt @@ -15,15 +15,11 @@ package com.keylesspalace.tusky.viewmodel -import android.content.Context -import android.graphics.Bitmap +import android.app.Application import android.net.Uri -import android.util.Log +import androidx.core.net.toUri import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import com.keylesspalace.tusky.EditProfileActivity.Companion.AVATAR_SIZE -import com.keylesspalace.tusky.EditProfileActivity.Companion.HEADER_HEIGHT -import com.keylesspalace.tusky.EditProfileActivity.Companion.HEADER_WIDTH import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.ProfileEditedEvent import com.keylesspalace.tusky.entity.Account @@ -31,16 +27,12 @@ import com.keylesspalace.tusky.entity.Instance import com.keylesspalace.tusky.entity.StringField import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.Error -import com.keylesspalace.tusky.util.IOUtils import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Resource import com.keylesspalace.tusky.util.Success -import com.keylesspalace.tusky.util.getSampledBitmap import com.keylesspalace.tusky.util.randomAlphanumericString -import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.kotlin.addTo -import io.reactivex.rxjava3.schedulers.Schedulers import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.RequestBody @@ -52,30 +44,26 @@ import retrofit2.Call import retrofit2.Callback import retrofit2.Response import java.io.File -import java.io.FileNotFoundException -import java.io.FileOutputStream -import java.io.OutputStream import javax.inject.Inject private const val HEADER_FILE_NAME = "header.png" private const val AVATAR_FILE_NAME = "avatar.png" -private const val TAG = "EditProfileViewModel" - class EditProfileViewModel @Inject constructor( private val mastodonApi: MastodonApi, - private val eventHub: EventHub + private val eventHub: EventHub, + private val application: Application ) : ViewModel() { val profileData = MutableLiveData>() - val avatarData = MutableLiveData>() - val headerData = MutableLiveData>() + val avatarData = MutableLiveData() + val headerData = MutableLiveData() val saveData = MutableLiveData>() val instanceData = MutableLiveData>() private var oldProfileData: Account? = null - private val disposeables = CompositeDisposable() + private val disposables = CompositeDisposable() fun obtainProfile() { if (profileData.value == null || profileData.value is Error) { @@ -92,70 +80,30 @@ class EditProfileViewModel @Inject constructor( profileData.postValue(Error()) } ) - .addTo(disposeables) + .addTo(disposables) } } - fun newAvatar(uri: Uri, context: Context) { - val cacheFile = getCacheFileForName(context, AVATAR_FILE_NAME) + fun getAvatarUri() = getCacheFileForName(AVATAR_FILE_NAME).toUri() - resizeImage(uri, context, AVATAR_SIZE, AVATAR_SIZE, cacheFile, avatarData) + fun getHeaderUri() = getCacheFileForName(HEADER_FILE_NAME).toUri() + + fun newAvatarPicked() { + avatarData.value = getAvatarUri() } - fun newHeader(uri: Uri, context: Context) { - val cacheFile = getCacheFileForName(context, HEADER_FILE_NAME) - - resizeImage(uri, context, HEADER_WIDTH, HEADER_HEIGHT, cacheFile, headerData) + fun newHeaderPicked() { + headerData.value = getHeaderUri() } - private fun resizeImage( - uri: Uri, - context: Context, - resizeWidth: Int, - resizeHeight: Int, - cacheFile: File, - imageLiveData: MutableLiveData> - ) { - - Single.fromCallable { - val contentResolver = context.contentResolver - val sourceBitmap = getSampledBitmap(contentResolver, uri, resizeWidth, resizeHeight) - - if (sourceBitmap == null) { - throw Exception() - } - - // dont upscale image if its smaller than the desired size - val bitmap = - if (sourceBitmap.width <= resizeWidth && sourceBitmap.height <= resizeHeight) { - sourceBitmap - } else { - Bitmap.createScaledBitmap(sourceBitmap, resizeWidth, resizeHeight, true) - } - - if (!saveBitmapToFile(bitmap, cacheFile)) { - throw Exception() - } - - bitmap - }.subscribeOn(Schedulers.io()) - .subscribe( - { - imageLiveData.postValue(Success(it)) - }, - { - imageLiveData.postValue(Error()) - } - ) - .addTo(disposeables) - } - - fun save(newDisplayName: String, newNote: String, newLocked: Boolean, newFields: List, context: Context) { + fun save(newDisplayName: String, newNote: String, newLocked: Boolean, newFields: List) { if (saveData.value is Loading || profileData.value !is Success) { return } + saveData.value = Loading() + val displayName = if (oldProfileData?.displayName == newDisplayName) { null } else { @@ -174,15 +122,15 @@ class EditProfileViewModel @Inject constructor( newLocked.toString().toRequestBody(MultipartBody.FORM) } - val avatar = if (avatarData.value is Success && avatarData.value?.data != null) { - val avatarBody = getCacheFileForName(context, AVATAR_FILE_NAME).asRequestBody("image/png".toMediaTypeOrNull()) + val avatar = if (avatarData.value != null) { + val avatarBody = getCacheFileForName(AVATAR_FILE_NAME).asRequestBody("image/png".toMediaTypeOrNull()) MultipartBody.Part.createFormData("avatar", randomAlphanumericString(12), avatarBody) } else { null } - val header = if (headerData.value is Success && headerData.value?.data != null) { - val headerBody = getCacheFileForName(context, HEADER_FILE_NAME).asRequestBody("image/png".toMediaTypeOrNull()) + val header = if (headerData.value != null) { + val headerBody = getCacheFileForName(HEADER_FILE_NAME).asRequestBody("image/png".toMediaTypeOrNull()) MultipartBody.Part.createFormData("header", randomAlphanumericString(12), headerBody) } else { null @@ -256,29 +204,12 @@ class EditProfileViewModel @Inject constructor( ) } - private fun getCacheFileForName(context: Context, filename: String): File { - return File(context.cacheDir, filename) - } - - private fun saveBitmapToFile(bitmap: Bitmap, file: File): Boolean { - - val outputStream: OutputStream - - try { - outputStream = FileOutputStream(file) - } catch (e: FileNotFoundException) { - Log.w(TAG, Log.getStackTraceString(e)) - return false - } - - bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream) - IOUtils.closeQuietly(outputStream) - - return true + private fun getCacheFileForName(filename: String): File { + return File(application.cacheDir, filename) } override fun onCleared() { - disposeables.dispose() + disposables.dispose() } fun obtainInstance() { @@ -293,7 +224,7 @@ class EditProfileViewModel @Inject constructor( instanceData.postValue(Error()) } ) - .addTo(disposeables) + .addTo(disposables) } } } diff --git a/app/src/main/res/layout/activity_edit_profile.xml b/app/src/main/res/layout/activity_edit_profile.xml index 101e89ca9..1429eecc7 100644 --- a/app/src/main/res/layout/activity_edit_profile.xml +++ b/app/src/main/res/layout/activity_edit_profile.xml @@ -4,7 +4,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - tools:context="com.keylesspalace.tusky.EditProfileActivity"> + tools:context=".EditProfileActivity"> - - - -