diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml
index 2b2cf0294..558a1f76b 100644
--- a/app/lint-baseline.xml
+++ b/app/lint-baseline.xml
@@ -102,7 +102,7 @@
errorLine2=" ^">
@@ -712,7 +712,7 @@
errorLine2=" ^">
@@ -745,7 +745,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -756,7 +756,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -767,7 +767,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -778,7 +778,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -789,7 +789,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1284,7 +1284,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1295,7 +1295,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1306,7 +1306,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1317,7 +1317,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
@@ -1328,7 +1328,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1339,7 +1339,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1350,7 +1350,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1361,7 +1361,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1372,7 +1372,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1383,7 +1383,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1394,7 +1394,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1405,7 +1405,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1416,7 +1416,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1427,7 +1427,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1438,7 +1438,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
@@ -1449,7 +1449,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1460,7 +1460,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
@@ -1471,7 +1471,7 @@
errorLine2=" ~~~~~~~~~~~~">
@@ -1482,7 +1482,7 @@
errorLine2=" ~~~~~~~~~~~~~~">
@@ -1493,7 +1493,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
@@ -1504,7 +1504,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
@@ -1515,7 +1515,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
@@ -1526,7 +1526,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1537,7 +1537,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1548,7 +1548,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
@@ -1559,7 +1559,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1570,7 +1570,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1581,7 +1581,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1592,7 +1592,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
@@ -1603,7 +1603,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1614,7 +1614,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1625,7 +1625,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1636,7 +1636,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
diff --git a/app/src/main/java/app/pachli/components/compose/ComposeActivity.kt b/app/src/main/java/app/pachli/components/compose/ComposeActivity.kt
index 37af76d2f..8ceec49ce 100644
--- a/app/src/main/java/app/pachli/components/compose/ComposeActivity.kt
+++ b/app/src/main/java/app/pachli/components/compose/ComposeActivity.kt
@@ -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,
diff --git a/app/src/main/java/app/pachli/components/compose/ComposeViewModel.kt b/app/src/main/java/app/pachli/components/compose/ComposeViewModel.kt
index e602a14d4..524aed9a2 100644
--- a/app/src/main/java/app/pachli/components/compose/ComposeViewModel.kt
+++ b/app/src/main/java/app/pachli/components/compose/ComposeViewModel.kt
@@ -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> = MutableStateFlow(emptyList())
val media = _media.asStateFlow()
- private val _uploadError = MutableSharedFlow(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
+ private val _uploadError = MutableSharedFlow(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 = withContext(Dispatchers.IO) {
- try {
- val (type, uri, size) = mediaUploader.prepareMedia(mediaUri, instanceInfo.value)
- val mediaItems = media.value
- if (type != QueuedMedia.Type.IMAGE &&
- mediaItems.isNotEmpty() &&
- mediaItems[0].type == QueuedMedia.Type.IMAGE
- ) {
- Result.failure(VideoOrImageException())
- } else {
- val queuedMedia = addMediaToQueue(type, uri, size, description, focus)
- Result.success(queuedMedia)
- }
- } catch (e: Exception) {
- Result.failure(e)
+ /** 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 = 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) {
+ Err(PickMediaError.MixedMediaTypesError)
+ } else {
+ val queuedMedia = addMediaToQueue(type, uri, size, description, focus)
+ 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
}
}
- _media.update { mediaList ->
- mediaList.map { mediaItem ->
- if (mediaItem.localId == newMediaItem.localId) {
- newMediaItem
- } else {
- mediaItem
- }
+ newMediaItem.let {
+ _media.update { mediaList ->
+ 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()
diff --git a/app/src/main/java/app/pachli/components/compose/MediaUploader.kt b/app/src/main/java/app/pachli/components/compose/MediaUploader.kt
index 6a53d33c0..c40ec844c 100644
--- a/app/src/main/java/app/pachli/components/compose/MediaUploader.kt
+++ b/app/src/main/java/app/pachli/components/compose/MediaUploader.kt
@@ -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,
+ val flow: Flow>,
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 {
return uploads[localId]?.flow
- ?.filterIsInstance()
+ ?.filterIsInstance>()
?.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 {
+ fun uploadMedia(media: QueuedMedia, instanceInfo: InstanceInfo): Flow> {
val uploadScope = CoroutineScope(Dispatchers.IO)
- val uploadFlow = flow {
+ val uploadFlow: Flow> = 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 {
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,62 +326,62 @@ 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) {
- return when (mimeType.substring(0, mimeType.indexOf('/'))) {
- "video" -> {
- if (mediaSize > instanceInfo.videoSizeLimit) {
- throw FileSizeException(instanceInfo.videoSizeLimit)
- }
- PreparedMedia(QueuedMedia.Type.VIDEO, uri, mediaSize)
- }
- "image" -> {
- PreparedMedia(QueuedMedia.Type.IMAGE, uri, mediaSize)
- }
- "audio" -> {
- if (mediaSize > instanceInfo.videoSizeLimit) {
- throw FileSizeException(instanceInfo.videoSizeLimit)
- }
- PreparedMedia(QueuedMedia.Type.AUDIO, uri, mediaSize)
- }
- else -> {
- throw MediaTypeException()
+ 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) {
+ Err(PrepareMediaError.FileIsTooLargeError(mediaSize, instanceInfo.videoSizeLimit))
+ } else {
+ Ok(PreparedMedia(QueuedMedia.Type.VIDEO, uri, mediaSize))
}
}
- } else {
- Timber.w("Could not determine mime type of upload")
- throw MediaTypeException()
+ "image" -> {
+ Ok(PreparedMedia(QueuedMedia.Type.IMAGE, uri, mediaSize))
+ }
+ "audio" -> {
+ if (mediaSize > instanceInfo.videoSizeLimit) {
+ Err(PrepareMediaError.FileIsTooLargeError(mediaSize, instanceInfo.videoSizeLimit))
+ } else {
+ Ok(PreparedMedia(QueuedMedia.Type.AUDIO, uri, mediaSize))
+ }
+ }
+ else -> {
+ Err(PrepareMediaError.UnsupportedMimeTypeError(mimeType))
+ }
}
}
private val contentResolver = context.contentResolver
- private suspend fun upload(media: QueuedMedia): Flow {
+ private suspend fun upload(media: QueuedMedia): Flow> {
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)
- )
- ) {
- val retriever = MediaMetadataRetriever()
- retriever.setDataSource(context, media.uri)
- mimeType = retriever.extractMetadata(METADATA_KEY_MIMETYPE)
+ 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)
@@ -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()
}
diff --git a/app/src/main/java/app/pachli/service/SendStatusService.kt b/app/src/main/java/app/pachli/service/SendStatusService.kt
index fb9b92cbf..9e3157b4a 100644
--- a/app/src/main/java/app/pachli/service/SendStatusService.kt
+++ b/app/src/main/java/app/pachli/service/SendStatusService.kt
@@ -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")
- failSending(statusId)
- stopSelfWhenDone()
- return@launch
- }
- }
+ 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
}
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index 0fda1b179..93c4adf1d 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -486,7 +486,6 @@
قام %s بتعديل منشوره
تحميل خيط المحادثة
يجب أن تضع وصفًا للوسائط.
- لا يمكن أن يتجاوز حجم ملفات الفيديو والصوت %s ميغا بايت.
لا يمكن تحرير الصورة.
التعديلات
هناك شكوى جديدة
diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml
index b14cc3494..9ddf1b22d 100644
--- a/app/src/main/res/values-be/strings.xml
+++ b/app/src/main/res/values-be/strings.xml
@@ -3,7 +3,6 @@
Не можа быць пустым.
Браўзер не знойдзены.
Допіс занадта доўгі!
- Памер відэа- ды аўдыяфайлаў не можа перавышаць %s Мб.
Немагчыма запампаваць файл гэтага тыпу.
Немагчыма адкрыць гэты файл.
Патрабуецца дазвол на захаванне медыя.
diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml
index b1cbecb00..c7698ebf3 100644
--- a/app/src/main/res/values-bg/strings.xml
+++ b/app/src/main/res/values-bg/strings.xml
@@ -423,6 +423,5 @@
Изтегляне на визуализации за мултимедии
Показване на отговори
Показване на споделяния
- Видео и аудио файловете не може да превишават %s МБ в размер.
Тази снимка не може да абъде редактирана.
diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml
index 3fa1ec8c1..7a18fcf7c 100644
--- a/app/src/main/res/values-ca/strings.xml
+++ b/app/src/main/res/values-ca/strings.xml
@@ -425,7 +425,6 @@
Limita les notificacions de la cronologia
%s (%s)
Vols suprimir aquesta conversa\?
- Els fitxers de vídeo i àudio no poden superar la mida de %s MB.
La imatge no s\'ha pogut editar.
Descartar
El port hauria d\'estar entre %d i %d
diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml
index 6b0c1db63..3428a34ec 100644
--- a/app/src/main/res/values-cs/strings.xml
+++ b/app/src/main/res/values-cs/strings.xml
@@ -443,7 +443,6 @@
Přestat odebírat
Vytvořit příspěvek
Koncept byl smazán
- Video a audio soubory nesmí překročit velikost %s MB.
Připnutí se nezdařilo
Zrušení připnutí se nezdařilo
Vždy
diff --git a/app/src/main/res/values-cy/strings.xml b/app/src/main/res/values-cy/strings.xml
index 0cc54aad6..1fd5a76c2 100644
--- a/app/src/main/res/values-cy/strings.xml
+++ b/app/src/main/res/values-cy/strings.xml
@@ -263,7 +263,6 @@
Does gennych ddim negeseuon amserlenwyd.
%s (%s)
Ydych chi\'n siŵr eich bod chi am glirio\'ch holl hysbysiadau\'n barhaol\?
- Ni all ffeiliau fideo a sain fod yn fwy na %s MB.
Gwall wrth ddilyn #%s
Gwall wrth ddad-ddilyn #%s
Dad-dewi %s
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 80bf0d1dd..0def38259 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -473,7 +473,6 @@
Details
Das Bild konnte nicht bearbeitet werden.
Entwurf wird gespeichert …
- Video- und Audiodateien dürfen nicht größer als %s MB sein.
Fehler beim Folgen von #%s
Fehler beim Entfolgen von #%s
Diesen geplanten Beitrag löschen\?
diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml
index dbdea4461..9925ea64a 100644
--- a/app/src/main/res/values-en-rGB/strings.xml
+++ b/app/src/main/res/values-en-rGB/strings.xml
@@ -46,7 +46,6 @@
Permission to store media is required.
With replies
Pinned
- Video and audio files cannot exceed %s MB in size.
Failed to load the status source from the server.
Trending links
Links
diff --git a/app/src/main/res/values-eo/strings.xml b/app/src/main/res/values-eo/strings.xml
index 12b456559..a43cf0140 100644
--- a/app/src/main/res/values-eo/strings.xml
+++ b/app/src/main/res/values-eo/strings.xml
@@ -454,7 +454,6 @@
Sciigoj pri novaj uzantoj
1+
Kvankam via konto ne estas ŝlosita, la teamo de %1$s pensas, ke vi eble volus permane validigi la sekvopetojn de tiuj ĉi kontoj.
- Filmetoj kaj sondosieroj ne povas esti pli grandaj ol %s MB.
La bildo ne povis esti redaktita.
Okazis eraro dum la sekvado de #%s
Okazos eraro dum la malsekvado de #%s
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index f51548645..53a853883 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -452,7 +452,6 @@
Eliminar conversación
Pedir confirmación antes de marcar como favorito
una publicación con la que interactué se editó
- Los archivos de video y audio no pueden pesar más de %s MB.
Esta imagen no puede ser editada.
Siempre
Nunca
@@ -664,4 +663,4 @@
Se necesita el título
Cuentas sugeridas
%1$s %2$s
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml
index d36d0127b..162bc87c4 100644
--- a/app/src/main/res/values-eu/strings.xml
+++ b/app/src/main/res/values-eu/strings.xml
@@ -440,7 +440,6 @@
Mezuetan estatistika kuantitatiboak ezkutatu
Laster-marka kendu
Ezin izan da irudia editatu.
- Bideo eta audio fitxategiek ezin dute %s MBeko tamaina baino handiagoa izan.
Akatsa #%s mututzerakoan
Errorea #%s desmututzerakoan
Instantzia honek traolak jarraitzeko funtzioarekin bateragarritasuna ez dauka.
diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml
index 74a761cf3..a89566d7e 100644
--- a/app/src/main/res/values-fa/strings.xml
+++ b/app/src/main/res/values-fa/strings.xml
@@ -471,7 +471,6 @@
%s (%s)
شکست در سنجاق کردن
شکست در برداشتن سنجاق
- پروندههای صوتی و ویدیویی نمیتوانند بیش از %sمب باشند.
تصویر نتوانست ویرایش شود.
زبان فرسته
همیشه
diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml
index 186444908..d514a7615 100644
--- a/app/src/main/res/values-fi/strings.xml
+++ b/app/src/main/res/values-fi/strings.xml
@@ -271,7 +271,6 @@
%ds
Käyttäjän mykistys poistettu
Jatka muokkausta
- Video- ja äänitiedostot eivät saa ylittää %s Mt.
Tilan lähteen lataaminen palvelimelta epäonnistui.
%dpv
Nousussa olevat linkit
@@ -645,4 +644,4 @@
Otsikko vaaditaan
Tilisuositukset
%1$s%2$s
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 6eb561cb7..900baa9a1 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -500,7 +500,6 @@
<non spécifié>
Modifications
Langue de publication par défaut
- Les fichiers vidéo et audio ne peuvent pas dépasser %s Mo.
Le port doit être entre %d et %d
Définir le point focal
Description
diff --git a/app/src/main/res/values-gd/strings.xml b/app/src/main/res/values-gd/strings.xml
index ceaaf4a3a..ae20bfdca 100644
--- a/app/src/main/res/values-gd/strings.xml
+++ b/app/src/main/res/values-gd/strings.xml
@@ -479,7 +479,6 @@
1+
Deasaich an dealbh
Mearachd a’ leantainn air #%s
- Chan fhaod na faidhlichean video ’ fuaime a bhith nas motha na %s MB.
Mearachd a’ sgur de leantainn air #%s
Cha b’ urrainn dhuinn an dealbh a dheasachadh.
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.
diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml
index ac5ed9017..fbb39c70d 100644
--- a/app/src/main/res/values-gl/strings.xml
+++ b/app/src/main/res/values-gl/strings.xml
@@ -462,7 +462,6 @@
Toca ou arrastra o círculo para elexir onde centrar a imaxe e sexa máis visible nas miniaturas.
(Sen cambio)
%s (%s)
- Os ficheiros de vídeo e audio non poden superar os %s MB.
Idioma de publicación
Establece foco
Erro ao seguir #%s
diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml
index acc0279d2..b5a40f715 100644
--- a/app/src/main/res/values-hi/strings.xml
+++ b/app/src/main/res/values-hi/strings.xml
@@ -343,7 +343,6 @@
- %d सेकेंड शेष
पोस्ट बहुत लंबा है!
- वीडियो और ऑडियो फ़ाइलों का आकार %s एमबी से अधिक नहीं हो सकता है।
फोटो संपादित नहीं किया जा सका।
फोटो और वीडियो दोनों को एक ही पोस्ट से अटैच नहीं किया जा सकता है।
diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml
index a44d73003..627f258f8 100644
--- a/app/src/main/res/values-hu/strings.xml
+++ b/app/src/main/res/values-hu/strings.xml
@@ -470,7 +470,6 @@
Töröljük ezt az időzített bejegyzést\?
Koppintsd vagy húzd a kört, hogy kijelöld azt a fókuszpontot, mely mindig látható lesz az előnézetekben.
%s (%s)
- Video és audio állományok mérete nem lehet %s MB-nál nagyobb.
Bejegyzés nyelve
Mindig
Ha több fiók is be van jelentkezve
diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml
index 147c0fb30..4a923710e 100644
--- a/app/src/main/res/values-in/strings.xml
+++ b/app/src/main/res/values-in/strings.xml
@@ -6,7 +6,6 @@
Notifikasi
Lokal
Pesan langsung
- Berkas video dan audio tidak boleh melebihi %s MB.
Gambar tidak dapat diubah.
Jenis berkas tersebut tidak dapat diunggah.
Berkas itu tidak dapat dibuka.
diff --git a/app/src/main/res/values-is/strings.xml b/app/src/main/res/values-is/strings.xml
index 8847110fa..24b70abbc 100644
--- a/app/src/main/res/values-is/strings.xml
+++ b/app/src/main/res/values-is/strings.xml
@@ -459,7 +459,6 @@
Hunsa
Nánar
%s (%s)
- Myndskeiða- og hljóðskrár geta ekki verið stærri en %s MB.
Tungumál færslu
(engin breyting)
Villa við að fylgjast með #%s
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index e87fb18c3..b2c64249a 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -496,7 +496,6 @@
%s (%s)
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.
Registrato da %1$s
- File video e audio non possono eccedere %s MB in dimensione.
L\'immagine non può essere modificata.
Riaccedi per le notifiche
Errore provando a seguire #%s
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index f3ffef627..8af8e0e83 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -424,7 +424,6 @@
画像 %s に対する操作
(変更なし)
%s (%s)
- ビデオと音声ファイルのサイズは %s MB を超えることはできません。
画像が編集できませんでした。
画像の編集
このインスタンスはハッシュタグのフォローに対応していません。
diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml
index 177bbfe5e..eb7be8687 100644
--- a/app/src/main/res/values-lv/strings.xml
+++ b/app/src/main/res/values-lv/strings.xml
@@ -204,7 +204,6 @@
Jauns ziņojums par %s
Apklusināt sarunu
Ieplānotie ieraksti
- Video un audio faili nevar pārsniegt %s MB izmēru.
Šī instance neatbalsta sekošanu tēmturiem.
Sūta ierakstus
Emocijzīmju stils
diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml
index af9698e34..23c8bbf05 100644
--- a/app/src/main/res/values-nb-rNO/strings.xml
+++ b/app/src/main/res/values-nb-rNO/strings.xml
@@ -461,7 +461,6 @@
1+
Rediger bilde
Bildet kunne ikke redigeres.
- Video- og lydfiler kan ikke være større enn %s MB.
Det oppsto en feil under følging av #%s
Kunne ikke slutte å følge #%s
%s (%s)
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index f386d6836..317b9b0cd 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -440,7 +440,6 @@
\nPushmeldingen worden hierdoor niet beïnvloed, maar je kunt de voorkeuren voor meldingen handmatig wijzigen.
%s heeft zich geregistreerd
Alle accounts opnieuw inloggen i.v.m. ondersteuning pushmeldingen.
- Afbeeldingen en video\'s kunnen niet groter zijn dan %s MB.
Deze afbeelding kon niet worden bewerkt.
Opnieuw inloggen i.v.m. pushmeldingen
%s heeft diens bericht bewerkt
diff --git a/app/src/main/res/values-oc/strings.xml b/app/src/main/res/values-oc/strings.xml
index 2bda5f2fd..c8daf70df 100644
--- a/app/src/main/res/values-oc/strings.xml
+++ b/app/src/main/res/values-oc/strings.xml
@@ -457,7 +457,6 @@
90 jorns
180 jorns
365 jorns
- Los fichièrs video e àudio pòdon pas despassar %s.
Tocatz o lisatz lo cercle per causir lo punt focal que deu aparéisser sus las miniaturas.
Mostrar lo nom d’utilizaire dins la barra d’aisinas
Quand mai d’un compte es connectat
diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index 58caf6d57..dce484a39 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -491,7 +491,6 @@
Zaloguj się ponownie aby włączyć powiadomienia push
Odrzuć
Detale
- Pliki wideo i audio nie mogą przekraczać rozmiarem %s MB.
%s (%s)
Język wpisu
(bez zmian)
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index a3e1a89f7..8d4fd24f1 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -462,7 +462,6 @@
<inválido>
Sempre
A imagem não pôde ser editada.
- Arquivos de vídeo e áudio não podem exceder %s MB de tamanho.
A mídia deve ter uma descrição.
Nunca
ALT
diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml
index 93b515af0..fd932e58e 100644
--- a/app/src/main/res/values-pt-rPT/strings.xml
+++ b/app/src/main/res/values-pt-rPT/strings.xml
@@ -482,7 +482,6 @@
Nunca
Idioma da publicação
Mostrar o nome de utilizador nas barras de ferramentas
- Os ficheiros de áudio e vídeo não podem exceder os %s MB.
Define o ponto de focagem
Erro ao seguir #%s
Erro ao deixar de seguir #%s
diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml
index 6d138a632..c6fb29d31 100644
--- a/app/src/main/res/values-sv/strings.xml
+++ b/app/src/main/res/values-sv/strings.xml
@@ -466,7 +466,6 @@
Gick med i %1$s
Sparar utkast…
Logga in igen på alla konton för att tillåta pushnotiser.
- Video- och ljudfiler kan inte överskrida %s MB i storlek.
Bilden kunde inte redigeras.
365 dagar
180 dagar
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index ae45b27cc..529ee84dc 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -402,7 +402,6 @@
Üst araç çubuğunun başlığını gizle
Konuşmayı sil
Duyurular
- Video ve ses dosyaları %s MB boyutunu aşamaz.
Görsel düzenlemedi.
#%s\'i izleyen hata
Sessize alma hatası #%s
diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml
index 238a01e5c..fe02043be 100644
--- a/app/src/main/res/values-uk/strings.xml
+++ b/app/src/main/res/values-uk/strings.xml
@@ -482,7 +482,6 @@
1+
Неможливо редагувати зображення.
Помилка підписки на #%s
- Розмір відео та аудіофайлів не може перевищувати %s Мб.
Помилка скасування підписки на #%s
%s (%s)
Мова допису
diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml
index c5b86f761..4c4715502 100644
--- a/app/src/main/res/values-vi/strings.xml
+++ b/app/src/main/res/values-vi/strings.xml
@@ -449,7 +449,6 @@
1+
Sửa ảnh
Hình ảnh này không thể sửa.
- Video và audio không thể quá %s MB.
Lỗi khi theo dõi #%s
Lỗi khi bỏ theo dõi #%s
%s (%s)
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 686eb92ab..7e0d947bb 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -463,7 +463,6 @@
1+
编辑图片
无法编辑图片。
- 音视频文件大小不能超出 %s MB。
关注 #%s 出错
取关 #%s 出错
%s (%s)
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index 900d91ea3..705232dac 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -467,7 +467,6 @@
重新登入所有帳號以啟用推播功能。
設置關注點
重新登入以啟用推播功能
- 影片和音訊檔案大小不能超過 %s MB。
1+
添加反應
有人進行了註冊
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 5d21c8499..5ae3d439d 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -18,13 +18,12 @@
This cannot be empty.
Couldn\'t find a web browser to use.
The post is too long!
- Video and audio files cannot exceed %s MB in size.
The image could not be edited.
That type of file cannot be uploaded.
That file could not be opened.
Permission to read media is required.
Permission to store media is required.
- Images and videos cannot both be attached to the same post.
+ images and videos cannot both be attached to the same post.
The upload failed.
The upload failed: %s
Error sending post.
@@ -697,4 +696,17 @@
The post\'s language is set to %1$s but you might have written it in %2$s.
Change language to "%1$s" and post
Post as-is (%1$s)
+
+ media upload with ID %1$s not found
+ %1$s
+
+ content resolver URI was missing a path: %1$s
+ content resolver URI has unsupported scheme: %1$s
+ %1$s
+ file size is %1$s, maximum allowed is %2$s
+ file size could not be determined
+ server does not support file type: %1$s
+ file\'s type is not known
+
+ Could not attach file to post: %1$s
diff --git a/app/src/test/java/app/pachli/usecase/TimelineCasesTest.kt b/app/src/test/java/app/pachli/usecase/TimelineCasesTest.kt
index a9ecbec3e..6e24d46c8 100644
--- a/app/src/test/java/app/pachli/usecase/TimelineCasesTest.kt
+++ b/app/src/test/java/app/pachli/usecase/TimelineCasesTest.kt
@@ -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(
- 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(),
),
),
)
diff --git a/core/common/src/main/kotlin/app/pachli/core/common/PachliError.kt b/core/common/src/main/kotlin/app/pachli/core/common/PachliError.kt
index 0589127af..7421a9518 100644
--- a/core/common/src/main/kotlin/app/pachli/core/common/PachliError.kt
+++ b/core/common/src/main/kotlin/app/pachli/core/common/PachliError.kt
@@ -75,7 +75,7 @@ interface PachliError {
val resourceId: Int
/** Arguments to be interpolated in to the string from [resourceId]. */
- val formatArgs: Array?
+ val formatArgs: Array?
/**
* The cause of this error. If present the string representation of `cause`
diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts
index 8d516b7cc..3b7cf5cfa 100644
--- a/core/network/build.gradle.kts
+++ b/core/network/build.gradle.kts
@@ -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)
}
diff --git a/core/network/src/main/kotlin/app/pachli/core/network/extensions/ThrowableExtensions.kt b/core/network/src/main/kotlin/app/pachli/core/network/extensions/ThrowableExtensions.kt
index cd75824a6..6a944a1fe 100644
--- a/core/network/src/main/kotlin/app/pachli/core/network/extensions/ThrowableExtensions.kt
+++ b/core/network/src/main/kotlin/app/pachli/core/network/extensions/ThrowableExtensions.kt
@@ -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"
}
- return null
}
diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/MediaUploadApi.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/MediaUploadApi.kt
index 6acba676a..9c0e87e95 100644
--- a/core/network/src/main/kotlin/app/pachli/core/network/model/MediaUploadApi.kt
+++ b/core/network/src/main/kotlin/app/pachli/core/network/model/MediaUploadApi.kt
@@ -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
+ ): ApiResult
}
diff --git a/core/network/src/main/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResult.kt b/core/network/src/main/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResult.kt
index a8b0f0b78..ebc227423 100644
--- a/core/network/src/main/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResult.kt
+++ b/core/network/src/main/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResult.kt
@@ -39,12 +39,14 @@ typealias ApiResult = Result, 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(
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? = (
+ 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
+ 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 Result.Companion.from(response: Response, successType: Type): ApiResult {
+ 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 Result.Companion.from(response: Response, 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)))
diff --git a/core/network/src/main/res/values/strings.xml b/core/network/src/main/res/values/strings.xml
index 80cecd6bd..49e9e8640 100644
--- a/core/network/src/main/res/values/strings.xml
+++ b/core/network/src/main/res/values/strings.xml
@@ -25,4 +25,6 @@
Your server does not support this feature: %1$s
Your server returned an invalid response: %1$s
A network error occurred: %s
+ your server is mis-configured, the response has no content-type: %1$s
+ your server is mis-configured and returned \'%2$s\' with the wrong content-type, \'%1$s\'
diff --git a/core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResultCallTest.kt b/core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResultCallTest.kt
index 699853d20..c4bd84c70 100644
--- a/core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResultCallTest.kt
+++ b/core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResultCallTest.kt
@@ -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()
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> {
@@ -81,18 +86,162 @@ class ApiResultCallTest {
}
@Test
- fun `should parse call with 404 error code as ApiResult-failure`() {
- val errorResponse = Response.error(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> {
override fun onResponse(call: Call>, response: Response>) {
- 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>, 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> {
+ override fun onResponse(call: Call>, response: Response>) {
+ 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>, 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(
+ "".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> {
+ override fun onResponse(call: Call>, response: Response>) {
+ 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>, 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(
+ "{\"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> {
+ override fun onResponse(call: Call>, response: Response>) {
+ 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>, 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(
+ "{\"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> {
+ override fun onResponse(call: Call>, response: Response>) {
+ 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>, t: Throwable) {
diff --git a/core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/ApiTest.kt b/core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/ApiTest.kt
index e8348897c..1e905c242 100644
--- a/core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/ApiTest.kt
+++ b/core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/ApiTest.kt
@@ -61,6 +61,7 @@ class ApiTest {
private fun mockResponse(responseCode: Int, body: String = "") = MockResponse()
.setResponseCode(responseCode)
+ .setHeader("content-type", "application/json")
.setBody(body)
@Test
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 8a543ed57..9e07529dd 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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" }