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