Handoff media upload (#2947)

* handoff media upload to SendStatusService

* fix bugd

* improve code

* don't check processing state when upload returned 200
This commit is contained in:
Konrad Pozniak 2022-12-29 19:58:23 +01:00 committed by GitHub
parent 9cf949fc2e
commit abca91a420
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 204 additions and 118 deletions

View file

@ -135,8 +135,6 @@ class ComposeActivity :
private lateinit var emojiBehavior: BottomSheetBehavior<*> private lateinit var emojiBehavior: BottomSheetBehavior<*>
private lateinit var scheduleBehavior: 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 var photoUploadUri: Uri? = null
private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) } private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) }
@ -957,16 +955,9 @@ class ComposeActivity :
binding.composeEditField.error = getString(R.string.error_empty) binding.composeEditField.error = getString(R.string.error_empty)
enableButtons(true, viewModel.editing) enableButtons(true, viewModel.editing)
} else if (characterCount <= maximumTootCharacters) { } 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 { lifecycleScope.launch {
viewModel.sendStatus(contentText, spoilerText) viewModel.sendStatus(contentText, spoilerText)
finishingUploadDialog?.dismiss()
deleteDraftAndFinish() deleteDraftAndFinish()
} }
} else { } else {
@ -1133,11 +1124,16 @@ class ComposeActivity :
AlertDialog.Builder(this) AlertDialog.Builder(this)
.setMessage(warning) .setMessage(warning)
.setPositiveButton(R.string.action_save) { _, _ -> .setPositiveButton(R.string.action_save) { _, _ ->
viewModel.stopUploads()
saveDraftAndFinish(contentText, contentWarning) saveDraftAndFinish(contentText, contentWarning)
} }
.setNegativeButton(R.string.action_delete) { _, _ -> deleteDraftAndFinish() } .setNegativeButton(R.string.action_delete) { _, _ ->
viewModel.stopUploads()
deleteDraftAndFinish()
}
.show() .show()
} else { } else {
viewModel.stopUploads()
finishWithoutSlideOutAnimation() finishWithoutSlideOutAnimation()
} }
} }
@ -1188,11 +1184,14 @@ class ComposeActivity :
val id: String? = null, val id: String? = null,
val description: String? = null, val description: String? = null,
val focus: Attachment.Focus? = null, val focus: Attachment.Focus? = null,
val processed: Boolean = false, val state: State
) { ) {
enum class Type { enum class Type {
IMAGE, VIDEO, AUDIO; IMAGE, VIDEO, AUDIO;
} }
enum class State {
UPLOADING, UNPROCESSED, PROCESSED, PUBLISHED
}
} }
override fun onTimeSet(time: String) { override fun onTimeSet(time: String) {

View file

@ -33,20 +33,18 @@ import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.service.MediaToSend
import com.keylesspalace.tusky.service.ServiceClient import com.keylesspalace.tusky.service.ServiceClient
import com.keylesspalace.tusky.service.StatusToSend import com.keylesspalace.tusky.service.StatusToSend
import com.keylesspalace.tusky.util.randomAlphanumericString import com.keylesspalace.tusky.util.randomAlphanumericString
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@ -97,8 +95,6 @@ class ComposeViewModel @Inject constructor(
val media: MutableStateFlow<List<QueuedMedia>> = MutableStateFlow(emptyList()) val media: MutableStateFlow<List<QueuedMedia>> = MutableStateFlow(emptyList())
val uploadError = MutableSharedFlow<Throwable>(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) val uploadError = MutableSharedFlow<Throwable>(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
private val mediaToJob = mutableMapOf<Int, Job>()
// Used in ComposeActivity to pass state to result function when cropImage contract inflight // Used in ComposeActivity to pass state to result function when cropImage contract inflight
var cropImageItemOld: QueuedMedia? = null var cropImageItemOld: QueuedMedia? = null
@ -134,17 +130,18 @@ class ComposeViewModel @Inject constructor(
media.updateAndGet { mediaValue -> media.updateAndGet { mediaValue ->
val mediaItem = QueuedMedia( val mediaItem = QueuedMedia(
localId = (mediaValue.maxOfOrNull { it.localId } ?: 0) + 1, localId = mediaUploader.getNewLocalMediaId(),
uri = uri, uri = uri,
type = type, type = type,
mediaSize = mediaSize, mediaSize = mediaSize,
description = description, description = description,
focus = focus focus = focus,
state = QueuedMedia.State.UPLOADING
) )
stashMediaItem = mediaItem stashMediaItem = mediaItem
if (replaceItem != null) { if (replaceItem != null) {
mediaToJob[replaceItem.localId]?.cancel() mediaUploader.cancelUploadScope(replaceItem.localId)
mediaValue.map { mediaValue.map {
if (it.localId == replaceItem.localId) mediaItem else it 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 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 mediaUploader
.uploadMedia(mediaItem, instanceInfo.first()) .uploadMedia(mediaItem, instanceInfo.first())
.catch { error ->
media.update { mediaValue -> mediaValue.filter { it.localId != mediaItem.localId } }
uploadError.emit(error)
}
.collect { event -> .collect { event ->
val item = media.value.find { it.localId == mediaItem.localId } val item = media.value.find { it.localId == mediaItem.localId }
?: return@collect ?: return@collect
@ -168,7 +161,16 @@ class ComposeViewModel @Inject constructor(
is UploadEvent.ProgressEvent -> is UploadEvent.ProgressEvent ->
item.copy(uploadPercent = event.percentage) item.copy(uploadPercent = event.percentage)
is UploadEvent.FinishedEvent -> 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 -> media.update { mediaValue ->
mediaValue.map { mediaItem -> 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?) { private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?, focus: Attachment.Focus?) {
media.update { mediaValue -> media.update { mediaValue ->
val mediaItem = QueuedMedia( val mediaItem = QueuedMedia(
localId = (mediaValue.maxOfOrNull { it.localId } ?: 0) + 1, localId = mediaUploader.getNewLocalMediaId(),
uri = uri, uri = uri,
type = type, type = type,
mediaSize = 0, mediaSize = 0,
@ -195,14 +197,14 @@ class ComposeViewModel @Inject constructor(
id = id, id = id,
description = description, description = description,
focus = focus, focus = focus,
processed = true, state = QueuedMedia.State.PUBLISHED
) )
mediaValue + mediaItem mediaValue + mediaItem
} }
} }
fun removeMediaFromQueue(item: QueuedMedia) { fun removeMediaFromQueue(item: QueuedMedia) {
mediaToJob[item.localId]?.cancel() mediaUploader.cancelUploadScope(item.localId)
media.update { mediaValue -> mediaValue.filter { it.localId != 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 { fun shouldShowSaveDraftDialog(): Boolean {
// if any of the media files need to be downloaded first it could take a while, so show a loading dialog // 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 -> return media.value.any { mediaValue ->
@ -289,30 +295,22 @@ class ComposeViewModel @Inject constructor(
api.deleteScheduledStatus(scheduledTootId!!) api.deleteScheduledStatus(scheduledTootId!!)
} }
media val attachedMedia = media.value.map { item ->
.filter { items -> items.all { it.uploadPercent == -1 } } MediaToSend(
.first { localId = item.localId,
val mediaIds: MutableList<String> = mutableListOf() id = item.id,
val mediaUris: MutableList<Uri> = mutableListOf() uri = item.uri.toString(),
val mediaDescriptions: MutableList<String> = mutableListOf() description = item.description,
val mediaFocus: MutableList<Attachment.Focus?> = mutableListOf() focus = item.focus,
val mediaProcessed: MutableList<Boolean> = mutableListOf() processed = item.state == QueuedMedia.State.PROCESSED || item.state == QueuedMedia.State.PUBLISHED
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( val tootToSend = StatusToSend(
text = content, text = content,
warningText = spoilerText, warningText = spoilerText,
visibility = statusVisibility.value.serverString(), visibility = statusVisibility.value.serverString(),
sensitive = mediaUris.isNotEmpty() && (markMediaAsSensitive.value || showContentWarning.value), sensitive = attachedMedia.isNotEmpty() && (markMediaAsSensitive.value || showContentWarning.value),
mediaIds = mediaIds, media = attachedMedia,
mediaUris = mediaUris.map { it.toString() },
mediaDescriptions = mediaDescriptions,
mediaFocus = mediaFocus,
scheduledAt = scheduledAt.value, scheduledAt = scheduledAt.value,
inReplyToId = inReplyToId, inReplyToId = inReplyToId,
poll = poll.value, poll = poll.value,
@ -322,14 +320,11 @@ class ComposeViewModel @Inject constructor(
draftId = draftId, draftId = draftId,
idempotencyKey = randomAlphanumericString(16), idempotencyKey = randomAlphanumericString(16),
retries = 0, retries = 0,
mediaProcessed = mediaProcessed,
language = postLanguage, language = postLanguage,
statusId = originalStatusId, statusId = originalStatusId
) )
serviceClient.sendToot(tootToSend) serviceClient.sendToot(tootToSend)
true
}
} }
// Updates a QueuedMedia item arbitrarily, then sends description and focus to server // 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 { suspend fun updateDescription(localId: Int, description: String): Boolean {
return updateMediaItem(localId, { mediaItem -> return updateMediaItem(localId) { mediaItem ->
mediaItem.copy(description = description) mediaItem.copy(description = description)
}) }
} }
suspend fun updateFocus(localId: Int, focus: Attachment.Focus): Boolean { suspend fun updateFocus(localId: Int, focus: Attachment.Focus): Boolean {
return updateMediaItem(localId, { mediaItem -> return updateMediaItem(localId) { mediaItem ->
mediaItem.copy(focus = focus) mediaItem.copy(focus = focus)
}) }
} }
fun searchAutocompleteSuggestions(token: String): List<AutocompleteResult> { fun searchAutocompleteSuggestions(token: String): List<AutocompleteResult> {

View file

@ -48,7 +48,7 @@ class MediaPreviewAdapter(
val addFocusId = 2 val addFocusId = 2
val editImageId = 3 val editImageId = 3
val removeId = 4 val removeId = 4
if (!item.processed) { if (item.state != ComposeActivity.QueuedMedia.State.PUBLISHED) {
// Already-published items can't have their metadata edited // Already-published items can't have their metadata edited
popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption) popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption)
if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) { if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) {

View file

@ -23,7 +23,6 @@ import android.util.Log
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.net.toUri import androidx.core.net.toUri
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia 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.getMediaSize
import com.keylesspalace.tusky.util.getServerErrorMessage import com.keylesspalace.tusky.util.getServerErrorMessage
import com.keylesspalace.tusky.util.randomAlphanumericString import com.keylesspalace.tusky.util.randomAlphanumericString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.callbackFlow 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.flatMapLatest
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.shareIn
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody import okhttp3.MultipartBody
import retrofit2.HttpException
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.util.Date import java.util.Date
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
sealed interface FinalUploadEvent
sealed class UploadEvent { sealed class UploadEvent {
data class ProgressEvent(val percentage: Int) : 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<UploadEvent>,
val scope: CoroutineScope
)
fun createNewImageFile(context: Context, suffix: String = ".jpg"): File { fun createNewImageFile(context: Context, suffix: String = ".jpg"): File {
// Create an image file name // Create an image file name
val randomId = randomAlphanumericString(12) val randomId = randomAlphanumericString(12)
@ -76,14 +91,38 @@ class MediaTypeException : Exception()
class CouldNotOpenFileException : Exception() class CouldNotOpenFileException : Exception()
class UploadServerError(val errorMessage: String) : Exception() class UploadServerError(val errorMessage: String) : Exception()
@Singleton
class MediaUploader @Inject constructor( class MediaUploader @Inject constructor(
private val context: Context, private val context: Context,
private val mediaUploadApi: MediaUploadApi private val mediaUploadApi: MediaUploadApi
) { ) {
private val uploads = mutableMapOf<Int, UploadData>()
private var mostRecentId: Int = 0
fun getNewLocalMediaId(): Int {
return mostRecentId++
}
suspend fun getMediaUploadState(localId: Int): FinalUploadEvent {
return uploads[localId]?.flow
?.filterIsInstance<FinalUploadEvent>()
?.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) @OptIn(ExperimentalCoroutinesApi::class)
fun uploadMedia(media: QueuedMedia, instanceInfo: InstanceInfo): Flow<UploadEvent> { fun uploadMedia(media: QueuedMedia, instanceInfo: InstanceInfo): Flow<UploadEvent> {
return flow { val uploadScope = CoroutineScope(Dispatchers.IO)
val uploadFlow = flow {
if (shouldResizeMedia(media, instanceInfo)) { if (shouldResizeMedia(media, instanceInfo)) {
emit(downsize(media, instanceInfo)) emit(downsize(media, instanceInfo))
} else { } else {
@ -91,7 +130,23 @@ class MediaUploader @Inject constructor(
} }
} }
.flatMapLatest { upload(it) } .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 { fun prepareMedia(inUri: Uri, instanceInfo: InstanceInfo): PreparedMedia {
@ -231,16 +286,20 @@ class MediaUploader @Inject constructor(
null null
} }
mediaUploadApi.uploadMedia(body, description, focus).fold({ result -> val uploadResponse = mediaUploadApi.uploadMedia(body, description, focus)
send(UploadEvent.FinishedEvent(result.id)) val responseBody = uploadResponse.body()
}, { throwable -> if (uploadResponse.isSuccessful && responseBody != null) {
val errorMessage = throwable.getServerErrorMessage() send(UploadEvent.FinishedEvent(responseBody.id, uploadResponse.code() == 200))
} else {
val error = HttpException(uploadResponse)
val errorMessage = error.getServerErrorMessage()
if (errorMessage == null) { if (errorMessage == null) {
throw throwable throw error
} else { } else {
throw UploadServerError(errorMessage) throw UploadServerError(errorMessage)
} }
}) }
awaitClose() awaitClose()
} }
} }

View file

@ -1,8 +1,8 @@
package com.keylesspalace.tusky.network package com.keylesspalace.tusky.network
import at.connyduck.calladapter.networkresult.NetworkResult
import com.keylesspalace.tusky.entity.MediaUploadResult import com.keylesspalace.tusky.entity.MediaUploadResult
import okhttp3.MultipartBody import okhttp3.MultipartBody
import retrofit2.Response
import retrofit2.http.Multipart import retrofit2.http.Multipart
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Part import retrofit2.http.Part
@ -17,5 +17,5 @@ interface MediaUploadApi {
@Part file: MultipartBody.Part, @Part file: MultipartBody.Part,
@Part description: MultipartBody.Part? = null, @Part description: MultipartBody.Part? = null,
@Part focus: MultipartBody.Part? = null @Part focus: MultipartBody.Part? = null
): NetworkResult<MediaUploadResult> ): Response<MediaUploadResult>
} }

View file

@ -86,10 +86,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
warningText = spoiler, warningText = spoiler,
visibility = visibility.serverString(), visibility = visibility.serverString(),
sensitive = false, sensitive = false,
mediaIds = emptyList(), media = emptyList(),
mediaUris = emptyList(),
mediaDescriptions = emptyList(),
mediaFocus = emptyList(),
scheduledAt = null, scheduledAt = null,
inReplyToId = citedStatusId, inReplyToId = citedStatusId,
poll = null, poll = null,
@ -99,7 +96,6 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
draftId = -1, draftId = -1,
idempotencyKey = randomAlphanumericString(16), idempotencyKey = randomAlphanumericString(16),
retries = 0, retries = 0,
mediaProcessed = mutableListOf(),
language = null, language = null,
statusId = null, statusId = null,
) )

View file

@ -22,6 +22,8 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.StatusComposedEvent import com.keylesspalace.tusky.appstore.StatusComposedEvent
import com.keylesspalace.tusky.appstore.StatusScheduledEvent 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.drafts.DraftHelper
import com.keylesspalace.tusky.components.notifications.NotificationHelper import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
@ -54,6 +56,8 @@ class SendStatusService : Service(), Injectable {
lateinit var eventHub: EventHub lateinit var eventHub: EventHub
@Inject @Inject
lateinit var draftHelper: DraftHelper lateinit var draftHelper: DraftHelper
@Inject
lateinit var mediaUploader: MediaUploader
private val supervisorJob = SupervisorJob() private val supervisorJob = SupervisorJob()
private val serviceScope = CoroutineScope(Dispatchers.Main + supervisorJob) private val serviceScope = CoroutineScope(Dispatchers.Main + supervisorJob)
@ -131,14 +135,33 @@ class SendStatusService : Service(), Injectable {
statusToSend.retries++ statusToSend.retries++
sendJobs[statusId] = serviceScope.launch { 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 { try {
var mediaCheckRetries = 0 var mediaCheckRetries = 0
while (statusToSend.mediaProcessed.any { !it }) { while (media.any { mediaItem -> !mediaItem.processed }) {
delay(1000L * mediaCheckRetries) delay(1000L * mediaCheckRetries)
statusToSend.mediaProcessed.forEachIndexed { index, processed -> media.forEach { mediaItem ->
if (!processed) { if (!mediaItem.processed) {
when (mastodonApi.getMedia(statusToSend.mediaIds[index]).code()) { when (mastodonApi.getMedia(mediaItem.id!!).code()) {
200 -> statusToSend.mediaProcessed[index] = true // success 200 -> mediaItem.processed = true // success
206 -> { } // media is still being processed, continue checking 206 -> { } // media is still being processed, continue checking
else -> { // some kind of server error, retrying probably doesn't make sense else -> { // some kind of server error, retrying probably doesn't make sense
failSending(statusId) failSending(statusId)
@ -156,16 +179,17 @@ class SendStatusService : Service(), Injectable {
return@launch return@launch
} }
// finally, send the new status
val newStatus = NewStatus( val newStatus = NewStatus(
statusToSend.text, status = statusToSend.text,
statusToSend.warningText, warningText = statusToSend.warningText,
statusToSend.inReplyToId, inReplyToId = statusToSend.inReplyToId,
statusToSend.visibility, visibility = statusToSend.visibility,
statusToSend.sensitive, sensitive = statusToSend.sensitive,
statusToSend.mediaIds, mediaIds = media.map { it.id!! },
statusToSend.scheduledAt, scheduledAt = statusToSend.scheduledAt,
statusToSend.poll, poll = statusToSend.poll,
statusToSend.language, language = statusToSend.language,
) )
val sendResult = if (statusToSend.statusId == null) { val sendResult = if (statusToSend.statusId == null) {
@ -192,6 +216,8 @@ class SendStatusService : Service(), Injectable {
draftHelper.deleteDraftAndAttachments(statusToSend.draftId) draftHelper.deleteDraftAndAttachments(statusToSend.draftId)
} }
mediaUploader.cancelUploadScope(*statusToSend.media.map { it.localId }.toIntArray())
val scheduled = !statusToSend.scheduledAt.isNullOrEmpty() val scheduled = !statusToSend.scheduledAt.isNullOrEmpty()
if (scheduled) { if (scheduled) {
@ -237,6 +263,8 @@ class SendStatusService : Service(), Injectable {
val failedStatus = statusesToSend.remove(statusId) val failedStatus = statusesToSend.remove(statusId)
if (failedStatus != null) { if (failedStatus != null) {
mediaUploader.cancelUploadScope(*failedStatus.media.map { it.localId }.toIntArray())
saveStatusToDrafts(failedStatus) saveStatusToDrafts(failedStatus)
val notification = buildDraftNotification( val notification = buildDraftNotification(
@ -254,6 +282,9 @@ class SendStatusService : Service(), Injectable {
private fun cancelSending(statusId: Int) = serviceScope.launch { private fun cancelSending(statusId: Int) = serviceScope.launch {
val statusToCancel = statusesToSend.remove(statusId) val statusToCancel = statusesToSend.remove(statusId)
if (statusToCancel != null) { if (statusToCancel != null) {
mediaUploader.cancelUploadScope(*statusToCancel.media.map { it.localId }.toIntArray())
val sendJob = sendJobs.remove(statusId) val sendJob = sendJobs.remove(statusId)
sendJob?.cancel() sendJob?.cancel()
@ -283,9 +314,9 @@ class SendStatusService : Service(), Injectable {
contentWarning = status.warningText, contentWarning = status.warningText,
sensitive = status.sensitive, sensitive = status.sensitive,
visibility = Status.Visibility.byString(status.visibility), visibility = Status.Visibility.byString(status.visibility),
mediaUris = status.mediaUris, mediaUris = status.media.map { it.uri },
mediaDescriptions = status.mediaDescriptions, mediaDescriptions = status.media.map { it.description },
mediaFocus = status.mediaFocus, mediaFocus = status.media.map { it.focus },
poll = status.poll, poll = status.poll,
failedToSend = true, failedToSend = true,
scheduledAt = status.scheduledAt, scheduledAt = status.scheduledAt,
@ -358,17 +389,17 @@ class SendStatusService : Service(), Injectable {
val intent = Intent(context, SendStatusService::class.java) val intent = Intent(context, SendStatusService::class.java)
intent.putExtra(KEY_STATUS, statusToSend) intent.putExtra(KEY_STATUS, statusToSend)
if (statusToSend.mediaUris.isNotEmpty()) { if (statusToSend.media.isNotEmpty()) {
// forward uri permissions // forward uri permissions
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val uriClip = ClipData( val uriClip = ClipData(
ClipDescription("Status Media", arrayOf("image/*", "video/*")), ClipDescription("Status Media", arrayOf("image/*", "video/*")),
ClipData.Item(statusToSend.mediaUris[0]) ClipData.Item(statusToSend.media[0].uri)
) )
statusToSend.mediaUris statusToSend.media
.drop(1) .drop(1)
.forEach { mediaUri -> .forEach { mediaItem ->
uriClip.addItem(ClipData.Item(mediaUri)) uriClip.addItem(ClipData.Item(mediaItem.uri))
} }
intent.clipData = uriClip intent.clipData = uriClip
@ -385,10 +416,7 @@ data class StatusToSend(
val warningText: String, val warningText: String,
val visibility: String, val visibility: String,
val sensitive: Boolean, val sensitive: Boolean,
val mediaIds: List<String>, val media: List<MediaToSend>,
val mediaUris: List<String>,
val mediaDescriptions: List<String>,
val mediaFocus: List<Attachment.Focus?>,
val scheduledAt: String?, val scheduledAt: String?,
val inReplyToId: String?, val inReplyToId: String?,
val poll: NewPoll?, val poll: NewPoll?,
@ -398,7 +426,16 @@ data class StatusToSend(
val draftId: Int, val draftId: Int,
val idempotencyKey: String, val idempotencyKey: String,
var retries: Int, var retries: Int,
val mediaProcessed: MutableList<Boolean>,
val language: String?, val language: String?,
val statusId: String?, val statusId: String?,
) : Parcelable ) : 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