diff --git a/app/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt b/app/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt index 7b0813c37..4e7d14ae8 100644 --- a/app/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt +++ b/app/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt @@ -2,73 +2,107 @@ package com.vitorpamplona.amethyst import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.ui.actions.FileServer -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.NostrFilesDevServer -import com.vitorpamplona.amethyst.ui.actions.NostrImgServer +import com.vitorpamplona.amethyst.service.FileHeader +import com.vitorpamplona.amethyst.service.Nip96MediaServers +import com.vitorpamplona.amethyst.service.Nip96Retriever +import com.vitorpamplona.amethyst.ui.actions.ImageDownloader +import com.vitorpamplona.amethyst.ui.actions.Nip96Uploader import com.vitorpamplona.quartz.crypto.KeyPair +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.fail import kotlinx.coroutines.runBlocking import org.junit.Assert import org.junit.Test import org.junit.runner.RunWith import java.util.Base64 -import java.util.concurrent.CountDownLatch @RunWith(AndroidJUnit4::class) class ImageUploadTesting { - + val contentType = "image/gif" val image = "R0lGODlhPQBEAPeoAJosM//AwO/AwHVYZ/z595kzAP/s7P+goOXMv8+fhw/v739/f+8PD98fH/8mJl+fn/9ZWb8/PzWlwv///6wWGbImAPgTEMImIN9gUFCEm/gDALULDN8PAD6atYdCTX9gUNKlj8wZAKUsAOzZz+UMAOsJAP/Z2ccMDA8PD/95eX5NWvsJCOVNQPtfX/8zM8+QePLl38MGBr8JCP+zs9myn/8GBqwpAP/GxgwJCPny78lzYLgjAJ8vAP9fX/+MjMUcAN8zM/9wcM8ZGcATEL+QePdZWf/29uc/P9cmJu9MTDImIN+/r7+/vz8/P8VNQGNugV8AAF9fX8swMNgTAFlDOICAgPNSUnNWSMQ5MBAQEJE3QPIGAM9AQMqGcG9vb6MhJsEdGM8vLx8fH98AANIWAMuQeL8fABkTEPPQ0OM5OSYdGFl5jo+Pj/+pqcsTE78wMFNGQLYmID4dGPvd3UBAQJmTkP+8vH9QUK+vr8ZWSHpzcJMmILdwcLOGcHRQUHxwcK9PT9DQ0O/v70w5MLypoG8wKOuwsP/g4P/Q0IcwKEswKMl8aJ9fX2xjdOtGRs/Pz+Dg4GImIP8gIH0sKEAwKKmTiKZ8aB/f39Wsl+LFt8dgUE9PT5x5aHBwcP+AgP+WltdgYMyZfyywz78AAAAAAAD///8AAP9mZv///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAKgALAAAAAA9AEQAAAj/AFEJHEiwoMGDCBMqXMiwocAbBww4nEhxoYkUpzJGrMixogkfGUNqlNixJEIDB0SqHGmyJSojM1bKZOmyop0gM3Oe2liTISKMOoPy7GnwY9CjIYcSRYm0aVKSLmE6nfq05QycVLPuhDrxBlCtYJUqNAq2bNWEBj6ZXRuyxZyDRtqwnXvkhACDV+euTeJm1Ki7A73qNWtFiF+/gA95Gly2CJLDhwEHMOUAAuOpLYDEgBxZ4GRTlC1fDnpkM+fOqD6DDj1aZpITp0dtGCDhr+fVuCu3zlg49ijaokTZTo27uG7Gjn2P+hI8+PDPERoUB318bWbfAJ5sUNFcuGRTYUqV/3ogfXp1rWlMc6awJjiAAd2fm4ogXjz56aypOoIde4OE5u/F9x199dlXnnGiHZWEYbGpsAEA3QXYnHwEFliKAgswgJ8LPeiUXGwedCAKABACCN+EA1pYIIYaFlcDhytd51sGAJbo3onOpajiihlO92KHGaUXGwWjUBChjSPiWJuOO/LYIm4v1tXfE6J4gCSJEZ7YgRYUNrkji9P55sF/ogxw5ZkSqIDaZBV6aSGYq/lGZplndkckZ98xoICbTcIJGQAZcNmdmUc210hs35nCyJ58fgmIKX5RQGOZowxaZwYA+JaoKQwswGijBV4C6SiTUmpphMspJx9unX4KaimjDv9aaXOEBteBqmuuxgEHoLX6Kqx+yXqqBANsgCtit4FWQAEkrNbpq7HSOmtwag5w57GrmlJBASEU18ADjUYb3ADTinIttsgSB1oJFfA63bduimuqKB1keqwUhoCSK374wbujvOSu4QG6UvxBRydcpKsav++Ca6G8A6Pr1x2kVMyHwsVxUALDq/krnrhPSOzXG1lUTIoffqGR7Goi2MAxbv6O2kEG56I7CSlRsEFKFVyovDJoIRTg7sugNRDGqCJzJgcKE0ywc0ELm6KBCCJo8DIPFeCWNGcyqNFE06ToAfV0HBRgxsvLThHn1oddQMrXj5DyAQgjEHSAJMWZwS3HPxT/QMbabI/iBCliMLEJKX2EEkomBAUCxRi42VDADxyTYDVogV+wSChqmKxEKCDAYFDFj4OmwbY7bDGdBhtrnTQYOigeChUmc1K3QTnAUfEgGFgAWt88hKA6aCRIXhxnQ1yg3BCayK44EWdkUQcBByEQChFXfCB776aQsG0BIlQgQgE8qO26X1h8cEUep8ngRBnOy74E9QgRgEAC8SvOfQkh7FDBDmS43PmGoIiKUUEGkMEC/PJHgxw0xH74yx/3XnaYRJgMB8obxQW6kL9QYEJ0FIFgByfIL7/IQAlvQwEpnAC7DtLNJCKUoO/w45c44GwCXiAFB/OXAATQryUxdN4LfFiwgjCNYg+kYMIEFkCKDs6PKAIJouyGWMS1FSKJOMRB/BoIxYJIUXFUxNwoIkEKPAgCBZSQHQ1A2EWDfDEUVLyADj5AChSIQW6gu10bE/JG2VnCZGfo4R4d0sdQoBAHhPjhIB94v/wRoRKQWGRHgrhGSQJxCS+0pCZbEhAAOw==" - private fun testBase(server: FileServer) { - val bytes = Base64.getDecoder().decode(image) + val contentTypePng = "image/png" + val imagePng = "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII=" + + private suspend fun testBase(server: Nip96MediaServers.ServerName) { + val serverInfo = Nip96Retriever().loadInfo( + server.baseUrl + ) + + val bytes = Base64.getDecoder().decode(imagePng) val inputStream = bytes.inputStream() - val countDownLatch = CountDownLatch(1) - var url: String? = null - var error: String? = null - - ImageUploader(Account(KeyPair())).uploadImage( + val result = Nip96Uploader(Account(KeyPair())).uploadImage( inputStream, bytes.size.toLong(), - "image/gif", - server, - onSuccess = { newUrl, contentType -> - println("Uploaded $contentType to $url") - url = newUrl - countDownLatch.countDown() - }, - onError = { - println("Failed to Upload") - error = it.message - countDownLatch.countDown() + contentTypePng, + alt = null, + sensitiveContent = null, + serverInfo, + onProgress = { } ) - countDownLatch.await() + val url = result.tags!!.first() { it[0] == "url" }.get(1) + val size = result.tags!!.firstOrNull() { it[0] == "size" }?.get(1) + val dim = result.tags!!.firstOrNull() { it[0] == "dim" }?.get(1) + val hash = result.tags!!.firstOrNull() { it[0] == "x" }?.get(1) - Assert.assertNull(error) - Assert.assertTrue(url?.startsWith("http") == true) + Assert.assertTrue(url.startsWith("http")) + + val imageData: ByteArray = ImageDownloader().waitAndGetImage(url) ?: run { + fail("Should not be null") + return + } + + FileHeader.prepare( + imageData, + contentTypePng, + null, + onReady = { + if (dim != null) { + assertEquals(dim, it.dim) + } + if (size != null) { + assertEquals(size, it.size.toString()) + } + if (hash != null) { + assertEquals(hash, it.hash) + } + }, + onError = { + fail("It should not fail") + } + ) } @Test() - fun testImgurUpload() = runBlocking { - testBase(ImgurServer()) + fun testNostrCheck() = runBlocking { + testBase(Nip96MediaServers.ServerName("nostrcheck.me", "https://nostrcheck.me")) } @Test() - fun testNostrBuildUpload() = runBlocking { - testBase(NostrBuildServer()) + fun testNostrage() = runBlocking { + testBase(Nip96MediaServers.ServerName("nostrage", "https://nostrage.com")) } @Test() - fun testNostrImgUpload() = runBlocking { - testBase(NostrImgServer()) + fun testSove() = runBlocking { + testBase(Nip96MediaServers.ServerName("sove", "https://sove.rent")) } @Test() - fun testNostrFilesDevUpload() = runBlocking { - testBase(NostrFilesDevServer()) + fun testNostrBuild() = runBlocking { + testBase(Nip96MediaServers.ServerName("nostr.build", "https://nostr.build")) + } + + @Test() + fun testSovbit() = runBlocking { + testBase(Nip96MediaServers.ServerName("sovbit", "https://files.sovbit.host")) + } + + @Test() + fun testVoidCat() = runBlocking { + testBase(Nip96MediaServers.ServerName("void.cat", "https://void.cat")) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 05192db76..50f1685e1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -15,13 +15,13 @@ import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS import com.vitorpamplona.amethyst.model.Nip47URI import com.vitorpamplona.amethyst.model.RelaySetupInfo -import com.vitorpamplona.amethyst.model.ServersAvailable import com.vitorpamplona.amethyst.model.Settings import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.model.parseBooleanType import com.vitorpamplona.amethyst.model.parseConnectivityType import com.vitorpamplona.amethyst.model.parseThemeType import com.vitorpamplona.amethyst.service.HttpClient +import com.vitorpamplona.amethyst.service.Nip96MediaServers import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.encoders.hexToByteArray @@ -264,7 +264,7 @@ object LocalPreferences { putString(PrefKeys.ZAP_AMOUNTS, Event.mapper.writeValueAsString(account.zapAmountChoices)) putString(PrefKeys.REACTION_CHOICES, Event.mapper.writeValueAsString(account.reactionChoices)) putString(PrefKeys.DEFAULT_ZAPTYPE, account.defaultZapType.name) - putString(PrefKeys.DEFAULT_FILE_SERVER, account.defaultFileServer.name) + putString(PrefKeys.DEFAULT_FILE_SERVER, Event.mapper.writeValueAsString(account.defaultFileServer)) putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList.value) putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList.value) putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, account.defaultNotificationFollowList.value) @@ -427,9 +427,15 @@ object LocalPreferences { LnZapEvent.ZapType.values().firstOrNull() { it.name == serverName } } ?: LnZapEvent.ZapType.PUBLIC - val defaultFileServer = getString(PrefKeys.DEFAULT_FILE_SERVER, "")?.let { serverName -> - ServersAvailable.values().firstOrNull() { it.name == serverName } - } ?: ServersAvailable.NOSTR_BUILD + val defaultFileServer = try { + getString(PrefKeys.DEFAULT_FILE_SERVER, "")?.let { serverName -> + Event.mapper.readValue(serverName) + } ?: Nip96MediaServers.DEFAULT[0] + } catch (e: Exception) { + Log.w("LocalPreferences", "Failed to decode saved File Server", e) + e.printStackTrace() + Nip96MediaServers.DEFAULT[0] + } val zapPaymentRequestServer = try { getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 247a94cc9..41593a595 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -4,8 +4,6 @@ import android.content.res.Resources import android.util.Log import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue import androidx.core.os.ConfigurationCompat import androidx.lifecycle.LiveData import androidx.lifecycle.asFlow @@ -15,6 +13,7 @@ import androidx.lifecycle.switchMap import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.OptOutFromFilters import com.vitorpamplona.amethyst.service.FileHeader +import com.vitorpamplona.amethyst.service.Nip96MediaServers import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.relays.Client @@ -132,7 +131,7 @@ class Account( var zapAmountChoices: List = DefaultZapAmounts, var reactionChoices: List = DefaultReactions, var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PRIVATE, - var defaultFileServer: ServersAvailable = ServersAvailable.NOSTR_BUILD, + var defaultFileServer: Nip96MediaServers.ServerName = Nip96MediaServers.DEFAULT[0], var defaultHomeFollowList: MutableStateFlow = MutableStateFlow(KIND3_FOLLOWS), var defaultStoriesFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), var defaultNotificationFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), @@ -711,7 +710,7 @@ class Account( } } - fun createHTTPAuthorization(url: String, method: String, body: String? = null, onReady: (HTTPAuthorizationEvent) -> Unit) { + fun createHTTPAuthorization(url: String, method: String, body: ByteArray? = null, onReady: (HTTPAuthorizationEvent) -> Unit) { if (!isWriteable()) return HTTPAuthorizationEvent.create(url, method, body, signer, onReady = onReady) @@ -967,7 +966,13 @@ class Account( } } - fun createNip95(byteArray: ByteArray, headerInfo: FileHeader, onReady: (Pair) -> Unit) { + fun createNip95( + byteArray: ByteArray, + headerInfo: FileHeader, + alt: String?, + sensitiveContent: Boolean, + onReady: (Pair) -> Unit + ) { if (!isWriteable()) return FileStorageEvent.create( @@ -982,8 +987,8 @@ class Account( size = headerInfo.size.toString(), dimensions = headerInfo.dim, blurhash = headerInfo.blurHash, - alt = headerInfo.alt, - sensitiveContent = headerInfo.sensitiveContent, + alt = alt, + sensitiveContent = sensitiveContent, signer = signer ) { signedEvent -> onReady( @@ -993,7 +998,7 @@ class Account( } } - fun sendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent, relayList: List? = null): Note? { + fun consumeAndSendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent, relayList: List? = null): Note? { if (!isWriteable()) return null Client.send(data, relayList = relayList) @@ -1005,7 +1010,19 @@ class Account( return LocalCache.notes[signedEvent.id] } - private fun sendHeader(signedEvent: FileHeaderEvent, relayList: List? = null, onReady: (Note) -> Unit) { + fun consumeNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent): Note? { + LocalCache.consume(data, null) + LocalCache.consume(signedEvent, null) + + return LocalCache.notes[signedEvent.id] + } + + fun sendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent, relayList: List? = null) { + Client.send(data, relayList = relayList) + Client.send(signedEvent, relayList = relayList) + } + + fun sendHeader(signedEvent: FileHeaderEvent, relayList: List? = null, onReady: (Note) -> Unit) { Client.send(signedEvent, relayList = relayList) LocalCache.consume(signedEvent, null) @@ -1014,18 +1031,57 @@ class Account( } } - fun sendHeader(headerInfo: FileHeader, relayList: List? = null, onReady: (Note) -> Unit) { + fun createHeader( + imageUrl: String, + magnetUri: String?, + headerInfo: FileHeader, + alt: String?, + sensitiveContent: Boolean, + originalHash: String? = null, + onReady: (FileHeaderEvent) -> Unit + ) { if (!isWriteable()) return FileHeaderEvent.create( - url = headerInfo.url, + url = imageUrl, + magnetUri = magnetUri, mimeType = headerInfo.mimeType, hash = headerInfo.hash, size = headerInfo.size.toString(), dimensions = headerInfo.dim, blurhash = headerInfo.blurHash, - alt = headerInfo.alt, - sensitiveContent = headerInfo.sensitiveContent, + alt = alt, + originalHash = originalHash, + sensitiveContent = sensitiveContent, + signer = signer + ) { event -> + onReady(event) + } + } + + fun sendHeader( + imageUrl: String, + magnetUri: String?, + headerInfo: FileHeader, + alt: String?, + sensitiveContent: Boolean, + originalHash: String? = null, + relayList: List? = null, + onReady: (Note) -> Unit + ) { + if (!isWriteable()) return + + FileHeaderEvent.create( + url = imageUrl, + magnetUri = magnetUri, + mimeType = headerInfo.mimeType, + hash = headerInfo.hash, + size = headerInfo.size.toString(), + dimensions = headerInfo.dim, + blurhash = headerInfo.blurHash, + alt = alt, + originalHash = originalHash, + sensitiveContent = sensitiveContent, signer = signer ) { event -> sendHeader(event, relayList = relayList, onReady) @@ -1046,7 +1102,8 @@ class Account( wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, relayList: List? = null, - geohash: String? = null + geohash: String? = null, + nip94attachments: List? = null ) { if (!isWriteable()) return @@ -1072,6 +1129,7 @@ class Account( zapRaiserAmount = zapRaiserAmount, directMentions = directMentions, geohash = geohash, + nip94attachments = nip94attachments, signer = signer ) { Client.send(it, relayList = relayList) @@ -1102,7 +1160,8 @@ class Account( root: String?, directMentions: Set, relayList: List? = null, - geohash: String? = null + geohash: String? = null, + nip94attachments: List? = null ) { if (!isWriteable()) return @@ -1123,6 +1182,7 @@ class Account( root = root, directMentions = directMentions, geohash = geohash, + nip94attachments = nip94attachments, signer = signer ) { Client.send(it, relayList = relayList) @@ -1160,7 +1220,8 @@ class Account( wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, relayList: List? = null, - geohash: String? = null + geohash: String? = null, + nip94attachments: List? = null ) { if (!isWriteable()) return @@ -1182,7 +1243,8 @@ class Account( zapReceiver = zapReceiver, markAsSensitive = wantsToMarkAsSensitive, zapRaiserAmount = zapRaiserAmount, - geohash = geohash + geohash = geohash, + nip94attachments = nip94attachments ) { Client.send(it, relayList = relayList) LocalCache.justConsume(it, null) @@ -1201,7 +1263,7 @@ class Account( } } - fun sendChannelMessage(message: String, toChannel: String, replyTo: List?, mentions: List?, zapReceiver: List? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null) { + fun sendChannelMessage(message: String, toChannel: String, replyTo: List?, mentions: List?, zapReceiver: List? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null, nip94attachments: List? = null) { if (!isWriteable()) return val repliesToHex = replyTo?.map { it.idHex } @@ -1216,6 +1278,7 @@ class Account( markAsSensitive = wantsToMarkAsSensitive, zapRaiserAmount = zapRaiserAmount, geohash = geohash, + nip94attachments = nip94attachments, signer = signer ) { Client.send(it) @@ -1223,7 +1286,7 @@ class Account( } } - fun sendLiveMessage(message: String, toChannel: ATag, replyTo: List?, mentions: List?, zapReceiver: List? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null) { + fun sendLiveMessage(message: String, toChannel: ATag, replyTo: List?, mentions: List?, zapReceiver: List? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null, nip94attachments: List? = null) { if (!isWriteable()) return // val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } @@ -1239,6 +1302,7 @@ class Account( markAsSensitive = wantsToMarkAsSensitive, zapRaiserAmount = zapRaiserAmount, geohash = geohash, + nip94attachments = nip94attachments, signer = signer ) { Client.send(it) @@ -1674,7 +1738,7 @@ class Account( saveable.invalidateData() } - fun changeDefaultFileServer(server: ServersAvailable) { + fun changeDefaultFileServer(server: Nip96MediaServers.ServerName) { defaultFileServer = server live.invalidateData() saveable.invalidateData() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/ServersAvailable.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/ServersAvailable.kt deleted file mode 100644 index ea8035dbd..000000000 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/ServersAvailable.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.vitorpamplona.amethyst.model - -enum class ServersAvailable { - // IMGUR, - NOSTR_BUILD, - NOSTRIMG, - NOSTRFILES_DEV, - NOSTRCHECK_ME, - - // IMGUR_NIP_94, - NOSTRIMG_NIP_94, - NOSTR_BUILD_NIP_94, - NOSTRFILES_DEV_NIP_94, - NOSTRCHECK_ME_NIP_94, - NIP95 -} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt index b9c247215..9dfe2caee 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt @@ -10,22 +10,25 @@ import io.trbl.blurhash.BlurHash import kotlin.math.roundToInt class FileHeader( - val url: String, val mimeType: String?, val hash: String, val size: Int, val dim: String?, - val blurHash: String?, - val alt: String? = null, - val sensitiveContent: Boolean = false + val blurHash: String? ) { companion object { - suspend fun prepare(fileUrl: String, mimeType: String?, alt: String?, sensitiveContent: Boolean, onReady: (FileHeader) -> Unit, onError: () -> Unit) { + suspend fun prepare( + fileUrl: String, + mimeType: String?, + dimPrecomputed: String?, + onReady: (FileHeader) -> Unit, + onError: () -> Unit + ) { try { val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl) if (imageData != null) { - prepare(imageData, fileUrl, mimeType, alt, sensitiveContent, onReady, onError) + prepare(imageData, mimeType, dimPrecomputed, onReady, onError) } else { onError() } @@ -37,10 +40,8 @@ class FileHeader( fun prepare( data: ByteArray, - fileUrl: String, mimeType: String?, - alt: String?, - sensitiveContent: Boolean, + dimPrecomputed: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit ) { @@ -76,10 +77,10 @@ class FileHeader( Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim) } } else { - Pair(null, null) + Pair(null, dimPrecomputed) } - onReady(FileHeader(fileUrl, mimeType, hash, size, dim, blurHash, alt, sensitiveContent)) + onReady(FileHeader(mimeType, hash, size, dim, blurHash)) } catch (e: Exception) { Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}") onError() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip96MediaServers.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip96MediaServers.kt new file mode 100644 index 000000000..9b57344b4 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip96MediaServers.kt @@ -0,0 +1,92 @@ +package com.vitorpamplona.amethyst.service + +import android.util.Log +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import okhttp3.Request + +object Nip96MediaServers { + val DEFAULT = + listOf( + ServerName("Nostr.Build", "https://nostr.build"), + ServerName("NostrCheck.me", "https://nostrcheck.me"), + ServerName("Nostrage", "https://nostrage.com"), + ServerName("Sove", "https://sove.rent"), + ServerName("Sovbit", "https://files.sovbit.host"), + ServerName("Void.cat", "https://void.cat") + ) + + data class ServerName(val name: String, val baseUrl: String) + + val cache: MutableMap = mutableMapOf() + + suspend fun load(url: String): Nip96Retriever.ServerInfo { + val cached = cache[url] + if (cached != null) return cached + + val fetched = Nip96Retriever().loadInfo(url) + cache[url] = fetched + return fetched + } +} + +class Nip96Retriever { + data class ServerInfo( + @JsonProperty("api_url") val apiUrl: String, + @JsonProperty("download_url") val downloadUrl: String? = null, + @JsonProperty("delegated_to_url") val delegatedToUrl: String? = null, + @JsonProperty("supported_nips") val supportedNips: ArrayList = arrayListOf(), + @JsonProperty("tos_url") val tosUrl: String? = null, + @JsonProperty("content_types") val contentTypes: ArrayList = arrayListOf(), + @JsonProperty("plans") val plans: Map = mapOf() + ) + + data class Plan( + @JsonProperty("name") val name: String? = null, + @JsonProperty("is_nip98_required") val isNip98Required: Boolean? = null, + @JsonProperty("url") val url: String? = null, + @JsonProperty("max_byte_size") val maxByteSize: Long? = null, + @JsonProperty("file_expiration") val fileExpiration: ArrayList = arrayListOf(), + @JsonProperty("media_transformations") val mediaTransformations: Map> = emptyMap() + ) + + fun parse(body: String): ServerInfo { + val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + return mapper.readValue(body, ServerInfo::class.java) + } + + suspend fun loadInfo( + baseUrl: String + ): ServerInfo { + checkNotInMainThread() + + val request: Request = Request + .Builder() + .header("Accept", "application/nostr+json") + .url(baseUrl.removeSuffix("/") + "/.well-known/nostr/nip96.json") + .build() + + HttpClient.getHttpClient() + .newCall(request) + .execute().use { response -> + checkNotInMainThread() + response.use { + val body = it.body.string() + try { + if (it.isSuccessful) { + return parse(body) + } else { + throw RuntimeException("Resulting Message from $baseUrl is an error: ${response.code} ${response.message}") + } + } catch (e: Exception) { + Log.e("RelayInfoFail", "Resulting Message from $baseUrl in not parseable: $body", e) + throw e + } + } + } + } +} + +typealias PlanName = String +typealias MimeType = String 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 deleted file mode 100644 index 22cff86ce..000000000 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt +++ /dev/null @@ -1,287 +0,0 @@ -package com.vitorpamplona.amethyst.ui.actions - -import android.content.ContentResolver -import android.net.Uri -import android.provider.OpenableColumns -import android.webkit.MimeTypeMap -import androidx.core.net.toFile -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.vitorpamplona.amethyst.BuildConfig -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.ServersAvailable -import com.vitorpamplona.amethyst.service.HttpClient -import com.vitorpamplona.amethyst.service.checkNotInMainThread -import okhttp3.Call -import okhttp3.Callback -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.MultipartBody -import okhttp3.Request -import okhttp3.RequestBody -import okhttp3.Response -import okio.BufferedSink -import okio.IOException -import okio.source -import java.io.InputStream -import java.time.Duration -import java.util.Base64 - -val charPool: List = ('a'..'z') + ('A'..'Z') + ('0'..'9') - -fun randomChars() = List(16) { charPool.random() }.joinToString("") -class ImageUploader(val account: Account?) { - fun uploadImage( - uri: Uri, - contentType: String?, - size: Long?, - server: ServersAvailable, - contentResolver: ContentResolver, - onSuccess: (String, String?) -> Unit, - onError: (Throwable) -> Unit - ) { - val myContentType = contentType ?: contentResolver.getType(uri) - val imageInputStream = contentResolver.openInputStream(uri) - - val length = size ?: contentResolver.query(uri, null, null, null, null)?.use { - it.moveToFirst() - val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE) - it.getLong(sizeIndex) - } ?: kotlin.runCatching { - uri.toFile().length() - }.getOrNull() ?: 0 - - checkNotNull(imageInputStream) { - "Can't open the image input stream" - } - val myServer = when (server) { - // ServersAvailable.IMGUR, ServersAvailable.IMGUR_NIP_94 -> { - // ImgurServer() - // } - ServersAvailable.NOSTRIMG, ServersAvailable.NOSTRIMG_NIP_94 -> { - NostrImgServer() - } - ServersAvailable.NOSTR_BUILD, ServersAvailable.NOSTR_BUILD_NIP_94 -> { - NostrBuildServer() - } - ServersAvailable.NOSTRFILES_DEV, ServersAvailable.NOSTRFILES_DEV_NIP_94 -> { - NostrFilesDevServer() - } - ServersAvailable.NOSTRCHECK_ME, ServersAvailable.NOSTRCHECK_ME_NIP_94 -> { - NostrCheckMeServer() - } - else -> { - NostrBuildServer() - } - } - - uploadImage(imageInputStream, length, myContentType, myServer, onSuccess, onError) - } - - fun uploadImage( - inputStream: InputStream, - length: Long, - contentType: String?, - server: FileServer, - onSuccess: (String, String?) -> Unit, - onError: (Throwable) -> Unit - ) { - val fileName = randomChars() - val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" - - val client = HttpClient.getHttpClient(Duration.ofSeconds(30L)) - val requestBody: RequestBody - val requestBuilder = Request.Builder() - - requestBody = MultipartBody.Builder() - .setType(MultipartBody.FORM) - .addFormDataPart( - server.inputParameterName(contentType), - "$fileName.$extension", - - object : RequestBody() { - override fun contentType() = contentType?.toMediaType() - - override fun contentLength() = length - - override fun writeTo(sink: BufferedSink) { - inputStream.source().use(sink::writeAll) - } - } - ) - .build() - - server.authorizationToken(account, requestBody.toString()) { authorizationToken -> - if (authorizationToken != null) { - requestBuilder.addHeader("Authorization", authorizationToken) - } - - requestBuilder - .addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") - .url(server.postUrl(contentType)) - .post(requestBody) - val request = requestBuilder.build() - - client.newCall(request).enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - try { - if (response.isSuccessful) { - response.body.use { body -> - val url = server.parseUrlFromSuccess(body.string(), authorizationToken) - checkNotNull(url) { - "There must be an uploaded image URL in the response" - } - - onSuccess(url, contentType) - } - } else { - onError(RuntimeException("Error Uploading image: ${response.code}")) - } - } catch (e: Exception) { - e.printStackTrace() - onError(e) - } - } - - override fun onFailure(call: Call, e: IOException) { - e.printStackTrace() - onError(e) - } - }) - } - } - - fun NIP98Header(url: String, method: String, body: String, onReady: (String?) -> Unit) { - val myAccount = account - - if (myAccount == null) { - onReady(null) - return - } - - myAccount.createHTTPAuthorization(url, method, body) { - val noteJson = it.toJson() - val encodedNIP98Event: String = Base64.getEncoder().encodeToString(noteJson.toByteArray()) - onReady("Nostr $encodedNIP98Event") - } - } -} - -abstract class FileServer { - abstract fun postUrl(contentType: String?): String - abstract fun parseUrlFromSuccess(body: String, authorizationToken: String?): String? - abstract fun inputParameterName(contentType: String?): String - - open fun authorizationToken(account: Account?, info: String, onReady: (String?) -> Unit) { - onReady(null) - } -} - -class NostrImgServer : FileServer() { - override fun postUrl(contentType: String?) = "https://nostrimg.com/api/upload" - - override fun parseUrlFromSuccess(body: String, authorizationToken: String?): String? { - val tree = jacksonObjectMapper().readTree(body) - val url = tree?.get("data")?.get("link")?.asText() - return url - } - - override fun inputParameterName(contentType: String?): String { - return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image" - } -} - -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 parseUrlFromSuccess(body: String, authorizationToken: String?): String? { - val tree = jacksonObjectMapper().readTree(body) - val url = tree?.get("data")?.get("link")?.asText() - return url - } - - override fun inputParameterName(contentType: String?): String { - return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image" - } - - override fun authorizationToken(account: Account?, info: String, onReady: (String?) -> Unit) { - onReady("Client-ID e6aea87296f3f96") - } -} - -class NostrBuildServer : FileServer() { - override fun postUrl(contentType: String?) = "https://nostr.build/api/v2/upload/files" - override fun parseUrlFromSuccess(body: String, authorizationToken: String?): String? { - val tree = jacksonObjectMapper().readTree(body) - val data = tree?.get("data") - val data0 = data?.get(0) - val url = data0?.get("url") - return url.toString().replace("\"", "") - } - - override fun inputParameterName(contentType: String?): String { - return "file" - } - - override fun authorizationToken(account: Account?, info: String, onReady: (String?) -> Unit) { - ImageUploader(account).NIP98Header("https://nostr.build/api/v2/upload/files", "POST", info, onReady) - } -} - -class NostrFilesDevServer : FileServer() { - override fun postUrl(contentType: String?) = "https://nostrfiles.dev/upload_image" - override fun parseUrlFromSuccess(body: String, authorizationToken: String?): String? { - val tree = jacksonObjectMapper().readTree(body) - return tree?.get("url")?.asText() - } - - override fun inputParameterName(contentType: String?): String { - return "file" - } -} - -class NostrCheckMeServer : FileServer() { - override fun postUrl(contentType: String?) = "https://nostrcheck.me/api/v1/media" - override fun parseUrlFromSuccess(body: String, authorizationToken: String?): String? { - checkNotInMainThread() - - val tree = jacksonObjectMapper().readTree(body) - val url = tree?.get("url")?.asText() - val id = tree?.get("id")?.asText() - var isCompleted = false - - val client = HttpClient.getHttpClient() - val requrl = - "https://nostrcheck.me/api/v1/media?id=$id" // + "&apikey=26d075787d261660682fb9d20dbffa538c708b1eda921d0efa2be95fbef4910a" - - val requestBuilder = Request.Builder().url(requrl) - - if (authorizationToken != null) { - requestBuilder.addHeader("Authorization", authorizationToken) - } - - val request = requestBuilder.get().build() - - while (!isCompleted) { - client.newCall(request).execute().use { - val tree = jacksonObjectMapper().readTree(it.body.string()) - isCompleted = tree?.get("status")?.asText() == "completed" - try { - Thread.sleep(500) - } catch (e: InterruptedException) { - e.printStackTrace() - } - } - } - return url - } - - override fun inputParameterName(contentType: String?): String { - return "mediafile" - } - - override fun authorizationToken(account: Account?, body: String, onReady: (String?) -> Unit) { - ImageUploader(account).NIP98Header("https://nostrcheck.me/api/v1/media", "POST", body, onReady) - } -} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt index 7affff81e..32d7d0373 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt @@ -10,8 +10,8 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.ServersAvailable import com.vitorpamplona.amethyst.service.FileHeader +import com.vitorpamplona.amethyst.service.Nip96MediaServers import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.ui.components.MediaCompressor import kotlinx.coroutines.Dispatchers @@ -19,6 +19,11 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch +data class ServerOption( + val server: Nip96MediaServers.ServerName, + val isNip95: Boolean +) + @Stable open class NewMediaModel : ViewModel() { var account: Account? = null @@ -27,7 +32,7 @@ open class NewMediaModel : ViewModel() { val imageUploadingError = MutableSharedFlow(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST) var mediaType by mutableStateOf(null) - var selectedServer by mutableStateOf(null) + var selectedServer by mutableStateOf(null) var alt by mutableStateOf("") var sensitiveContent by mutableStateOf(false) @@ -43,20 +48,7 @@ open class NewMediaModel : ViewModel() { this.account = account this.galleryUri = uri this.mediaType = contentType - this.selectedServer = defaultServer() - - // if (selectedServer == ServersAvailable.IMGUR) { - // selectedServer = ServersAvailable.IMGUR_NIP_94 - // } else - if (selectedServer == ServersAvailable.NOSTRIMG) { - selectedServer = ServersAvailable.NOSTRIMG_NIP_94 - } else if (selectedServer == ServersAvailable.NOSTR_BUILD) { - selectedServer = ServersAvailable.NOSTR_BUILD_NIP_94 - } else if (selectedServer == ServersAvailable.NOSTRFILES_DEV) { - selectedServer = ServersAvailable.NOSTRFILES_DEV_NIP_94 - } else if (selectedServer == ServersAvailable.NOSTRCHECK_ME) { - selectedServer = ServersAvailable.NOSTRCHECK_ME_NIP_94 - } + this.selectedServer = ServerOption(defaultServer(), false) } fun upload(context: Context, relayList: List? = null) { @@ -76,8 +68,7 @@ open class NewMediaModel : ViewModel() { contentType, context.applicationContext, onReady = { fileUri, contentType, size -> - - if (selectedServer == ServersAvailable.NIP95) { + if (serverToUse.isNip95) { uploadingPercentage.value = 0.2f uploadingDescription.value = "Loading" contentResolver.openInputStream(fileUri)?.use { @@ -95,30 +86,35 @@ open class NewMediaModel : ViewModel() { uploadingPercentage.value = 0.2f uploadingDescription.value = "Uploading" viewModelScope.launch(Dispatchers.IO) { - ImageUploader(account).uploadImage( - uri = fileUri, - contentType = contentType, - size = size, - server = serverToUse, - contentResolver = contentResolver, - onSuccess = { imageUrl, mimeType -> - createNIP94Record( - imageUrl, - mimeType, - alt, - sensitiveContent, - relayList = relayList - ) - }, - onError = { - isUploadingImage = false - uploadingPercentage.value = 0.00f - uploadingDescription.value = null - viewModelScope.launch { - imageUploadingError.emit("Failed to upload the image / video") + try { + val result = Nip96Uploader(account).uploadImage( + uri = fileUri, + contentType = contentType, + size = size, + alt = alt, + sensitiveContent = if (sensitiveContent) "" else null, + server = serverToUse.server, + contentResolver = contentResolver, + onProgress = { percent: Float -> + uploadingPercentage.value = 0.2f + (0.2f * percent) } + ) + + createNIP94Record( + uploadingResult = result, + localContentType = contentType, + alt = alt, + sensitiveContent = sensitiveContent, + relayList = relayList + ) + } catch (e: Exception) { + isUploadingImage = false + uploadingPercentage.value = 0.00f + uploadingDescription.value = null + viewModelScope.launch { + imageUploadingError.emit("Failed to upload: ${e.message}") } - ) + } } } }, @@ -142,63 +138,83 @@ open class NewMediaModel : ViewModel() { uploadingPercentage.value = 0.0f alt = "" - selectedServer = account?.defaultFileServer + selectedServer = ServerOption(defaultServer(), false) } fun canPost(): Boolean { return !isUploadingImage && galleryUri != null && selectedServer != null } - fun createNIP94Record(imageUrl: String, mimeType: String?, alt: String, sensitiveContent: Boolean, relayList: List? = null) { + suspend fun createNIP94Record( + uploadingResult: Nip96Uploader.PartialEvent, + localContentType: String?, + alt: String, + sensitiveContent: Boolean, + relayList: List? = null + ) { uploadingPercentage.value = 0.40f - viewModelScope.launch(Dispatchers.IO) { - uploadingDescription.value = "Server Processing" - // Images don't seem to be ready immediately after upload + uploadingDescription.value = "Server Processing" + // Images don't seem to be ready immediately after upload - val imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl) + 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 } + val originalHash = 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 } + val magnet = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "magnet" }?.get(1)?.ifBlank { null } - uploadingDescription.value = "Downloading" - uploadingPercentage.value = 0.60f + if (imageUrl.isNullOrBlank()) { + Log.e("ImageDownload", "Couldn't download image from server") + cancel() + uploadingPercentage.value = 0.00f + uploadingDescription.value = null + isUploadingImage = false + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") + } + return + } - if (imageData != null) { - uploadingPercentage.value = 0.80f - uploadingDescription.value = "Hashing" + uploadingDescription.value = "Downloading" + uploadingPercentage.value = 0.60f - FileHeader.prepare( - imageData, - imageUrl, - mimeType, - alt, - sensitiveContent, - onReady = { - uploadingPercentage.value = 0.90f - uploadingDescription.value = "Sending" - account?.sendHeader(it, relayList) { - uploadingPercentage.value = 1.00f - isUploadingImage = false - onceUploaded() - cancel() - } - }, - onError = { - cancel() - uploadingPercentage.value = 0.00f - uploadingDescription.value = null + val imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl) + + if (imageData != null) { + uploadingPercentage.value = 0.80f + uploadingDescription.value = "Hashing" + + FileHeader.prepare( + data = imageData, + mimeType = remoteMimeType ?: localContentType, + dimPrecomputed = dim, + onReady = { + uploadingPercentage.value = 0.90f + uploadingDescription.value = "Sending" + account?.sendHeader(imageUrl, magnet, it, alt, sensitiveContent, originalHash, relayList) { + uploadingPercentage.value = 1.00f isUploadingImage = false - viewModelScope.launch { - imageUploadingError.emit("Failed to upload the image / video") - } + onceUploaded() + cancel() + } + }, + onError = { + cancel() + uploadingPercentage.value = 0.00f + uploadingDescription.value = null + isUploadingImage = false + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") } - ) - } else { - Log.e("ImageDownload", "Couldn't download image from server") - cancel() - uploadingPercentage.value = 0.00f - uploadingDescription.value = null - isUploadingImage = false - viewModelScope.launch { - imageUploadingError.emit("Failed to upload the image / video") } + ) + } else { + Log.e("ImageDownload", "Couldn't download image from server") + cancel() + uploadingPercentage.value = 0.00f + uploadingDescription.value = null + isUploadingImage = false + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") } } } @@ -210,17 +226,15 @@ open class NewMediaModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { FileHeader.prepare( bytes, - "", mimeType, - alt, - sensitiveContent, + null, onReady = { uploadingDescription.value = "Signing" uploadingPercentage.value = 0.40f - account?.createNip95(bytes, headerInfo = it) { nip95 -> + account?.createNip95(bytes, headerInfo = it, alt, sensitiveContent) { nip95 -> uploadingDescription.value = "Sending" uploadingPercentage.value = 0.60f - account?.sendNip95(nip95.first, nip95.second, relayList) + account?.consumeAndSendNip95(nip95.first, nip95.second, relayList) uploadingPercentage.value = 1.00f isUploadingImage = false @@ -243,7 +257,7 @@ open class NewMediaModel : ViewModel() { fun isImage() = mediaType?.startsWith("image") fun isVideo() = mediaType?.startsWith("video") - fun defaultServer() = account?.defaultFileServer + fun defaultServer() = account?.defaultFileServer ?: Nip96MediaServers.DEFAULT[0] fun onceUploaded(onceUploaded: () -> Unit) { this.onceUploaded = onceUploaded } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt index c360b88a2..93d3e4347 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt @@ -46,7 +46,7 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import coil.compose.AsyncImage import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.ServersAvailable +import com.vitorpamplona.amethyst.service.Nip96MediaServers import com.vitorpamplona.amethyst.ui.components.VideoView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner @@ -146,7 +146,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac onPost = { onClose() postViewModel.upload(context, relayList) - postViewModel.selectedServer?.let { account.changeDefaultFileServer(it) } + postViewModel.selectedServer?.let { account.changeDefaultFileServer(it.server) } }, isActive = postViewModel.canPost() ) @@ -170,26 +170,19 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac } } -fun isNIP94Server(selectedServer: ServersAvailable?): Boolean { - return selectedServer == ServersAvailable.NOSTRIMG_NIP_94 || - // selectedServer == ServersAvailable.IMGUR_NIP_94 || - selectedServer == ServersAvailable.NOSTR_BUILD_NIP_94 || - selectedServer == ServersAvailable.NOSTRFILES_DEV_NIP_94 || - selectedServer == ServersAvailable.NOSTRCHECK_ME_NIP_94 -} - @Composable fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewModel) { - val fileServers = listOf( - // Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)), - Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)), - Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)), - Triple(ServersAvailable.NOSTRFILES_DEV_NIP_94, stringResource(id = R.string.upload_server_nostrfilesdev_nip94), stringResource(id = R.string.upload_server_nostrfilesdev_nip94_explainer)), - Triple(ServersAvailable.NOSTRCHECK_ME_NIP_94, stringResource(id = R.string.upload_server_nostrcheckme_nip94), stringResource(id = R.string.upload_server_nostrcheckme_nip94_explainer)), - Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer)) + val fileServers = Nip96MediaServers.DEFAULT.map { ServerOption(it, false) } + listOf( + ServerOption( + Nip96MediaServers.ServerName( + "NIP95", + stringResource(id = R.string.upload_server_relays_nip95) + ), + true + ) ) - val fileServerOptions = remember { fileServers.map { TitleExplainer(it.second, it.third) }.toImmutableList() } + val fileServerOptions = remember { fileServers.map { TitleExplainer(it.server.name, it.server.baseUrl) }.toImmutableList() } val resolver = LocalContext.current.contentResolver Row( @@ -252,10 +245,12 @@ fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewMo ) { TextSpinner( label = stringResource(id = R.string.file_server), - placeholder = fileServers.firstOrNull { it.first == accountViewModel.account.defaultFileServer }?.second ?: fileServers[0].second, + placeholder = fileServers.firstOrNull { + it.server == accountViewModel.account.defaultFileServer + }?.server?.name ?: fileServers[0].server.name, options = fileServerOptions, onSelect = { - postViewModel.selectedServer = fileServers[it].first + postViewModel.selectedServer = fileServers[it] }, modifier = Modifier .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) @@ -263,44 +258,40 @@ fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewMo ) } - if (isNIP94Server(postViewModel.selectedServer) || - postViewModel.selectedServer == ServersAvailable.NIP95 + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - SettingSwitchItem( - checked = postViewModel.sensitiveContent, - onCheckedChange = { postViewModel.sensitiveContent = it }, - title = R.string.add_sensitive_content_label, - description = R.string.add_sensitive_content_description - ) - } + SettingSwitchItem( + checked = postViewModel.sensitiveContent, + onCheckedChange = { postViewModel.sensitiveContent = it }, + title = R.string.add_sensitive_content_label, + description = R.string.add_sensitive_content_description + ) + } - Row( - verticalAlignment = Alignment.CenterVertically, + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.content_description)) }, modifier = Modifier .fillMaxWidth() - .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) - ) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.content_description)) }, - modifier = Modifier - .fillMaxWidth() - .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), - value = postViewModel.alt, - onValueChange = { postViewModel.alt = it }, - placeholder = { - Text( - text = stringResource(R.string.content_description_example), - color = MaterialTheme.colorScheme.placeholderText - ) - }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), + value = postViewModel.alt, + onValueChange = { postViewModel.alt = it }, + placeholder = { + Text( + text = stringResource(R.string.content_description_example), + color = MaterialTheme.colorScheme.placeholderText ) + }, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences ) - } + ) } } 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 a8a275e0d..171264e77 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 @@ -112,8 +112,8 @@ import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.ServersAvailable import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.Nip96MediaServers import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource import com.vitorpamplona.amethyst.service.ReverseGeoLocationUtil import com.vitorpamplona.amethyst.service.noProtocolUrlValidator @@ -404,8 +404,8 @@ fun NewPostView( url, accountViewModel.account.defaultFileServer, onAdd = { alt, server, sensitiveContent -> - postViewModel.upload(url, alt, sensitiveContent, server, context, relayList) - accountViewModel.account.changeDefaultFileServer(server) + postViewModel.upload(url, alt, sensitiveContent, false, server, context, relayList) + accountViewModel.account.changeDefaultFileServer(server.server) }, onCancel = { postViewModel.contentToAddUrl = null @@ -1597,8 +1597,8 @@ fun CreateButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier @Composable fun ImageVideoDescription( uri: Uri, - defaultServer: ServersAvailable, - onAdd: (String, ServersAvailable, Boolean) -> Unit, + defaultServer: Nip96MediaServers.ServerName, + onAdd: (String, ServerOption, Boolean) -> Unit, onCancel: () -> Unit, onError: (String) -> Unit, accountViewModel: AccountViewModel @@ -1609,23 +1609,19 @@ fun ImageVideoDescription( val isImage = mediaType.startsWith("image") val isVideo = mediaType.startsWith("video") - val fileServers = listOf( - // Triple(ServersAvailable.IMGUR, stringResource(id = R.string.upload_server_imgur), stringResource(id = R.string.upload_server_imgur_explainer)), - Triple(ServersAvailable.NOSTRIMG, stringResource(id = R.string.upload_server_nostrimg), stringResource(id = R.string.upload_server_nostrimg_explainer)), - Triple(ServersAvailable.NOSTR_BUILD, stringResource(id = R.string.upload_server_nostrbuild), stringResource(id = R.string.upload_server_nostrbuild_explainer)), - Triple(ServersAvailable.NOSTRFILES_DEV, stringResource(id = R.string.upload_server_nostrfilesdev), stringResource(id = R.string.upload_server_nostrfilesdev_explainer)), - Triple(ServersAvailable.NOSTRCHECK_ME, stringResource(id = R.string.upload_server_nostrcheckme), stringResource(id = R.string.upload_server_nostrcheckme_explainer)), - // Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)), - Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)), - Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)), - Triple(ServersAvailable.NOSTRFILES_DEV_NIP_94, stringResource(id = R.string.upload_server_nostrfilesdev_nip94), stringResource(id = R.string.upload_server_nostrfilesdev_nip94_explainer)), - Triple(ServersAvailable.NOSTRCHECK_ME_NIP_94, stringResource(id = R.string.upload_server_nostrcheckme_nip94), stringResource(id = R.string.upload_server_nostrcheckme_nip94_explainer)), - Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer)) + val fileServers = Nip96MediaServers.DEFAULT.map { ServerOption(it, false) } + listOf( + ServerOption( + Nip96MediaServers.ServerName( + "NIP95", + stringResource(id = R.string.upload_server_relays_nip95) + ), + true + ) ) - val fileServerOptions = remember { fileServers.map { TitleExplainer(it.second, it.third) }.toImmutableList() } + val fileServerOptions = remember { fileServers.map { TitleExplainer(it.server.name, it.server.baseUrl) }.toImmutableList() } - var selectedServer by remember { mutableStateOf(defaultServer) } + var selectedServer by remember { mutableStateOf(ServerOption(defaultServer, false)) } var message by remember { mutableStateOf("") } var sensitiveContent by remember { mutableStateOf(false) } @@ -1735,10 +1731,12 @@ fun ImageVideoDescription( ) { TextSpinner( label = stringResource(id = R.string.file_server), - placeholder = fileServers.filter { it.first == defaultServer }.firstOrNull()?.second ?: fileServers[0].second, + placeholder = fileServers.firstOrNull { + it.server == accountViewModel.account.defaultFileServer + }?.server?.name ?: fileServers[0].server.name, options = fileServerOptions, onSelect = { - selectedServer = fileServers[it].first + selectedServer = fileServers[it] }, modifier = Modifier .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) @@ -1746,45 +1744,41 @@ fun ImageVideoDescription( ) } - if (isNIP94Server(selectedServer) || - selectedServer == ServersAvailable.NIP95 + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - SettingSwitchItem( - checked = sensitiveContent, - onCheckedChange = { sensitiveContent = it }, - title = R.string.add_sensitive_content_label, - description = R.string.add_sensitive_content_description - ) - } + SettingSwitchItem( + checked = sensitiveContent, + onCheckedChange = { sensitiveContent = it }, + title = R.string.add_sensitive_content_label, + description = R.string.add_sensitive_content_description + ) + } - Row( - verticalAlignment = Alignment.CenterVertically, + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.content_description)) }, modifier = Modifier .fillMaxWidth() - .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)) - ) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.content_description)) }, - modifier = Modifier - .fillMaxWidth() - .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), - value = message, - onValueChange = { message = it }, - placeholder = { - Text( - text = stringResource(R.string.content_description_example), - color = MaterialTheme.colorScheme.placeholderText - ) - }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences + .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), + value = message, + onValueChange = { message = it }, + placeholder = { + Text( + text = stringResource(R.string.content_description_example), + color = MaterialTheme.colorScheme.placeholderText ) + }, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences ) - } + ) } Button( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index ea55db6ef..e60118266 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -18,7 +18,6 @@ import com.fonfon.kgeohash.toGeoHash import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.ServersAvailable import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.FileHeader import com.vitorpamplona.amethyst.service.LocationUtil @@ -35,10 +34,14 @@ import com.vitorpamplona.quartz.events.BaseTextNoteEvent import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent +import com.vitorpamplona.quartz.events.FileHeaderEvent +import com.vitorpamplona.quartz.events.FileStorageEvent +import com.vitorpamplona.quartz.events.FileStorageHeaderEvent import com.vitorpamplona.quartz.events.Price import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.ZapSplitSetup +import com.vitorpamplona.quartz.events.findURLs import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow @@ -63,6 +66,9 @@ open class NewPostViewModel() : ViewModel() { var mentions by mutableStateOf?>(null) var replyTos by mutableStateOf?>(null) + var nip94attachments by mutableStateOf>(emptyList()) + var nip95attachments by mutableStateOf>>(emptyList()) + var message by mutableStateOf(TextFieldValue("")) var urlPreview by mutableStateOf(null) var isUploadingImage by mutableStateOf(false) @@ -219,11 +225,25 @@ open class NewPostViewModel() : ViewModel() { val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount else null + nip95attachments.forEach { + if (replyTos?.contains(LocalCache.getNoteIfExists(it.second.id)) == true) { + account?.sendNip95(it.first, it.second, relayList) + } + } + + val urls = findURLs(tagger.message) + val usedAttachments = nip94attachments.filter { + it.urls().intersect(urls).isNotEmpty() + } + usedAttachments.forEach { + account?.sendHeader(it, relayList, { }) + } + if (originalNote?.channelHex() != null) { if (originalNote is AddressableEvent && originalNote?.address() != null) { - account?.sendLiveMessage(tagger.message, originalNote?.address()!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash) + account?.sendLiveMessage(tagger.message, originalNote?.address()!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash, nip94attachments = usedAttachments) } else { - account?.sendChannelMessage(tagger.message, tagger.channelHex!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash) + account?.sendChannelMessage(tagger.message, tagger.channelHex!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash, nip94attachments = usedAttachments) } } else if (originalNote?.event is PrivateDmEvent) { account?.sendPrivateMessage(tagger.message, originalNote!!.author!!, originalNote!!, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash) @@ -281,7 +301,8 @@ open class NewPostViewModel() : ViewModel() { wantsToMarkAsSensitive, localZapRaiserAmount, relayList, - geoHash + geoHash, + nip94attachments = usedAttachments ) } else if (wantsProduct) { account?.sendClassifieds( @@ -298,7 +319,8 @@ open class NewPostViewModel() : ViewModel() { wantsToMarkAsSensitive = wantsToMarkAsSensitive, zapRaiserAmount = localZapRaiserAmount, relayList = relayList, - geohash = geoHash + geohash = geoHash, + nip94attachments = usedAttachments ) } else { // adds markers @@ -322,7 +344,8 @@ open class NewPostViewModel() : ViewModel() { root = rootId, directMentions = tagger.directMentions, relayList = relayList, - geohash = geoHash + geohash = geoHash, + nip94attachments = usedAttachments ) } } @@ -330,7 +353,15 @@ open class NewPostViewModel() : ViewModel() { cancel() } - fun upload(galleryUri: Uri, alt: String, sensitiveContent: Boolean, server: ServersAvailable, context: Context, relayList: List? = null) { + fun upload( + galleryUri: Uri, + alt: String?, + sensitiveContent: Boolean, + isPrivate: Boolean = false, + server: ServerOption, + context: Context, + relayList: List? = null + ) { isUploadingImage = true contentToAddUrl = null @@ -343,35 +374,49 @@ open class NewPostViewModel() : ViewModel() { contentType, context.applicationContext, onReady = { fileUri, contentType, size -> - if (server == ServersAvailable.NIP95) { + if (server.isNip95) { contentResolver.openInputStream(fileUri)?.use { createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent, relayList = relayList) } } else { viewModelScope.launch(Dispatchers.IO) { - ImageUploader(account).uploadImage( - uri = fileUri, - contentType = contentType, - size = size, - server = server, - contentResolver = contentResolver, - onSuccess = { imageUrl, mimeType -> - if (isNIP94Server(server)) { - createNIP94Record(imageUrl, mimeType, alt, sensitiveContent) - } else { - isUploadingImage = false - message = TextFieldValue(message.text + "\n" + imageUrl) - urlPreview = findUrlInMessage() - } - }, - onError = { - Log.e("ImageUploader", "Failed to upload the image / video", it) + try { + val result = Nip96Uploader(account).uploadImage( + uri = fileUri, + contentType = contentType, + size = size, + alt = alt, + sensitiveContent = if (sensitiveContent) "" else null, + server = server.server, + contentResolver = contentResolver, + onProgress = { } + ) + + if (!isPrivate) { + createNIP94Record( + uploadingResult = result, + localContentType = contentType, + alt = alt, + sensitiveContent = sensitiveContent + ) + } else { + val url = result.tags?.firstOrNull() { it.size > 1 && it[0] == "url" }?.get(1) + isUploadingImage = false - viewModelScope.launch { - imageUploadingError.emit("Failed to upload the image / video") - } + message = TextFieldValue(message.text + "\n" + url) + urlPreview = findUrlInMessage() } - ) + } catch (e: Exception) { + Log.e( + "ImageUploader", + "Failed to upload ${e.message}", + e + ) + isUploadingImage = false + viewModelScope.launch { + imageUploadingError.emit("Failed to upload: ${e.message}") + } + } } } }, @@ -576,44 +621,66 @@ open class NewPostViewModel() : ViewModel() { } } - fun createNIP94Record(imageUrl: String, mimeType: String?, alt: String, sensitiveContent: Boolean, relayList: List? = null) { - viewModelScope.launch(Dispatchers.IO) { - // Images don't seem to be ready immediately after upload - FileHeader.prepare( - imageUrl, - mimeType, - alt, - sensitiveContent, - onReady = { - account?.sendHeader(it, relayList = relayList) { note -> - isUploadingImage = false + suspend fun createNIP94Record( + uploadingResult: Nip96Uploader.PartialEvent, + localContentType: String?, + alt: String?, + sensitiveContent: Boolean + ) { + // 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 } + val originalHash = 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 } + val magnet = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "magnet" }?.get(1)?.ifBlank { null } - message = TextFieldValue(message.text + "\nnostr:" + note.toNEvent()) - - urlPreview = findUrlInMessage() - } - }, - onError = { - isUploadingImage = false - viewModelScope.launch { - imageUploadingError.emit("Failed to upload the image / video") - } - } - ) + if (imageUrl.isNullOrBlank()) { + Log.e("ImageDownload", "Couldn't download image from server") + cancel() + isUploadingImage = false + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") + } + return } + + FileHeader.prepare( + fileUrl = imageUrl, + mimeType = remoteMimeType ?: localContentType, + dimPrecomputed = dim, + onReady = { header: FileHeader -> + account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event -> + isUploadingImage = false + nip94attachments = nip94attachments + event + message = TextFieldValue(message.text + "\n" + imageUrl) + urlPreview = findUrlInMessage() + } + }, + onError = { + isUploadingImage = false + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") + } + } + ) } - fun createNIP95Record(bytes: ByteArray, mimeType: String?, alt: String, sensitiveContent: Boolean, relayList: List? = null) { + fun createNIP95Record( + bytes: ByteArray, + mimeType: String?, + alt: String?, + sensitiveContent: Boolean, + relayList: List? = null + ) { viewModelScope.launch(Dispatchers.IO) { FileHeader.prepare( bytes, - "", mimeType, - alt, - sensitiveContent, + null, onReady = { - account?.createNip95(bytes, headerInfo = it) { nip95 -> - val note = nip95.let { it1 -> account?.sendNip95(it1.first, it1.second, relayList = relayList) } + account?.createNip95(bytes, headerInfo = it, alt, sensitiveContent) { nip95 -> + nip95attachments = nip95attachments + nip95 + val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } isUploadingImage = false diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt index c6fc4de3f..f5e5224a9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -184,23 +184,35 @@ class NewUserMetadataViewModel : ViewModel() { context.applicationContext, onReady = { fileUri, contentType, size -> viewModelScope.launch(Dispatchers.IO) { - ImageUploader(account).uploadImage( - uri = fileUri, - contentType = contentType, - size = size, - server = account.defaultFileServer, - contentResolver = contentResolver, - onSuccess = { imageUrl, mimeType -> + try { + val result = Nip96Uploader(account).uploadImage( + uri = fileUri, + contentType = contentType, + size = size, + alt = null, + sensitiveContent = null, + server = account.defaultFileServer, + contentResolver = contentResolver, + onProgress = { } + ) + + val url = result.tags?.firstOrNull() { it.size > 1 && it[0] == "url" }?.get(1) + + if (url != null) { onUploading(false) - onUploaded(imageUrl) - }, - onError = { + onUploaded(url) + } else { onUploading(false) viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") } } - ) + } catch (e: Exception) { + onUploading(false) + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") + } + } } }, onError = { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/Nip96Uploader.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/Nip96Uploader.kt new file mode 100644 index 000000000..7e96ae9ba --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/Nip96Uploader.kt @@ -0,0 +1,237 @@ +package com.vitorpamplona.amethyst.ui.actions + +import android.content.ContentResolver +import android.net.Uri +import android.provider.OpenableColumns +import android.webkit.MimeTypeMap +import androidx.core.net.toFile +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.model.Account +import com.vitorpamplona.amethyst.service.HttpClient +import com.vitorpamplona.amethyst.service.Nip96MediaServers +import com.vitorpamplona.amethyst.service.Nip96Retriever +import com.vitorpamplona.amethyst.service.checkNotInMainThread +import kotlinx.coroutines.delay +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.Request +import okhttp3.RequestBody +import okio.BufferedSink +import okio.source +import java.io.InputStream +import java.util.Base64 +import kotlin.coroutines.resume + +val charPool: List = ('a'..'z') + ('A'..'Z') + ('0'..'9') + +fun randomChars() = List(16) { charPool.random() }.joinToString("") + +class Nip96Uploader(val account: Account?) { + + suspend fun uploadImage( + uri: Uri, + contentType: String?, + size: Long?, + alt: String?, + sensitiveContent: String?, + server: Nip96MediaServers.ServerName, + contentResolver: ContentResolver, + onProgress: (percentage: Float) -> Unit + ): PartialEvent { + val serverInfo = Nip96Retriever().loadInfo( + server.baseUrl + ) + + return uploadImage(uri, contentType, size, alt, sensitiveContent, serverInfo, contentResolver, onProgress) + } + + suspend fun uploadImage( + uri: Uri, + contentType: String?, + size: Long?, + alt: String?, + sensitiveContent: String?, + server: Nip96Retriever.ServerInfo, + contentResolver: ContentResolver, + onProgress: (percentage: Float) -> Unit + ): PartialEvent { + checkNotInMainThread() + + val myContentType = contentType ?: contentResolver.getType(uri) + val imageInputStream = contentResolver.openInputStream(uri) + + val length = size ?: contentResolver.query(uri, null, null, null, null)?.use { + it.moveToFirst() + val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE) + it.getLong(sizeIndex) + } ?: kotlin.runCatching { + uri.toFile().length() + }.getOrNull() ?: 0 + + checkNotNull(imageInputStream) { + "Can't open the image input stream" + } + + return uploadImage(imageInputStream, length, myContentType, alt, sensitiveContent, server, onProgress) + } + + suspend fun uploadImage( + inputStream: InputStream, + length: Long, + contentType: String?, + alt: String?, + sensitiveContent: String?, + server: Nip96Retriever.ServerInfo, + onProgress: (percentage: Float) -> Unit + ): PartialEvent { + checkNotInMainThread() + + val fileName = randomChars() + val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" + + val client = HttpClient.getHttpClient() + val requestBody: RequestBody + val requestBuilder = Request.Builder() + + requestBody = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("expiration", "") + .addFormDataPart("size", length.toString()) + .also { body -> + alt?.let { body.addFormDataPart("alt", it) } + sensitiveContent?.let { body.addFormDataPart("content-warning", it) } + contentType?.let { body.addFormDataPart("content_type", it) } + } + .addFormDataPart( + "file", + "$fileName.$extension", + + object : RequestBody() { + override fun contentType() = contentType?.toMediaType() + + override fun contentLength() = length + + override fun writeTo(sink: BufferedSink) { + inputStream.source().use(sink::writeAll) + } + } + ) + .build() + + nip98Header(server.apiUrl)?.let { + requestBuilder.addHeader("Authorization", it) + } + + requestBuilder + .addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") + .url(server.apiUrl) + .post(requestBody) + + val request = requestBuilder.build() + + client.newCall(request).execute().use { response -> + if (response.isSuccessful) { + response.body.use { body -> + val str = body.string() + val result = parseResults(str) + + if (!result.processingUrl.isNullOrBlank()) { + return waitProcessing(result, server, onProgress) + } else if (result.status == "success" && result.nip94Event != null) { + return result.nip94Event + } else { + throw RuntimeException("Failed to upload with message: ${result.message}") + } + } + } else { + throw RuntimeException("Error Uploading image: ${response.code}") + } + } + } + + private suspend fun waitProcessing( + result: Nip96Result, + server: Nip96Retriever.ServerInfo, + onProgress: (percentage: Float) -> Unit + ): PartialEvent { + val client = HttpClient.getHttpClient() + var currentResult = result + + while (!result.processingUrl.isNullOrBlank() && (currentResult.percentage ?: 100) < 100) { + onProgress((currentResult.percentage ?: 100) / 100f) + + val request: Request = Request.Builder() + .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") + .url(result.processingUrl) + .build() + + client.newCall(request).execute().use { + if (it.isSuccessful) { + it.body.use { + currentResult = parseResults(it.string()) + } + } + } + + delay(500) + } + onProgress((currentResult.percentage ?: 100) / 100f) + + val nip94 = currentResult.nip94Event + + if (nip94 != null) { + return nip94 + } else { + throw RuntimeException("Error waiting for processing. Final result is unavailable") + } + } + + suspend fun nip98Header(url: String): String? { + return withTimeoutOrNull(5000) { + suspendCancellableCoroutine { continuation -> + nip98Header(url, "POST") { authorizationToken -> + continuation.resume(authorizationToken) + } + } + } + } + + fun nip98Header(url: String, method: String, file: ByteArray? = null, onReady: (String?) -> Unit) { + val myAccount = account + + if (myAccount == null) { + onReady(null) + return + } + + myAccount.createHTTPAuthorization(url, method, file) { + val encodedNIP98Event = Base64.getEncoder().encodeToString(it.toJson().toByteArray()) + onReady("Nostr $encodedNIP98Event") + } + } + + data class Nip96Result( + val status: String, + val message: String? = null, + @JsonProperty("processing_url") + val processingUrl: String? = null, + val percentage: Int? = null, + @JsonProperty("nip94_event") + val nip94Event: PartialEvent? = null + ) + + class PartialEvent( + val tags: Array>? = null, + val content: String? = null + ) + + private fun parseResults(body: String): Nip96Result { + val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + return mapper.readValue(body, Nip96Result::class.java) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt index e7dacbd57..cc07681a1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt @@ -85,12 +85,12 @@ import com.vitorpamplona.amethyst.model.LiveActivitiesChannel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.amethyst.model.ServersAvailable import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.NostrChannelDataSource import com.vitorpamplona.amethyst.ui.actions.NewChannelView import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel +import com.vitorpamplona.amethyst.ui.actions.ServerOption import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy @@ -401,24 +401,13 @@ fun EditFieldRow( tint = MaterialTheme.colorScheme.placeholderText, modifier = EditFieldLeadingIconModifier ) { - val fileServer = if (isPrivate) { - // TODO: Make private servers - when (accountViewModel.account.defaultFileServer) { - ServersAvailable.NOSTR_BUILD -> ServersAvailable.NOSTR_BUILD - ServersAvailable.NOSTRIMG -> ServersAvailable.NOSTRIMG - ServersAvailable.NOSTRFILES_DEV -> ServersAvailable.NOSTRFILES_DEV - ServersAvailable.NOSTRCHECK_ME -> ServersAvailable.NOSTRCHECK_ME - ServersAvailable.NOSTR_BUILD_NIP_94 -> ServersAvailable.NOSTR_BUILD - ServersAvailable.NOSTRIMG_NIP_94 -> ServersAvailable.NOSTRIMG - ServersAvailable.NOSTRFILES_DEV_NIP_94 -> ServersAvailable.NOSTRFILES_DEV - ServersAvailable.NOSTRCHECK_ME_NIP_94 -> ServersAvailable.NOSTRCHECK_ME - ServersAvailable.NIP95 -> ServersAvailable.NOSTR_BUILD - } - } else { - accountViewModel.account.defaultFileServer - } - - channelScreenModel.upload(it, "", false, fileServer, context) + channelScreenModel.upload( + galleryUri = it, + alt = null, + sensitiveContent = false, + server = ServerOption(accountViewModel.account.defaultFileServer, false), + context = context + ) } }, colors = TextFieldDefaults.colors( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt index 6e1c0eaa4..21fa4f57c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt @@ -65,12 +65,12 @@ import androidx.lifecycle.map import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.ServersAvailable import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.NostrChatroomDataSource import com.vitorpamplona.amethyst.ui.actions.CloseButton import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel import com.vitorpamplona.amethyst.ui.actions.PostButton +import com.vitorpamplona.amethyst.ui.actions.ServerOption import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture @@ -379,26 +379,14 @@ fun PrivateMessageEditFieldRow( .size(30.dp) .padding(start = 2.dp) ) { - val fileServer = if (isPrivate) { - // TODO: Make private servers - when (accountViewModel.account.defaultFileServer) { - ServersAvailable.NOSTR_BUILD -> ServersAvailable.NOSTR_BUILD - ServersAvailable.NOSTRIMG -> ServersAvailable.NOSTRIMG - ServersAvailable.NOSTRFILES_DEV -> ServersAvailable.NOSTRFILES_DEV - ServersAvailable.NOSTRCHECK_ME -> ServersAvailable.NOSTRCHECK_ME - - ServersAvailable.NOSTR_BUILD_NIP_94 -> ServersAvailable.NOSTR_BUILD - ServersAvailable.NOSTRIMG_NIP_94 -> ServersAvailable.NOSTRIMG - ServersAvailable.NOSTRFILES_DEV_NIP_94 -> ServersAvailable.NOSTRFILES_DEV - ServersAvailable.NOSTRCHECK_ME_NIP_94 -> ServersAvailable.NOSTRCHECK_ME - - ServersAvailable.NIP95 -> ServersAvailable.NOSTR_BUILD - } - } else { - accountViewModel.account.defaultFileServer - } - - channelScreenModel.upload(it, "", false, fileServer, context) + channelScreenModel.upload( + galleryUri = it, + alt = null, + sensitiveContent = false, + isPrivate = isPrivate, + server = ServerOption(accountViewModel.account.defaultFileServer, false), + context = context + ) } var wantsToActivateNIP24 by remember { diff --git a/app/src/test/java/com/vitorpamplona/amethyst/service/Nip96Test.kt b/app/src/test/java/com/vitorpamplona/amethyst/service/Nip96Test.kt new file mode 100644 index 000000000..62648457c --- /dev/null +++ b/app/src/test/java/com/vitorpamplona/amethyst/service/Nip96Test.kt @@ -0,0 +1,119 @@ +package com.vitorpamplona.amethyst.service + +import junit.framework.TestCase.assertEquals +import org.junit.Test + +class Nip96Test { + + val json = """ + { + "api_url": "https://nostr.build/api/v2/nip96/upload", + "download_url": "https://media.nostr.build", + "supported_nips": [ + 94, + 96, + 98 + ], + "tos_url": "https://nostr.build/tos/", + "content_types": [ + "image/*", + "video/*", + "audio/*" + ], + "plans": { + "free": { + "name": "Free", + "is_nip98_required": true, + "url": "https://nostr.build", + "max_byte_size": 26214400, + "file_expiration": [ + 0, + 0 + ], + "media_transformations": { + "image": [ + "resizing", + "format_conversion", + "compression", + "metadata_stripping" + ], + "video": [ + "resizing", + "format_conversion", + "compression" + ] + } + }, + "professional": { + "name": "Professional", + "is_nip98_required": true, + "url": "https://nostr.build/signup/new/", + "max_byte_size": 10737418240, + "file_expiration": [ + 0, + 0 + ], + "media_transformations": { + "image": [ + "resizing", + "format_conversion", + "compression", + "metadata_stripping" + ], + "video": [ + "resizing", + "format_conversion", + "compression" + ] + } + }, + "creator": { + "name": "Creator", + "is_nip98_required": true, + "url": "https://nostr.build/signup/new/", + "max_byte_size": 26843545600, + "file_expiration": [ + 0, + 0 + ], + "media_transformations": { + "image": [ + "resizing", + "format_conversion", + "compression", + "metadata_stripping" + ], + "video": [ + "resizing", + "format_conversion", + "compression" + ] + } + } + } + } + """.trimIndent() + + @Test() + fun parseNostrBuild() { + val info = Nip96Retriever().parse(json) + + assertEquals("https://nostr.build/api/v2/nip96/upload", info.apiUrl) + assertEquals("https://media.nostr.build", info.downloadUrl) + assertEquals(listOf(94, 96, 98), info.supportedNips) + assertEquals("https://nostr.build/tos/", info.tosUrl) + assertEquals(listOf("image/*", "video/*", "audio/*"), info.contentTypes) + + assertEquals(listOf("creator", "free", "professional"), info.plans.keys.sorted()) + + assertEquals("Free", info.plans["free"]?.name) + assertEquals(true, info.plans["free"]?.isNip98Required) + assertEquals("https://nostr.build", info.plans["free"]?.url) + assertEquals(26214400L, info.plans["free"]?.maxByteSize) + assertEquals(listOf(0, 0), info.plans["free"]?.fileExpiration) + assertEquals(listOf("image", "video"), info.plans["free"]?.mediaTransformations?.keys?.sorted()) + + assertEquals(26843545600L, info.plans["creator"]?.maxByteSize) + assertEquals(10737418240L, info.plans["professional"]?.maxByteSize) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt index 0c77bbd66..b84e3bbd4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt @@ -37,6 +37,7 @@ class ChannelMessageEvent( markAsSensitive: Boolean, zapRaiserAmount: Long?, geohash: String? = null, + nip94attachments: List? = null, onReady: (ChannelMessageEvent) -> Unit ) { val tags = mutableListOf( @@ -60,6 +61,11 @@ class ChannelMessageEvent( geohash?.let { tags.addAll(geohashMipMap(it)) } + nip94attachments?.let { + it.forEach { + tags.add(arrayOf("nip94", it.toJson())) + } + } signer.sign(createdAt, kind, tags.toTypedArray(), message, onReady) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt index 8f4f2fdf2..b893d5f79 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt @@ -63,6 +63,7 @@ class ClassifiedsEvent( markAsSensitive: Boolean, zapRaiserAmount: Long?, geohash: String? = null, + nip94attachments: List? = null, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (ClassifiedsEvent) -> Unit @@ -133,6 +134,11 @@ class ClassifiedsEvent( geohash?.let { tags.addAll(geohashMipMap(it)) } + nip94attachments?.let { + it.forEach { + tags.add(arrayOf("nip94", it.toJson())) + } + } signer.sign(createdAt, kind, tags.toTypedArray(), message, onReady) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt index 05fafdc40..4c177d6fc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt @@ -19,6 +19,8 @@ class FileHeaderEvent( ) : Event(id, pubKey, createdAt, kind, tags, content, sig) { fun url() = tags.firstOrNull { it.size > 1 && it[0] == URL }?.get(1) + fun urls() = tags.filter { it.size > 1 && it[0] == URL }.map { it[1] } + fun encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) } fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1) fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1) @@ -43,16 +45,19 @@ class FileHeaderEvent( private const val MAGNET_URI = "magnet" private const val TORRENT_INFOHASH = "i" private const val BLUR_HASH = "blurhash" + private const val ORIGINAL_HASH = "ox" private const val ALT = "alt" fun create( url: String, + magnetUri: String? = null, mimeType: String? = null, alt: String? = null, hash: String? = null, size: String? = null, dimensions: String? = null, blurhash: String? = null, + originalHash: String? = null, magnetURI: String? = null, torrentInfoHash: String? = null, encryptionKey: AESGCM? = null, @@ -63,12 +68,14 @@ class FileHeaderEvent( ) { val tags = listOfNotNull( arrayOf(URL, url), - mimeType?.let { arrayOf(MIME_TYPE, mimeType) }, + magnetUri?.let { arrayOf(MAGNET_URI, it) }, + mimeType?.let { arrayOf(MIME_TYPE, it) }, alt?.ifBlank { null }?.let { arrayOf(ALT, it) }, hash?.let { arrayOf(HASH, it) }, size?.let { arrayOf(FILE_SIZE, it) }, dimensions?.let { arrayOf(DIMENSION, it) }, blurhash?.let { arrayOf(BLUR_HASH, it) }, + originalHash?.let { arrayOf(ORIGINAL_HASH, it) }, magnetURI?.let { arrayOf(MAGNET_URI, it) }, torrentInfoHash?.let { arrayOf(TORRENT_INFOHASH, it) }, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/HTTPAuthorizationEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/HTTPAuthorizationEvent.kt index b0590267f..cfc3f5312 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/HTTPAuthorizationEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/HTTPAuthorizationEvent.kt @@ -24,14 +24,14 @@ class HTTPAuthorizationEvent( fun create( url: String, method: String, - body: String? = null, + file: ByteArray? = null, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (HTTPAuthorizationEvent) -> Unit ) { var hash = "" - body?.let { - hash = CryptoUtils.sha256(it.toByteArray()).toHexKey() + file?.let { + hash = CryptoUtils.sha256(file).toHexKey() } val tags = listOfNotNull( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt index ba8c65db7..7fae80a7d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt @@ -56,6 +56,7 @@ class LiveActivitiesChatMessageEvent( markAsSensitive: Boolean, zapRaiserAmount: Long?, geohash: String? = null, + nip94attachments: List? = null, onReady: (LiveActivitiesChatMessageEvent) -> Unit ) { val content = message @@ -80,6 +81,11 @@ class LiveActivitiesChatMessageEvent( geohash?.let { tags.addAll(geohashMipMap(it)) } + nip94attachments?.let { + it.forEach { + tags.add(arrayOf("nip94", it.toJson())) + } + } signer.sign(createdAt, kind, tags.toTypedArray(), content, onReady) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt index 981f80ff6..a42daa59c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt @@ -60,6 +60,7 @@ class PollNoteEvent( markAsSensitive: Boolean, zapRaiserAmount: Long?, geohash: String? = null, + nip94attachments: List? = null, onReady: (PollNoteEvent) -> Unit ) { val tags = mutableListOf>() @@ -99,6 +100,11 @@ class PollNoteEvent( geohash?.let { tags.addAll(geohashMipMap(it)) } + nip94attachments?.let { + it.forEach { + tags.add(arrayOf("nip94", it.toJson())) + } + } signer.sign(createdAt, kind, tags.toTypedArray(), msg, onReady) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt index ce699a73f..37ed4e7c8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt @@ -39,6 +39,7 @@ class TextNoteEvent( root: String?, directMentions: Set, geohash: String? = null, + nip94attachments: List? = null, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (TextNoteEvent) -> Unit @@ -96,6 +97,11 @@ class TextNoteEvent( geohash?.let { tags.addAll(geohashMipMap(it)) } + nip94attachments?.let { + it.forEach { + tags.add(arrayOf("nip94", it.toJson())) + } + } signer.sign(createdAt, kind, tags.toTypedArray(), msg, onReady) }