Ability to crop images attached to posts (#2531)

* First attachment crop attempt: Can crop in place, but does not delete/replace on server so has no effect

* Attachment crop feature works

* ktlint fixes on attachment crop patch

* Upgrade Android-Image-Cropper to 4.2.1

* An error message should be displayed if attachment cropping fails and it is not because the user intentionally cancelled.

* Remove 2 of the 3 "state passing" variables by using MediaUtils

* Cropper should use content uri (MIME type bearing) and setOutputCompressFormat so that PNGs reach the server safely.

* Change to crop requested by Conny: Store inflight cropImageItemOld in view model

* Change to crop requested by Conny: Sort cropImage with the other contracts

* ktlint fixes on attachment crop patch (again)
This commit is contained in:
mcclure 2022-05-22 15:01:14 -04:00 committed by GitHub
parent 4188670b42
commit 00c139190e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 86 additions and 9 deletions

View File

@ -175,7 +175,7 @@ dependencies {
implementation "com.mikepenz:materialdrawer-iconics:$materialdrawerVersion" implementation "com.mikepenz:materialdrawer-iconics:$materialdrawerVersion"
implementation 'com.mikepenz:google-material-typeface:4.0.0.2-kotlin@aar' implementation 'com.mikepenz:google-material-typeface:4.0.0.2-kotlin@aar'
implementation "com.github.CanHub:Android-Image-Cropper:4.1.0" implementation "com.github.CanHub:Android-Image-Cropper:4.2.1"
implementation "de.c1710:filemojicompat-ui:$filemojicompat_version" implementation "de.c1710:filemojicompat-ui:$filemojicompat_version"
implementation "de.c1710:filemojicompat:$filemojicompat_version" implementation "de.c1710:filemojicompat:$filemojicompat_version"

View File

@ -23,6 +23,7 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.PorterDuff import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter import android.graphics.PorterDuffColorFilter
import android.net.Uri import android.net.Uri
@ -56,6 +57,9 @@ import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.transition.TransitionManager import androidx.transition.TransitionManager
import com.canhub.cropper.CropImage
import com.canhub.cropper.CropImageContract
import com.canhub.cropper.options
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BaseActivity
@ -83,6 +87,7 @@ import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.afterTextChanged import com.keylesspalace.tusky.util.afterTextChanged
import com.keylesspalace.tusky.util.combineLiveData import com.keylesspalace.tusky.util.combineLiveData
import com.keylesspalace.tusky.util.combineOptionalLiveData import com.keylesspalace.tusky.util.combineOptionalLiveData
import com.keylesspalace.tusky.util.getMediaSize
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.highlightSpans import com.keylesspalace.tusky.util.highlightSpans
import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.loadAvatar
@ -151,6 +156,32 @@ class ComposeActivity :
} }
} }
// Contract kicked off by editImageInQueue; expects viewModel.cropImageItemOld set
private val cropImage = registerForActivityResult(CropImageContract()) { result ->
val uriNew = result.uriContent
if (result.isSuccessful && uriNew != null) {
viewModel.cropImageItemOld?.let { itemOld ->
val size = getMediaSize(getApplicationContext().getContentResolver(), uriNew)
lifecycleScope.launch {
viewModel.addMediaToQueue(
itemOld.type,
uriNew,
size,
itemOld.description,
itemOld
)
}
}
} else if (result == CropImage.CancelledResult) {
Log.w("ComposeActivity", "Edit image cancelled by user")
} else {
Log.w("ComposeActivity", "Edit image failed: " + result.error)
displayTransientError(R.string.error_media_edit_failed)
}
viewModel.cropImageItemOld = null
}
public override fun onCreate(savedInstanceState: Bundle?) { public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -185,6 +216,7 @@ class ComposeActivity :
viewModel.updateDescription(item.localId, newDescription) viewModel.updateDescription(item.localId, newDescription)
} }
}, },
onEditImage = this::editImageInQueue,
onRemove = this::removeMediaFromQueue onRemove = this::removeMediaFromQueue
) )
binding.composeMediaPreviewBar.layoutManager = binding.composeMediaPreviewBar.layoutManager =
@ -867,6 +899,27 @@ class ComposeActivity :
binding.addPollTextActionTextView.compoundDrawablesRelative[0].colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN) binding.addPollTextActionTextView.compoundDrawablesRelative[0].colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN)
} }
private fun editImageInQueue(item: QueuedMedia) {
// If input image is lossless, output image should be lossless.
// Currently the only supported lossless format is png.
val mimeType: String? = contentResolver.getType(item.uri)
val isPng: Boolean = mimeType != null && mimeType.endsWith("/png")
val context = getApplicationContext()
val tempFile = createNewImageFile(context, if (isPng) ".png" else ".jpg")
// "Authority" must be the same as the android:authorities string in AndroidManifest.xml
val uriNew = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileprovider", tempFile)
viewModel.cropImageItemOld = item
cropImage.launch(
options(uri = item.uri) {
setOutputUri(uriNew)
setOutputCompressFormat(if (isPng) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG)
}
)
}
private fun removeMediaFromQueue(item: QueuedMedia) { private fun removeMediaFromQueue(item: QueuedMedia) {
viewModel.removeMediaFromQueue(item) viewModel.removeMediaFromQueue(item)
} }

