From a9cbbf66bdd8a6430e9d689984170814ece9b2e8 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 28 Jun 2024 10:40:55 -0400 Subject: [PATCH] Adds uploading error messages for common HTTP status codes. --- .../amethyst/service/HttpStatusMessages.kt | 63 +++++++++++++++++++ .../amethyst/service/Nip96Uploader.kt | 49 ++++++++++----- .../amethyst/ui/actions/EditPostViewModel.kt | 41 ++++++++---- .../amethyst/ui/actions/NewMediaModel.kt | 1 + .../amethyst/ui/actions/NewPostViewModel.kt | 1 + .../ui/actions/NewUserMetadataViewModel.kt | 1 + amethyst/src/main/res/values/strings.xml | 33 ++++++++++ 7 files changed, 161 insertions(+), 28 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/HttpStatusMessages.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/HttpStatusMessages.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/HttpStatusMessages.kt new file mode 100644 index 000000000..009038d54 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/HttpStatusMessages.kt @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service + +import com.vitorpamplona.amethyst.R + +class HttpStatusMessages { + companion object { + fun resourceIdFor(statusCode: Int = 0): Int? = + when (statusCode) { + 400 -> R.string.http_status_400 + 401 -> R.string.http_status_401 + 402 -> R.string.http_status_402 + 403 -> R.string.http_status_403 + 404 -> R.string.http_status_404 + 405 -> R.string.http_status_405 + 406 -> R.string.http_status_406 + 407 -> R.string.http_status_407 + 408 -> R.string.http_status_408 + 409 -> R.string.http_status_409 + 410 -> R.string.http_status_410 + 411 -> R.string.http_status_411 + 412 -> R.string.http_status_412 + 413 -> R.string.http_status_413 + 414 -> R.string.http_status_414 + 415 -> R.string.http_status_415 + 416 -> R.string.http_status_416 + 417 -> R.string.http_status_417 + 426 -> R.string.http_status_426 + + 500 -> R.string.http_status_500 + 501 -> R.string.http_status_501 + 502 -> R.string.http_status_502 + 503 -> R.string.http_status_503 + 504 -> R.string.http_status_504 + 505 -> R.string.http_status_505 + 506 -> R.string.http_status_506 + 507 -> R.string.http_status_507 + 508 -> R.string.http_status_508 + 511 -> R.string.http_status_511 + + else -> null + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt index 7d069d0f1..a2b915363 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.service import android.content.ContentResolver +import android.content.Context import android.net.Uri import android.provider.OpenableColumns import android.webkit.MimeTypeMap @@ -29,7 +30,9 @@ import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.amethyst.BuildConfig +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.ammolite.service.HttpClientManager import kotlinx.coroutines.delay import kotlinx.coroutines.suspendCancellableCoroutine @@ -48,7 +51,9 @@ val charPool: List = ('a'..'z') + ('A'..'Z') + ('0'..'9') fun randomChars() = List(16) { charPool.random() }.joinToString("") -class Nip96Uploader(val account: Account?) { +class Nip96Uploader( + val account: Account?, +) { suspend fun uploadImage( uri: Uri, contentType: String?, @@ -58,6 +63,7 @@ class Nip96Uploader(val account: Account?) { server: Nip96MediaServers.ServerName, contentResolver: ContentResolver, onProgress: (percentage: Float) -> Unit, + context: Context, ): PartialEvent { val serverInfo = Nip96Retriever() @@ -74,6 +80,7 @@ class Nip96Uploader(val account: Account?) { serverInfo, contentResolver, onProgress, + context, ) } @@ -86,6 +93,7 @@ class Nip96Uploader(val account: Account?) { server: Nip96Retriever.ServerInfo, contentResolver: ContentResolver, onProgress: (percentage: Float) -> Unit, + context: Context, ): PartialEvent { checkNotInMainThread() @@ -111,6 +119,7 @@ class Nip96Uploader(val account: Account?) { sensitiveContent, server, onProgress, + context, ) } @@ -122,6 +131,7 @@ class Nip96Uploader(val account: Account?) { sensitiveContent: String?, server: Nip96Retriever.ServerInfo, onProgress: (percentage: Float) -> Unit, + context: Context, ): PartialEvent { checkNotInMainThread() @@ -130,11 +140,11 @@ class Nip96Uploader(val account: Account?) { contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" val client = HttpClientManager.getHttpClient() - val requestBody: RequestBody val requestBuilder = Request.Builder() - requestBody = - MultipartBody.Builder() + val requestBody: RequestBody = + MultipartBody + .Builder() .setType(MultipartBody.FORM) .addFormDataPart("expiration", "") .addFormDataPart("size", length.toString()) @@ -142,8 +152,7 @@ class Nip96Uploader(val account: Account?) { alt?.let { body.addFormDataPart("alt", it) } sensitiveContent?.let { body.addFormDataPart("content-warning", it) } contentType?.let { body.addFormDataPart("content_type", it) } - } - .addFormDataPart( + }.addFormDataPart( "file", "$fileName.$extension", object : RequestBody() { @@ -155,8 +164,7 @@ class Nip96Uploader(val account: Account?) { inputStream.source().use(sink::writeAll) } }, - ) - .build() + ).build() nip98Header(server.apiUrl)?.let { requestBuilder.addHeader("Authorization", it) } @@ -178,11 +186,16 @@ class Nip96Uploader(val account: Account?) { } else if (result.status == "success" && result.nip94Event != null) { return result.nip94Event } else { - throw RuntimeException("Failed to upload with message: ${result.message}") + throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, result.message)) } } } else { - throw RuntimeException("Error Uploading image: ${response.code}") + val explanation = HttpStatusMessages.resourceIdFor(response.code) + if (explanation != null) { + throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, stringRes(context, explanation))) + } else { + throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, response.code)) + } } } } @@ -191,6 +204,7 @@ class Nip96Uploader(val account: Account?) { hash: String, contentType: String?, server: Nip96Retriever.ServerInfo, + context: Context, ): Boolean { val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" @@ -216,7 +230,12 @@ class Nip96Uploader(val account: Account?) { return result.status == "success" } } else { - throw RuntimeException("Error Uploading image: ${response.code}") + val explanation = HttpStatusMessages.resourceIdFor(response.code) + if (explanation != null) { + throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, stringRes(context, explanation))) + } else { + throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, response.code)) + } } } } @@ -233,7 +252,8 @@ class Nip96Uploader(val account: Account?) { onProgress((currentResult.percentage ?: 100) / 100f) val request: Request = - Request.Builder() + Request + .Builder() .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") .url(result.processingUrl) .build() @@ -257,13 +277,12 @@ class Nip96Uploader(val account: Account?) { } } - suspend fun nip98Header(url: String): String? { - return withTimeoutOrNull(5000) { + suspend fun nip98Header(url: String): String? = + withTimeoutOrNull(5000) { suspendCancellableCoroutine { continuation -> nip98Header(url, "POST") { authorizationToken -> continuation.resume(authorizationToken) } } } - } fun nip98Header( url: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt index a951d4a7a..53aa0a0e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt @@ -53,7 +53,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch @Stable -open class EditPostViewModel() : ViewModel() { +open class EditPostViewModel : ViewModel() { var accountViewModel: AccountViewModel? = null var account: Account? = null @@ -185,6 +185,7 @@ open class EditPostViewModel() : ViewModel() { server = server.server, contentResolver = contentResolver, onProgress = {}, + context = context, ) createNIP94Record( @@ -235,13 +236,12 @@ open class EditPostViewModel() : ViewModel() { NostrSearchEventOrUserDataSource.clear() } - open fun findUrlInMessage(): String? { - return message.text.split('\n').firstNotNullOfOrNull { paragraph -> + open fun findUrlInMessage(): String? = + message.text.split('\n').firstNotNullOfOrNull { paragraph -> paragraph.split(' ').firstOrNull { word: String -> RichTextParser.isValidURL(word) || RichTextParser.isUrlWithoutScheme(word) } } - } open fun updateMessage(it: TextFieldValue) { message = it @@ -249,14 +249,18 @@ open class EditPostViewModel() : ViewModel() { if (it.selection.collapsed) { val lastWord = - it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ") + it.text + .substring(0, it.selection.end) + .substringAfterLast("\n") + .substringAfterLast(" ") userSuggestionAnchor = it.selection userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE if (lastWord.startsWith("@") && lastWord.length > 2) { NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@")) viewModelScope.launch(Dispatchers.IO) { userSuggestions = - LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) + LocalCache + .findUsersStartingWith(lastWord.removePrefix("@")) .sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex })) .reversed() } @@ -271,7 +275,10 @@ open class EditPostViewModel() : ViewModel() { userSuggestionAnchor?.let { if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { val lastWord = - message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ") + message.text + .substring(0, it.end) + .substringAfterLast("\n") + .substringAfterLast(" ") val lastWordStart = it.end - lastWord.length val wordToInsert = "@${item.pubkeyNpub()}" @@ -288,12 +295,11 @@ open class EditPostViewModel() : ViewModel() { } } - fun canPost(): Boolean { - return message.text.isNotBlank() && + fun canPost(): Boolean = + message.text.isNotBlank() && !isUploadingImage && !wantsInvoice && contentToAddUrl == null - } suspend fun createNIP94Record( uploadingResult: Nip96Uploader.PartialEvent, @@ -304,11 +310,20 @@ open class EditPostViewModel() : ViewModel() { // Images don't seem to be ready immediately after upload val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1) val remoteMimeType = - uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "m" }?.get(1)?.ifBlank { null } + uploadingResult.tags + ?.firstOrNull { it.size > 1 && it[0] == "m" } + ?.get(1) + ?.ifBlank { null } val originalHash = - uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "ox" }?.get(1)?.ifBlank { null } + uploadingResult.tags + ?.firstOrNull { it.size > 1 && it[0] == "ox" } + ?.get(1) + ?.ifBlank { null } val dim = - uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "dim" }?.get(1)?.ifBlank { null } + uploadingResult.tags + ?.firstOrNull { it.size > 1 && it[0] == "dim" } + ?.get(1) + ?.ifBlank { null } val magnet = uploadingResult.tags ?.firstOrNull { it.size > 1 && it[0] == "magnet" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt index c9d3ccf79..d3cdcfa45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt @@ -139,6 +139,7 @@ open class NewMediaModel : ViewModel() { onProgress = { percent: Float -> uploadingPercentage.value = 0.2f + (0.2f * percent) }, + context = context, ) createNIP94Record( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 4dd0e23a5..55dc95a68 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -801,6 +801,7 @@ open class NewPostViewModel : ViewModel() { server = server.server, contentResolver = contentResolver, onProgress = {}, + context = context, ) createNIP94Record( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt index 02dc193f1..93fac1474 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -184,6 +184,7 @@ class NewUserMetadataViewModel : ViewModel() { server = account.defaultFileServer, contentResolver = contentResolver, onProgress = {}, + context = context, ) val url = result.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 6d8f93237..0a6fd89e1 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -760,6 +760,8 @@ Server did not provide a URL after uploading Could not download uploaded media from the server Could not prepare local file to upload: %1$s + Failed to upload: %1$s + Failed to delete: %1$s Edit draft @@ -895,4 +897,35 @@ Requesting Job from DVM Payment request sent, waiting for confirmation from your wallet Waiting for DVM to confirm payment or send results + + Bad Request - The server can’t or won’t process the request. + Unauthorized - The user doesn’t have valid authentication credentials + Payment Required - The server requires payment to complete the request + Forbidden - The user doesn’t have access rights to make the request + Not Found - The server can’t find the requested address + Method Not Allowed - The server supports the request method, but not the target resource + Not Acceptable - The server doesn’t find any content that satisfies the request. + Proxy Authentication Required - The user doesn’t have valid authentication credentials + Request Timeout - The server timed out waiting for somebody else + Conflict - The server can’t fulfill the request because there’s a conflict with the resource + Gone - The content requested has been permanently deleted from the server and will not be reinstated + Length Required - The server rejects the request because it requires a defined + Precondition Failed - The request\'s preconditions in the header fields that the server fails to meet + Payload Too Large - The request is larger than the server’s defined limits, and the server refuses to process it + URI Too Long - The url requested by the client is too long for the server to process. + Unsupported Media Type - The request uses a media format the server does not support + Range Not Satisfiable - The server can’t fulfill the value indicated in the request’s Range header field. + Expectation Failed - The server can’t meet the requirements indicated by the Expect request header field + Upgrade Required - The server refuses to process the request using the current protocol unless the client upgrades to a different protocol. + + Internal Server Error - The server has encountered an unexpected error and cannot complete the request + Not Implemented - The server can’t fulfill the request or doesn’t recognize the request method + Bad Gateway - The server acts as a gateway and got an invalid response from the host + Service Unavailable - This often occurs when a server is overloaded or down for maintenance + Gateway Timeout - The server was acting as a gateway or proxy and timed out, waiting for a response + HTTP Version Not Supported - The server doesn’t support the HTTP version in the request + Variant Also Negotiates - The server has an internal configuration error + Insufficient Storage - The server doesn’t have enough storage to process the request successfully + Loop Detected - The server detects an infinite loop while processing the request + Network Authentication Required - The client must be authenticated to access the network