Revert "fix: Don't lose images / captions when editing with failed uploads"
This reverts commit 7abd74ad88
.
This commit is contained in:
parent
7abd74ad88
commit
67fe600f2c
|
@ -109,9 +109,7 @@ import app.pachli.util.setDrawableTint
|
|||
import com.canhub.cropper.CropImage
|
||||
import com.canhub.cropper.CropImageContract
|
||||
import com.canhub.cropper.options
|
||||
import com.github.michaelbull.result.Result
|
||||
import com.github.michaelbull.result.getOrElse
|
||||
import com.github.michaelbull.result.mapBoth
|
||||
import com.github.michaelbull.result.onFailure
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.color.MaterialColors
|
||||
|
@ -255,7 +253,7 @@ class ComposeActivity :
|
|||
val mediaAdapter = MediaPreviewAdapter(
|
||||
this,
|
||||
onAddCaption = { item ->
|
||||
CaptionDialog.newInstance(item.localId, item.serverId, item.description, item.uri).show(supportFragmentManager, "caption_dialog")
|
||||
CaptionDialog.newInstance(item.localId, item.description, item.uri).show(supportFragmentManager, "caption_dialog")
|
||||
},
|
||||
onAddFocus = { item ->
|
||||
makeFocusDialog(item.focus, item.uri) { newFocus ->
|
||||
|
@ -526,6 +524,14 @@ class ComposeActivity :
|
|||
enablePollButton(media.isEmpty())
|
||||
}.collect()
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.uploadError.collect { mediaUploaderError ->
|
||||
val message = mediaUploaderError.fmt(this@ComposeActivity)
|
||||
|
||||
displayPermamentMessage(getString(R.string.error_media_upload_sending_fmt, message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @return List of states of the different bottomsheets */
|
||||
|
@ -1266,8 +1272,14 @@ class ComposeActivity :
|
|||
* User is editing a new post, and can either save the changes as a draft or discard them.
|
||||
*/
|
||||
private fun getSaveAsDraftOrDiscardDialog(contentText: String, contentWarning: String): AlertDialog.Builder {
|
||||
val builder = AlertDialog.Builder(this)
|
||||
.setTitle(R.string.compose_save_draft)
|
||||
val warning = if (viewModel.media.value.isNotEmpty()) {
|
||||
R.string.compose_save_draft_loses_media
|
||||
} else {
|
||||
R.string.compose_save_draft
|
||||
}
|
||||
|
||||
return AlertDialog.Builder(this)
|
||||
.setMessage(warning)
|
||||
.setPositiveButton(R.string.action_save) { _, _ ->
|
||||
viewModel.stopUploads()
|
||||
saveDraftAndFinish(contentText, contentWarning)
|
||||
|
@ -1276,12 +1288,6 @@ class ComposeActivity :
|
|||
viewModel.stopUploads()
|
||||
deleteDraftAndFinish()
|
||||
}
|
||||
|
||||
if (viewModel.media.value.isNotEmpty()) {
|
||||
builder.setMessage(R.string.compose_save_draft_loses_media)
|
||||
}
|
||||
|
||||
return builder
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1289,8 +1295,14 @@ class ComposeActivity :
|
|||
* discard them.
|
||||
*/
|
||||
private fun getUpdateDraftOrDiscardDialog(contentText: String, contentWarning: String): AlertDialog.Builder {
|
||||
val builder = AlertDialog.Builder(this)
|
||||
.setTitle(R.string.compose_save_draft)
|
||||
val warning = if (viewModel.media.value.isNotEmpty()) {
|
||||
R.string.compose_save_draft_loses_media
|
||||
} else {
|
||||
R.string.compose_save_draft
|
||||
}
|
||||
|
||||
return AlertDialog.Builder(this)
|
||||
.setMessage(warning)
|
||||
.setPositiveButton(R.string.action_save) { _, _ ->
|
||||
viewModel.stopUploads()
|
||||
saveDraftAndFinish(contentText, contentWarning)
|
||||
|
@ -1299,12 +1311,6 @@ class ComposeActivity :
|
|||
viewModel.stopUploads()
|
||||
finish()
|
||||
}
|
||||
|
||||
if (viewModel.media.value.isNotEmpty()) {
|
||||
builder.setMessage(R.string.compose_save_draft_loses_media)
|
||||
}
|
||||
|
||||
return builder
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1386,39 +1392,23 @@ class ComposeActivity :
|
|||
val uri: Uri,
|
||||
val type: Type,
|
||||
val mediaSize: Long,
|
||||
val uploadPercent: Int = 0,
|
||||
val id: String? = null,
|
||||
val description: String? = null,
|
||||
val focus: Attachment.Focus? = null,
|
||||
val uploadState: Result<UploadState, MediaUploaderError>,
|
||||
val state: State,
|
||||
) {
|
||||
enum class Type {
|
||||
IMAGE,
|
||||
VIDEO,
|
||||
AUDIO,
|
||||
}
|
||||
|
||||
/**
|
||||
* Server's ID for this attachment. May be null if the media is still
|
||||
* being uploaded, or it was uploaded and there was an error that
|
||||
* meant it couldn't be processed. Attachments that have an error
|
||||
* *after* processing have a non-null `serverId`.
|
||||
*/
|
||||
val serverId: String?
|
||||
get() = uploadState.mapBoth(
|
||||
{ state ->
|
||||
when (state) {
|
||||
is UploadState.Uploading -> null
|
||||
is UploadState.Uploaded.Processing -> state.serverId
|
||||
is UploadState.Uploaded.Processed -> state.serverId
|
||||
is UploadState.Uploaded.Published -> state.serverId
|
||||
}
|
||||
},
|
||||
{ error ->
|
||||
when (error) {
|
||||
is MediaUploaderError.UpdateMediaError -> error.serverId
|
||||
else -> null
|
||||
}
|
||||
},
|
||||
)
|
||||
enum class State {
|
||||
UPLOADING,
|
||||
UNPROCESSED,
|
||||
PROCESSED,
|
||||
PUBLISHED,
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTimeSet(time: Date?) {
|
||||
|
@ -1435,8 +1425,8 @@ class ComposeActivity :
|
|||
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
}
|
||||
|
||||
override fun onUpdateDescription(localId: Int, serverId: String?, description: String) {
|
||||
viewModel.updateDescription(localId, serverId, description)
|
||||
override fun onUpdateDescription(localId: Int, description: String) {
|
||||
viewModel.updateDescription(localId, description)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -28,7 +28,6 @@ import androidx.lifecycle.viewModelScope
|
|||
import app.pachli.R
|
||||
import app.pachli.components.compose.ComposeActivity.QueuedMedia
|
||||
import app.pachli.components.compose.ComposeAutoCompleteAdapter.AutocompleteResult
|
||||
import app.pachli.components.compose.UploadState.Uploaded
|
||||
import app.pachli.components.drafts.DraftHelper
|
||||
import app.pachli.components.search.SearchType
|
||||
import app.pachli.core.common.PachliError
|
||||
|
@ -53,20 +52,20 @@ import at.connyduck.calladapter.networkresult.fold
|
|||
import com.github.michaelbull.result.Err
|
||||
import com.github.michaelbull.result.Ok
|
||||
import com.github.michaelbull.result.Result
|
||||
import com.github.michaelbull.result.andThen
|
||||
import com.github.michaelbull.result.get
|
||||
import com.github.michaelbull.result.getOrElse
|
||||
import com.github.michaelbull.result.mapBoth
|
||||
import com.github.michaelbull.result.mapError
|
||||
import com.github.michaelbull.result.onFailure
|
||||
import com.github.michaelbull.result.onSuccess
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import io.github.z4kn4fein.semver.constraints.toConstraint
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
@ -145,6 +144,8 @@ class ComposeViewModel @Inject constructor(
|
|||
|
||||
private val _media: MutableStateFlow<List<QueuedMedia>> = MutableStateFlow(emptyList())
|
||||
val media = _media.asStateFlow()
|
||||
private val _uploadError = MutableSharedFlow<MediaUploaderError>(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
val uploadError = _uploadError.asSharedFlow()
|
||||
private val _closeConfirmation = MutableStateFlow(ConfirmationKind.NONE)
|
||||
val closeConfirmation = _closeConfirmation.asStateFlow()
|
||||
private val _statusLength = MutableStateFlow(0)
|
||||
|
@ -226,7 +227,7 @@ class ComposeViewModel @Inject constructor(
|
|||
mediaSize = mediaSize,
|
||||
description = description,
|
||||
focus = focus,
|
||||
uploadState = Ok(UploadState.Uploading(percentage = 0)),
|
||||
state = QueuedMedia.State.UPLOADING,
|
||||
)
|
||||
stashMediaItem = mediaItem
|
||||
|
||||
|
@ -244,8 +245,35 @@ class ComposeViewModel @Inject constructor(
|
|||
viewModelScope.launch {
|
||||
mediaUploader
|
||||
.uploadMedia(mediaItem, instanceInfo.value)
|
||||
.collect { uploadResult ->
|
||||
updateMediaItem(mediaItem.localId) { it.copy(uploadState = uploadResult) }
|
||||
.collect { event ->
|
||||
val item = media.value.find { it.localId == mediaItem.localId }
|
||||
?: return@collect
|
||||
var newMediaItem: QueuedMedia? = null
|
||||
val uploadEvent = event.getOrElse {
|
||||
_media.update { mediaList -> mediaList.filter { it.localId != mediaItem.localId } }
|
||||
_uploadError.emit(it)
|
||||
return@collect
|
||||
}
|
||||
|
||||
newMediaItem = when (uploadEvent) {
|
||||
is UploadEvent.ProgressEvent -> item.copy(uploadPercent = uploadEvent.percentage)
|
||||
is UploadEvent.FinishedEvent -> {
|
||||
item.copy(
|
||||
id = uploadEvent.media.mediaId,
|
||||
uploadPercent = -1,
|
||||
state = if (uploadEvent.media.processed) {
|
||||
QueuedMedia.State.PROCESSED
|
||||
} else {
|
||||
QueuedMedia.State.UNPROCESSED
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
newMediaItem.let {
|
||||
_media.update { mediaList ->
|
||||
mediaList.map { mediaItem -> if (mediaItem.localId == it.localId) it else mediaItem }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -260,9 +288,11 @@ class ComposeViewModel @Inject constructor(
|
|||
uri = uri,
|
||||
type = type,
|
||||
mediaSize = 0,
|
||||
uploadPercent = -1,
|
||||
id = id,
|
||||
description = description,
|
||||
focus = focus,
|
||||
uploadState = Ok(Uploaded.Published(id)),
|
||||
state = QueuedMedia.State.PUBLISHED,
|
||||
)
|
||||
mediaList + mediaItem
|
||||
}
|
||||
|
@ -427,11 +457,11 @@ class ComposeViewModel @Inject constructor(
|
|||
val attachedMedia = media.value.map { item ->
|
||||
MediaToSend(
|
||||
localId = item.localId,
|
||||
id = item.serverId,
|
||||
id = item.id,
|
||||
uri = item.uri.toString(),
|
||||
description = item.description,
|
||||
focus = item.focus,
|
||||
processed = item.uploadState.get() is Uploaded.Processed || item.uploadState.get() is Uploaded.Published,
|
||||
processed = item.state == QueuedMedia.State.PROCESSED || item.state == QueuedMedia.State.PUBLISHED,
|
||||
)
|
||||
}
|
||||
val tootToSend = StatusToSend(
|
||||
|
@ -468,45 +498,16 @@ class ComposeViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun updateDescription(localId: Int, serverId: String?, description: String) {
|
||||
// If the image hasn't been uploaded then update the state locally.
|
||||
if (serverId == null) {
|
||||
updateMediaItem(localId) { mediaItem -> mediaItem.copy(description = description) }
|
||||
return
|
||||
}
|
||||
|
||||
// Update the remote description and report any errors. Update the local description
|
||||
// if there are errors so the user still has the text and can try and correct it.
|
||||
viewModelScope.launch {
|
||||
api.updateMedia(serverId, description = description)
|
||||
.andThen { api.getMedia(serverId) }
|
||||
.onSuccess { response ->
|
||||
val state = if (response.code == 200) {
|
||||
Uploaded.Processed(serverId)
|
||||
} else {
|
||||
Uploaded.Processing(serverId)
|
||||
}
|
||||
updateMediaItem(localId) {
|
||||
it.copy(
|
||||
description = description,
|
||||
uploadState = Ok(state),
|
||||
)
|
||||
}
|
||||
}
|
||||
.mapError { MediaUploaderError.UpdateMediaError(serverId, it) }
|
||||
.onFailure { error ->
|
||||
updateMediaItem(localId) {
|
||||
it.copy(
|
||||
description = description,
|
||||
uploadState = Err(error),
|
||||
)
|
||||
}
|
||||
}
|
||||
fun updateDescription(localId: Int, description: String) {
|
||||
updateMediaItem(localId) { mediaItem ->
|
||||
mediaItem.copy(description = description)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateFocus(localId: Int, focus: Attachment.Focus) {
|
||||
updateMediaItem(localId) { mediaItem -> mediaItem.copy(focus = focus) }
|
||||
updateMediaItem(localId) { mediaItem ->
|
||||
mediaItem.copy(focus = focus)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun searchAutocompleteSuggestions(token: String): List<AutocompleteResult> {
|
||||
|
|
|
@ -21,44 +21,30 @@ import android.view.View
|
|||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.PopupMenu
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.recyclerview.widget.AsyncListDiffer
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import app.pachli.R
|
||||
import app.pachli.components.compose.ComposeActivity.QueuedMedia
|
||||
import app.pachli.components.compose.UploadState.Uploaded
|
||||
import app.pachli.components.compose.view.ProgressImageView
|
||||
import app.pachli.core.designsystem.R as DR
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.github.michaelbull.result.get
|
||||
import com.github.michaelbull.result.onFailure
|
||||
import com.github.michaelbull.result.onSuccess
|
||||
|
||||
class MediaPreviewAdapter(
|
||||
context: Context,
|
||||
private val onAddCaption: (QueuedMedia) -> Unit,
|
||||
private val onAddFocus: (QueuedMedia) -> Unit,
|
||||
private val onEditImage: (QueuedMedia) -> Unit,
|
||||
private val onRemove: (QueuedMedia) -> Unit,
|
||||
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<MediaPreviewAdapter.PreviewViewHolder>() {
|
||||
|
||||
fun submitList(list: List<QueuedMedia>) {
|
||||
fun submitList(list: List<ComposeActivity.QueuedMedia>) {
|
||||
this.differ.submitList(list)
|
||||
}
|
||||
|
||||
private fun onMediaClick(position: Int, view: View) {
|
||||
val item = differ.currentList[position]
|
||||
|
||||
// Handle error
|
||||
item.uploadState
|
||||
.onSuccess { showMediaPopup(item, view) }
|
||||
.onFailure { showMediaError(item, it, view) }
|
||||
}
|
||||
|
||||
private fun showMediaPopup(item: QueuedMedia, view: View) {
|
||||
val popup = PopupMenu(view.context, view)
|
||||
val addCaptionId = 1
|
||||
val addFocusId = 2
|
||||
|
@ -66,9 +52,9 @@ class MediaPreviewAdapter(
|
|||
val removeId = 4
|
||||
|
||||
popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption)
|
||||
if (item.type == QueuedMedia.Type.IMAGE) {
|
||||
if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) {
|
||||
popup.menu.add(0, addFocusId, 0, R.string.action_set_focus)
|
||||
if (item.uploadState.get() !is Uploaded.Published) {
|
||||
if (item.state != ComposeActivity.QueuedMedia.State.PUBLISHED) {
|
||||
// Already-published items can't be edited
|
||||
popup.menu.add(0, editImageId, 0, R.string.action_edit_image)
|
||||
}
|
||||
|
@ -86,15 +72,6 @@ class MediaPreviewAdapter(
|
|||
popup.show()
|
||||
}
|
||||
|
||||
private fun showMediaError(item: QueuedMedia, error: MediaUploaderError, view: View) {
|
||||
AlertDialog.Builder(view.context)
|
||||
.setTitle(R.string.action_post_failed)
|
||||
.setMessage(view.context.getString(R.string.upload_failed_msg_fmt, error.fmt(view.context)))
|
||||
.setPositiveButton(android.R.string.ok) { _, _ -> }
|
||||
.setNegativeButton(R.string.upload_failed_modify_attachment) { _, _ -> showMediaPopup(item, view) }
|
||||
.show()
|
||||
}
|
||||
|
||||
private val thumbnailViewSize =
|
||||
context.resources.getDimensionPixelSize(DR.dimen.compose_media_preview_size)
|
||||
|
||||
|
@ -107,10 +84,8 @@ class MediaPreviewAdapter(
|
|||
override fun onBindViewHolder(holder: PreviewViewHolder, position: Int) {
|
||||
val item = differ.currentList[position]
|
||||
holder.progressImageView.setChecked(!item.description.isNullOrEmpty())
|
||||
|
||||
holder.progressImageView.setResult(item.uploadState)
|
||||
|
||||
if (item.type == QueuedMedia.Type.AUDIO) {
|
||||
holder.progressImageView.setProgress(item.uploadPercent)
|
||||
if (item.type == ComposeActivity.QueuedMedia.Type.AUDIO) {
|
||||
// TODO: Fancy waveform display?
|
||||
holder.progressImageView.setImageResource(R.drawable.ic_music_box_preview_24dp)
|
||||
} else {
|
||||
|
@ -139,12 +114,12 @@ class MediaPreviewAdapter(
|
|||
|
||||
private val differ = AsyncListDiffer(
|
||||
this,
|
||||
object : DiffUtil.ItemCallback<QueuedMedia>() {
|
||||
override fun areItemsTheSame(oldItem: QueuedMedia, newItem: QueuedMedia): Boolean {
|
||||
object : DiffUtil.ItemCallback<ComposeActivity.QueuedMedia>() {
|
||||
override fun areItemsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean {
|
||||
return oldItem.localId == newItem.localId
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: QueuedMedia, newItem: QueuedMedia): Boolean {
|
||||
override fun areContentsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
},
|
||||
|
|
|
@ -29,7 +29,6 @@ import app.pachli.BuildConfig
|
|||
import app.pachli.R
|
||||
import app.pachli.components.compose.ComposeActivity.QueuedMedia
|
||||
import app.pachli.components.compose.MediaUploaderError.PrepareMediaError
|
||||
import app.pachli.components.compose.UploadState.Uploaded
|
||||
import app.pachli.core.common.PachliError
|
||||
import app.pachli.core.common.string.randomAlphanumericString
|
||||
import app.pachli.core.common.util.formatNumber
|
||||
|
@ -58,7 +57,6 @@ import kotlinx.coroutines.channels.awaitClose
|
|||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
|
@ -70,6 +68,18 @@ import okio.sink
|
|||
import okio.source
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Media that has been fully uploaded to the server and may still be being
|
||||
* processed.
|
||||
*
|
||||
* @property mediaId Server-side identifier for this media item
|
||||
* @property processed True if the server has finished processing this media item
|
||||
*/
|
||||
data class UploadedMedia(
|
||||
val mediaId: String,
|
||||
val processed: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Media that has been prepared for uploading.
|
||||
*
|
||||
|
@ -154,66 +164,44 @@ sealed interface MediaUploaderError : PachliError {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An error occurred uploading the media, and there is no remote ID.
|
||||
*
|
||||
* [ApiError] wrapper.
|
||||
*/
|
||||
/** [ApiError] wrapper. */
|
||||
@JvmInline
|
||||
value class UploadMediaError(private val error: ApiError) : MediaUploaderError, PachliError by error
|
||||
|
||||
/**
|
||||
* An error occurred updating media that has already been uploaded.
|
||||
*
|
||||
* @param serverId Server's ID for the media
|
||||
*/
|
||||
data class UpdateMediaError(val serverId: String, val error: ApiError) : MediaUploaderError, PachliError by error
|
||||
|
||||
/** Server did return media with ID [uploadId]. */
|
||||
data class UploadIdNotFoundError(val uploadId: Int) : MediaUploaderError {
|
||||
override val resourceId = R.string.error_media_uploader_upload_not_found_fmt
|
||||
override val formatArgs = arrayOf(uploadId.toString())
|
||||
override val cause = null
|
||||
}
|
||||
}
|
||||
|
||||
/** State of a media upload. */
|
||||
sealed interface UploadState {
|
||||
/**
|
||||
* Upload is in progress, but incomplete.
|
||||
*
|
||||
* @property percentage What percent of the file has been uploaded.
|
||||
*/
|
||||
data class Uploading(val percentage: Int) : UploadState
|
||||
|
||||
sealed interface Uploaded : UploadState {
|
||||
val serverId: String
|
||||
|
||||
/**
|
||||
* Upload has completed, but the server is still processing the media.
|
||||
*
|
||||
* @property serverId Server-side identifier for this media item
|
||||
*/
|
||||
data class Processing(override val serverId: String) : UploadState.Uploaded
|
||||
|
||||
/**
|
||||
* Upload has completed, and the server has processed the media.
|
||||
*
|
||||
* @property serverId Server-side identifier for this media item
|
||||
*/
|
||||
data class Processed(override val serverId: String) : UploadState.Uploaded
|
||||
|
||||
/**
|
||||
* Post has been published, editing is impossible.
|
||||
*
|
||||
* @property serverId Server-side identifier for this media item
|
||||
*/
|
||||
data class Published(override val serverId: String) : UploadState.Uploaded
|
||||
/** Catch-all for arbitrary throwables */
|
||||
data class ThrowableError(private val throwable: Throwable) : MediaUploaderError {
|
||||
override val resourceId = R.string.error_media_uploader_throwable_fmt
|
||||
override val formatArgs = arrayOf(throwable.localizedMessage ?: "")
|
||||
override val cause = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Events that happen over the life of a media upload. */
|
||||
sealed interface UploadEvent {
|
||||
/**
|
||||
* Upload has made progress.
|
||||
*
|
||||
* @property percentage What percent of the file has been uploaded.
|
||||
*/
|
||||
data class ProgressEvent(val percentage: Int) : UploadEvent
|
||||
|
||||
/**
|
||||
* Upload has finished.
|
||||
*
|
||||
* @property media The uploaded media
|
||||
*/
|
||||
data class FinishedEvent(val media: UploadedMedia) : UploadEvent
|
||||
}
|
||||
|
||||
data class UploadData(
|
||||
val flow: Flow<Result<UploadState, MediaUploaderError>>,
|
||||
val flow: Flow<Result<UploadEvent, MediaUploaderError>>,
|
||||
val scope: CoroutineScope,
|
||||
)
|
||||
|
||||
|
@ -243,29 +231,28 @@ class MediaUploader @Inject constructor(
|
|||
return mostRecentId++
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the upload with [localId] to finish (Ok state is one of the
|
||||
* [Uploaded][UploadState.Uploaded] types), or return an error.
|
||||
*/
|
||||
suspend fun waitForUploadToFinish(localId: Int): Result<Uploaded, MediaUploaderError> {
|
||||
return uploads[localId]?.flow?.filter {
|
||||
it.get() is Uploaded || it.get() == null
|
||||
}?.first() as? Result<Uploaded, MediaUploaderError>
|
||||
suspend fun getMediaUploadState(localId: Int): Result<UploadEvent.FinishedEvent, MediaUploaderError> {
|
||||
return uploads[localId]?.flow
|
||||
// Can't use filterIsInstance<Ok<UploadEvent.FinishedEvent>> here because the type
|
||||
// inside Ok<...> is erased, so the first Ok<_> result is returned, crashing with a
|
||||
// class cast error if it's a ProgressEvent.
|
||||
// Kotlin doesn't warn about this, see
|
||||
// https://discuss.kotlinlang.org/t/is-as-operators-are-unsafe-for-reified-types/22470
|
||||
?.first { it.get() is UploadEvent.FinishedEvent } as? Ok<UploadEvent.FinishedEvent>
|
||||
?: Err(MediaUploaderError.UploadIdNotFoundError(localId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads media.
|
||||
*
|
||||
* @param media the media to upload
|
||||
* @param instanceInfo info about the current media to make sure the media gets resized correctly
|
||||
* @return A Flow emitting upload events.
|
||||
* The Flow is hot, in order to cancel upload or clear resources call [cancelUploadScope].
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun uploadMedia(media: QueuedMedia, instanceInfo: InstanceInfo): Flow<Result<UploadState, MediaUploaderError>> {
|
||||
fun uploadMedia(media: QueuedMedia, instanceInfo: InstanceInfo): Flow<Result<UploadEvent, MediaUploaderError>> {
|
||||
val uploadScope = CoroutineScope(Dispatchers.IO)
|
||||
val uploadFlow: Flow<Result<UploadState, MediaUploaderError>> = flow {
|
||||
val uploadFlow: Flow<Result<UploadEvent, MediaUploaderError>> = flow {
|
||||
if (shouldResizeMedia(media, instanceInfo)) {
|
||||
emit(downsize(media, instanceInfo))
|
||||
} else {
|
||||
|
@ -384,7 +371,7 @@ class MediaUploader @Inject constructor(
|
|||
|
||||
private val contentResolver = context.contentResolver
|
||||
|
||||
private suspend fun upload(media: QueuedMedia): Flow<Result<UploadState, MediaUploaderError.UploadMediaError>> {
|
||||
private suspend fun upload(media: QueuedMedia): Flow<Result<UploadEvent, MediaUploaderError.UploadMediaError>> {
|
||||
return callbackFlow {
|
||||
var mimeType = contentResolver.getType(media.uri)
|
||||
|
||||
|
@ -418,7 +405,7 @@ class MediaUploader @Inject constructor(
|
|||
media.mediaSize,
|
||||
) { percentage ->
|
||||
if (percentage != lastProgress) {
|
||||
trySend(Ok(UploadState.Uploading(percentage)))
|
||||
trySend(Ok(UploadEvent.ProgressEvent(percentage)))
|
||||
}
|
||||
lastProgress = percentage
|
||||
}
|
||||
|
@ -437,13 +424,7 @@ class MediaUploader @Inject constructor(
|
|||
|
||||
val uploadResult = mediaUploadApi.uploadMedia(body, description, focus)
|
||||
.mapEither(
|
||||
{
|
||||
if (it.code == 200) {
|
||||
Uploaded.Processed(it.body.id)
|
||||
} else {
|
||||
Uploaded.Processing(it.body.id)
|
||||
}
|
||||
},
|
||||
{ UploadEvent.FinishedEvent(UploadedMedia(it.body.id, it.code == 200)) },
|
||||
{ MediaUploaderError.UploadMediaError(it) },
|
||||
)
|
||||
send(uploadResult)
|
||||
|
|
|
@ -68,12 +68,10 @@ class CaptionDialog : DialogFragment() {
|
|||
input.requestFocus()
|
||||
|
||||
val localId = arguments?.getInt(ARG_LOCAL_ID) ?: error("Missing localId")
|
||||
val serverId = arguments?.getString(ARG_SERVER_ID)
|
||||
|
||||
val dialog = AlertDialog.Builder(context)
|
||||
.setView(binding.root)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
listener.onUpdateDescription(localId, serverId, input.text.toString())
|
||||
listener.onUpdateDescription(localId, input.text.toString())
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.create()
|
||||
|
@ -127,25 +125,22 @@ class CaptionDialog : DialogFragment() {
|
|||
}
|
||||
|
||||
interface Listener {
|
||||
fun onUpdateDescription(localId: Int, serverId: String?, description: String)
|
||||
fun onUpdateDescription(localId: Int, description: String)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_DESCRIPTION = "app.pachli.KEY_DESCRIPTION"
|
||||
private const val ARG_LOCAL_ID = "app.pachli.ARG_LOCAL_ID"
|
||||
private const val ARG_SERVER_ID = "app.pachli.ARG_SERVER_ID"
|
||||
private const val ARG_EXISTING_DESCRIPTION = "app.pachli.ARG_EXISTING_DESCRIPTION"
|
||||
private const val ARG_PREVIEW_URI = "app.pachli.ARG_PREVIEW_URI"
|
||||
private const val ARG_LOCAL_ID = "app.pachli.ARG_LOCAL_ID"
|
||||
|
||||
fun newInstance(
|
||||
localId: Int,
|
||||
serverId: String? = null,
|
||||
existingDescription: String?,
|
||||
previewUri: Uri,
|
||||
) = CaptionDialog().apply {
|
||||
arguments = bundleOf(
|
||||
ARG_LOCAL_ID to localId,
|
||||
ARG_SERVER_ID to serverId,
|
||||
ARG_EXISTING_DESCRIPTION to existingDescription,
|
||||
ARG_PREVIEW_URI to previewUri,
|
||||
)
|
||||
|
|
|
@ -24,22 +24,12 @@ import android.graphics.PorterDuff
|
|||
import android.graphics.PorterDuffXfermode
|
||||
import android.graphics.RectF
|
||||
import android.util.AttributeSet
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import app.pachli.R
|
||||
import app.pachli.components.compose.MediaUploaderError
|
||||
import app.pachli.components.compose.UploadState
|
||||
import app.pachli.core.designsystem.R as DR
|
||||
import app.pachli.core.ui.makeIcon
|
||||
import app.pachli.view.MediaPreviewImageView
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import com.github.michaelbull.result.Ok
|
||||
import com.github.michaelbull.result.Result
|
||||
import com.github.michaelbull.result.onFailure
|
||||
import com.github.michaelbull.result.onSuccess
|
||||
import com.google.android.material.badge.ExperimentalBadgeUtils
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
||||
|
||||
class ProgressImageView
|
||||
@JvmOverloads constructor(
|
||||
|
@ -47,7 +37,7 @@ class ProgressImageView
|
|||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0,
|
||||
) : MediaPreviewImageView(context, attrs, defStyleAttr) {
|
||||
private var result: Result<UploadState, MediaUploaderError> = Ok(UploadState.Uploading(0))
|
||||
private var progress = -1
|
||||
private val progressRect = RectF()
|
||||
private val biggerRect = RectF()
|
||||
private val circlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
|
@ -70,15 +60,14 @@ class ProgressImageView
|
|||
}
|
||||
private val circleRadius = Utils.dpToPx(context, 14)
|
||||
private val circleMargin = Utils.dpToPx(context, 14)
|
||||
private val uploadErrorRadius = Utils.dpToPx(context, 24)
|
||||
|
||||
private val uploadErrorDrawable = makeIcon(context, GoogleMaterial.Icon.gmd_error, 48).apply {
|
||||
setTint(Color.WHITE)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalBadgeUtils::class)
|
||||
fun setResult(result: Result<UploadState, MediaUploaderError>) {
|
||||
this.result = result
|
||||
fun setProgress(progress: Int) {
|
||||
this.progress = progress
|
||||
if (progress != -1) {
|
||||
setColorFilter(Color.rgb(123, 123, 123), PorterDuff.Mode.MULTIPLY)
|
||||
} else {
|
||||
clearColorFilter()
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
|
||||
|
@ -93,24 +82,6 @@ class ProgressImageView
|
|||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
|
||||
result.onSuccess { value ->
|
||||
val percentage = when (value) {
|
||||
is UploadState.Uploading -> value.percentage
|
||||
else -> -1
|
||||
}
|
||||
onDrawSuccess(canvas, percentage)
|
||||
}.onFailure { error ->
|
||||
onDrawError(canvas)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onDrawSuccess(canvas: Canvas, progress: Int) {
|
||||
clearColorFilter()
|
||||
if (progress != -1) {
|
||||
setColorFilter(Color.rgb(123, 123, 123), PorterDuff.Mode.MULTIPLY)
|
||||
}
|
||||
|
||||
val angle = progress / 100f * 360 - 90
|
||||
val halfWidth = width / 2f
|
||||
val halfHeight = height / 2f
|
||||
|
@ -136,18 +107,4 @@ class ProgressImageView
|
|||
)
|
||||
captionDrawable.draw(canvas)
|
||||
}
|
||||
|
||||
private fun onDrawError(canvas: Canvas) {
|
||||
setColorFilter(
|
||||
MaterialColors.getColor(this, androidx.appcompat.R.attr.colorError),
|
||||
PorterDuff.Mode.DARKEN,
|
||||
)
|
||||
uploadErrorDrawable.setBounds(
|
||||
(width / 2) - uploadErrorRadius,
|
||||
(height / 2) - uploadErrorRadius,
|
||||
(width / 2) + uploadErrorRadius,
|
||||
(height / 2) + uploadErrorRadius,
|
||||
)
|
||||
uploadErrorDrawable.draw(canvas)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -38,8 +38,6 @@ import app.pachli.core.network.model.Status
|
|||
import app.pachli.core.network.retrofit.MastodonApi
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.github.michaelbull.result.getOrElse
|
||||
import com.github.michaelbull.result.onFailure
|
||||
import com.github.michaelbull.result.onSuccess
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import java.io.IOException
|
||||
import java.util.Date
|
||||
|
@ -147,14 +145,14 @@ class SendStatusService : Service() {
|
|||
// first, wait for media uploads to finish
|
||||
val media = statusToSend.media.map { mediaItem ->
|
||||
if (mediaItem.id == null) {
|
||||
val uploadState = mediaUploader.waitForUploadToFinish(mediaItem.localId)
|
||||
val uploadState = mediaUploader.getMediaUploadState(mediaItem.localId)
|
||||
val media = uploadState.getOrElse {
|
||||
Timber.w("failed uploading media: %s", it.fmt(this@SendStatusService))
|
||||
failSending(statusId)
|
||||
stopSelfWhenDone()
|
||||
return@launch
|
||||
}
|
||||
mediaItem.copy(id = media.serverId)
|
||||
}.media
|
||||
mediaItem.copy(id = media.mediaId, processed = media.processed)
|
||||
} else {
|
||||
mediaItem
|
||||
}
|
||||
|
@ -167,13 +165,15 @@ class SendStatusService : Service() {
|
|||
delay(1000L * mediaCheckRetries)
|
||||
media.forEach { mediaItem ->
|
||||
if (!mediaItem.processed) {
|
||||
mastodonApi.getMedia(mediaItem.id!!)
|
||||
.onSuccess { mediaItem.processed = it.code == 200 }
|
||||
.onFailure {
|
||||
when (mastodonApi.getMedia(mediaItem.id!!).code()) {
|
||||
200 -> mediaItem.processed = true // success
|
||||
206 -> { } // media is still being processed, continue checking
|
||||
else -> { // some kind of server error, retrying probably doesn't make sense
|
||||
failSending(statusId)
|
||||
stopSelfWhenDone()
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
mediaCheckRetries++
|
||||
|
@ -190,11 +190,13 @@ class SendStatusService : Service() {
|
|||
media.forEach { mediaItem ->
|
||||
if (mediaItem.processed && (mediaItem.description != null || mediaItem.focus != null)) {
|
||||
mastodonApi.updateMedia(mediaItem.id!!, mediaItem.description, mediaItem.focus?.toMastodonApiString())
|
||||
.onFailure { error ->
|
||||
Timber.w("failed to update media on status send: %s", error)
|
||||
failOrRetry(error.throwable, statusId)
|
||||
.fold({
|
||||
}, { throwable ->
|
||||
Timber.w(throwable, "failed to update media on status send")
|
||||
failOrRetry(throwable, statusId)
|
||||
|
||||
return@launch
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -853,7 +853,4 @@
|
|||
|
||||
<string name="action_copy_item">Copy item</string>
|
||||
<string name="item_copied">Text copied</string>
|
||||
|
||||
<string name="upload_failed_msg_fmt">The upload will be retried when you send the post. If it fails again the post will be saved in your drafts.\n\nThe error was: %1$s</string>
|
||||
<string name="upload_failed_modify_attachment">Modify attachment</string>
|
||||
</resources>
|
||||
|
|
|
@ -201,14 +201,14 @@ interface MastodonApi {
|
|||
@PUT("api/v1/media/{mediaId}")
|
||||
suspend fun updateMedia(
|
||||
@Path("mediaId") mediaId: String,
|
||||
@Field("description") description: String? = null,
|
||||
@Field("focus") focus: String? = null,
|
||||
): ApiResult<Attachment>
|
||||
@Field("description") description: String?,
|
||||
@Field("focus") focus: String?,
|
||||
): NetworkResult<Attachment>
|
||||
|
||||
@GET("api/v1/media/{mediaId}")
|
||||
suspend fun getMedia(
|
||||
@Path("mediaId") mediaId: String,
|
||||
): ApiResult<MediaUploadResult>
|
||||
): Response<MediaUploadResult>
|
||||
|
||||
@POST("api/v1/statuses")
|
||||
suspend fun createStatus(
|
||||
|
|
|
@ -21,8 +21,8 @@
|
|||
<string name="node_info_error_no_software_version">software version is missing, empty, or blank</string>
|
||||
|
||||
<string name="server_error_unparseable_version">could not parse \"%1$s\" as a version: %2$s</string>
|
||||
<string name="error_generic_fmt">%1$s</string>
|
||||
<string name="error_404_not_found_fmt">your server does not support this feature: %1$s</string>
|
||||
<string name="error_generic_fmt">An error occurred: %s</string>
|
||||
<string name="error_404_not_found_fmt">Your server does not support this feature: %1$s</string>
|
||||
<string name="error_429_rate_limit_fmt">your server is rate-limiting your requests: %1$s</string>
|
||||
<string name="error_json_data_fmt">Your server returned an invalid response: %1$s</string>
|
||||
<string name="error_network_fmt">A network error occurred: %s</string>
|
||||
|
|
Loading…
Reference in New Issue