View File

@ -95,6 +95,9 @@ class ComposeViewModel @Inject constructor(
private val isEditingScheduledToot get() = !scheduledTootId.isNullOrEmpty() private val isEditingScheduledToot get() = !scheduledTootId.isNullOrEmpty()
// Used in ComposeActivity to pass state to result function when cropImage contract inflight
var cropImageItemOld: QueuedMedia? = null
init { init {
viewModelScope.launch { viewModelScope.launch {
emoji.postValue(instanceInfoRepo.getEmojis()) emoji.postValue(instanceInfoRepo.getEmojis())
@ -122,13 +125,16 @@ class ComposeViewModel @Inject constructor(
} }
} }
private suspend fun addMediaToQueue( suspend fun addMediaToQueue(
type: QueuedMedia.Type, type: QueuedMedia.Type,
uri: Uri, uri: Uri,
mediaSize: Long, mediaSize: Long,
description: String? = null description: String? = null,
replaceItem: QueuedMedia? = null
): QueuedMedia { ): QueuedMedia {
val mediaItem = media.updateAndGet { mediaValue -> var stashMediaItem: QueuedMedia? = null
media.updateAndGet { mediaValue ->
val mediaItem = QueuedMedia( val mediaItem = QueuedMedia(
localId = (mediaValue.maxOfOrNull { it.localId } ?: 0) + 1, localId = (mediaValue.maxOfOrNull { it.localId } ?: 0) + 1,
uri = uri, uri = uri,
@ -136,8 +142,19 @@ class ComposeViewModel @Inject constructor(
mediaSize = mediaSize, mediaSize = mediaSize,
description = description description = description
) )
stashMediaItem = mediaItem
if (replaceItem != null) {
mediaToJob[replaceItem.localId]?.cancel()
mediaValue.map {
if (it.localId == replaceItem.localId) mediaItem else it
}
} else { // Append
mediaValue + mediaItem mediaValue + mediaItem
}.last() }
}
val mediaItem = stashMediaItem!! // stashMediaItem is always non-null and uncaptured at this point, but Kotlin doesn't know that
mediaToJob[mediaItem.localId] = viewModelScope.launch { mediaToJob[mediaItem.localId] = viewModelScope.launch {
mediaUploader mediaUploader
.uploadMedia(mediaItem) .uploadMedia(mediaItem)

View File

@ -32,6 +32,7 @@ import com.keylesspalace.tusky.components.compose.view.ProgressImageView
class MediaPreviewAdapter( class MediaPreviewAdapter(
context: Context, context: Context,
private val onAddCaption: (ComposeActivity.QueuedMedia) -> Unit, private val onAddCaption: (ComposeActivity.QueuedMedia) -> Unit,
private val onEditImage: (ComposeActivity.QueuedMedia) -> Unit,
private val onRemove: (ComposeActivity.QueuedMedia) -> Unit private val onRemove: (ComposeActivity.QueuedMedia) -> Unit
) : RecyclerView.Adapter<MediaPreviewAdapter.PreviewViewHolder>() { ) : RecyclerView.Adapter<MediaPreviewAdapter.PreviewViewHolder>() {
@ -43,12 +44,16 @@ class MediaPreviewAdapter(
val item = differ.currentList[position] val item = differ.currentList[position]
val popup = PopupMenu(view.context, view) val popup = PopupMenu(view.context, view)
val addCaptionId = 1 val addCaptionId = 1
val removeId = 2 val editImageId = 2
val removeId = 3
popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption) popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption)
if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE)
popup.menu.add(0, editImageId, 0, R.string.action_edit_image)
popup.menu.add(0, removeId, 0, R.string.action_remove) popup.menu.add(0, removeId, 0, R.string.action_remove)
popup.setOnMenuItemClickListener { menuItem -> popup.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) { when (menuItem.itemId) {
addCaptionId -> onAddCaption(item) addCaptionId -> onAddCaption(item)
editImageId -> onEditImage(item)
removeId -> onRemove(item) removeId -> onRemove(item)
} }
true true

View File

@ -54,14 +54,14 @@ sealed class UploadEvent {
data class FinishedEvent(val mediaId: String) : UploadEvent() data class FinishedEvent(val mediaId: String) : UploadEvent()
} }
fun createNewImageFile(context: Context): File { fun createNewImageFile(context: Context, suffix: String = ".jpg"): File {
// Create an image file name // Create an image file name
val randomId = randomAlphanumericString(12) val randomId = randomAlphanumericString(12)
val imageFileName = "Tusky_${randomId}_" val imageFileName = "Tusky_${randomId}_"
val storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) val storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
return File.createTempFile( return File.createTempFile(
imageFileName, /* prefix */ imageFileName, /* prefix */
".jpg", /* suffix */ suffix, /* suffix */
storageDir /* directory */ storageDir /* directory */
) )
} }

View File

@ -14,6 +14,7 @@
<string name="error_image_upload_size">The file must be less than 8MB.</string> <string name="error_image_upload_size">The file must be less than 8MB.</string>
<string name="error_video_upload_size">Video files must be less than 40MB.</string> <string name="error_video_upload_size">Video files must be less than 40MB.</string>
<string name="error_audio_upload_size">Audio files must be less than 40MB.</string> <string name="error_audio_upload_size">Audio files must be less than 40MB.</string>
<string name="error_media_edit_failed">The attachment could not be edited.</string>
<string name="error_media_upload_type">That type of file cannot be uploaded.</string> <string name="error_media_upload_type">That type of file cannot be uploaded.</string>
<string name="error_media_upload_opening">That file could not be opened.</string> <string name="error_media_upload_opening">That file could not be opened.</string>
<string name="error_media_upload_permission">Permission to read media is required.</string> <string name="error_media_upload_permission">Permission to read media is required.</string>
@ -404,6 +405,7 @@
<item quantity="other">Describe for visually impaired\n(%d character limit)</item> <item quantity="other">Describe for visually impaired\n(%d character limit)</item>
</plurals> </plurals>
<string name="action_set_caption">Set caption</string> <string name="action_set_caption">Set caption</string>
<string name="action_edit_image">Edit image</string>
<string name="action_remove">Remove</string> <string name="action_remove">Remove</string>
<string name="lock_account_label">Lock account</string> <string name="lock_account_label">Lock account</string>
<string name="lock_account_label_description">Requires you to manually approve followers</string> <string name="lock_account_label_description">Requires you to manually approve followers</string>