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
This commit is contained in:
Konrad Pozniak 2022-03-02 20:39:56 +01:00 committed by GitHub
parent 4dee5c2774
commit a6335e6bcd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 104 additions and 323 deletions

View file

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

View file

@ -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<Resource<Bitmap>>,
liveData: LiveData<Uri>,
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<String>,
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()
}
}

View file

@ -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<Resource<Account>>()
val avatarData = MutableLiveData<Resource<Bitmap>>()
val headerData = MutableLiveData<Resource<Bitmap>>()
val avatarData = MutableLiveData<Uri>()
val headerData = MutableLiveData<Uri>()
val saveData = MutableLiveData<Resource<Nothing>>()
val instanceData = MutableLiveData<Resource<Instance>>()
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<Resource<Bitmap>>
) {
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<StringField>, context: Context) {
fun save(newDisplayName: String, newNote: String, newLocked: Boolean, newFields: List<StringField>) {
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)
}
}
}

View file

@ -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">
<include
android:id="@+id/includedToolbar"
@ -37,17 +37,6 @@
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_add_a_photo_32dp" />
<ProgressBar
android:id="@+id/headerProgressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:indeterminate="true"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/headerPreview"
app:layout_constraintEnd_toEndOf="@id/headerPreview"
app:layout_constraintStart_toStartOf="@id/headerPreview"
app:layout_constraintTop_toTopOf="@id/headerPreview" />
<ImageView
android:id="@+id/avatarPreview"
android:layout_width="80dp"
@ -71,18 +60,6 @@
app:layout_constraintTop_toBottomOf="@id/headerPreview"
app:srcCompat="@drawable/ic_add_a_photo_32dp" />
<ProgressBar
android:id="@+id/avatarProgressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:indeterminate="true"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/avatarPreview"
app:layout_constraintEnd_toEndOf="@id/avatarPreview"
app:layout_constraintStart_toStartOf="@id/avatarPreview"
app:layout_constraintTop_toTopOf="@id/avatarPreview" />
<LinearLayout
android:id="@+id/contentContainer"
android:layout_width="@dimen/timeline_width"