feat: Provide more detail in errors, especially media upload errors (#801)
Previous code assumed server responses would always be JSON, and had no special handling for mis-configured servers that sometimes return HTML; for example, if the server has a bug, or there's a reverse proxy in front of the server issuing DoS-prevention challenges. This could cause errors to show with no useful debugging information. Update `ApiResult` to check the content-type in the response and return one of two new errors if the content-type is missing or wrong. Also include the HTTP code in `ApiResponse` for use elsewhere. Update `ThrowableExtensions` to pull the `error` and optional `description` out of the error body. Update `PachliError` so `formatArgs` can be an array of arbitrary types, not just strings. Update `MediaUploader`; expose the different errors as new `MediaUploaderError` types instead of `Exception` subclasses, and return `Result<V, E>` where appropriate. Update `ComposeViewModel` to use the new `MediaUploaderError` types and create new `PickMediaError` to report issues there, replacing `VideoOrImageException`. Update `ComposeActivity` to use the new error types and show errors until the user dismisses them, so they're better able to see and report problems. Fixes #704.
This commit is contained in:
parent
4878c23ac2
commit
5aacb02ea0
|
@ -102,7 +102,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/java/app/pachli/components/compose/MediaUploader.kt"
|
||||
line="268"
|
||||
line="388"
|
||||
column="28"/>
|
||||
<location
|
||||
file="${:core:activity*buildDir}/generated/res/resValues/orangeFdroid/debug/values/gradleResValues.xml"
|
||||
|
@ -701,7 +701,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-tr/strings.xml"
|
||||
line="498"
|
||||
line="497"
|
||||
column="294"/>
|
||||
</issue>
|
||||
|
||||
|
@ -712,7 +712,7 @@
|
|||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/res/values-nb-rNO/strings.xml"
|
||||
line="520"
|
||||
line="519"
|
||||
column="51"/>
|
||||
</issue>
|
||||
|
||||
|
@ -745,7 +745,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="100"
|
||||
line="99"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -756,7 +756,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="283"
|
||||
line="282"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -767,7 +767,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="334"
|
||||
line="333"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -778,7 +778,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="453"
|
||||
line="452"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -789,7 +789,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="629"
|
||||
line="628"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1284,7 +1284,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="23"
|
||||
line="22"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1295,7 +1295,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="33"
|
||||
line="32"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1306,7 +1306,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="75"
|
||||
line="74"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1317,7 +1317,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="81"
|
||||
line="80"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1328,7 +1328,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="102"
|
||||
line="101"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1339,7 +1339,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="110"
|
||||
line="109"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1350,7 +1350,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="144"
|
||||
line="143"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1361,7 +1361,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="199"
|
||||
line="198"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1372,7 +1372,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="220"
|
||||
line="219"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1383,7 +1383,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="221"
|
||||
line="220"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1394,7 +1394,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="244"
|
||||
line="243"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1405,7 +1405,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="273"
|
||||
line="272"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1416,7 +1416,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="344"
|
||||
line="343"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1427,7 +1427,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="384"
|
||||
line="383"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1438,7 +1438,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="420"
|
||||
line="419"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1449,7 +1449,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="423"
|
||||
line="422"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1460,7 +1460,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="424"
|
||||
line="423"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1471,7 +1471,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="425"
|
||||
line="424"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1482,7 +1482,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="426"
|
||||
line="425"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1493,7 +1493,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="427"
|
||||
line="426"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1504,7 +1504,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="439"
|
||||
line="438"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1515,7 +1515,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="440"
|
||||
line="439"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1526,7 +1526,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="491"
|
||||
line="490"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1537,7 +1537,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="492"
|
||||
line="491"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1548,7 +1548,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="503"
|
||||
line="502"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1559,7 +1559,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="504"
|
||||
line="503"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1570,7 +1570,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="505"
|
||||
line="504"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1581,7 +1581,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="508"
|
||||
line="507"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1592,7 +1592,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="546"
|
||||
line="545"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1603,7 +1603,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="587"
|
||||
line="586"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1614,7 +1614,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="593"
|
||||
line="592"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1625,7 +1625,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="621"
|
||||
line="620"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
@ -1636,7 +1636,7 @@
|
|||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="647"
|
||||
line="646"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
|
|
|
@ -94,7 +94,6 @@ import app.pachli.core.preferences.AppTheme
|
|||
import app.pachli.core.preferences.PrefKeys
|
||||
import app.pachli.core.preferences.SharedPreferencesRepository
|
||||
import app.pachli.core.ui.extensions.await
|
||||
import app.pachli.core.ui.extensions.getErrorString
|
||||
import app.pachli.core.ui.makeIcon
|
||||
import app.pachli.databinding.ActivityComposeBinding
|
||||
import app.pachli.languageidentification.LanguageIdentifier
|
||||
|
@ -111,6 +110,7 @@ import com.canhub.cropper.CropImage
|
|||
import com.canhub.cropper.CropImageContract
|
||||
import com.canhub.cropper.options
|
||||
import com.github.michaelbull.result.getOrElse
|
||||
import com.github.michaelbull.result.onFailure
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
|
@ -121,7 +121,6 @@ import com.mikepenz.iconics.utils.sizeDp
|
|||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.text.DecimalFormat
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.max
|
||||
|
@ -518,14 +517,10 @@ class ComposeActivity :
|
|||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.uploadError.collect { throwable ->
|
||||
if (throwable is UploadServerError) {
|
||||
displayTransientMessage(throwable.getErrorString(this@ComposeActivity))
|
||||
} else {
|
||||
displayTransientMessage(
|
||||
getString(R.string.error_media_upload_sending_fmt, throwable.getErrorString(this@ComposeActivity)),
|
||||
)
|
||||
}
|
||||
viewModel.uploadError.collect { mediaUploaderError ->
|
||||
val message = mediaUploaderError.fmt(this@ComposeActivity)
|
||||
|
||||
displayPermamentMessage(getString(R.string.error_media_upload_sending_fmt, message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -719,6 +714,14 @@ class ComposeActivity :
|
|||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
private fun displayPermamentMessage(message: String) {
|
||||
val bar = Snackbar.make(binding.activityCompose, message, Snackbar.LENGTH_INDEFINITE)
|
||||
// necessary so snackbar is shown over everything
|
||||
bar.view.elevation = resources.getDimension(DR.dimen.compose_activity_snackbar_elevation)
|
||||
bar.setAnchorView(R.id.composeBottomBar)
|
||||
bar.show()
|
||||
}
|
||||
|
||||
private fun displayTransientMessage(message: String) {
|
||||
val bar = Snackbar.make(binding.activityCompose, message, Snackbar.LENGTH_LONG)
|
||||
// necessary so snackbar is shown over everything
|
||||
|
@ -726,6 +729,7 @@ class ComposeActivity :
|
|||
bar.setAnchorView(R.id.composeBottomBar)
|
||||
bar.show()
|
||||
}
|
||||
|
||||
private fun displayTransientMessage(@StringRes stringId: Int) {
|
||||
displayTransientMessage(getString(stringId))
|
||||
}
|
||||
|
@ -1181,18 +1185,12 @@ class ComposeActivity :
|
|||
|
||||
private fun pickMedia(uri: Uri, description: String? = null) {
|
||||
lifecycleScope.launch {
|
||||
viewModel.pickMedia(uri, description).onFailure { throwable ->
|
||||
val errorString = when (throwable) {
|
||||
is FileSizeException -> {
|
||||
val decimalFormat = DecimalFormat("0.##")
|
||||
val allowedSizeInMb = throwable.allowedSizeInBytes.toDouble() / (1024 * 1024)
|
||||
val formattedSize = decimalFormat.format(allowedSizeInMb)
|
||||
getString(R.string.error_multimedia_size_limit, formattedSize)
|
||||
}
|
||||
is VideoOrImageException -> getString(R.string.error_media_upload_image_or_video)
|
||||
else -> getString(R.string.error_media_upload_opening)
|
||||
}
|
||||
displayTransientMessage(errorString)
|
||||
viewModel.pickMedia(uri, description).onFailure {
|
||||
val message = getString(
|
||||
R.string.error_pick_media_fmt,
|
||||
it.fmt(this@ComposeActivity),
|
||||
)
|
||||
displayPermamentMessage(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1377,6 +1375,7 @@ class ComposeActivity :
|
|||
}
|
||||
}
|
||||
|
||||
/** Media queued for upload. */
|
||||
data class QueuedMedia(
|
||||
val localId: Int,
|
||||
val uri: Uri,
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
package app.pachli.components.compose
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import android.text.Editable
|
||||
import android.text.Spanned
|
||||
|
@ -24,11 +25,13 @@ import androidx.annotation.VisibleForTesting
|
|||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.ViewModel
|
||||
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.drafts.DraftHelper
|
||||
import app.pachli.components.search.SearchType
|
||||
import app.pachli.core.accounts.AccountManager
|
||||
import app.pachli.core.common.PachliError
|
||||
import app.pachli.core.common.string.mastodonLength
|
||||
import app.pachli.core.common.string.randomAlphanumericString
|
||||
import app.pachli.core.data.repository.InstanceInfoRepository
|
||||
|
@ -43,7 +46,12 @@ import app.pachli.service.MediaToSend
|
|||
import app.pachli.service.ServiceClient
|
||||
import app.pachli.service.StatusToSend
|
||||
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.getOrElse
|
||||
import com.github.michaelbull.result.mapBoth
|
||||
import com.github.michaelbull.result.mapError
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
@ -125,7 +133,7 @@ class ComposeViewModel @Inject constructor(
|
|||
|
||||
private val _media: MutableStateFlow<List<QueuedMedia>> = MutableStateFlow(emptyList())
|
||||
val media = _media.asStateFlow()
|
||||
private val _uploadError = MutableSharedFlow<Throwable>(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
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()
|
||||
|
@ -139,21 +147,38 @@ class ComposeViewModel @Inject constructor(
|
|||
|
||||
private var setupComplete = false
|
||||
|
||||
suspend fun pickMedia(mediaUri: Uri, description: String? = null, focus: Attachment.Focus? = null): Result<QueuedMedia> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
/** Errors preparing media for upload. */
|
||||
sealed interface PickMediaError : PachliError {
|
||||
@JvmInline
|
||||
value class PrepareMediaError(val error: MediaUploaderError.PrepareMediaError) : PickMediaError, MediaUploaderError.PrepareMediaError by error
|
||||
|
||||
/**
|
||||
* User is trying to add an image to a post that already has a video
|
||||
* attachment, or vice-versa.
|
||||
*/
|
||||
data object MixedMediaTypesError : PickMediaError {
|
||||
override val resourceId = R.string.error_media_upload_image_or_video
|
||||
override val formatArgs = null
|
||||
override val cause = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies selected media and adds to the upload queue.
|
||||
*
|
||||
* @param mediaUri [ContentResolver] URI for the file to copy
|
||||
* @param description media description / caption
|
||||
* @param focus focus, if relevant
|
||||
*/
|
||||
suspend fun pickMedia(mediaUri: Uri, description: String? = null, focus: Attachment.Focus? = null): Result<QueuedMedia, PickMediaError> = withContext(Dispatchers.IO) {
|
||||
val (type, uri, size) = mediaUploader.prepareMedia(mediaUri, instanceInfo.value)
|
||||
.mapError { PickMediaError.PrepareMediaError(it) }.getOrElse { return@withContext Err(it) }
|
||||
val mediaItems = media.value
|
||||
if (type != QueuedMedia.Type.IMAGE &&
|
||||
mediaItems.isNotEmpty() &&
|
||||
mediaItems[0].type == QueuedMedia.Type.IMAGE
|
||||
) {
|
||||
Result.failure(VideoOrImageException())
|
||||
if (type != QueuedMedia.Type.IMAGE && mediaItems.isNotEmpty() && mediaItems[0].type == QueuedMedia.Type.IMAGE) {
|
||||
Err(PickMediaError.MixedMediaTypesError)
|
||||
} else {
|
||||
val queuedMedia = addMediaToQueue(type, uri, size, description, focus)
|
||||
Result.success(queuedMedia)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
Ok(queuedMedia)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -196,32 +221,30 @@ class ComposeViewModel @Inject constructor(
|
|||
.collect { event ->
|
||||
val item = media.value.find { it.localId == mediaItem.localId }
|
||||
?: return@collect
|
||||
val newMediaItem = when (event) {
|
||||
is UploadEvent.ProgressEvent ->
|
||||
item.copy(uploadPercent = event.percentage)
|
||||
is UploadEvent.FinishedEvent ->
|
||||
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 = event.mediaId,
|
||||
id = uploadEvent.media.mediaId,
|
||||
uploadPercent = -1,
|
||||
state = if (event.processed) {
|
||||
state = if (uploadEvent.media.processed) {
|
||||
QueuedMedia.State.PROCESSED
|
||||
} else {
|
||||
QueuedMedia.State.UNPROCESSED
|
||||
},
|
||||
)
|
||||
is UploadEvent.ErrorEvent -> {
|
||||
_media.update { mediaList -> mediaList.filter { it.localId != mediaItem.localId } }
|
||||
_uploadError.emit(event.error)
|
||||
return@collect
|
||||
}
|
||||
}
|
||||
newMediaItem.let {
|
||||
_media.update { mediaList ->
|
||||
mediaList.map { mediaItem ->
|
||||
if (mediaItem.localId == newMediaItem.localId) {
|
||||
newMediaItem
|
||||
} else {
|
||||
mediaItem
|
||||
}
|
||||
mediaList.map { mediaItem -> if (mediaItem.localId == it.localId) it else mediaItem }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -662,8 +685,3 @@ class ComposeViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when trying to add an image when video is already present or the other way around
|
||||
*/
|
||||
class VideoOrImageException : Exception()
|
||||
|
|
|
@ -28,14 +28,21 @@ import androidx.core.net.toUri
|
|||
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.core.common.PachliError
|
||||
import app.pachli.core.common.string.randomAlphanumericString
|
||||
import app.pachli.core.common.util.formatNumber
|
||||
import app.pachli.core.data.model.InstanceInfo
|
||||
import app.pachli.core.network.model.MediaUploadApi
|
||||
import app.pachli.core.ui.extensions.getErrorString
|
||||
import app.pachli.core.network.retrofit.apiresult.ApiError
|
||||
import app.pachli.util.MEDIA_SIZE_UNKNOWN
|
||||
import app.pachli.util.asRequestBody
|
||||
import app.pachli.util.getImageSquarePixels
|
||||
import app.pachli.util.getMediaSize
|
||||
import com.github.michaelbull.result.Err
|
||||
import com.github.michaelbull.result.Ok
|
||||
import com.github.michaelbull.result.Result
|
||||
import com.github.michaelbull.result.mapEither
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
@ -49,7 +56,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.catch
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
|
@ -60,21 +66,142 @@ import okhttp3.MultipartBody
|
|||
import okio.buffer
|
||||
import okio.sink
|
||||
import okio.source
|
||||
import retrofit2.HttpException
|
||||
import timber.log.Timber
|
||||
|
||||
sealed interface FinalUploadEvent
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param type file's general type (image, video, etc)
|
||||
* @param uri content URI for the prepared media file
|
||||
* @param size size of the media file, in bytes
|
||||
*/
|
||||
data class PreparedMedia(val type: QueuedMedia.Type, val uri: Uri, val size: Long)
|
||||
|
||||
/* Errors that can be returned when uploading media. */
|
||||
sealed interface MediaUploaderError : PachliError {
|
||||
/** Errors that can occur while preparing media for upload. */
|
||||
sealed interface PrepareMediaError : MediaUploaderError {
|
||||
/**
|
||||
* Content resolver returned an empty URI for the file.
|
||||
*
|
||||
* @property uri URI returned by the content resolver.
|
||||
*/
|
||||
data class ContentResolverMissingPathError(val uri: Uri) : PrepareMediaError {
|
||||
override val resourceId = R.string.error_prepare_media_content_resolver_missing_path_fmt
|
||||
override val formatArgs = arrayOf(uri)
|
||||
override val cause = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Content resolver returned an unsupported URI scheme.
|
||||
*
|
||||
* @property uri URI returned by the content provider.
|
||||
*/
|
||||
data class ContentResolverUnsupportedSchemeError(val uri: Uri) : PrepareMediaError {
|
||||
override val resourceId = R.string.error_prepare_media_content_resolver_unsupported_scheme_fmt
|
||||
override val formatArgs = arrayOf(uri)
|
||||
override val cause = null
|
||||
}
|
||||
|
||||
/**
|
||||
* [IOException] while operating on the file
|
||||
*
|
||||
* @property exception Thrown exception.
|
||||
*/
|
||||
data class IoError(val exception: IOException) : PrepareMediaError {
|
||||
override val resourceId = R.string.error_prepare_media_io_fmt
|
||||
override val formatArgs = arrayOf(exception.localizedMessage ?: "")
|
||||
override val cause = null
|
||||
}
|
||||
|
||||
/**
|
||||
* File's size exceeds servers limits.
|
||||
*
|
||||
* @property fileSizeBytes size of the file being uploaded
|
||||
* @property allowedSizeBytes maximum size of file server accepts
|
||||
*/
|
||||
data class FileIsTooLargeError(val fileSizeBytes: Long, val allowedSizeBytes: Long) : PrepareMediaError {
|
||||
override val resourceId = R.string.error_prepare_media_file_is_too_large_fmt
|
||||
override val formatArgs = arrayOf(formatNumber(fileSizeBytes), formatNumber(allowedSizeBytes))
|
||||
override val cause = null
|
||||
}
|
||||
|
||||
/** File's size could not be determined. */
|
||||
data object UnknownFileSizeError : PrepareMediaError {
|
||||
override val resourceId = R.string.error_prepare_media_unknown_file_size
|
||||
override val formatArgs = null
|
||||
override val cause = null
|
||||
}
|
||||
|
||||
/**
|
||||
* File's MIME type is not supported by server.
|
||||
*
|
||||
* @property mimeType File's MIME type.
|
||||
*/
|
||||
data class UnsupportedMimeTypeError(val mimeType: String) : PrepareMediaError {
|
||||
override val resourceId = R.string.error_prepare_media_unsupported_mime_type_fmt
|
||||
override val formatArgs = arrayOf(mimeType)
|
||||
override val cause = null
|
||||
}
|
||||
|
||||
/** File's MIME type is not known. */
|
||||
data object UnknownMimeTypeError : PrepareMediaError {
|
||||
override val resourceId = R.string.error_prepare_media_unknown_mime_type
|
||||
override val formatArgs = null
|
||||
override val cause = null
|
||||
}
|
||||
}
|
||||
|
||||
/** [ApiError] wrapper. */
|
||||
@JvmInline
|
||||
value class UploadMediaError(private 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
|
||||
}
|
||||
|
||||
/** 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
|
||||
data class FinishedEvent(val mediaId: String, val processed: Boolean) :
|
||||
UploadEvent,
|
||||
FinalUploadEvent
|
||||
data class ErrorEvent(val error: Throwable) : UploadEvent, FinalUploadEvent
|
||||
|
||||
/**
|
||||
* Upload has finished.
|
||||
*
|
||||
* @property media The uploaded media
|
||||
*/
|
||||
data class FinishedEvent(val media: UploadedMedia) : UploadEvent
|
||||
}
|
||||
|
||||
data class UploadData(
|
||||
val flow: Flow<UploadEvent>,
|
||||
val flow: Flow<Result<UploadEvent, MediaUploaderError>>,
|
||||
val scope: CoroutineScope,
|
||||
)
|
||||
|
||||
|
@ -90,13 +217,6 @@ fun createNewImageFile(context: Context, suffix: String = ".jpg"): File {
|
|||
)
|
||||
}
|
||||
|
||||
data class PreparedMedia(val type: QueuedMedia.Type, val uri: Uri, val size: Long)
|
||||
|
||||
class FileSizeException(val allowedSizeInBytes: Long) : Exception()
|
||||
class MediaTypeException : Exception()
|
||||
class CouldNotOpenFileException : Exception()
|
||||
class UploadServerError(val errorMessage: String) : Exception()
|
||||
|
||||
@Singleton
|
||||
class MediaUploader @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
|
@ -111,11 +231,11 @@ class MediaUploader @Inject constructor(
|
|||
return mostRecentId++
|
||||
}
|
||||
|
||||
suspend fun getMediaUploadState(localId: Int): FinalUploadEvent {
|
||||
suspend fun getMediaUploadState(localId: Int): Result<UploadEvent.FinishedEvent, MediaUploaderError> {
|
||||
return uploads[localId]?.flow
|
||||
?.filterIsInstance<FinalUploadEvent>()
|
||||
?.filterIsInstance<Ok<UploadEvent.FinishedEvent>>()
|
||||
?.first()
|
||||
?: UploadEvent.ErrorEvent(IllegalStateException("media upload with id $localId not found"))
|
||||
?: Err(MediaUploaderError.UploadIdNotFoundError(localId))
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -126,9 +246,9 @@ class MediaUploader @Inject constructor(
|
|||
* The Flow is hot, in order to cancel upload or clear resources call [cancelUploadScope].
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun uploadMedia(media: QueuedMedia, instanceInfo: InstanceInfo): Flow<UploadEvent> {
|
||||
fun uploadMedia(media: QueuedMedia, instanceInfo: InstanceInfo): Flow<Result<UploadEvent, MediaUploaderError>> {
|
||||
val uploadScope = CoroutineScope(Dispatchers.IO)
|
||||
val uploadFlow = flow {
|
||||
val uploadFlow: Flow<Result<UploadEvent, MediaUploaderError>> = flow {
|
||||
if (shouldResizeMedia(media, instanceInfo)) {
|
||||
emit(downsize(media, instanceInfo))
|
||||
} else {
|
||||
|
@ -136,9 +256,6 @@ class MediaUploader @Inject constructor(
|
|||
}
|
||||
}
|
||||
.flatMapLatest { upload(it) }
|
||||
.catch { exception ->
|
||||
emit(UploadEvent.ErrorEvent(exception))
|
||||
}
|
||||
.shareIn(uploadScope, SharingStarted.Lazily, 1)
|
||||
|
||||
uploads[media.localId] = UploadData(uploadFlow, uploadScope)
|
||||
|
@ -155,7 +272,14 @@ class MediaUploader @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun prepareMedia(inUri: Uri, instanceInfo: InstanceInfo): PreparedMedia {
|
||||
/**
|
||||
* Prepares media for the upload queue by copying it from the [ContentResolver] to
|
||||
* a temporary location.
|
||||
*
|
||||
* @param inUri [ContentResolver] URI for the file to prepare
|
||||
* @param instanceInfo server's configuration, for maximum file size limits
|
||||
*/
|
||||
fun prepareMedia(inUri: Uri, instanceInfo: InstanceInfo): Result<PreparedMedia, PrepareMediaError> {
|
||||
var mediaSize = MEDIA_SIZE_UNKNOWN
|
||||
var uri = inUri
|
||||
val mimeType: String?
|
||||
|
@ -184,11 +308,7 @@ class MediaUploader @Inject constructor(
|
|||
}
|
||||
}
|
||||
ContentResolver.SCHEME_FILE -> {
|
||||
val path = uri.path
|
||||
if (path == null) {
|
||||
Timber.w("empty uri path %s", uri)
|
||||
throw CouldNotOpenFileException()
|
||||
}
|
||||
val path = uri.path ?: return Err(PrepareMediaError.ContentResolverMissingPathError(uri))
|
||||
val inputFile = File(path)
|
||||
val suffix = inputFile.name.substringAfterLast('.', "tmp")
|
||||
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(suffix)
|
||||
|
@ -206,63 +326,63 @@ class MediaUploader @Inject constructor(
|
|||
}
|
||||
else -> {
|
||||
Timber.w("Unknown uri scheme %s", uri)
|
||||
throw CouldNotOpenFileException()
|
||||
return Err(PrepareMediaError.ContentResolverUnsupportedSchemeError(uri))
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Timber.w(e)
|
||||
throw CouldNotOpenFileException()
|
||||
}
|
||||
if (mediaSize == MEDIA_SIZE_UNKNOWN) {
|
||||
Timber.w("Could not determine file size of upload")
|
||||
throw MediaTypeException()
|
||||
return Err(PrepareMediaError.IoError(e))
|
||||
}
|
||||
|
||||
if (mimeType != null) {
|
||||
if (mediaSize == MEDIA_SIZE_UNKNOWN) {
|
||||
Timber.w("Could not determine file size of upload")
|
||||
return Err(PrepareMediaError.UnknownFileSizeError)
|
||||
}
|
||||
|
||||
mimeType ?: return Err(PrepareMediaError.UnknownMimeTypeError)
|
||||
|
||||
return when (mimeType.substring(0, mimeType.indexOf('/'))) {
|
||||
"video" -> {
|
||||
if (mediaSize > instanceInfo.videoSizeLimit) {
|
||||
throw FileSizeException(instanceInfo.videoSizeLimit)
|
||||
Err(PrepareMediaError.FileIsTooLargeError(mediaSize, instanceInfo.videoSizeLimit))
|
||||
} else {
|
||||
Ok(PreparedMedia(QueuedMedia.Type.VIDEO, uri, mediaSize))
|
||||
}
|
||||
PreparedMedia(QueuedMedia.Type.VIDEO, uri, mediaSize)
|
||||
}
|
||||
"image" -> {
|
||||
PreparedMedia(QueuedMedia.Type.IMAGE, uri, mediaSize)
|
||||
Ok(PreparedMedia(QueuedMedia.Type.IMAGE, uri, mediaSize))
|
||||
}
|
||||
"audio" -> {
|
||||
if (mediaSize > instanceInfo.videoSizeLimit) {
|
||||
throw FileSizeException(instanceInfo.videoSizeLimit)
|
||||
Err(PrepareMediaError.FileIsTooLargeError(mediaSize, instanceInfo.videoSizeLimit))
|
||||
} else {
|
||||
Ok(PreparedMedia(QueuedMedia.Type.AUDIO, uri, mediaSize))
|
||||
}
|
||||
PreparedMedia(QueuedMedia.Type.AUDIO, uri, mediaSize)
|
||||
}
|
||||
else -> {
|
||||
throw MediaTypeException()
|
||||
Err(PrepareMediaError.UnsupportedMimeTypeError(mimeType))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Timber.w("Could not determine mime type of upload")
|
||||
throw MediaTypeException()
|
||||
}
|
||||
}
|
||||
|
||||
private val contentResolver = context.contentResolver
|
||||
|
||||
private suspend fun upload(media: QueuedMedia): Flow<UploadEvent> {
|
||||
private suspend fun upload(media: QueuedMedia): Flow<Result<UploadEvent, MediaUploaderError.UploadMediaError>> {
|
||||
return callbackFlow {
|
||||
var mimeType = contentResolver.getType(media.uri)
|
||||
|
||||
// Android's MIME type suggestions from file extensions is broken for at least
|
||||
// .m4a files. See https://github.com/tuskyapp/Tusky/issues/3189 for details.
|
||||
// Sniff the content of the file to determine the actual type.
|
||||
if (mimeType != null && (
|
||||
mimeType.startsWith("audio/", ignoreCase = true) ||
|
||||
mimeType.startsWith("video/", ignoreCase = true)
|
||||
)
|
||||
mimeType?.let {
|
||||
if (it.startsWith("audio/", ignoreCase = true) ||
|
||||
it.startsWith("video/", ignoreCase = true)
|
||||
) {
|
||||
val retriever = MediaMetadataRetriever()
|
||||
retriever.setDataSource(context, media.uri)
|
||||
mimeType = retriever.extractMetadata(METADATA_KEY_MIMETYPE)
|
||||
}
|
||||
}
|
||||
val map = MimeTypeMap.getSingleton()
|
||||
val fileExtension = map.getExtensionFromMimeType(mimeType)
|
||||
val filename = "%s_%d_%s.%s".format(
|
||||
|
@ -277,11 +397,11 @@ class MediaUploader @Inject constructor(
|
|||
var lastProgress = -1
|
||||
val fileBody = media.uri.asRequestBody(
|
||||
contentResolver,
|
||||
requireNotNull(mimeType.toMediaTypeOrNull()) { "Invalid Content Type" },
|
||||
requireNotNull(mimeType!!.toMediaTypeOrNull()) { "Invalid Content Type" },
|
||||
media.mediaSize,
|
||||
) { percentage ->
|
||||
if (percentage != lastProgress) {
|
||||
trySend(UploadEvent.ProgressEvent(percentage))
|
||||
trySend(Ok(UploadEvent.ProgressEvent(percentage)))
|
||||
}
|
||||
lastProgress = percentage
|
||||
}
|
||||
|
@ -294,20 +414,16 @@ class MediaUploader @Inject constructor(
|
|||
null
|
||||
}
|
||||
|
||||
val focus = if (media.focus != null) {
|
||||
MultipartBody.Part.createFormData("focus", "${media.focus.x},${media.focus.y}")
|
||||
} else {
|
||||
null
|
||||
val focus = media.focus?.let {
|
||||
MultipartBody.Part.createFormData("focus", "${it.x},${it.y}")
|
||||
}
|
||||
|
||||
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)
|
||||
throw UploadServerError(error.getErrorString(context))
|
||||
}
|
||||
val uploadResult = mediaUploadApi.uploadMedia(body, description, focus)
|
||||
.mapEither(
|
||||
{ UploadEvent.FinishedEvent(UploadedMedia(it.body.id, it.code == 200)) },
|
||||
{ MediaUploaderError.UploadMediaError(it) },
|
||||
)
|
||||
send(uploadResult)
|
||||
|
||||
awaitClose()
|
||||
}
|
||||
|
|
|
@ -24,7 +24,6 @@ import app.pachli.appstore.StatusComposedEvent
|
|||
import app.pachli.appstore.StatusEditedEvent
|
||||
import app.pachli.appstore.StatusScheduledEvent
|
||||
import app.pachli.components.compose.MediaUploader
|
||||
import app.pachli.components.compose.UploadEvent
|
||||
import app.pachli.components.drafts.DraftHelper
|
||||
import app.pachli.components.notifications.pendingIntentFlags
|
||||
import app.pachli.core.accounts.AccountManager
|
||||
|
@ -38,6 +37,7 @@ import app.pachli.core.network.model.NewStatus
|
|||
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 dagger.hilt.android.AndroidEntryPoint
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
@ -144,15 +144,14 @@ class SendStatusService : Service() {
|
|||
// 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 -> {
|
||||
Timber.w(uploadState.error, "failed uploading media")
|
||||
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
|
||||
}
|
||||
}
|
||||
}.media
|
||||
mediaItem.copy(id = media.mediaId, processed = media.processed)
|
||||
} else {
|
||||
mediaItem
|
||||
}
|
||||
|
|
|
@ -486,7 +486,6 @@
|
|||
<string name="notification_update_format">قام %s بتعديل منشوره</string>
|
||||
<string name="a11y_label_loading_thread">تحميل خيط المحادثة</string>
|
||||
<string name="hint_media_description_missing">يجب أن تضع وصفًا للوسائط.</string>
|
||||
<string name="error_multimedia_size_limit">لا يمكن أن يتجاوز حجم ملفات الفيديو والصوت %s ميغا بايت.</string>
|
||||
<string name="error_image_edit_failed">لا يمكن تحرير الصورة.</string>
|
||||
<string name="title_edits">التعديلات</string>
|
||||
<string name="pref_title_notification_filter_reports">هناك شكوى جديدة</string>
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
<string name="error_empty">Не можа быць пустым.</string>
|
||||
<string name="error_no_web_browser_found">Браўзер не знойдзены.</string>
|
||||
<string name="error_compose_character_limit">Допіс занадта доўгі!</string>
|
||||
<string name="error_multimedia_size_limit">Памер відэа- ды аўдыяфайлаў не можа перавышаць %s Мб.</string>
|
||||
<string name="error_media_upload_type">Немагчыма запампаваць файл гэтага тыпу.</string>
|
||||
<string name="error_media_upload_opening">Немагчыма адкрыць гэты файл.</string>
|
||||
<string name="error_media_download_permission">Патрабуецца дазвол на захаванне медыя.</string>
|
||||
|
|
|
@ -423,6 +423,5 @@
|
|||
<string name="pref_title_show_media_preview">Изтегляне на визуализации за мултимедии</string>
|
||||
<string name="pref_title_show_replies">Показване на отговори</string>
|
||||
<string name="pref_title_show_boosts">Показване на споделяния</string>
|
||||
<string name="error_multimedia_size_limit">Видео и аудио файловете не може да превишават %s МБ в размер.</string>
|
||||
<string name="error_image_edit_failed">Тази снимка не може да абъде редактирана.</string>
|
||||
</resources>
|
||||
|
|
|
@ -425,7 +425,6 @@
|
|||
<string name="limit_notifications">Limita les notificacions de la cronologia</string>
|
||||
<string name="filter_expiration_format">%s (%s)</string>
|
||||
<string name="dialog_delete_conversation_warning">Vols suprimir aquesta conversa\?</string>
|
||||
<string name="error_multimedia_size_limit">Els fitxers de vídeo i àudio no poden superar la mida de %s MB.</string>
|
||||
<string name="error_image_edit_failed">La imatge no s\'ha pogut editar.</string>
|
||||
<string name="action_dismiss">Descartar</string>
|
||||
<string name="pref_title_http_proxy_port_message">El port hauria d\'estar entre %d i %d</string>
|
||||
|
|
|
@ -443,7 +443,6 @@
|
|||
<string name="action_unsubscribe_account">Přestat odebírat</string>
|
||||
<string name="pachli_compose_post_quicksetting_label">Vytvořit příspěvek</string>
|
||||
<string name="draft_deleted">Koncept byl smazán</string>
|
||||
<string name="error_multimedia_size_limit">Video a audio soubory nesmí překročit velikost %s MB.</string>
|
||||
<string name="failed_to_pin">Připnutí se nezdařilo</string>
|
||||
<string name="failed_to_unpin">Zrušení připnutí se nezdařilo</string>
|
||||
<string name="pref_show_self_username_always">Vždy</string>
|
||||
|
|
|
@ -263,7 +263,6 @@
|
|||
<string name="no_scheduled_posts">Does gennych ddim negeseuon amserlenwyd.</string>
|
||||
<string name="filter_expiration_format">%s (%s)</string>
|
||||
<string name="notification_clear_text">Ydych chi\'n siŵr eich bod chi am glirio\'ch holl hysbysiadau\'n barhaol\?</string>
|
||||
<string name="error_multimedia_size_limit">Ni all ffeiliau fideo a sain fod yn fwy na %s MB.</string>
|
||||
<string name="error_following_hashtag_format">Gwall wrth ddilyn #%s</string>
|
||||
<string name="error_unfollowing_hashtag_format">Gwall wrth ddad-ddilyn #%s</string>
|
||||
<string name="action_unmute_domain">Dad-dewi %s</string>
|
||||
|
|
|
@ -473,7 +473,6 @@
|
|||
<string name="action_details">Details</string>
|
||||
<string name="error_image_edit_failed">Das Bild konnte nicht bearbeitet werden.</string>
|
||||
<string name="saving_draft">Entwurf wird gespeichert …</string>
|
||||
<string name="error_multimedia_size_limit">Video- und Audiodateien dürfen nicht größer als %s MB sein.</string>
|
||||
<string name="error_following_hashtag_format">Fehler beim Folgen von #%s</string>
|
||||
<string name="error_unfollowing_hashtag_format">Fehler beim Entfolgen von #%s</string>
|
||||
<string name="delete_scheduled_post_warning">Diesen geplanten Beitrag löschen\?</string>
|
||||
|
|
|
@ -46,7 +46,6 @@
|
|||
<string name="error_media_download_permission">Permission to store media is required.</string>
|
||||
<string name="title_posts_with_replies">With replies</string>
|
||||
<string name="title_posts_pinned">Pinned</string>
|
||||
<string name="error_multimedia_size_limit">Video and audio files cannot exceed %s MB in size.</string>
|
||||
<string name="error_status_source_load">Failed to load the status source from the server.</string>
|
||||
<string name="title_public_trending_links">Trending links</string>
|
||||
<string name="title_tab_public_trending_links">Links</string>
|
||||
|
|
|
@ -454,7 +454,6 @@
|
|||
<string name="notification_sign_up_description">Sciigoj pri novaj uzantoj</string>
|
||||
<string name="status_count_one_plus">1+</string>
|
||||
<string name="follow_requests_info">Kvankam via konto ne estas ŝlosita, la teamo de %1$s pensas, ke vi eble volus permane validigi la sekvopetojn de tiuj ĉi kontoj.</string>
|
||||
<string name="error_multimedia_size_limit">Filmetoj kaj sondosieroj ne povas esti pli grandaj ol %s MB.</string>
|
||||
<string name="error_image_edit_failed">La bildo ne povis esti redaktita.</string>
|
||||
<string name="error_following_hashtag_format">Okazis eraro dum la sekvado de #%s</string>
|
||||
<string name="error_unfollowing_hashtag_format">Okazos eraro dum la malsekvado de #%s</string>
|
||||
|
|
|
@ -452,7 +452,6 @@
|
|||
<string name="action_delete_conversation">Eliminar conversación</string>
|
||||
<string name="pref_title_confirm_favourites">Pedir confirmación antes de marcar como favorito</string>
|
||||
<string name="pref_title_notification_filter_updates">una publicación con la que interactué se editó</string>
|
||||
<string name="error_multimedia_size_limit">Los archivos de video y audio no pueden pesar más de %s MB.</string>
|
||||
<string name="error_image_edit_failed">Esta imagen no puede ser editada.</string>
|
||||
<string name="pref_show_self_username_always">Siempre</string>
|
||||
<string name="pref_show_self_username_never">Nunca</string>
|
||||
|
|
|
@ -440,7 +440,6 @@
|
|||
<string name="wellbeing_hide_stats_posts">Mezuetan estatistika kuantitatiboak ezkutatu</string>
|
||||
<string name="action_unbookmark">Laster-marka kendu</string>
|
||||
<string name="error_image_edit_failed">Ezin izan da irudia editatu.</string>
|
||||
<string name="error_multimedia_size_limit">Bideo eta audio fitxategiek ezin dute %s MBeko tamaina baino handiagoa izan.</string>
|
||||
<string name="error_muting_hashtag_format">Akatsa #%s mututzerakoan</string>
|
||||
<string name="error_unmuting_hashtag_format">Errorea #%s desmututzerakoan</string>
|
||||
<string name="error_following_hashtags_unsupported">Instantzia honek traolak jarraitzeko funtzioarekin bateragarritasuna ez dauka.</string>
|
||||
|
|
|
@ -471,7 +471,6 @@
|
|||
<string name="filter_expiration_format">%s (%s)</string>
|
||||
<string name="failed_to_pin">شکست در سنجاق کردن</string>
|
||||
<string name="failed_to_unpin">شکست در برداشتن سنجاق</string>
|
||||
<string name="error_multimedia_size_limit">پروندههای صوتی و ویدیویی نمیتوانند بیش از %sمب باشند.</string>
|
||||
<string name="error_image_edit_failed">تصویر نتوانست ویرایش شود.</string>
|
||||
<string name="description_post_language">زبان فرسته</string>
|
||||
<string name="pref_show_self_username_always">همیشه</string>
|
||||
|
|
|
@ -271,7 +271,6 @@
|
|||
<string name="abbreviated_seconds_ago">%ds</string>
|
||||
<string name="confirmation_unmuted">Käyttäjän mykistys poistettu</string>
|
||||
<string name="action_continue_edit">Jatka muokkausta</string>
|
||||
<string name="error_multimedia_size_limit">Video- ja äänitiedostot eivät saa ylittää %s Mt.</string>
|
||||
<string name="error_status_source_load">Tilan lähteen lataaminen palvelimelta epäonnistui.</string>
|
||||
<string name="abbreviated_days_ago">%dpv</string>
|
||||
<string name="title_public_trending_links">Nousussa olevat linkit</string>
|
||||
|
|
|
@ -500,7 +500,6 @@
|
|||
<string name="pref_summary_http_proxy_missing"><non spécifié></string>
|
||||
<string name="title_edits">Modifications</string>
|
||||
<string name="pref_default_post_language">Langue de publication par défaut</string>
|
||||
<string name="error_multimedia_size_limit">Les fichiers vidéo et audio ne peuvent pas dépasser %s Mo.</string>
|
||||
<string name="pref_title_http_proxy_port_message">Le port doit être entre %d et %d</string>
|
||||
<string name="action_set_focus">Définir le point focal</string>
|
||||
<string name="hint_description">Description</string>
|
||||
|
|
|
@ -479,7 +479,6 @@
|
|||
<string name="status_count_one_plus">1+</string>
|
||||
<string name="action_edit_image">Deasaich an dealbh</string>
|
||||
<string name="error_following_hashtag_format">Mearachd a’ leantainn air #%s</string>
|
||||
<string name="error_multimedia_size_limit">Chan fhaod na faidhlichean video ’ fuaime a bhith nas motha na %s MB.</string>
|
||||
<string name="error_unfollowing_hashtag_format">Mearachd a’ sgur de leantainn air #%s</string>
|
||||
<string name="error_image_edit_failed">Cha b’ urrainn dhuinn an dealbh a dheasachadh.</string>
|
||||
<string name="dialog_push_notification_migration">Airson brathan putaidh slighe UnifiedPush a chleachdadh, feumaidh Pachli cead airson fo-sgrìobhadh air brathan air an fhrithealaiche Mastodon agad fhaighinn. Bidh feum air clàradh a-steach às ùr airson na sgòpaichean OAuth a chaidh a cheadachadh dha Pachli atharrachadh. Ma nì thu clàradh a-steach às ùr an-seo no ann an “Roghainnean a’ chunntais”, cumaidh sinn na dreachdan is an tasgadan ionadail agad.</string>
|
||||
|
|
|
@ -462,7 +462,6 @@
|
|||
<string name="set_focus_description">Toca ou arrastra o círculo para elexir onde centrar a imaxe e sexa máis visible nas miniaturas.</string>
|
||||
<string name="duration_no_change">(Sen cambio)</string>
|
||||
<string name="filter_expiration_format">%s (%s)</string>
|
||||
<string name="error_multimedia_size_limit">Os ficheiros de vídeo e audio non poden superar os %s MB.</string>
|
||||
<string name="description_post_language">Idioma de publicación</string>
|
||||
<string name="action_set_focus">Establece foco</string>
|
||||
<string name="error_following_hashtag_format">Erro ao seguir #%s</string>
|
||||
|
|
|
@ -343,7 +343,6 @@
|
|||
<item quantity="other">%d सेकेंड शेष</item>
|
||||
</plurals>
|
||||
<string name="error_compose_character_limit">पोस्ट बहुत लंबा है!</string>
|
||||
<string name="error_multimedia_size_limit">वीडियो और ऑडियो फ़ाइलों का आकार %s एमबी से अधिक नहीं हो सकता है।</string>
|
||||
<string name="error_image_edit_failed">फोटो संपादित नहीं किया जा सका।</string>
|
||||
<string name="error_media_upload_image_or_video">फोटो और वीडियो दोनों को एक ही पोस्ट से अटैच नहीं किया जा सकता है।</string>
|
||||
</resources>
|
||||
|
|
|
@ -470,7 +470,6 @@
|
|||
<string name="delete_scheduled_post_warning">Töröljük ezt az időzített bejegyzést\?</string>
|
||||
<string name="set_focus_description">Koppintsd vagy húzd a kört, hogy kijelöld azt a fókuszpontot, mely mindig látható lesz az előnézetekben.</string>
|
||||
<string name="filter_expiration_format">%s (%s)</string>
|
||||
<string name="error_multimedia_size_limit">Video és audio állományok mérete nem lehet %s MB-nál nagyobb.</string>
|
||||
<string name="description_post_language">Bejegyzés nyelve</string>
|
||||
<string name="pref_show_self_username_always">Mindig</string>
|
||||
<string name="pref_show_self_username_disambiguate">Ha több fiók is be van jelentkezve</string>
|
||||
|
|
|
@ -6,7 +6,6 @@
|
|||
<string name="title_notifications">Notifikasi</string>
|
||||
<string name="title_public_local">Lokal</string>
|
||||
<string name="title_direct_messages">Pesan langsung</string>
|
||||
<string name="error_multimedia_size_limit">Berkas video dan audio tidak boleh melebihi %s MB.</string>
|
||||
<string name="error_image_edit_failed">Gambar tidak dapat diubah.</string>
|
||||
<string name="error_media_upload_type">Jenis berkas tersebut tidak dapat diunggah.</string>
|
||||
<string name="error_media_upload_opening">Berkas itu tidak dapat dibuka.</string>
|
||||
|
|
|
@ -459,7 +459,6 @@
|
|||
<string name="action_dismiss">Hunsa</string>
|
||||
<string name="action_details">Nánar</string>
|
||||
<string name="filter_expiration_format">%s (%s)</string>
|
||||
<string name="error_multimedia_size_limit">Myndskeiða- og hljóðskrár geta ekki verið stærri en %s MB.</string>
|
||||
<string name="description_post_language">Tungumál færslu</string>
|
||||
<string name="duration_no_change">(engin breyting)</string>
|
||||
<string name="error_following_hashtag_format">Villa við að fylgjast með #%s</string>
|
||||
|
|
|
@ -496,7 +496,6 @@
|
|||
<string name="filter_expiration_format">%s (%s)</string>
|
||||
<string name="dialog_push_notification_migration_other_accounts">Nuovo accesso eseguito per l\'utenza corrente al fine di garantire il permesso delle notifiche a Pachli. Però hai altre utenze che non sono state migrate in questo modo. Cambia utenza e riaccedi una alla volta per abilitare il supporto alle notifiche UnifiedPush.</string>
|
||||
<string name="account_date_joined">Registrato da %1$s</string>
|
||||
<string name="error_multimedia_size_limit">File video e audio non possono eccedere %s MB in dimensione.</string>
|
||||
<string name="error_image_edit_failed">L\'immagine non può essere modificata.</string>
|
||||
<string name="title_migration_relogin">Riaccedi per le notifiche</string>
|
||||
<string name="error_following_hashtag_format">Errore provando a seguire #%s</string>
|
||||
|
|
|
@ -424,7 +424,6 @@
|
|||
<string name="compose_preview_image_description">画像 %s に対する操作</string>
|
||||
<string name="duration_no_change">(変更なし)</string>
|
||||
<string name="language_display_name_format">%s (%s)</string>
|
||||
<string name="error_multimedia_size_limit">ビデオと音声ファイルのサイズは %s MB を超えることはできません。</string>
|
||||
<string name="error_image_edit_failed">画像が編集できませんでした。</string>
|
||||
<string name="action_edit_image">画像の編集</string>
|
||||
<string name="error_following_hashtags_unsupported">このインスタンスはハッシュタグのフォローに対応していません。</string>
|
||||
|
|
|
@ -204,7 +204,6 @@
|
|||
<string name="notification_report_format">Jauns ziņojums par %s</string>
|
||||
<string name="action_mute_conversation">Apklusināt sarunu</string>
|
||||
<string name="action_access_scheduled_posts">Ieplānotie ieraksti</string>
|
||||
<string name="error_multimedia_size_limit">Video un audio faili nevar pārsniegt %s MB izmēru.</string>
|
||||
<string name="error_following_hashtags_unsupported">Šī instance neatbalsta sekošanu tēmturiem.</string>
|
||||
<string name="send_post_notification_channel_name">Sūta ierakstus</string>
|
||||
<string name="emoji_style">Emocijzīmju stils</string>
|
||||
|
|
|
@ -461,7 +461,6 @@
|
|||
<string name="status_count_one_plus">1+</string>
|
||||
<string name="action_edit_image">Rediger bilde</string>
|
||||
<string name="error_image_edit_failed">Bildet kunne ikke redigeres.</string>
|
||||
<string name="error_multimedia_size_limit">Video- og lydfiler kan ikke være større enn %s MB.</string>
|
||||
<string name="error_following_hashtag_format">Det oppsto en feil under følging av #%s</string>
|
||||
<string name="error_unfollowing_hashtag_format">Kunne ikke slutte å følge #%s</string>
|
||||
<string name="filter_expiration_format">%s (%s)</string>
|
||||
|
|
|
@ -440,7 +440,6 @@
|
|||
\nPushmeldingen worden hierdoor niet beïnvloed, maar je kunt de voorkeuren voor meldingen handmatig wijzigen.</string>
|
||||
<string name="notification_sign_up_format">%s heeft zich geregistreerd</string>
|
||||
<string name="tips_push_notification_migration">Alle accounts opnieuw inloggen i.v.m. ondersteuning pushmeldingen.</string>
|
||||
<string name="error_multimedia_size_limit">Afbeeldingen en video\'s kunnen niet groter zijn dan %s MB.</string>
|
||||
<string name="error_image_edit_failed">Deze afbeelding kon niet worden bewerkt.</string>
|
||||
<string name="title_migration_relogin">Opnieuw inloggen i.v.m. pushmeldingen</string>
|
||||
<string name="notification_update_format">%s heeft diens bericht bewerkt</string>
|
||||
|
|
|
@ -457,7 +457,6 @@
|
|||
<string name="duration_90_days">90 jorns</string>
|
||||
<string name="duration_180_days">180 jorns</string>
|
||||
<string name="duration_365_days">365 jorns</string>
|
||||
<string name="error_multimedia_size_limit">Los fichièrs video e àudio pòdon pas despassar %s.</string>
|
||||
<string name="set_focus_description">Tocatz o lisatz lo cercle per causir lo punt focal que deu aparéisser sus las miniaturas.</string>
|
||||
<string name="pref_title_show_self_username">Mostrar lo nom d’utilizaire dins la barra d’aisinas</string>
|
||||
<string name="pref_show_self_username_disambiguate">Quand mai d’un compte es connectat</string>
|
||||
|
|
|
@ -491,7 +491,6 @@
|
|||
<string name="title_migration_relogin">Zaloguj się ponownie aby włączyć powiadomienia push</string>
|
||||
<string name="action_dismiss">Odrzuć</string>
|
||||
<string name="action_details">Detale</string>
|
||||
<string name="error_multimedia_size_limit">Pliki wideo i audio nie mogą przekraczać rozmiarem %s MB.</string>
|
||||
<string name="filter_expiration_format">%s (%s)</string>
|
||||
<string name="description_post_language">Język wpisu</string>
|
||||
<string name="duration_no_change">(bez zmian)</string>
|
||||
|
|
|
@ -462,7 +462,6 @@
|
|||
<string name="pref_summary_http_proxy_invalid"><inválido></string>
|
||||
<string name="pref_show_self_username_always">Sempre</string>
|
||||
<string name="error_image_edit_failed">A imagem não pôde ser editada.</string>
|
||||
<string name="error_multimedia_size_limit">Arquivos de vídeo e áudio não podem exceder %s MB de tamanho.</string>
|
||||
<string name="hint_media_description_missing">A mídia deve ter uma descrição.</string>
|
||||
<string name="pref_show_self_username_never">Nunca</string>
|
||||
<string name="post_media_alt">ALT</string>
|
||||
|
|
|
@ -482,7 +482,6 @@
|
|||
<string name="pref_show_self_username_never">Nunca</string>
|
||||
<string name="description_post_language">Idioma da publicação</string>
|
||||
<string name="pref_title_show_self_username">Mostrar o nome de utilizador nas barras de ferramentas</string>
|
||||
<string name="error_multimedia_size_limit">Os ficheiros de áudio e vídeo não podem exceder os %s MB.</string>
|
||||
<string name="action_set_focus">Define o ponto de focagem</string>
|
||||
<string name="error_following_hashtag_format">Erro ao seguir #%s</string>
|
||||
<string name="error_unfollowing_hashtag_format">Erro ao deixar de seguir #%s</string>
|
||||
|
|
|
@ -466,7 +466,6 @@
|
|||
<string name="account_date_joined">Gick med i %1$s</string>
|
||||
<string name="saving_draft">Sparar utkast…</string>
|
||||
<string name="tips_push_notification_migration">Logga in igen på alla konton för att tillåta pushnotiser.</string>
|
||||
<string name="error_multimedia_size_limit">Video- och ljudfiler kan inte överskrida %s MB i storlek.</string>
|
||||
<string name="error_image_edit_failed">Bilden kunde inte redigeras.</string>
|
||||
<string name="duration_365_days">365 dagar</string>
|
||||
<string name="duration_180_days">180 dagar</string>
|
||||
|
|
|
@ -402,7 +402,6 @@
|
|||
<string name="pref_title_hide_top_toolbar">Üst araç çubuğunun başlığını gizle</string>
|
||||
<string name="action_delete_conversation">Konuşmayı sil</string>
|
||||
<string name="title_announcements">Duyurular</string>
|
||||
<string name="error_multimedia_size_limit">Video ve ses dosyaları %s MB boyutunu aşamaz.</string>
|
||||
<string name="error_image_edit_failed">Görsel düzenlemedi.</string>
|
||||
<string name="error_following_hashtag_format">#%s\'i izleyen hata</string>
|
||||
<string name="error_muting_hashtag_format">Sessize alma hatası #%s</string>
|
||||
|
|
|
@ -482,7 +482,6 @@
|
|||
<string name="status_count_one_plus">1+</string>
|
||||
<string name="error_image_edit_failed">Неможливо редагувати зображення.</string>
|
||||
<string name="error_following_hashtag_format">Помилка підписки на #%s</string>
|
||||
<string name="error_multimedia_size_limit">Розмір відео та аудіофайлів не може перевищувати %s Мб.</string>
|
||||
<string name="error_unfollowing_hashtag_format">Помилка скасування підписки на #%s</string>
|
||||
<string name="filter_expiration_format">%s (%s)</string>
|
||||
<string name="description_post_language">Мова допису</string>
|
||||
|
|
|
@ -449,7 +449,6 @@
|
|||
<string name="status_count_one_plus">1+</string>
|
||||
<string name="action_edit_image">Sửa ảnh</string>
|
||||
<string name="error_image_edit_failed">Hình ảnh này không thể sửa.</string>
|
||||
<string name="error_multimedia_size_limit">Video và audio không thể quá %s MB.</string>
|
||||
<string name="error_following_hashtag_format">Lỗi khi theo dõi #%s</string>
|
||||
<string name="error_unfollowing_hashtag_format">Lỗi khi bỏ theo dõi #%s</string>
|
||||
<string name="filter_expiration_format">%s (%s)</string>
|
||||
|
|
|
@ -463,7 +463,6 @@
|
|||
<string name="status_count_one_plus">1+</string>
|
||||
<string name="action_edit_image">编辑图片</string>
|
||||
<string name="error_image_edit_failed">无法编辑图片。</string>
|
||||
<string name="error_multimedia_size_limit">音视频文件大小不能超出 %s MB。</string>
|
||||
<string name="error_following_hashtag_format">关注 #%s 出错</string>
|
||||
<string name="error_unfollowing_hashtag_format">取关 #%s 出错</string>
|
||||
<string name="filter_expiration_format">%s (%s)</string>
|
||||
|
|
|
@ -467,7 +467,6 @@
|
|||
<string name="tips_push_notification_migration">重新登入所有帳號以啟用推播功能。</string>
|
||||
<string name="action_set_focus">設置關注點</string>
|
||||
<string name="title_migration_relogin">重新登入以啟用推播功能</string>
|
||||
<string name="error_multimedia_size_limit">影片和音訊檔案大小不能超過 %s MB。</string>
|
||||
<string name="status_count_one_plus">1+</string>
|
||||
<string name="action_add_reaction">添加反應</string>
|
||||
<string name="pref_title_notification_filter_sign_ups">有人進行了註冊</string>
|
||||
|
|
|
@ -18,13 +18,12 @@
|
|||
<string name="error_empty">This cannot be empty.</string>
|
||||
<string name="error_no_web_browser_found">Couldn\'t find a web browser to use.</string>
|
||||
<string name="error_compose_character_limit">The post is too long!</string>
|
||||
<string name="error_multimedia_size_limit">Video and audio files cannot exceed %s MB in size.</string>
|
||||
<string name="error_image_edit_failed">The image could not be edited.</string>
|
||||
<string name="error_media_upload_type">That type of file cannot be uploaded.</string>
|
||||
<string name="error_media_upload_opening">That file could not be opened.</string>
|
||||
<string name="error_media_upload_permission">Permission to read media is required.</string>
|
||||
<string name="error_media_download_permission">Permission to store media is required.</string>
|
||||
<string name="error_media_upload_image_or_video">Images and videos cannot both be attached to the same post.</string>
|
||||
<string name="error_media_upload_image_or_video">images and videos cannot both be attached to the same post.</string>
|
||||
<string name="error_media_upload_sending">The upload failed.</string>
|
||||
<string name="error_media_upload_sending_fmt">The upload failed: %s</string>
|
||||
<string name="error_sender_account_gone">Error sending post.</string>
|
||||
|
@ -697,4 +696,17 @@
|
|||
<string name="compose_warn_language_dialog_fmt">The post\'s language is set to %1$s but you might have written it in %2$s.</string>
|
||||
<string name="compose_warn_language_dialog_change_language_fmt">Change language to "%1$s" and post</string>
|
||||
<string name="compose_warn_language_dialog_accept_language_fmt">Post as-is (%1$s)</string>
|
||||
|
||||
<string name="error_media_uploader_upload_not_found_fmt">media upload with ID %1$s not found</string>
|
||||
<string name="error_media_uploader_throwable_fmt">%1$s</string>
|
||||
|
||||
<string name="error_prepare_media_content_resolver_missing_path_fmt">content resolver URI was missing a path: %1$s</string>
|
||||
<string name="error_prepare_media_content_resolver_unsupported_scheme_fmt">content resolver URI has unsupported scheme: %1$s</string>
|
||||
<string name="error_prepare_media_io_fmt">%1$s</string>
|
||||
<string name="error_prepare_media_file_is_too_large_fmt">file size is %1$s, maximum allowed is %2$s</string>
|
||||
<string name="error_prepare_media_unknown_file_size">file size could not be determined</string>
|
||||
<string name="error_prepare_media_unsupported_mime_type_fmt">server does not support file type: %1$s</string>
|
||||
<string name="error_prepare_media_unknown_mime_type">file\'s type is not known</string>
|
||||
|
||||
<string name="error_pick_media_fmt">Could not attach file to post: %1$s</string>
|
||||
</resources>
|
||||
|
|
|
@ -11,6 +11,7 @@ import app.pachli.core.network.retrofit.MastodonApi
|
|||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
import java.util.Date
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
|
@ -60,8 +61,14 @@ class TimelineCasesTest {
|
|||
onBlocking { pinStatus(statusId) } doReturn NetworkResult.failure(
|
||||
HttpException(
|
||||
Response.error<Status>(
|
||||
422,
|
||||
"{\"error\":\"Validation Failed: You have already pinned the maximum number of toots\"}".toResponseBody(),
|
||||
okhttp3.Response.Builder()
|
||||
.request(okhttp3.Request.Builder().url("http://localhost/").build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.addHeader("content-type", "application/json")
|
||||
.code(422)
|
||||
.message("")
|
||||
.build(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
@ -75,7 +75,7 @@ interface PachliError {
|
|||
val resourceId: Int
|
||||
|
||||
/** Arguments to be interpolated in to the string from [resourceId]. */
|
||||
val formatArgs: Array<out String>?
|
||||
val formatArgs: Array<out Any>?
|
||||
|
||||
/**
|
||||
* The cause of this error. If present the string representation of `cause`
|
||||
|
|
|
@ -45,4 +45,8 @@ dependencies {
|
|||
|
||||
testImplementation(libs.mockwebserver)
|
||||
testImplementation(libs.bundles.mockito)
|
||||
|
||||
// ThrowableExtensions uses JSONObject, which is missing from Robolectric.
|
||||
// Use the real implementation
|
||||
testImplementation(libs.org.json)
|
||||
}
|
||||
|
|
|
@ -22,22 +22,34 @@ import org.json.JSONObject
|
|||
import retrofit2.HttpException
|
||||
|
||||
/**
|
||||
* checks if this throwable indicates an error causes by a 4xx/5xx server response and
|
||||
* tries to retrieve the error message the server sent
|
||||
* Checks if this throwable indicates an error causes by a 4xx/5xx server response and
|
||||
* tries to retrieve the error message the server sent.
|
||||
*
|
||||
* @return the error message, or null if this is no server error or it had no error message
|
||||
*/
|
||||
fun Throwable.getServerErrorMessage(): String? {
|
||||
if (this is HttpException) {
|
||||
val errorResponse = response()?.errorBody()?.string()
|
||||
return if (!errorResponse.isNullOrBlank()) {
|
||||
try {
|
||||
JSONObject(errorResponse).getString("error")
|
||||
} catch (e: JSONException) {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
if (this !is HttpException) return null
|
||||
|
||||
response()?.headers()?.get("content-type")?.startsWith("application/json") == true ||
|
||||
return null
|
||||
|
||||
// Try and parse the body as JSON with `error` and an optional `description`
|
||||
// property.
|
||||
return response()?.errorBody()?.string()?.let { errorBody ->
|
||||
if (errorBody.isBlank()) return null
|
||||
|
||||
val errorObj = try {
|
||||
JSONObject(errorBody)
|
||||
} catch (e: JSONException) {
|
||||
return@let "$errorBody ($e)"
|
||||
}
|
||||
|
||||
val error = errorObj.optString("error")
|
||||
val description = errorObj.optString("description")
|
||||
|
||||
if (error.isNullOrEmpty()) return null
|
||||
if (description.isNullOrEmpty()) return error
|
||||
|
||||
return "$error: $description"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,8 +17,8 @@
|
|||
|
||||
package app.pachli.core.network.model
|
||||
|
||||
import app.pachli.core.network.retrofit.apiresult.ApiResult
|
||||
import okhttp3.MultipartBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Part
|
||||
|
@ -33,5 +33,5 @@ interface MediaUploadApi {
|
|||
@Part file: MultipartBody.Part,
|
||||
@Part description: MultipartBody.Part? = null,
|
||||
@Part focus: MultipartBody.Part? = null,
|
||||
): Response<MediaUploadResult>
|
||||
): ApiResult<MediaUploadResult>
|
||||
}
|
||||
|
|
|
@ -39,12 +39,14 @@ typealias ApiResult<T> = Result<ApiResponse<T>, ApiError>
|
|||
/**
|
||||
* A successful response from an API call.
|
||||
*
|
||||
* @param headers The HTTP headers from the response
|
||||
* @param body The response body, converted to [T]
|
||||
* @param headers HTTP headers from the response
|
||||
* @param body Response body, converted to [T]
|
||||
* @param code HTTP response code (200, etc)
|
||||
*/
|
||||
data class ApiResponse<out T>(
|
||||
val headers: Headers,
|
||||
val body: T,
|
||||
val code: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
|
@ -59,8 +61,8 @@ sealed class ApiError(
|
|||
@StringRes override val resourceId: Int,
|
||||
val throwable: Throwable,
|
||||
) : PachliError {
|
||||
override val formatArgs = (
|
||||
throwable.getServerErrorMessage() ?: throwable.localizedMessage
|
||||
override val formatArgs: Array<out Any>? = (
|
||||
throwable.getServerErrorMessage() ?: throwable.localizedMessage?.trim()
|
||||
)?.let { arrayOf(it) }
|
||||
override val cause: PachliError? = null
|
||||
|
||||
|
@ -167,6 +169,27 @@ sealed class ServerError(
|
|||
ServerError(R.string.error_generic_fmt, exception)
|
||||
}
|
||||
|
||||
/**
|
||||
* The server sent a response without a content type. Note that the underlying
|
||||
* response in [exception] may be a success, as the server may have sent a 2xx
|
||||
* without a content-type.
|
||||
*/
|
||||
data class MissingContentType(val exception: HttpException) :
|
||||
ApiError(R.string.error_missing_content_type_fmt, exception)
|
||||
|
||||
/**
|
||||
* The server sent a response with the wrong content type (not "application/json")
|
||||
* Note that the underlying response in [exception] may be a success, as the server
|
||||
* may have sent a 2xx with the wrong content-type.
|
||||
*/
|
||||
data class WrongContentType(val contentType: String, val exception: HttpException) :
|
||||
ApiError(R.string.error_wrong_content_type_fmt, exception) {
|
||||
override val formatArgs: Array<out Any>
|
||||
get() = super.formatArgs?.let {
|
||||
arrayOf(contentType, *it)
|
||||
} ?: arrayOf(contentType)
|
||||
}
|
||||
|
||||
data class JsonParseError(val exception: JsonDataException) :
|
||||
ApiError(R.string.error_json_data_fmt, exception)
|
||||
|
||||
|
@ -177,6 +200,12 @@ data class IoError(val exception: IOException) :
|
|||
* Creates an [ApiResult] from a [Response].
|
||||
*/
|
||||
fun <T> Result.Companion.from(response: Response<T>, successType: Type): ApiResult<T> {
|
||||
response.headers()["content-type"]?.let { contentType ->
|
||||
if (!contentType.startsWith("application/json")) {
|
||||
return Err(WrongContentType(contentType, HttpException(response)))
|
||||
}
|
||||
} ?: return Err(MissingContentType(HttpException(response)))
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
val err = ApiError.from(HttpException(response))
|
||||
return Err(err)
|
||||
|
@ -185,11 +214,11 @@ fun <T> Result.Companion.from(response: Response<T>, successType: Type): ApiResu
|
|||
// Skip body processing for successful responses expecting Unit
|
||||
if (successType == Unit::class.java) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return Ok(ApiResponse(response.headers(), Unit as T))
|
||||
return Ok(ApiResponse(response.headers(), Unit as T, response.code()))
|
||||
}
|
||||
|
||||
response.body()?.let { body ->
|
||||
return Ok(ApiResponse(response.headers(), body))
|
||||
return Ok(ApiResponse(response.headers(), body, response.code()))
|
||||
}
|
||||
|
||||
return Err(ApiError.from(HttpException(response)))
|
||||
|
|
|
@ -25,4 +25,6 @@
|
|||
<string name="error_404_not_found_fmt">Your server does not support this feature: %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>
|
||||
<string name="error_missing_content_type_fmt">your server is mis-configured, the response has no content-type: %1$s</string>
|
||||
<string name="error_wrong_content_type_fmt">your server is mis-configured and returned \'%2$s\' with the wrong content-type, \'%1$s\'</string>
|
||||
</resources>
|
||||
|
|
|
@ -18,9 +18,11 @@
|
|||
package app.pachli.core.network.retrofit.apiresult
|
||||
|
||||
import com.github.michaelbull.result.Err
|
||||
import com.github.michaelbull.result.getError
|
||||
import com.github.michaelbull.result.unwrapError
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import java.io.IOException
|
||||
import okhttp3.Headers
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Test
|
||||
|
@ -32,6 +34,9 @@ import retrofit2.Response
|
|||
class ApiResultCallTest {
|
||||
private val backingCall = TestCall<String>()
|
||||
private val networkApiResultCall = ApiResultCall(backingCall, String::class.java)
|
||||
private val jsonHeaders = Headers.Builder()
|
||||
.add("content-type: application/json")
|
||||
.build()
|
||||
|
||||
@Test
|
||||
fun `should throw an error when invoking 'execute'`() {
|
||||
|
@ -63,7 +68,7 @@ class ApiResultCallTest {
|
|||
|
||||
@Test
|
||||
fun `should parse successful call as ApiResult-success`() {
|
||||
val okResponse = Response.success("Test body")
|
||||
val okResponse = Response.success("Test body", jsonHeaders)
|
||||
|
||||
networkApiResultCall.enqueue(
|
||||
object : Callback<ApiResult<String>> {
|
||||
|
@ -81,18 +86,162 @@ class ApiResultCallTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `should parse call with 404 error code as ApiResult-failure`() {
|
||||
val errorResponse = Response.error<String>(404, "not found".toResponseBody())
|
||||
fun `should require content-type on successful results`() {
|
||||
// Test "should parse successful call as ApiResult-success" tested responses with
|
||||
// the correct content-type. This test ensures the content-type is required.
|
||||
|
||||
// Given - response that has no content-type
|
||||
val okResponse = Response.success("Test body")
|
||||
|
||||
networkApiResultCall.enqueue(
|
||||
object : Callback<ApiResult<String>> {
|
||||
override fun onResponse(call: Call<ApiResult<String>>, response: Response<ApiResult<String>>) {
|
||||
val error = response.body()?.getError() as? ClientError.NotFound
|
||||
val error = response.body()?.unwrapError()
|
||||
assertThat(error).isInstanceOf(MissingContentType::class.java)
|
||||
assertThat(response.isSuccessful).isTrue()
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<ApiResult<String>>, t: Throwable) {
|
||||
throw IllegalStateException()
|
||||
}
|
||||
},
|
||||
)
|
||||
backingCall.complete(okResponse)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should require application-slash-json content-type on successful results`() {
|
||||
// Test "should parse successful call as ApiResult-success" tested responses with
|
||||
// the correct content-type. This test ensures the content-type is required,
|
||||
// and is set to "application/json". If it's not set then the
|
||||
|
||||
// Given - response that has no content-type
|
||||
val okResponse = Response.success(
|
||||
"Test body",
|
||||
Headers.Builder().add("content-type: text/html").build(),
|
||||
)
|
||||
|
||||
networkApiResultCall.enqueue(
|
||||
object : Callback<ApiResult<String>> {
|
||||
override fun onResponse(call: Call<ApiResult<String>>, response: Response<ApiResult<String>>) {
|
||||
val error = response.body()?.unwrapError() as? WrongContentType
|
||||
assertThat(error).isInstanceOf(WrongContentType::class.java)
|
||||
assertThat(error?.contentType).isEqualTo("text/html")
|
||||
assertThat(response.isSuccessful).isTrue()
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<ApiResult<String>>, t: Throwable) {
|
||||
throw IllegalStateException()
|
||||
}
|
||||
},
|
||||
)
|
||||
backingCall.complete(okResponse)
|
||||
}
|
||||
|
||||
// If the JSON body does not parse as an object with `error` and optional `description`
|
||||
// properties then the error message should fall back to the HTTP error message.
|
||||
@Test
|
||||
fun `should parse call with 404 error code as ApiResult-failure (no JSON)`() {
|
||||
val errorMsg = "dummy error message"
|
||||
val errorResponse = Response.error<String>(
|
||||
"".toResponseBody(),
|
||||
okhttp3.Response.Builder()
|
||||
.request(okhttp3.Request.Builder().url("http://localhost/").build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.addHeader("content-type", "application/json")
|
||||
.code(404)
|
||||
.message(errorMsg)
|
||||
.build(),
|
||||
|
||||
)
|
||||
|
||||
networkApiResultCall.enqueue(
|
||||
object : Callback<ApiResult<String>> {
|
||||
override fun onResponse(call: Call<ApiResult<String>>, response: Response<ApiResult<String>>) {
|
||||
val error = response.body()?.unwrapError() as? ClientError.NotFound
|
||||
assertThat(error).isInstanceOf(ClientError.NotFound::class.java)
|
||||
|
||||
val exception = error?.exception
|
||||
assertThat(exception).isInstanceOf(HttpException::class.java)
|
||||
assertThat(exception?.code()).isEqualTo(404)
|
||||
assertThat(error?.formatArgs).isEqualTo(arrayOf("HTTP 404 $errorMsg"))
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<ApiResult<String>>, t: Throwable) {
|
||||
throw IllegalStateException()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
backingCall.complete(errorResponse)
|
||||
}
|
||||
|
||||
// If the JSON body *does* parse as an object with an `error` property that should be used
|
||||
// as the user visible error message.
|
||||
@Test
|
||||
fun `should parse call with 404 error code as ApiResult-failure (JSON error message)`() {
|
||||
val errorMsg = "JSON error message"
|
||||
val errorResponse = Response.error<String>(
|
||||
"{\"error\": \"$errorMsg\"}".toResponseBody(),
|
||||
okhttp3.Response.Builder()
|
||||
.request(okhttp3.Request.Builder().url("http://localhost/").build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.addHeader("content-type", "application/json")
|
||||
.code(404)
|
||||
.message("")
|
||||
.build(),
|
||||
|
||||
)
|
||||
|
||||
networkApiResultCall.enqueue(
|
||||
object : Callback<ApiResult<String>> {
|
||||
override fun onResponse(call: Call<ApiResult<String>>, response: Response<ApiResult<String>>) {
|
||||
val error = response.body()?.unwrapError() as? ClientError.NotFound
|
||||
assertThat(error).isInstanceOf(ClientError.NotFound::class.java)
|
||||
|
||||
val exception = error?.exception
|
||||
assertThat(exception).isInstanceOf(HttpException::class.java)
|
||||
assertThat(exception?.code()).isEqualTo(404)
|
||||
assertThat(error?.formatArgs).isEqualTo(arrayOf(errorMsg))
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<ApiResult<String>>, t: Throwable) {
|
||||
throw IllegalStateException()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
backingCall.complete(errorResponse)
|
||||
}
|
||||
|
||||
// If the JSON body *does* parse as an object with an `error` property that should be used
|
||||
// as the user visible error message.
|
||||
@Test
|
||||
fun `should parse call with 404 error code as ApiResult-failure (JSON error and description message)`() {
|
||||
val errorMsg = "JSON error message"
|
||||
val descriptionMsg = "JSON error description"
|
||||
val errorResponse = Response.error<String>(
|
||||
"{\"error\": \"$errorMsg\", \"description\": \"$descriptionMsg\"}".toResponseBody(),
|
||||
okhttp3.Response.Builder()
|
||||
.request(okhttp3.Request.Builder().url("http://localhost/").build())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.addHeader("content-type", "application/json")
|
||||
.code(404)
|
||||
.message("")
|
||||
.build(),
|
||||
|
||||
)
|
||||
|
||||
networkApiResultCall.enqueue(
|
||||
object : Callback<ApiResult<String>> {
|
||||
override fun onResponse(call: Call<ApiResult<String>>, response: Response<ApiResult<String>>) {
|
||||
val error = response.body()?.unwrapError() as? ClientError.NotFound
|
||||
assertThat(error).isInstanceOf(ClientError.NotFound::class.java)
|
||||
|
||||
val exception = error?.exception
|
||||
assertThat(exception).isInstanceOf(HttpException::class.java)
|
||||
assertThat(exception?.code()).isEqualTo(404)
|
||||
assertThat(error?.formatArgs).isEqualTo(arrayOf("$errorMsg: $descriptionMsg"))
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<ApiResult<String>>, t: Throwable) {
|
||||
|
|
|
@ -61,6 +61,7 @@ class ApiTest {
|
|||
|
||||
private fun mockResponse(responseCode: Int, body: String = "") = MockResponse()
|
||||
.setResponseCode(responseCode)
|
||||
.setHeader("content-type", "application/json")
|
||||
.setBody(body)
|
||||
|
||||
@Test
|
||||
|
|
|
@ -44,6 +44,7 @@ glide = "4.16.0"
|
|||
# Deliberate downgrade, https://github.com/tuskyapp/Tusky/issues/3631
|
||||
glide-animation-plugin = "2.23.0"
|
||||
hilt = "2.51.1"
|
||||
org-json = "20240303"
|
||||
junit = "4.13.2"
|
||||
kotlin = "2.0.0"
|
||||
kotlin-result = "1.1.20"
|
||||
|
@ -208,6 +209,7 @@ okhttp-core = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
|||
okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
|
||||
okhttp-tls = { module = "com.squareup.okhttp3:okhttp-tls", version.ref = "okhttp" }
|
||||
okio = { module = "com.squareup.okio:okio", version.ref = "okio" }
|
||||
org-json = { module = "org.json:json", version.ref = "org-json"}
|
||||
play-services-base = { module = "com.google.android.gms:play-services-base", version.ref = "play-services-base" }
|
||||
retrofit-converter-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit" }
|
||||
retrofit-core = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
|
||||
|
|
Loading…
Reference in New Issue