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:
Nik Clayton 2024-07-04 19:16:24 +02:00 committed by GitHub
parent 4878c23ac2
commit 5aacb02ea0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
51 changed files with 574 additions and 259 deletions

View File

@ -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>

View File

@ -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,

View File

@ -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()

View File

@ -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()
}

View File

@ -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
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -500,7 +500,6 @@
<string name="pref_summary_http_proxy_missing">&lt;non spécifié&gt;</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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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 dutilizaire dins la barra daisinas</string>
<string name="pref_show_self_username_disambiguate">Quand mai dun compte es connectat</string>

View File

@ -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>

View File

@ -462,7 +462,6 @@
<string name="pref_summary_http_proxy_invalid">&lt;inválido&gt;</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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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(),
),
),
)

View File

@ -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`

View File

@ -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)
}

View File

@ -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"
}
}

View File

@ -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>
}

View File

@ -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)))

View File

@ -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>

View File

@ -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) {

View File

@ -61,6 +61,7 @@ class ApiTest {
private fun mockResponse(responseCode: Int, body: String = "") = MockResponse()
.setResponseCode(responseCode)
.setHeader("content-type", "application/json")
.setBody(body)
@Test

View File

@ -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" }