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
|
@ -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"
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
mediaValue + mediaItem
|
stashMediaItem = mediaItem
|
||||||
}.last()
|
|
||||||
|
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 {
|
mediaToJob[mediaItem.localId] = viewModelScope.launch {
|
||||||
mediaUploader
|
mediaUploader
|
||||||
.uploadMedia(mediaItem)
|
.uploadMedia(mediaItem)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 */
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in New Issue