From 00c139190e1ff8be6ea14f139e9f1ab4911b8145 Mon Sep 17 00:00:00 2001 From: mcclure Date: Sun, 22 May 2022 15:01:14 -0400 Subject: [PATCH] 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) --- app/build.gradle | 2 +- .../components/compose/ComposeActivity.kt | 53 +++++++++++++++++++ .../components/compose/ComposeViewModel.kt | 27 ++++++++-- .../components/compose/MediaPreviewAdapter.kt | 7 ++- .../tusky/components/compose/MediaUploader.kt | 4 +- app/src/main/res/values/strings.xml | 2 + 6 files changed, 86 insertions(+), 9 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 0b41eb704..821a8e5cb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -175,7 +175,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:4.1.0" + implementation "com.github.CanHub:Android-Image-Cropper:4.2.1" implementation "de.c1710:filemojicompat-ui:$filemojicompat_version" implementation "de.c1710:filemojicompat:$filemojicompat_version" diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt index 57723c721..c5bdf4654 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt @@ -23,6 +23,7 @@ import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.content.pm.PackageManager +import android.graphics.Bitmap import android.graphics.PorterDuff import android.graphics.PorterDuffColorFilter import android.net.Uri @@ -56,6 +57,9 @@ import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager 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.snackbar.Snackbar 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.combineLiveData import com.keylesspalace.tusky.util.combineOptionalLiveData +import com.keylesspalace.tusky.util.getMediaSize import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.highlightSpans 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?) { super.onCreate(savedInstanceState) @@ -185,6 +216,7 @@ class ComposeActivity : viewModel.updateDescription(item.localId, newDescription) } }, + onEditImage = this::editImageInQueue, onRemove = this::removeMediaFromQueue ) binding.composeMediaPreviewBar.layoutManager = @@ -867,6 +899,27 @@ class ComposeActivity : 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) { viewModel.removeMediaFromQueue(item) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt index 2c0da5833..b7726c38e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt @@ -95,6 +95,9 @@ class ComposeViewModel @Inject constructor( private val isEditingScheduledToot get() = !scheduledTootId.isNullOrEmpty() + // Used in ComposeActivity to pass state to result function when cropImage contract inflight + var cropImageItemOld: QueuedMedia? = null + init { viewModelScope.launch { emoji.postValue(instanceInfoRepo.getEmojis()) @@ -122,13 +125,16 @@ class ComposeViewModel @Inject constructor( } } - private suspend fun addMediaToQueue( + suspend fun addMediaToQueue( type: QueuedMedia.Type, uri: Uri, mediaSize: Long, - description: String? = null + description: String? = null, + replaceItem: QueuedMedia? = null ): QueuedMedia { - val mediaItem = media.updateAndGet { mediaValue -> + var stashMediaItem: QueuedMedia? = null + + media.updateAndGet { mediaValue -> val mediaItem = QueuedMedia( localId = (mediaValue.maxOfOrNull { it.localId } ?: 0) + 1, uri = uri, @@ -136,8 +142,19 @@ class ComposeViewModel @Inject constructor( mediaSize = mediaSize, description = description ) - mediaValue + mediaItem - }.last() + stashMediaItem = mediaItem + + if (replaceItem != null) { + mediaToJob[replaceItem.localId]?.cancel() + mediaValue.map { + if (it.localId == replaceItem.localId) mediaItem else it + } + } else { // Append + mediaValue + mediaItem + } + } + val mediaItem = stashMediaItem!! // stashMediaItem is always non-null and uncaptured at this point, but Kotlin doesn't know that + mediaToJob[mediaItem.localId] = viewModelScope.launch { mediaUploader .uploadMedia(mediaItem) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt index 0b1fa8c41..be54a1aa9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt @@ -32,6 +32,7 @@ import com.keylesspalace.tusky.components.compose.view.ProgressImageView class MediaPreviewAdapter( context: Context, private val onAddCaption: (ComposeActivity.QueuedMedia) -> Unit, + private val onEditImage: (ComposeActivity.QueuedMedia) -> Unit, private val onRemove: (ComposeActivity.QueuedMedia) -> Unit ) : RecyclerView.Adapter() { @@ -43,12 +44,16 @@ class MediaPreviewAdapter( val item = differ.currentList[position] val popup = PopupMenu(view.context, view) val addCaptionId = 1 - val removeId = 2 + val editImageId = 2 + val removeId = 3 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.setOnMenuItemClickListener { menuItem -> when (menuItem.itemId) { addCaptionId -> onAddCaption(item) + editImageId -> onEditImage(item) removeId -> onRemove(item) } true diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt index f1debc98b..b2915c799 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt @@ -54,14 +54,14 @@ sealed class 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 val randomId = randomAlphanumericString(12) val imageFileName = "Tusky_${randomId}_" val storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) return File.createTempFile( imageFileName, /* prefix */ - ".jpg", /* suffix */ + suffix, /* suffix */ storageDir /* directory */ ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 27902cefa..ad87b6fc8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -14,6 +14,7 @@ The file must be less than 8MB. Video files must be less than 40MB. Audio files must be less than 40MB. + The attachment could not be edited. That type of file cannot be uploaded. That file could not be opened. Permission to read media is required. @@ -404,6 +405,7 @@ Describe for visually impaired\n(%d character limit) Set caption + Edit image Remove Lock account Requires you to manually approve followers