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:
parent
9cf949fc2e
commit
abca91a420
7 changed files with 204 additions and 118 deletions
|
@ -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) {
|
||||||
|
|
|
@ -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> {
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue