From 7684f06938277ab9a5e58e73a1f00898c30977b1 Mon Sep 17 00:00:00 2001 From: mcclure Date: Wed, 21 Sep 2022 14:28:06 -0400 Subject: [PATCH] Add UI for image-attachment "focus" (#2620) * Attempt-zero implementation of a "focus" feature for image attachments. Choose "Set focus" in the attachment menu, tap once to select focus point (no visual feedback currently), tap "OK". Works in tests. * Remove code duplication between 'update description' and 'update focus' * Fix ktlint/bitrise failures * Make updateMediaItem private * When focus is set on a post attachment the preview focuses correctly. ProgressImageView now inherits from MediaPreviewImageView. * Replace use of PointF for Focus where focus is represented, fix ktlint * Substitute 'focus' for 'focus point' in strings * First attempt draw focus point. Only updates on initial load. Modeled on code from RoundedCorners builtin from Glide * Redraw focus after each tap * Dark curtain where focus isn't (now looks like mastosoc) * Correct ktlint for FocusDialog * draft: switch to overlay for focus indicator * Draw focus circle, but ImageView and FocusIndicatorView seem to share a single canvas * Switch focus circle to path approach * Correctly scale, save and load focuses. Clamp to visible area. Focus editor looks and feels right * ktlint fixes and comments * Focus indicator drawing should use device-independent pixels * Shrink focus window when it gets unattractively tall (no linting, misbehaves on wide aspect ratio screens) * Correct max-height behavior for screens in landscape mode * Focus attachment result is are flipped on x axis; fix this * Correctly thread focus through on scheduled posts, redrafted posts, and drafts (but draft focus is lost on post) * More focus ktlint fixes * Fix specific case where a draft is given a focus, then deleted, then posted in that order * Fix accidental file change in focus PR * ktLint fix * Fix property style warnings in focus * Fix remaining style warnings from focus PR Co-authored-by: Conny Duck --- .../components/compose/ComposeActivity.kt | 10 +- .../components/compose/ComposeViewModel.kt | 44 ++++-- .../components/compose/MediaPreviewAdapter.kt | 28 +++- .../tusky/components/compose/MediaUploader.kt | 8 +- .../components/compose/dialog/FocusDialog.kt | 105 ++++++++++++++ .../compose/view/FocusIndicatorView.kt | 130 ++++++++++++++++++ .../compose/view/ProgressImageView.java | 3 +- .../tusky/components/drafts/DraftHelper.kt | 3 + .../components/drafts/DraftMediaAdapter.kt | 20 ++- .../com/keylesspalace/tusky/db/DraftEntity.kt | 2 + .../tusky/network/MastodonApi.kt | 3 +- .../tusky/network/MediaUploadApi.kt | 3 +- .../receiver/SendStatusBroadcastReceiver.kt | 1 + .../tusky/service/SendStatusService.kt | 3 + .../tusky/view/MediaPreviewImageView.kt | 2 +- app/src/main/res/layout/dialog_focus.xml | 17 +++ app/src/main/res/values/strings.xml | 2 + 17 files changed, 358 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/FocusDialog.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/compose/view/FocusIndicatorView.kt create mode 100644 app/src/main/res/layout/dialog_focus.xml 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 6a1949683..cd4b57ddc 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 @@ -69,6 +69,7 @@ import com.keylesspalace.tusky.adapter.EmojiAdapter import com.keylesspalace.tusky.adapter.LocaleAdapter import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener import com.keylesspalace.tusky.components.compose.dialog.CaptionDialog +import com.keylesspalace.tusky.components.compose.dialog.makeFocusDialog import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener import com.keylesspalace.tusky.components.compose.view.ComposeScheduleView @@ -171,6 +172,7 @@ class ComposeActivity : uriNew, size, itemOld.description, + null, // Intentionally reset focus when cropping itemOld ) } @@ -217,6 +219,11 @@ class ComposeActivity : CaptionDialog.newInstance(item.localId, item.description, item.uri) .show(supportFragmentManager, "caption_dialog") }, + onAddFocus = { item -> + makeFocusDialog(item.focus, item.uri) { newFocus -> + viewModel.updateFocus(item.localId, newFocus) + } + }, onEditImage = this::editImageInQueue, onRemove = this::removeMediaFromQueue ) @@ -1139,7 +1146,8 @@ class ComposeActivity : val mediaSize: Long, val uploadPercent: Int = 0, val id: String? = null, - val description: String? = null + val description: String? = null, + val focus: Attachment.Focus? = null ) { enum class Type { IMAGE, VIDEO, AUDIO; 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 c650f72db..71d1ae3fa 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 @@ -103,7 +103,7 @@ class ComposeViewModel @Inject constructor( private var setupComplete = false - suspend fun pickMedia(mediaUri: Uri, description: String? = null): Result = withContext(Dispatchers.IO) { + suspend fun pickMedia(mediaUri: Uri, description: String? = null, focus: Attachment.Focus? = null): Result = withContext(Dispatchers.IO) { try { val (type, uri, size) = mediaUploader.prepareMedia(mediaUri, instanceInfo.first()) val mediaItems = media.value @@ -113,7 +113,7 @@ class ComposeViewModel @Inject constructor( ) { Result.failure(VideoOrImageException()) } else { - val queuedMedia = addMediaToQueue(type, uri, size, description) + val queuedMedia = addMediaToQueue(type, uri, size, description, focus) Result.success(queuedMedia) } } catch (e: Exception) { @@ -126,6 +126,7 @@ class ComposeViewModel @Inject constructor( uri: Uri, mediaSize: Long, description: String? = null, + focus: Attachment.Focus? = null, replaceItem: QueuedMedia? = null ): QueuedMedia { var stashMediaItem: QueuedMedia? = null @@ -136,7 +137,8 @@ class ComposeViewModel @Inject constructor( uri = uri, type = type, mediaSize = mediaSize, - description = description + description = description, + focus = focus ) stashMediaItem = mediaItem @@ -181,7 +183,7 @@ class ComposeViewModel @Inject constructor( return mediaItem } - private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?) { + private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?, focus: Attachment.Focus?) { media.update { mediaValue -> val mediaItem = QueuedMedia( localId = (mediaValue.maxOfOrNull { it.localId } ?: 0) + 1, @@ -190,7 +192,8 @@ class ComposeViewModel @Inject constructor( mediaSize = 0, uploadPercent = -1, id = id, - description = description + description = description, + focus = focus ) mediaValue + mediaItem } @@ -245,9 +248,11 @@ class ComposeViewModel @Inject constructor( suspend fun saveDraft(content: String, contentWarning: String) { val mediaUris: MutableList = mutableListOf() val mediaDescriptions: MutableList = mutableListOf() + val mediaFocus: MutableList = mutableListOf() media.value.forEach { item -> mediaUris.add(item.uri.toString()) mediaDescriptions.add(item.description) + mediaFocus.add(item.focus) } draftHelper.saveDraft( @@ -260,6 +265,7 @@ class ComposeViewModel @Inject constructor( visibility = statusVisibility.value, mediaUris = mediaUris, mediaDescriptions = mediaDescriptions, + mediaFocus = mediaFocus, poll = poll.value, failedToSend = false, scheduledAt = scheduledAt.value, @@ -286,11 +292,13 @@ class ComposeViewModel @Inject constructor( val mediaIds: MutableList = mutableListOf() val mediaUris: MutableList = mutableListOf() val mediaDescriptions: MutableList = mutableListOf() + val mediaFocus: MutableList = mutableListOf() val mediaProcessed: MutableList = mutableListOf() media.value.forEach { item -> mediaIds.add(item.id!!) mediaUris.add(item.uri) mediaDescriptions.add(item.description ?: "") + mediaFocus.add(item.focus) mediaProcessed.add(false) } val tootToSend = StatusToSend( @@ -301,6 +309,7 @@ class ComposeViewModel @Inject constructor( mediaIds = mediaIds, mediaUris = mediaUris.map { it.toString() }, mediaDescriptions = mediaDescriptions, + mediaFocus = mediaFocus, scheduledAt = scheduledAt.value, inReplyToId = inReplyToId, poll = poll.value, @@ -319,11 +328,12 @@ class ComposeViewModel @Inject constructor( } } - suspend fun updateDescription(localId: Int, description: String): Boolean { + // Updates a QueuedMedia item arbitrarily, then sends description and focus to server + private suspend fun updateMediaItem(localId: Int, mutator: (QueuedMedia) -> QueuedMedia): Boolean { val newMediaList = media.updateAndGet { mediaValue -> mediaValue.map { mediaItem -> if (mediaItem.localId == localId) { - mediaItem.copy(description = description) + mutator(mediaItem) } else { mediaItem } @@ -332,7 +342,9 @@ class ComposeViewModel @Inject constructor( val updatedItem = newMediaList.find { it.localId == localId } if (updatedItem?.id != null) { - return api.updateMedia(updatedItem.id, description) + val focus = updatedItem.focus + val focusString = if (focus != null) "${focus.x},${focus.y}" else null + return api.updateMedia(updatedItem.id, updatedItem.description, focusString) .fold({ true }, { throwable -> @@ -343,6 +355,18 @@ class ComposeViewModel @Inject constructor( return true } + suspend fun updateDescription(localId: Int, description: String): Boolean { + return updateMediaItem(localId, { mediaItem -> + mediaItem.copy(description = description) + }) + } + + suspend fun updateFocus(localId: Int, focus: Attachment.Focus): Boolean { + return updateMediaItem(localId, { mediaItem -> + mediaItem.copy(focus = focus) + }) + } + fun searchAutocompleteSuggestions(token: String): List { when (token[0]) { '@' -> { @@ -413,7 +437,7 @@ class ComposeViewModel @Inject constructor( // when coming from DraftActivity viewModelScope.launch { draftAttachments.forEach { attachment -> - pickMedia(attachment.uri, attachment.description) + pickMedia(attachment.uri, attachment.description, attachment.focus) } } } else composeOptions?.mediaAttachments?.forEach { a -> @@ -423,7 +447,7 @@ class ComposeViewModel @Inject constructor( Attachment.Type.UNKNOWN, Attachment.Type.IMAGE -> QueuedMedia.Type.IMAGE Attachment.Type.AUDIO -> QueuedMedia.Type.AUDIO } - addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description) + addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description, a.meta?.focus) } draftId = composeOptions?.draftId ?: 0 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 be54a1aa9..2855e6969 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 onAddFocus: (ComposeActivity.QueuedMedia) -> Unit, private val onEditImage: (ComposeActivity.QueuedMedia) -> Unit, private val onRemove: (ComposeActivity.QueuedMedia) -> Unit ) : RecyclerView.Adapter() { @@ -44,15 +45,19 @@ class MediaPreviewAdapter( val item = differ.currentList[position] val popup = PopupMenu(view.context, view) val addCaptionId = 1 - val editImageId = 2 - val removeId = 3 + val addFocusId = 2 + val editImageId = 3 + val removeId = 4 popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption) - if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) + if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) { + popup.menu.add(0, addFocusId, 0, R.string.action_set_focus) 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) + addFocusId -> onAddFocus(item) editImageId -> onEditImage(item) removeId -> onRemove(item) } @@ -78,11 +83,24 @@ class MediaPreviewAdapter( // TODO: Fancy waveform display? holder.progressImageView.setImageResource(R.drawable.ic_music_box_preview_24dp) } else { - Glide.with(holder.itemView.context) + val imageView = holder.progressImageView + val focus = item.focus + + if (focus != null) + imageView.setFocalPoint(focus) + else + imageView.removeFocalPoint() // Probably unnecessary since we have no UI for removal once added. + + var glide = Glide.with(holder.itemView.context) .load(item.uri) .diskCacheStrategy(DiskCacheStrategy.NONE) .dontAnimate() - .into(holder.progressImageView) + .centerInside() + + if (focus != null) + glide = glide.addListener(imageView) + + glide.into(imageView) } } 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 d7769cbd7..450cb5aa7 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 @@ -225,7 +225,13 @@ class MediaUploader @Inject constructor( null } - mediaUploadApi.uploadMedia(body, description).fold({ result -> + val focus = if (media.focus != null) { + MultipartBody.Part.createFormData("focus", "${media.focus.x},${media.focus.y}") + } else { + null + } + + mediaUploadApi.uploadMedia(body, description, focus).fold({ result -> send(UploadEvent.FinishedEvent(result.id)) }, { throwable -> val errorMessage = throwable.getServerErrorMessage() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/FocusDialog.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/FocusDialog.kt new file mode 100644 index 000000000..4764ec544 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/FocusDialog.kt @@ -0,0 +1,105 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.compose.dialog + +import android.app.Activity +import android.content.DialogInterface +import android.graphics.drawable.Drawable +import android.net.Uri +import android.view.WindowManager +import android.widget.FrameLayout +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import com.bumptech.glide.Glide +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.Target +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.DialogFocusBinding +import com.keylesspalace.tusky.entity.Attachment.Focus +import kotlinx.coroutines.launch + +fun T.makeFocusDialog( + existingFocus: Focus?, + previewUri: Uri, + onUpdateFocus: suspend (Focus) -> Boolean +) where T : Activity, T : LifecycleOwner { + val focus = existingFocus ?: Focus(0.0f, 0.0f) // Default to center + + val dialogBinding = DialogFocusBinding.inflate(layoutInflater) + + dialogBinding.focusIndicator.setFocus(focus) + + Glide.with(this) + .load(previewUri) + .downsample(DownsampleStrategy.CENTER_INSIDE) + .listener(object : RequestListener { + override fun onLoadFailed(p0: GlideException?, p1: Any?, p2: Target?, p3: Boolean): Boolean { + return false + } + + override fun onResourceReady(resource: Drawable?, model: Any?, target: Target?, dataSource: DataSource?, isFirstResource: Boolean): Boolean { + val width = resource!!.intrinsicWidth + val height = resource.intrinsicHeight + + dialogBinding.focusIndicator.setImageSize(width, height) + + // We want the dialog to be a little taller than the image, so you can slide your thumb past the image border, + // but if it's *too* much taller that looks weird. See if a threshold has been crossed: + if (width > height) { + val maxHeight = dialogBinding.focusIndicator.maxAttractiveHeight() + + if (dialogBinding.imageView.height > maxHeight) { + val verticalShrinkLayout = FrameLayout.LayoutParams(width, maxHeight) + dialogBinding.imageView.layoutParams = verticalShrinkLayout + dialogBinding.focusIndicator.layoutParams = verticalShrinkLayout + } + } + return false // Pass through + } + }) + .into(dialogBinding.imageView) + + val okListener = { dialog: DialogInterface, _: Int -> + lifecycleScope.launch { + if (!onUpdateFocus(dialogBinding.focusIndicator.getFocus())) { + showFailedFocusMessage() + } + } + dialog.dismiss() + } + + val dialog = AlertDialog.Builder(this) + .setView(dialogBinding.root) + .setPositiveButton(android.R.string.ok, okListener) + .setNegativeButton(android.R.string.cancel, null) + .create() + + val window = dialog.window + window?.setSoftInputMode( + WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE + ) + + dialog.show() +} + +private fun Activity.showFailedFocusMessage() { + Toast.makeText(this, R.string.error_failed_set_focus, Toast.LENGTH_SHORT).show() +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/FocusIndicatorView.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/FocusIndicatorView.kt new file mode 100644 index 000000000..9a3e4b00a --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/FocusIndicatorView.kt @@ -0,0 +1,130 @@ +package com.keylesspalace.tusky.components.compose.view + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Point +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import com.keylesspalace.tusky.entity.Attachment +import kotlin.math.ceil +import kotlin.math.max +import kotlin.math.min + +class FocusIndicatorView +@JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + private var focus: Attachment.Focus? = null + private var imageSize: Point? = null + private var circleRadius: Float? = null + + fun setImageSize(width: Int, height: Int) { + this.imageSize = Point(width, height) + if (focus != null) + invalidate() + } + + fun setFocus(focus: Attachment.Focus) { + this.focus = focus + if (imageSize != null) + invalidate() + } + + // Assumes setFocus called first + fun getFocus(): Attachment.Focus { + return focus!! + } + + // This needs to be consistent every time it is consulted over the lifetime of the object, + // so base it on the view width/height whenever the first access occurs. + private fun getCircleRadius(): Float { + val circleRadius = this.circleRadius + if (circleRadius != null) + return circleRadius + val newCircleRadius = min(this.width, this.height).toFloat() / 4.0f + this.circleRadius = newCircleRadius + return newCircleRadius + } + + // Remember focus uses -1..1 y-down coordinates (so focus value should be negated for y) + private fun axisToFocus(value: Float, innerLimit: Int, outerLimit: Int): Float { + val offset = (outerLimit - innerLimit) / 2 // Assume image is centered in widget frame + val result = (value - offset) / innerLimit.toFloat() * 2.0f - 1.0f // To range -1..1 + return min(1.0f, max(-1.0f, result)) // Clamp + } + + private fun axisFromFocus(value: Float, innerLimit: Int, outerLimit: Int): Float { + val offset = (outerLimit - innerLimit) / 2 + return offset.toFloat() + ((value + 1.0f) / 2.0f) * innerLimit.toFloat() // From range -1..1 + } + + @SuppressLint("ClickableViewAccessibility") // Android Studio wants us to implement PerformClick for accessibility, but that unfortunately cannot be made meaningful for this widget. + override fun onTouchEvent(event: MotionEvent): Boolean { + if (event.actionMasked == MotionEvent.ACTION_CANCEL) + return false + + val imageSize = this.imageSize + if (imageSize == null) + return false + + // Convert touch xy to point inside image + focus = Attachment.Focus(axisToFocus(event.x, imageSize.x, this.width), -axisToFocus(event.y, imageSize.y, this.height)) + invalidate() + return true + } + + private val transparentDarkGray = 0x40000000 + private val strokeWidth = 4.0f * this.resources.displayMetrics.density + + private val curtainPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG) + + private val curtainPath = Path() + + init { + curtainPaint.color = transparentDarkGray + curtainPaint.style = Paint.Style.FILL + + strokePaint.style = Paint.Style.STROKE + strokePaint.strokeWidth = strokeWidth + strokePaint.color = Color.WHITE + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + val imageSize = this.imageSize + val focus = this.focus + + if (imageSize != null && focus != null) { + val x = axisFromFocus(focus.x, imageSize.x, this.width) + val y = axisFromFocus(-focus.y, imageSize.y, this.height) + val circleRadius = getCircleRadius() + + curtainPath.reset() // Draw a flood fill with a hole cut out of it + curtainPath.fillType = Path.FillType.WINDING + curtainPath.addRect(0.0f, 0.0f, this.width.toFloat(), this.height.toFloat(), Path.Direction.CW) + curtainPath.addCircle(x, y, circleRadius, Path.Direction.CCW) + canvas.drawPath(curtainPath, curtainPaint) + + canvas.drawCircle(x, y, circleRadius, strokePaint) // Draw white circle + canvas.drawCircle(x, y, strokeWidth / 2.0f, strokePaint) // Draw white dot + } + } + + // Give a "safe" height based on currently set image size. Assume imageSize is set and height>width already checked + fun maxAttractiveHeight(): Int { + val height = this.imageSize!!.y + val circleRadius = getCircleRadius() + + // Give us enough space for the image, plus on each side half a focus indicator circle, plus a strokeWidth + return ceil(height.toFloat() + circleRadius * 2.0f + strokeWidth).toInt() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressImageView.java b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressImageView.java index 335196104..3be800e78 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressImageView.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressImageView.java @@ -30,9 +30,10 @@ import androidx.appcompat.widget.AppCompatImageView; import android.util.AttributeSet; import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.view.MediaPreviewImageView; import at.connyduck.sparkbutton.helpers.Utils; -public final class ProgressImageView extends AppCompatImageView { +public final class ProgressImageView extends MediaPreviewImageView { private int progress = -1; private final RectF progressRect = new RectF(); diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt index c3714371f..c6eea4af0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt @@ -25,6 +25,7 @@ import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.DraftAttachment import com.keylesspalace.tusky.db.DraftEntity +import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.util.IOUtils @@ -59,6 +60,7 @@ class DraftHelper @Inject constructor( visibility: Status.Visibility, mediaUris: List, mediaDescriptions: List, + mediaFocus: List, poll: NewPoll?, failedToSend: Boolean, scheduledAt: String?, @@ -103,6 +105,7 @@ class DraftHelper @Inject constructor( DraftAttachment( uriString = uris[i].toString(), description = mediaDescriptions[i], + focus = mediaFocus[i], type = types[i] ) ) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt index acee683b6..98a288b42 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt @@ -17,7 +17,6 @@ package com.keylesspalace.tusky.components.drafts import android.view.ViewGroup import android.widget.ImageView -import androidx.appcompat.widget.AppCompatImageView import androidx.constraintlayout.widget.ConstraintLayout import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter @@ -26,6 +25,7 @@ import com.bumptech.glide.Glide import com.bumptech.glide.load.engine.DiskCacheStrategy import com.keylesspalace.tusky.R import com.keylesspalace.tusky.db.DraftAttachment +import com.keylesspalace.tusky.view.MediaPreviewImageView class DraftMediaAdapter( private val attachmentClick: () -> Unit @@ -42,24 +42,34 @@ class DraftMediaAdapter( ) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DraftMediaViewHolder { - return DraftMediaViewHolder(AppCompatImageView(parent.context)) + return DraftMediaViewHolder(MediaPreviewImageView(parent.context)) } override fun onBindViewHolder(holder: DraftMediaViewHolder, position: Int) { getItem(position)?.let { attachment -> if (attachment.type == DraftAttachment.Type.AUDIO) { + holder.imageView.clearFocus() holder.imageView.setImageResource(R.drawable.ic_music_box_preview_24dp) } else { - Glide.with(holder.itemView.context) + if (attachment.focus != null) + holder.imageView.setFocalPoint(attachment.focus) + else + holder.imageView.clearFocus() + var glide = Glide.with(holder.itemView.context) .load(attachment.uri) .diskCacheStrategy(DiskCacheStrategy.NONE) .dontAnimate() - .into(holder.imageView) + .centerInside() + + if (attachment.focus != null) + glide = glide.addListener(holder.imageView) + + glide.into(holder.imageView) } } } - inner class DraftMediaViewHolder(val imageView: ImageView) : + inner class DraftMediaViewHolder(val imageView: MediaPreviewImageView) : RecyclerView.ViewHolder(imageView) { init { val thumbnailViewSize = diff --git a/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt index 5479fd886..79b7243f1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt @@ -22,6 +22,7 @@ import androidx.room.Entity import androidx.room.PrimaryKey import androidx.room.TypeConverters import com.google.gson.annotations.SerializedName +import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Status import kotlinx.parcelize.Parcelize @@ -52,6 +53,7 @@ data class DraftEntity( data class DraftAttachment( @SerializedName(value = "uriString", alternate = ["e", "i"]) val uriString: String, @SerializedName(value = "description", alternate = ["f", "j"]) val description: String?, + @SerializedName(value = "focus") val focus: Attachment.Focus?, @SerializedName(value = "type", alternate = ["g", "k"]) val type: Type ) : Parcelable { val uri: Uri diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index ca322031e..307c65dc4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -147,7 +147,8 @@ interface MastodonApi { @PUT("api/v1/media/{mediaId}") suspend fun updateMedia( @Path("mediaId") mediaId: String, - @Field("description") description: String + @Field("description") description: String?, + @Field("focus") focus: String? ): NetworkResult @GET("api/v1/media/{mediaId}") diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MediaUploadApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MediaUploadApi.kt index a179e71d2..24636a641 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MediaUploadApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MediaUploadApi.kt @@ -15,6 +15,7 @@ interface MediaUploadApi { @POST("api/v2/media") suspend fun uploadMedia( @Part file: MultipartBody.Part, - @Part description: MultipartBody.Part? = null + @Part description: MultipartBody.Part? = null, + @Part focus: MultipartBody.Part? = null ): NetworkResult } diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt index 40b2771a1..c1f5a2cca 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt @@ -89,6 +89,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { mediaIds = emptyList(), mediaUris = emptyList(), mediaDescriptions = emptyList(), + mediaFocus = emptyList(), scheduledAt = null, inReplyToId = citedStatusId, poll = null, diff --git a/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt b/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt index 899fab485..4357db432 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt @@ -26,6 +26,7 @@ import com.keylesspalace.tusky.components.drafts.DraftHelper import com.keylesspalace.tusky.components.notifications.NotificationHelper import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.NewStatus import com.keylesspalace.tusky.entity.Status @@ -258,6 +259,7 @@ class SendStatusService : Service(), Injectable { visibility = Status.Visibility.byString(status.visibility), mediaUris = status.mediaUris, mediaDescriptions = status.mediaDescriptions, + mediaFocus = status.mediaFocus, poll = status.poll, failedToSend = true, scheduledAt = status.scheduledAt, @@ -359,6 +361,7 @@ data class StatusToSend( val mediaIds: List, val mediaUris: List, val mediaDescriptions: List, + val mediaFocus: List, val scheduledAt: String?, val inReplyToId: String?, val poll: NewPoll?, diff --git a/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt b/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt index 8922fafd5..bd6d3adb2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt @@ -37,7 +37,7 @@ import com.keylesspalace.tusky.util.FocalPointUtil * However if there is no focal point set (e.g. it is null), then this view should simply * act exactly the same as an ordinary android ImageView. */ -class MediaPreviewImageView +open class MediaPreviewImageView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, diff --git a/app/src/main/res/layout/dialog_focus.xml b/app/src/main/res/layout/dialog_focus.xml new file mode 100644 index 000000000..a50928920 --- /dev/null +++ b/app/src/main/res/layout/dialog_focus.xml @@ -0,0 +1,17 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5b0e3f0c0..18c5c8bbd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -404,10 +404,12 @@ Posting as %1$s Failed to set caption + Failed to set focus point Describe for visually impaired\n(%d character limit) Set caption + Set focus point Edit image Remove Lock account