From abca91a420939b058d59b8ccb6bb3ca9fdc96fab Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Thu, 29 Dec 2022 19:58:23 +0100 Subject: [PATCH] Handoff media upload (#2947) * handoff media upload to SendStatusService * fix bugd * improve code * don't check processing state when upload returned 200 --- .../components/compose/ComposeActivity.kt | 21 ++-- .../components/compose/ComposeViewModel.kt | 117 +++++++++--------- .../components/compose/MediaPreviewAdapter.kt | 2 +- .../tusky/components/compose/MediaUploader.kt | 81 ++++++++++-- .../tusky/network/MediaUploadApi.kt | 4 +- .../receiver/SendStatusBroadcastReceiver.kt | 6 +- .../tusky/service/SendStatusService.kt | 91 ++++++++++---- 7 files changed, 204 insertions(+), 118 deletions(-) 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 cecf40a2..1bd71d68 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 @@ -135,8 +135,6 @@ class ComposeActivity : private lateinit var emojiBehavior: BottomSheetBehavior<*> private lateinit var scheduleBehavior: BottomSheetBehavior<*> - // this only exists when a status is trying to be sent, but uploads are still occurring - private var finishingUploadDialog: ProgressDialog? = null private var photoUploadUri: Uri? = null private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) } @@ -957,16 +955,9 @@ class ComposeActivity : binding.composeEditField.error = getString(R.string.error_empty) enableButtons(true, viewModel.editing) } else if (characterCount <= maximumTootCharacters) { - if (viewModel.media.value.isNotEmpty()) { - finishingUploadDialog = ProgressDialog.show( - this, getString(R.string.dialog_title_finishing_media_upload), - getString(R.string.dialog_message_uploading_media), true, true - ) - } lifecycleScope.launch { viewModel.sendStatus(contentText, spoilerText) - finishingUploadDialog?.dismiss() deleteDraftAndFinish() } } else { @@ -1133,11 +1124,16 @@ class ComposeActivity : AlertDialog.Builder(this) .setMessage(warning) .setPositiveButton(R.string.action_save) { _, _ -> + viewModel.stopUploads() saveDraftAndFinish(contentText, contentWarning) } - .setNegativeButton(R.string.action_delete) { _, _ -> deleteDraftAndFinish() } + .setNegativeButton(R.string.action_delete) { _, _ -> + viewModel.stopUploads() + deleteDraftAndFinish() + } .show() } else { + viewModel.stopUploads() finishWithoutSlideOutAnimation() } } @@ -1188,11 +1184,14 @@ class ComposeActivity : val id: String? = null, val description: String? = null, val focus: Attachment.Focus? = null, - val processed: Boolean = false, + val state: State ) { enum class Type { IMAGE, VIDEO, AUDIO; } + enum class State { + UPLOADING, UNPROCESSED, PROCESSED, PUBLISHED + } } override fun onTimeSet(time: String) { 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 5807fb74..e697fad6 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 @@ -33,20 +33,18 @@ import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.service.MediaToSend import com.keylesspalace.tusky.service.ServiceClient import com.keylesspalace.tusky.service.StatusToSend import com.keylesspalace.tusky.util.randomAlphanumericString import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.Job import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.update @@ -97,8 +95,6 @@ class ComposeViewModel @Inject constructor( val media: MutableStateFlow> = MutableStateFlow(emptyList()) val uploadError = MutableSharedFlow(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) - private val mediaToJob = mutableMapOf() - // Used in ComposeActivity to pass state to result function when cropImage contract inflight var cropImageItemOld: QueuedMedia? = null @@ -134,17 +130,18 @@ class ComposeViewModel @Inject constructor( media.updateAndGet { mediaValue -> val mediaItem = QueuedMedia( - localId = (mediaValue.maxOfOrNull { it.localId } ?: 0) + 1, + localId = mediaUploader.getNewLocalMediaId(), uri = uri, type = type, mediaSize = mediaSize, description = description, - focus = focus + focus = focus, + state = QueuedMedia.State.UPLOADING ) stashMediaItem = mediaItem if (replaceItem != null) { - mediaToJob[replaceItem.localId]?.cancel() + mediaUploader.cancelUploadScope(replaceItem.localId) mediaValue.map { if (it.localId == replaceItem.localId) mediaItem else it } @@ -154,13 +151,9 @@ class ComposeViewModel @Inject constructor( } val mediaItem = stashMediaItem!! // stashMediaItem is always non-null and uncaptured at this point, but Kotlin doesn't know that - mediaToJob[mediaItem.localId] = viewModelScope.launch { + viewModelScope.launch { mediaUploader .uploadMedia(mediaItem, instanceInfo.first()) - .catch { error -> - media.update { mediaValue -> mediaValue.filter { it.localId != mediaItem.localId } } - uploadError.emit(error) - } .collect { event -> val item = media.value.find { it.localId == mediaItem.localId } ?: return@collect @@ -168,7 +161,16 @@ class ComposeViewModel @Inject constructor( is UploadEvent.ProgressEvent -> item.copy(uploadPercent = event.percentage) is UploadEvent.FinishedEvent -> - item.copy(id = event.mediaId, uploadPercent = -1) + item.copy( + id = event.mediaId, + uploadPercent = -1, + state = if (event.processed) { QueuedMedia.State.PROCESSED } else { QueuedMedia.State.UNPROCESSED } + ) + is UploadEvent.ErrorEvent -> { + media.update { mediaValue -> mediaValue.filter { it.localId != mediaItem.localId } } + uploadError.emit(event.error) + return@collect + } } media.update { mediaValue -> mediaValue.map { mediaItem -> @@ -187,7 +189,7 @@ class ComposeViewModel @Inject constructor( 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, + localId = mediaUploader.getNewLocalMediaId(), uri = uri, type = type, mediaSize = 0, @@ -195,14 +197,14 @@ class ComposeViewModel @Inject constructor( id = id, description = description, focus = focus, - processed = true, + state = QueuedMedia.State.PUBLISHED ) mediaValue + mediaItem } } fun removeMediaFromQueue(item: QueuedMedia) { - mediaToJob[item.localId]?.cancel() + mediaUploader.cancelUploadScope(item.localId) media.update { mediaValue -> mediaValue.filter { it.localId != item.localId } } } @@ -240,6 +242,10 @@ class ComposeViewModel @Inject constructor( } } + fun stopUploads() { + mediaUploader.cancelUploadScope(*media.value.map { it.localId }.toIntArray()) + } + fun shouldShowSaveDraftDialog(): Boolean { // if any of the media files need to be downloaded first it could take a while, so show a loading dialog return media.value.any { mediaValue -> @@ -289,47 +295,36 @@ class ComposeViewModel @Inject constructor( api.deleteScheduledStatus(scheduledTootId!!) } - media - .filter { items -> items.all { it.uploadPercent == -1 } } - .first { - 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(item.processed) - } - val tootToSend = StatusToSend( - text = content, - warningText = spoilerText, - visibility = statusVisibility.value.serverString(), - sensitive = mediaUris.isNotEmpty() && (markMediaAsSensitive.value || showContentWarning.value), - mediaIds = mediaIds, - mediaUris = mediaUris.map { it.toString() }, - mediaDescriptions = mediaDescriptions, - mediaFocus = mediaFocus, - scheduledAt = scheduledAt.value, - inReplyToId = inReplyToId, - poll = poll.value, - replyingStatusContent = null, - replyingStatusAuthorUsername = null, - accountId = accountManager.activeAccount!!.id, - draftId = draftId, - idempotencyKey = randomAlphanumericString(16), - retries = 0, - mediaProcessed = mediaProcessed, - language = postLanguage, - statusId = originalStatusId, - ) + val attachedMedia = media.value.map { item -> + MediaToSend( + localId = item.localId, + id = item.id, + uri = item.uri.toString(), + description = item.description, + focus = item.focus, + processed = item.state == QueuedMedia.State.PROCESSED || item.state == QueuedMedia.State.PUBLISHED + ) + } + val tootToSend = StatusToSend( + text = content, + warningText = spoilerText, + visibility = statusVisibility.value.serverString(), + sensitive = attachedMedia.isNotEmpty() && (markMediaAsSensitive.value || showContentWarning.value), + media = attachedMedia, + scheduledAt = scheduledAt.value, + inReplyToId = inReplyToId, + poll = poll.value, + replyingStatusContent = null, + replyingStatusAuthorUsername = null, + accountId = accountManager.activeAccount!!.id, + draftId = draftId, + idempotencyKey = randomAlphanumericString(16), + retries = 0, + language = postLanguage, + statusId = originalStatusId + ) - serviceClient.sendToot(tootToSend) - true - } + serviceClient.sendToot(tootToSend) } // Updates a QueuedMedia item arbitrarily, then sends description and focus to server @@ -360,15 +355,15 @@ class ComposeViewModel @Inject constructor( } suspend fun updateDescription(localId: Int, description: String): Boolean { - return updateMediaItem(localId, { mediaItem -> + return updateMediaItem(localId) { mediaItem -> mediaItem.copy(description = description) - }) + } } suspend fun updateFocus(localId: Int, focus: Attachment.Focus): Boolean { - return updateMediaItem(localId, { mediaItem -> + return updateMediaItem(localId) { mediaItem -> mediaItem.copy(focus = focus) - }) + } } fun searchAutocompleteSuggestions(token: String): List { 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 fd4219a5..cababaf0 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 @@ -48,7 +48,7 @@ class MediaPreviewAdapter( val addFocusId = 2 val editImageId = 3 val removeId = 4 - if (!item.processed) { + if (item.state != ComposeActivity.QueuedMedia.State.PUBLISHED) { // Already-published items can't have their metadata edited popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption) if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) { 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 450cb5aa..7259097c 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 @@ -23,7 +23,6 @@ import android.util.Log import android.webkit.MimeTypeMap import androidx.core.content.FileProvider import androidx.core.net.toUri -import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia @@ -35,28 +34,44 @@ import com.keylesspalace.tusky.util.getImageSquarePixels import com.keylesspalace.tusky.util.getMediaSize import com.keylesspalace.tusky.util.getServerErrorMessage import com.keylesspalace.tusky.util.randomAlphanumericString +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.shareIn import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody +import retrofit2.HttpException import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.io.IOException import java.util.Date import javax.inject.Inject +import javax.inject.Singleton + +sealed interface FinalUploadEvent sealed class UploadEvent { data class ProgressEvent(val percentage: Int) : UploadEvent() - data class FinishedEvent(val mediaId: String) : UploadEvent() + data class FinishedEvent(val mediaId: String, val processed: Boolean) : UploadEvent(), FinalUploadEvent + data class ErrorEvent(val error: Throwable) : UploadEvent(), FinalUploadEvent } +data class UploadData( + val flow: Flow, + val scope: CoroutineScope +) + fun createNewImageFile(context: Context, suffix: String = ".jpg"): File { // Create an image file name val randomId = randomAlphanumericString(12) @@ -76,14 +91,38 @@ class MediaTypeException : Exception() class CouldNotOpenFileException : Exception() class UploadServerError(val errorMessage: String) : Exception() +@Singleton class MediaUploader @Inject constructor( private val context: Context, private val mediaUploadApi: MediaUploadApi ) { + private val uploads = mutableMapOf() + + private var mostRecentId: Int = 0 + + fun getNewLocalMediaId(): Int { + return mostRecentId++ + } + + suspend fun getMediaUploadState(localId: Int): FinalUploadEvent { + return uploads[localId]?.flow + ?.filterIsInstance() + ?.first() + ?: UploadEvent.ErrorEvent(IllegalStateException("media upload with id $localId not found")) + } + + /** + * 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 { - return flow { + val uploadScope = CoroutineScope(Dispatchers.IO) + val uploadFlow = flow { if (shouldResizeMedia(media, instanceInfo)) { emit(downsize(media, instanceInfo)) } else { @@ -91,7 +130,23 @@ class MediaUploader @Inject constructor( } } .flatMapLatest { upload(it) } - .flowOn(Dispatchers.IO) + .catch { exception -> + emit(UploadEvent.ErrorEvent(exception)) + } + .shareIn(uploadScope, SharingStarted.Lazily, 1) + + uploads[media.localId] = UploadData(uploadFlow, uploadScope) + return uploadFlow + } + + /** + * Cancels the CoroutineScope of a media upload. + * Call this when to abort the upload or to clean up resources after upload info is no longer needed + */ + fun cancelUploadScope(vararg localMediaIds: Int) { + localMediaIds.forEach { localId -> + uploads.remove(localId)?.scope?.cancel() + } } fun prepareMedia(inUri: Uri, instanceInfo: InstanceInfo): PreparedMedia { @@ -231,16 +286,20 @@ class MediaUploader @Inject constructor( null } - mediaUploadApi.uploadMedia(body, description, focus).fold({ result -> - send(UploadEvent.FinishedEvent(result.id)) - }, { throwable -> - val errorMessage = throwable.getServerErrorMessage() + val uploadResponse = mediaUploadApi.uploadMedia(body, description, focus) + val responseBody = uploadResponse.body() + if (uploadResponse.isSuccessful && responseBody != null) { + send(UploadEvent.FinishedEvent(responseBody.id, uploadResponse.code() == 200)) + } else { + val error = HttpException(uploadResponse) + val errorMessage = error.getServerErrorMessage() if (errorMessage == null) { - throw throwable + throw error } else { throw UploadServerError(errorMessage) } - }) + } + awaitClose() } } 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 24636a64..0cfab9b7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MediaUploadApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MediaUploadApi.kt @@ -1,8 +1,8 @@ package com.keylesspalace.tusky.network -import at.connyduck.calladapter.networkresult.NetworkResult import com.keylesspalace.tusky.entity.MediaUploadResult import okhttp3.MultipartBody +import retrofit2.Response import retrofit2.http.Multipart import retrofit2.http.POST import retrofit2.http.Part @@ -17,5 +17,5 @@ interface MediaUploadApi { @Part file: MultipartBody.Part, @Part description: MultipartBody.Part? = null, @Part focus: MultipartBody.Part? = null - ): NetworkResult + ): Response } 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 ca2358e0..0ac30b10 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt @@ -86,10 +86,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { warningText = spoiler, visibility = visibility.serverString(), sensitive = false, - mediaIds = emptyList(), - mediaUris = emptyList(), - mediaDescriptions = emptyList(), - mediaFocus = emptyList(), + media = emptyList(), scheduledAt = null, inReplyToId = citedStatusId, poll = null, @@ -99,7 +96,6 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { draftId = -1, idempotencyKey = randomAlphanumericString(16), retries = 0, - mediaProcessed = mutableListOf(), language = null, statusId = 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 5849fc81..9815ad8e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt @@ -22,6 +22,8 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.StatusComposedEvent import com.keylesspalace.tusky.appstore.StatusScheduledEvent +import com.keylesspalace.tusky.components.compose.MediaUploader +import com.keylesspalace.tusky.components.compose.UploadEvent import com.keylesspalace.tusky.components.drafts.DraftHelper import com.keylesspalace.tusky.components.notifications.NotificationHelper import com.keylesspalace.tusky.db.AccountManager @@ -54,6 +56,8 @@ class SendStatusService : Service(), Injectable { lateinit var eventHub: EventHub @Inject lateinit var draftHelper: DraftHelper + @Inject + lateinit var mediaUploader: MediaUploader private val supervisorJob = SupervisorJob() private val serviceScope = CoroutineScope(Dispatchers.Main + supervisorJob) @@ -131,14 +135,33 @@ class SendStatusService : Service(), Injectable { statusToSend.retries++ sendJobs[statusId] = serviceScope.launch { + + // first, wait for media uploads to finish + val media = statusToSend.media.map { mediaItem -> + if (mediaItem.id == null) { + when (val uploadState = mediaUploader.getMediaUploadState(mediaItem.localId)) { + is UploadEvent.FinishedEvent -> mediaItem.copy(id = uploadState.mediaId, processed = uploadState.processed) + is UploadEvent.ErrorEvent -> { + Log.w(TAG, "failed uploading media", uploadState.error) + failSending(statusId) + stopSelfWhenDone() + return@launch + } + } + } else { + mediaItem + } + } + + // then wait until server finished processing the media try { var mediaCheckRetries = 0 - while (statusToSend.mediaProcessed.any { !it }) { + while (media.any { mediaItem -> !mediaItem.processed }) { delay(1000L * mediaCheckRetries) - statusToSend.mediaProcessed.forEachIndexed { index, processed -> - if (!processed) { - when (mastodonApi.getMedia(statusToSend.mediaIds[index]).code()) { - 200 -> statusToSend.mediaProcessed[index] = true // success + media.forEach { mediaItem -> + if (!mediaItem.processed) { + 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) @@ -156,16 +179,17 @@ class SendStatusService : Service(), Injectable { return@launch } + // finally, send the new status val newStatus = NewStatus( - statusToSend.text, - statusToSend.warningText, - statusToSend.inReplyToId, - statusToSend.visibility, - statusToSend.sensitive, - statusToSend.mediaIds, - statusToSend.scheduledAt, - statusToSend.poll, - statusToSend.language, + status = statusToSend.text, + warningText = statusToSend.warningText, + inReplyToId = statusToSend.inReplyToId, + visibility = statusToSend.visibility, + sensitive = statusToSend.sensitive, + mediaIds = media.map { it.id!! }, + scheduledAt = statusToSend.scheduledAt, + poll = statusToSend.poll, + language = statusToSend.language, ) val sendResult = if (statusToSend.statusId == null) { @@ -192,6 +216,8 @@ class SendStatusService : Service(), Injectable { draftHelper.deleteDraftAndAttachments(statusToSend.draftId) } + mediaUploader.cancelUploadScope(*statusToSend.media.map { it.localId }.toIntArray()) + val scheduled = !statusToSend.scheduledAt.isNullOrEmpty() if (scheduled) { @@ -237,6 +263,8 @@ class SendStatusService : Service(), Injectable { val failedStatus = statusesToSend.remove(statusId) if (failedStatus != null) { + mediaUploader.cancelUploadScope(*failedStatus.media.map { it.localId }.toIntArray()) + saveStatusToDrafts(failedStatus) val notification = buildDraftNotification( @@ -254,6 +282,9 @@ class SendStatusService : Service(), Injectable { private fun cancelSending(statusId: Int) = serviceScope.launch { val statusToCancel = statusesToSend.remove(statusId) if (statusToCancel != null) { + + mediaUploader.cancelUploadScope(*statusToCancel.media.map { it.localId }.toIntArray()) + val sendJob = sendJobs.remove(statusId) sendJob?.cancel() @@ -283,9 +314,9 @@ class SendStatusService : Service(), Injectable { contentWarning = status.warningText, sensitive = status.sensitive, visibility = Status.Visibility.byString(status.visibility), - mediaUris = status.mediaUris, - mediaDescriptions = status.mediaDescriptions, - mediaFocus = status.mediaFocus, + mediaUris = status.media.map { it.uri }, + mediaDescriptions = status.media.map { it.description }, + mediaFocus = status.media.map { it.focus }, poll = status.poll, failedToSend = true, scheduledAt = status.scheduledAt, @@ -358,17 +389,17 @@ class SendStatusService : Service(), Injectable { val intent = Intent(context, SendStatusService::class.java) intent.putExtra(KEY_STATUS, statusToSend) - if (statusToSend.mediaUris.isNotEmpty()) { + if (statusToSend.media.isNotEmpty()) { // forward uri permissions intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) val uriClip = ClipData( ClipDescription("Status Media", arrayOf("image/*", "video/*")), - ClipData.Item(statusToSend.mediaUris[0]) + ClipData.Item(statusToSend.media[0].uri) ) - statusToSend.mediaUris + statusToSend.media .drop(1) - .forEach { mediaUri -> - uriClip.addItem(ClipData.Item(mediaUri)) + .forEach { mediaItem -> + uriClip.addItem(ClipData.Item(mediaItem.uri)) } intent.clipData = uriClip @@ -385,10 +416,7 @@ data class StatusToSend( val warningText: String, val visibility: String, val sensitive: Boolean, - val mediaIds: List, - val mediaUris: List, - val mediaDescriptions: List, - val mediaFocus: List, + val media: List, val scheduledAt: String?, val inReplyToId: String?, val poll: NewPoll?, @@ -398,7 +426,16 @@ data class StatusToSend( val draftId: Int, val idempotencyKey: String, var retries: Int, - val mediaProcessed: MutableList, val language: String?, val statusId: String?, ) : Parcelable + +@Parcelize +data class MediaToSend( + val localId: Int, + val id: String?, // null if media is not yet completely uploaded + val uri: String, + val description: String?, + val focus: Attachment.Focus?, + var processed: Boolean +) : Parcelable