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:
parent
4188670b42
commit
00c139190e
6 changed files with 86 additions and 9 deletions
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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<MediaPreviewAdapter.PreviewViewHolder>() {
|
||||
|
||||
|
@ -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
|
||||
|
|
|
@ -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 */
|
||||
)
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@
|
|||
<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_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_opening">That file could not be opened.</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>
|
||||
</plurals>
|
||||
<string name="action_set_caption">Set caption</string>
|
||||
<string name="action_edit_image">Edit image</string>
|
||||
<string name="action_remove">Remove</string>
|
||||
<string name="lock_account_label">Lock account</string>
|
||||
<string name="lock_account_label_description">Requires you to manually approve followers</string>
|
||||
|
|
Loading…
Reference in a new issue