diff --git a/app/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt b/app/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt new file mode 100644 index 000000000..c8803f82d --- /dev/null +++ b/app/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt @@ -0,0 +1,91 @@ +package com.vitorpamplona.amethyst + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.amethyst.ui.actions.ImageUploader +import com.vitorpamplona.amethyst.ui.actions.ImgurServer +import com.vitorpamplona.amethyst.ui.actions.NostrBuildServer +import com.vitorpamplona.amethyst.ui.actions.NostrImgServer +import junit.framework.TestCase.assertNotNull +import junit.framework.TestCase.fail +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import java.util.Base64 + +@RunWith(AndroidJUnit4::class) +class ImageUploadTesting { + + val image = "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" + + @Test() + fun testImgurUpload() = runBlocking { + val inputStream = Base64.getDecoder().decode(image).inputStream() + + println("Uploading") + + ImageUploader.uploadImage( + inputStream, + "image/gif", + ImgurServer(), + onSuccess = { url, contentType -> + println("Uploaded to $url") + assertNotNull(url) + }, + onError = { + println("Failed to Upload") + fail("${it.message}") + } + ) + + delay(1000) + } + + @Test() + @Ignore + fun testNostrBuildUpload() = runBlocking { + val inputStream = Base64.getDecoder().decode(image).inputStream() + + println("Uploading") + + ImageUploader.uploadImage( + inputStream, + "image/gif", + NostrBuildServer(), + onSuccess = { url, contentType -> + println("Uploaded to $url") + assertNotNull(url) + }, + onError = { + println("Failed to Upload") + fail("${it.message}") + } + ) + + delay(1000) + } + + @Test() + fun testNostrImgUpload() = runBlocking { + val inputStream = Base64.getDecoder().decode(image).inputStream() + + println("Uploading") + + ImageUploader.uploadImage( + inputStream, + "image/gif", + NostrImgServer(), + onSuccess = { url, contentType -> + println("Uploaded to $url") + assertNotNull(url) + }, + onError = { + println("Failed to Upload") + fail("${it.message}") + } + ) + + delay(1000) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt index 61c45d61a..85746e500 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -116,7 +116,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { LnZapEvent.kind, LnZapRequestEvent.kind, ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind, - PrivateDmEvent.kind, FileHeaderEvent.kind + PrivateDmEvent.kind, + FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind ), ids = interestedEvents.toList() ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt index ffc78709c..6238ddf9a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.actions import android.content.ContentResolver import android.net.Uri +import android.webkit.MimeTypeMap import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.amethyst.BuildConfig import okhttp3.* @@ -9,9 +10,15 @@ import okhttp3.MediaType.Companion.toMediaType import okio.BufferedSink import okio.source import java.io.IOException +import java.io.InputStream import java.util.* +val charPool: List = ('a'..'z') + ('A'..'Z') + ('0'..'9') + +fun randomChars() = List(16) { charPool.random() }.joinToString("") + object ImageUploader { + fun uploadImage( uri: Uri, server: ServersAvailable, @@ -20,9 +27,33 @@ object ImageUploader { onError: (Throwable) -> Unit ) { val contentType = contentResolver.getType(uri) - val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image" + val imageInputStream = contentResolver.openInputStream(uri) - val url = if (category == "image") "https://api.imgur.com/3/image" else "https://api.imgur.com/3/upload" + checkNotNull(imageInputStream) { + "Can't open the image input stream" + } + + val myServer = if (server == ServersAvailable.IMGUR) { + ImgurServer() + } else if (server == ServersAvailable.NOSTR_IMG) { + NostrImgServer() + } else { + ImgurServer() + } + + uploadImage(imageInputStream, contentType, myServer, onSuccess, onError) + } + + fun uploadImage( + inputStream: InputStream, + contentType: String?, + server: FileServer, + onSuccess: (String, String?) -> Unit, + onError: (Throwable) -> Unit + ) { + val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image" + val fileName = randomChars() + val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" val client = OkHttpClient.Builder().build() @@ -30,37 +61,37 @@ object ImageUploader { .setType(MultipartBody.FORM) .addFormDataPart( category, - "${UUID.randomUUID()}", + "$fileName.$extension", object : RequestBody() { override fun contentType(): MediaType? = contentType?.toMediaType() override fun writeTo(sink: BufferedSink) { - val imageInputStream = contentResolver.openInputStream(uri) - checkNotNull(imageInputStream) { - "Can't open the image input stream" - } - - imageInputStream.source().use(sink::writeAll) + inputStream.source().use(sink::writeAll) } } ) .build() - val request: Request = Request.Builder() - .header("Authorization", "Client-ID e6aea87296f3f96") + val requestBuilder = Request.Builder() + + server.clientID()?.let { + requestBuilder.header("Authorization", it) + } + + requestBuilder .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") - .url(url) + .url(server.postUrl(contentType)) .post(requestBody) - .build() + + val request = requestBuilder.build() client.newCall(request).enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { try { check(response.isSuccessful) response.body.use { body -> - val tree = jacksonObjectMapper().readTree(body.string()) - val url = tree?.get("data")?.get("link")?.asText() + val url = server.parseUrlFromSucess(body.string()) checkNotNull(url) { "There must be an uploaded image URL in the response" } @@ -80,3 +111,49 @@ object ImageUploader { }) } } + +abstract class FileServer { + abstract fun postUrl(contentType: String?): String + abstract fun parseUrlFromSucess(body: String): String? + + open fun clientID(): String? = null +} + +class NostrImgServer : FileServer() { + override fun postUrl(contentType: String?) = "https://nostrimg.com/api/upload" + + override fun parseUrlFromSucess(body: String): String? { + val tree = jacksonObjectMapper().readTree(body) + val url = tree?.get("data")?.get("link")?.asText() + return url + } + + override fun clientID() = null +} + +class ImgurServer : FileServer() { + override fun postUrl(contentType: String?): String { + val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image" + return if (category == "image") "https://api.imgur.com/3/image" else "https://api.imgur.com/3/upload" + } + + override fun parseUrlFromSucess(body: String): String? { + val tree = jacksonObjectMapper().readTree(body) + val url = tree?.get("data")?.get("link")?.asText() + return url + } + + override fun clientID() = "Client-ID e6aea87296f3f96" +} + +class NostrBuildServer : FileServer() { + override fun postUrl(contentType: String?) = "https://nostr.build/api/upload/amethyst.php" + + override fun parseUrlFromSucess(body: String): String? { + val tree = jacksonObjectMapper().readTree(body) + val url = tree?.get("data")?.get("link")?.asText() + return url + } + + override fun clientID() = null +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index e99552cd1..7180b3b17 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -488,8 +488,8 @@ fun SearchButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier } } -enum class ServersAvailable() { - IMGUR +enum class ServersAvailable { + IMGUR, NOSTR_BUILD, NOSTR_IMG } @Composable @@ -507,7 +507,8 @@ fun ImageVideoDescription( val isVideo = mediaType.startsWith("video") val fileServers = listOf( - Pair(ServersAvailable.IMGUR, "imgur.com") + Pair(ServersAvailable.IMGUR, "imgur.com"), + Pair(ServersAvailable.NOSTR_IMG, "nostrimg.com") ) val fileServerOptions = fileServers.map { it.second } @@ -618,7 +619,7 @@ fun ImageVideoDescription( ) { TextSpinner( label = stringResource(id = R.string.file_server), - placeholder = fileServers.filter { it.first == defaultServer }.first().second, + placeholder = fileServers.filter { it.first == defaultServer }.firstOrNull()?.second ?: fileServers[0].second, options = fileServerOptions, onSelect = { selectedServer = fileServers[it].first