diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt index 304e8d7d4..96f956ab5 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt @@ -27,13 +27,13 @@ import androidx.test.platform.app.InstrumentationRegistry import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.service.uploads.FileHeader +import com.vitorpamplona.amethyst.service.uploads.ImageDownloader import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader import com.vitorpamplona.amethyst.service.uploads.nip96.ServerInfoRetriever import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType -import com.vitorpamplona.amethyst.ui.actions.uploads.ImageDownloader import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.encoders.toHexKey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index c59a46044..db0cebeb6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -67,7 +67,6 @@ import com.vitorpamplona.quartz.events.BookmarkListEvent import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent -import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.CommentEvent @@ -101,6 +100,7 @@ import com.vitorpamplona.quartz.events.LnZapRequestEvent import com.vitorpamplona.quartz.events.MetadataEvent import com.vitorpamplona.quartz.events.MuteListEvent import com.vitorpamplona.quartz.events.NIP17Factory +import com.vitorpamplona.quartz.events.NIP17Group import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryRequestEvent import com.vitorpamplona.quartz.events.OtsEvent import com.vitorpamplona.quartz.events.PeopleListEvent @@ -1233,14 +1233,9 @@ class Account( return } - if (note.event is ChatMessageEvent) { - val event = note.event as ChatMessageEvent - val users = - event - .recipientsPubKey() - .plus(event.pubKey) - .toSet() - .toList() + val noteEvent = note.event + if (noteEvent is NIP17Group) { + val users = noteEvent.groupMembers().toList() if (reaction.startsWith(":")) { val emojiUrl = EmojiUrl.decode(reaction) @@ -2959,6 +2954,48 @@ class Account( } } + fun sendNIP17EncryptedFile( + url: String, + toUsers: List, + replyingTo: Note? = null, + contentType: String?, + algo: String, + key: ByteArray, + nonce: ByteArray? = null, + originalHash: String? = null, + hash: String? = null, + size: Int? = null, + dimensions: Dimension? = null, + blurhash: String? = null, + sensitiveContent: Boolean? = null, + alt: String?, + ) { + if (!isWriteable()) return + + val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } + + NIP17Factory().createEncryptedFileNIP17( + url = url, + to = toUsers, + repliesToHex = repliesToHex, + contentType = contentType, + algo = algo, + key = key, + nonce = nonce, + originalHash = originalHash, + hash = hash, + size = size, + dimensions = dimensions, + blurhash = blurhash, + sensitiveContent = sensitiveContent, + alt = alt, + draftTag = null, + signer = signer, + ) { + broadcastPrivately(it) + } + } + fun sendNIP17PrivateMessage( message: String, toUsers: List, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index cfb7ae2ab..8918b0878 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -60,6 +60,7 @@ import com.vitorpamplona.quartz.events.ChannelListEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent import com.vitorpamplona.quartz.events.ChannelMuteUserEvent +import com.vitorpamplona.quartz.events.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent import com.vitorpamplona.quartz.events.ChatroomKey @@ -625,6 +626,8 @@ object LocalCache { is CommentEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } is ChatMessageEvent -> event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) } + is ChatMessageEncryptedFileHeaderEvent -> event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) } + is LnZapEvent -> event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } + event.taggedAddresses().map { getOrCreateAddressableNote(it) } + @@ -1621,7 +1624,50 @@ object LocalCache { // Already processed this event. if (note.event != null) return - val recipientsHex = event.recipientsPubKey().plus(event.pubKey).toSet() + val recipientsHex = event.groupMembers() + val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet() + + // Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}") + + val repliesTo = computeReplyTo(event) + + note.loadEvent(event, author, repliesTo) + + if (recipients.isNotEmpty()) { + recipients.forEach { + val groupMinusRecipient = recipientsHex.minus(it.pubkeyHex) + + val authorGroup = + if (groupMinusRecipient.isEmpty()) { + // note to self + ChatroomKey(persistentSetOf(it.pubkeyHex)) + } else { + ChatroomKey(groupMinusRecipient.toImmutableSet()) + } + + it.addMessage(authorGroup, note) + } + } + + refreshObservers(note) + } + + private fun consume( + event: ChatMessageEncryptedFileHeaderEvent, + relay: Relay?, + ) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } + + // Already processed this event. + if (note.event != null) return + + val recipientsHex = event.groupMembers() val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet() // Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}") @@ -2264,7 +2310,27 @@ object LocalCache { } } is ChatMessageEvent -> { - val recipientsHex = draft.recipientsPubKey().plus(draftWrap.pubKey).toSet() + val recipientsHex = draft.groupMembers() + val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet() + + if (recipients.isNotEmpty()) { + recipients.forEach { + val groupMinusRecipient = recipientsHex.minus(it.pubkeyHex) + + val authorGroup = + if (groupMinusRecipient.isEmpty()) { + // note to self + ChatroomKey(persistentSetOf(it.pubkeyHex)) + } else { + ChatroomKey(groupMinusRecipient.toImmutableSet()) + } + + it.addMessage(authorGroup, note) + } + } + } + is ChatMessageEncryptedFileHeaderEvent -> { + val recipientsHex = draft.groupMembers() val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet() if (recipients.isNotEmpty()) { @@ -2332,6 +2398,26 @@ object LocalCache { } } } + is ChatMessageEncryptedFileHeaderEvent -> { + val recipientsHex = draft.groupMembers() + val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet() + + if (recipients.isNotEmpty()) { + recipients.forEach { + val groupMinusRecipient = recipientsHex.minus(it.pubkeyHex) + + val authorGroup = + if (groupMinusRecipient.isEmpty()) { + // note to self + ChatroomKey(persistentSetOf(it.pubkeyHex)) + } else { + ChatroomKey(groupMinusRecipient.toImmutableSet()) + } + + it.removeMessage(authorGroup, draftWrap) + } + } + } is ChannelMessageEvent -> { draft.channel()?.let { channelId -> checkGetOrCreateChannel(channelId)?.let { channel -> @@ -2398,6 +2484,7 @@ object LocalCache { is ChannelMessageEvent -> consume(event, relay) is ChannelMetadataEvent -> consume(event) is ChannelMuteUserEvent -> consume(event) + is ChatMessageEncryptedFileHeaderEvent -> consume(event, relay) is ChatMessageEvent -> consume(event, relay) is ChatMessageRelayListEvent -> consume(event, relay) is ClassifiedsEvent -> consume(event, relay) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index cc9efa88b..fc4db56e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZa import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.encoders.toNpub +import com.vitorpamplona.quartz.events.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.DraftEvent import com.vitorpamplona.quartz.events.Event @@ -121,6 +122,9 @@ class EventNotificationConsumer( } else if (innerEvent is ChatMessageEvent) { Log.d(TAG, "New ChatMessage to Notify") notify(innerEvent, signer, account) + } else if (innerEvent is ChatMessageEncryptedFileHeaderEvent) { + Log.d(TAG, "New ChatMessage File to Notify") + notify(innerEvent, signer, account) } } } @@ -194,6 +198,51 @@ class EventNotificationConsumer( } } + private fun notify( + event: ChatMessageEncryptedFileHeaderEvent, + signer: NostrSigner, + acc: AccountSettings, + ) { + if ( + // old event being re-broadcasted + event.createdAt > TimeUtils.fifteenMinutesAgo() && + // don't display if it comes from me. + event.pubKey != signer.pubKey + ) { // from the user + Log.d(TAG, "Notifying") + val myUser = LocalCache.getUserIfExists(signer.pubKey) ?: return + val chatNote = LocalCache.getNoteIfExists(event.id) ?: return + val chatRoom = event.chatroomKey(signer.pubKey) + + val followingKeySet = acc.backupContactList?.unverifiedFollowKeySet()?.toSet() ?: return + + val isKnownRoom = + ( + myUser.privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true || + myUser.hasSentMessagesTo(chatRoom) + ) + + if (isKnownRoom) { + val content = chatNote.event?.content() ?: "" + val user = chatNote.author?.toBestDisplayName() ?: "" + val userPicture = chatNote.author?.profilePicture() + val noteUri = chatNote.toNEvent() + "?account=" + acc.keyPair.pubKey.toNpub() + + // TODO: Show Image on notification + notificationManager() + .sendDMNotification( + event.id, + content, + user, + event.createdAt, + userPicture, + noteUri, + applicationContext, + ) + } + } + } + private fun notify( event: ChatMessageEvent, signer: NostrSigner, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/CombinedUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DefaultContentTypeInterceptor.kt similarity index 65% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/CombinedUploader.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DefaultContentTypeInterceptor.kt index a7b553038..e53ae2ee1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/CombinedUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DefaultContentTypeInterceptor.kt @@ -18,6 +18,22 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.uploads +package com.vitorpamplona.amethyst.service.okhttp -class CombinedUploader +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response + +class DefaultContentTypeInterceptor( + private val userAgentHeader: String, +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest: Request = chain.request() + val requestWithUserAgent: Request = + originalRequest + .newBuilder() + .header("User-Agent", userAgentHeader) + .build() + return chain.proceed(requestWithUserAgent) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptedBlobInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptedBlobInterceptor.kt new file mode 100644 index 000000000..c42df6d5c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptedBlobInterceptor.kt @@ -0,0 +1,72 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.okhttp + +import android.util.Log +import com.vitorpamplona.quartz.crypto.nip17.AESGCM +import com.vitorpamplona.quartz.crypto.nip17.NostrCipher +import okhttp3.Interceptor +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody + +class EncryptedBlobInterceptor( + val cache: EncryptionKeyCache, +) : Interceptor { + fun Response.decrypt(cipher: NostrCipher): Response { + val body = peekBody(Long.MAX_VALUE) + val decryptedBytes = cipher.decrypt(body.bytes()) + val newBody = decryptedBytes.toResponseBody(body.contentType()) + return newBuilder().body(newBody).build() + } + + fun Response.decryptOrNull(cipher: NostrCipher): Response? = + try { + decrypt(cipher) + } catch (e: Exception) { + Log.w("EncryptedBlobInterceptor", "Failed to decrypt", e) + null + } + + private fun Response.decryptOrNullWithErrorCorrection(cipher: NostrCipher): Response? { + return decryptOrNull(cipher) ?: return if (cipher is AESGCM) { + decryptOrNull(cipher.copyUsingUTF8Nonce()) + } else { + null + } + } + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val response = chain.proceed(request) + + val cipher = cache.get(request.url.toString()) ?: return response + + if (response.isSuccessful) { + return response.decryptOrNullWithErrorCorrection(cipher) ?: response + } else { + // Log redirections to be able to use the cipher. + response.header("Location")?.let { + cache.add(it, cipher) + } + } + return response + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptionKeyCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptionKeyCache.kt new file mode 100644 index 000000000..7c9006525 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptionKeyCache.kt @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.okhttp + +import android.util.LruCache +import com.vitorpamplona.quartz.crypto.nip17.NostrCipher + +/** + * Neigther ExoPlayer, nor Coil support passing key and nonce to the Interceptor via + * Request.tag, which would be the right way to do this. + * + * This class serves as a key cache to decrypt the body of HTTP calls that need it. + */ +class EncryptionKeyCache { + val cache = LruCache(100) + + fun add( + url: String?, + cipher: NostrCipher, + ) { + if (cache.get(url) == null) { + cache.put(url, cipher) + } + } + + fun get(url: String): NostrCipher? = cache.get(url) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/HttpClientManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/HttpClientManager.kt index 8f5f9c7b4..b3dd23c4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/HttpClientManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/HttpClientManager.kt @@ -22,18 +22,13 @@ package com.vitorpamplona.amethyst.service.okhttp import android.util.Log import com.vitorpamplona.quartz.crypto.nip17.NostrCipher -import okhttp3.Interceptor import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import okhttp3.ResponseBody.Companion.toResponseBody -import java.io.IOException import java.net.InetSocketAddress import java.net.Proxy import java.time.Duration object HttpClientManager { - val rootClient = + private val rootClient = OkHttpClient .Builder() .followRedirects(true) @@ -50,6 +45,8 @@ object HttpClientManager { private var currentProxy: Proxy? = null + private val cache = EncryptionKeyCache() + fun setDefaultProxy(proxy: Proxy?) { if (currentProxy != proxy) { Log.d("HttpClient", "Changing proxy to: ${proxy != null}") @@ -96,67 +93,10 @@ object HttpClientManager { .writeTimeout(duration) .addInterceptor(DefaultContentTypeInterceptor(userAgent)) .addNetworkInterceptor(LoggingInterceptor()) - .addNetworkInterceptor(EncryptedBlobInterceptor()) + .addNetworkInterceptor(EncryptedBlobInterceptor(cache)) .build() } - class DefaultContentTypeInterceptor( - private val userAgentHeader: String, - ) : Interceptor { - @Throws(IOException::class) - override fun intercept(chain: Interceptor.Chain): Response { - val originalRequest: Request = chain.request() - val requestWithUserAgent: Request = - originalRequest - .newBuilder() - .header("User-Agent", userAgentHeader) - .build() - return chain.proceed(requestWithUserAgent) - } - } - - class EncryptedBlobInterceptor : Interceptor { - override fun intercept(chain: Interceptor.Chain): Response { - val response = chain.proceed(chain.request()) - - if (response.isSuccessful) { - val cipher = chain.request().tag(NostrCipher::class) - - println("AABBCC Cipher ${chain.request().tag(NostrCipher::class)}") - - if (cipher != null) { - val body = response.peekBody(Long.MAX_VALUE) - val decryptedBytes = cipher.decrypt(body.bytes()) - val newBody = decryptedBytes.toResponseBody(body.contentType()) - return response.newBuilder().body(newBody).build() - } - } - return response - } - } - - class LoggingInterceptor : Interceptor { - @Throws(IOException::class) - override fun intercept(chain: Interceptor.Chain): Response { - val request: Request = chain.request() - val t1 = System.nanoTime() - val port = - ( - chain - .connection() - ?.route() - ?.proxy - ?.address() as? InetSocketAddress - )?.port - val response: Response = chain.proceed(request) - val t2 = System.nanoTime() - - Log.d("OkHttpLog", "Req $port ${request.url} in ${(t2 - t1) / 1e6}ms") - - return response - } - } - fun getCurrentProxyPort(useProxy: Boolean): Int? = if (useProxy) { (currentProxy?.address() as? InetSocketAddress)?.port @@ -180,4 +120,9 @@ object HttpClientManager { fun setDefaultProxyOnPort(port: Int) { setDefaultProxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port))) } + + fun addCipherToCache( + url: String, + cipher: NostrCipher, + ) = cache.add(url, cipher) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LoggingInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LoggingInterceptor.kt new file mode 100644 index 000000000..4f55de1bf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LoggingInterceptor.kt @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.okhttp + +import android.util.Log +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import java.net.InetSocketAddress + +class LoggingInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request: Request = chain.request() + val t1 = System.nanoTime() + val port = + ( + chain + .connection() + ?.route() + ?.proxy + ?.address() as? InetSocketAddress + )?.port + val response: Response = chain.proceed(request) + val t2 = System.nanoTime() + + Log.d("OkHttpLog", "Req $port ${request.url} in ${(t2 - t1) / 1e6}ms") + + return response + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/MultiPlayerPlaybackManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/MultiPlayerPlaybackManager.kt index c0c1f0b97..7619f61aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/MultiPlayerPlaybackManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/MultiPlayerPlaybackManager.kt @@ -91,7 +91,7 @@ class MultiPlayerPlaybackManager( val player = ExoPlayer.Builder(context).run { - dataSourceFactory?.let { setMediaSourceFactory(it) } + setMediaSourceFactory(dataSourceFactory) build() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/EncryptFiles.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/EncryptFiles.kt new file mode 100644 index 000000000..188993569 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/EncryptFiles.kt @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.uploads + +import android.content.Context +import android.net.Uri +import androidx.core.net.toUri +import com.vitorpamplona.quartz.crypto.CryptoUtils +import com.vitorpamplona.quartz.crypto.nip17.NostrCipher +import com.vitorpamplona.quartz.encoders.toHexKey +import java.io.File + +class EncryptFilesResult( + val uri: Uri, + val contentType: String?, + val originalHash: String, + val encryptedHash: String, + val size: Long?, +) + +class EncryptFiles { + fun encryptFile( + context: Context, + inputFile: Uri, + cipher: NostrCipher, + ): EncryptFilesResult { + val resolver = context.contentResolver + + val encryptedFile = File.createTempFile("EncryptFiles", ".encrypted", context.getCacheDir()) + + resolver.openInputStream(inputFile)!!.use { inputStream -> + val bytes = inputStream.readBytes() + val originalHash = CryptoUtils.sha256(bytes).toHexKey() + val encrypted = cipher.encrypt(bytes) + val encryptedHash = CryptoUtils.sha256(encrypted).toHexKey() + + encryptedFile.outputStream().use { outputStream -> + outputStream.write(encrypted) + } + + return EncryptFilesResult( + encryptedFile.toUri(), + "application/octet-stream", + originalHash, + encryptedHash, + encrypted.size.toLong(), + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt index eb7813763..2564574f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt @@ -30,7 +30,6 @@ import android.os.Build import android.util.Log import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash import com.vitorpamplona.amethyst.service.Blurhash -import com.vitorpamplona.amethyst.ui.actions.uploads.ImageDownloader import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.Dimension import com.vitorpamplona.quartz.encoders.toHexKey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ImageDownloader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/ImageDownloader.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ImageDownloader.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/ImageDownloader.kt index 684f1013c..7645ba560 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ImageDownloader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/ImageDownloader.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.actions.uploads +package com.vitorpamplona.amethyst.service.uploads import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager import kotlinx.coroutines.CancellationException diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MediaCompressor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MediaCompressor.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt index 64a3ee8a0..68639de84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MediaCompressor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.components +package com.vitorpamplona.amethyst.service.uploads import android.content.Context import android.graphics.Bitmap diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt new file mode 100644 index 000000000..f460e7f4f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt @@ -0,0 +1,130 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.uploads + +import android.content.Context +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing +import com.vitorpamplona.quartz.crypto.nip17.NostrCipher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch + +class MultiOrchestrator( + uris: List, +) { + private var list: List = uris.map { SelectedMediaProcessing(it) } + + @Stable + class Result( + val allGood: Boolean, + val successful: List, + val errors: List, + ) + + fun first() = list.first() + + suspend fun upload( + scope: CoroutineScope, + alt: String?, + sensitiveContent: Boolean, + mediaQuality: CompressorQuality, + server: ServerName, + account: Account, + context: Context, + ): Result { + val jobs = + list.map { item -> + scope.launch(Dispatchers.IO) { + item.orchestrator.upload( + item.media.uri, + item.media.mimeType, + alt, + sensitiveContent, + mediaQuality, + server, + account, + context, + ) + } + } + + jobs.joinAll() + + return computeFinalResults() + } + + suspend fun uploadEncrypted( + scope: CoroutineScope, + alt: String?, + sensitiveContent: Boolean, + mediaQuality: CompressorQuality, + cipher: NostrCipher, + server: ServerName, + account: Account, + context: Context, + ): Result { + val jobs = + list.map { item -> + scope.launch(Dispatchers.IO) { + item.orchestrator.uploadEncrypted( + item.media.uri, + item.media.mimeType, + alt, + sensitiveContent, + mediaQuality, + cipher, + server, + account, + context, + ) + } + } + + jobs.joinAll() + + return computeFinalResults() + } + + private fun computeFinalResults(): Result { + val resultsByState = + list.map { + it.orchestrator.progressState.value + } + + val finished = resultsByState.filterIsInstance() + val errors = resultsByState.filterIsInstance() + + return Result(finished.size == list.size, finished, errors) + } + + fun remove(selected: SelectedMediaProcessing) { + list = list.filter { it != selected } + } + + fun size() = list.size + + fun get(index: Int) = list.get(index) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt similarity index 70% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/UploadOrchestrator.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt index 6272cd39e..b8766d932 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/UploadOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt @@ -18,22 +18,18 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.actions.uploads +package com.vitorpamplona.amethyst.service.uploads import android.content.Context import android.net.Uri import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.uploads.FileHeader -import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult +import com.vitorpamplona.amethyst.service.uploads.UploadingState.UploadingFinalState import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType -import com.vitorpamplona.amethyst.ui.actions.uploads.UploadingState.UploadingFinalState -import com.vitorpamplona.amethyst.ui.components.CompressorQuality -import com.vitorpamplona.amethyst.ui.components.MediaCompressor -import com.vitorpamplona.amethyst.ui.components.MediaCompressorResult +import com.vitorpamplona.quartz.crypto.nip17.NostrCipher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map import kotlin.coroutines.cancellation.CancellationException @@ -64,8 +60,6 @@ sealed class UploadingState { } class UploadOrchestrator { - private val compressor = MediaCompressor() - val progress = MutableStateFlow(0.0) val progressState = MutableStateFlow(UploadingState.Ready) @@ -79,7 +73,10 @@ class UploadOrchestrator { vararg params: String, ) = UploadingState.Error(resId, params).also { updateState(0.0, it) } - fun finish(result: OrchestratorResult) = UploadingState.Finished(result).also { updateState(1.0, it) } + fun finish(result: OrchestratorResult) = + UploadingState + .Finished(result) + .also { updateState(1.0, it) } fun updateState( newProgress: Double, @@ -92,6 +89,8 @@ class UploadOrchestrator { private fun uploadNIP95( fileUri: Uri, contentType: String?, + originalContentType: String?, + originalHash: String?, context: Context, ): UploadingFinalState { updateState(0.4, UploadingState.Uploading) @@ -117,7 +116,7 @@ class UploadOrchestrator { result.fold( onSuccess = { - return finish(OrchestratorResult.NIP95Result(it, bytes)) + return finish(OrchestratorResult.NIP95Result(it, bytes, originalContentType, originalHash)) }, onFailure = { return error(R.string.could_not_check_downloaded_file, it.message ?: it.javaClass.simpleName) @@ -135,6 +134,8 @@ class UploadOrchestrator { alt: String?, sensitiveContent: Boolean, serverBaseUrl: String, + contentTypeForResult: String?, + originalHash: String?, account: Account, context: Context, ): UploadingFinalState { @@ -159,6 +160,8 @@ class UploadOrchestrator { verifyHeader( uploadResult = result, localContentType = contentType, + originalContentType = contentTypeForResult, + originalHash = originalHash, forceProxy = account::shouldUseTorForNIP96, ) } catch (e: Exception) { @@ -174,6 +177,8 @@ class UploadOrchestrator { alt: String?, sensitiveContent: Boolean, serverBaseUrl: String, + contentTypeForResult: String?, + originalHash: String?, account: Account, context: Context, ): UploadingFinalState { @@ -197,6 +202,8 @@ class UploadOrchestrator { uploadResult = result, localContentType = contentType, forceProxy = account::shouldUseTorForNIP96, + originalHash = originalHash, + originalContentType = contentTypeForResult, ) } catch (e: Exception) { if (e is CancellationException) throw e @@ -207,6 +214,8 @@ class UploadOrchestrator { private suspend fun verifyHeader( uploadResult: MediaUploadResult, localContentType: String?, + originalContentType: String?, + originalHash: String?, forceProxy: (String) -> Boolean, ): UploadingFinalState { if (uploadResult.url.isNullOrBlank()) { @@ -229,7 +238,16 @@ class UploadOrchestrator { result.fold( onSuccess = { - return finish(OrchestratorResult.ServerResult(it, uploadResult.url, uploadResult.magnet, uploadResult.sha256)) + return finish( + OrchestratorResult.ServerResult( + it, + uploadResult.url, + uploadResult.magnet, + uploadResult.sha256, + originalContentType, + originalHash, + ), + ) }, onFailure = { return error(R.string.could_not_prepare_local_file_to_upload, it.message ?: it.javaClass.simpleName) @@ -244,16 +262,32 @@ class UploadOrchestrator { class NIP95Result( val fileHeader: FileHeader, val bytes: ByteArray, + val mimeTypeBeforeEncryption: String?, + val hashBeforeEncryption: String?, ) : OrchestratorResult() class ServerResult( val fileHeader: FileHeader, val url: String, val magnet: String?, - val originalHash: String?, + val uploadedHash: String?, + val mimeTypeBeforeEncryption: String?, + val hashBeforeEncryption: String?, ) : OrchestratorResult() } + suspend fun compressIfNeeded( + uri: Uri, + mimeType: String?, + compressionQuality: CompressorQuality, + context: Context, + ) = if (compressionQuality != CompressorQuality.UNCOMPRESSED) { + updateState(0.02, UploadingState.Compressing) + MediaCompressor().compress(uri, mimeType, compressionQuality, context.applicationContext) + } else { + MediaCompressorResult(uri, mimeType, null) + } + suspend fun upload( uri: Uri, mimeType: String?, @@ -264,18 +298,33 @@ class UploadOrchestrator { account: Account, context: Context, ): UploadingFinalState { - val result = - if (compressionQuality != CompressorQuality.UNCOMPRESSED) { - updateState(0.02, UploadingState.Compressing) - compressor.compress(uri, mimeType, compressionQuality, context.applicationContext) - } else { - MediaCompressorResult(uri, mimeType, null) - } + val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context) return when (server.type) { - ServerType.NIP95 -> uploadNIP95(result.uri, result.contentType, context) - ServerType.NIP96 -> uploadNIP96(result.uri, result.contentType, result.size, alt, sensitiveContent, server.baseUrl, account, context) - ServerType.Blossom -> uploadBlossom(result.uri, result.contentType, result.size, alt, sensitiveContent, server.baseUrl, account, context) + ServerType.NIP95 -> uploadNIP95(compressed.uri, compressed.contentType, null, null, context) + ServerType.NIP96 -> uploadNIP96(compressed.uri, compressed.contentType, compressed.size, alt, sensitiveContent, server.baseUrl, null, null, account, context) + ServerType.Blossom -> uploadBlossom(compressed.uri, compressed.contentType, compressed.size, alt, sensitiveContent, server.baseUrl, null, null, account, context) + } + } + + suspend fun uploadEncrypted( + uri: Uri, + mimeType: String?, + alt: String?, + sensitiveContent: Boolean, + compressionQuality: CompressorQuality, + encrypt: NostrCipher, + server: ServerName, + account: Account, + context: Context, + ): UploadingFinalState { + val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context) + val encrypted = EncryptFiles().encryptFile(context, compressed.uri, encrypt) + + return when (server.type) { + ServerType.NIP95 -> uploadNIP95(encrypted.uri, encrypted.contentType, compressed.contentType, encrypted.originalHash, context) + ServerType.NIP96 -> uploadNIP96(encrypted.uri, encrypted.contentType, encrypted.size, alt, sensitiveContent, server.baseUrl, compressed.contentType, encrypted.originalHash, account, context) + ServerType.Blossom -> uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, sensitiveContent, server.baseUrl, compressed.contentType, encrypted.originalHash, account, context) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt index c1c8c250d..e19502ff3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt @@ -175,16 +175,18 @@ class BlossomUploader { val explanation = HttpStatusMessages.resourceIdFor(response.code) if (errorMessage != null) { - throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, errorMessage)) + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), errorMessage)) } else if (explanation != null) { - throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, stringRes(context, explanation))) + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), stringRes(context, explanation))) } else { - throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, response.code)) + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), response.code.toString())) } } } } + fun String.displayUrl() = this.removeSuffix("/").removePrefix("https://") + suspend fun delete( hash: String, contentType: String?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt index dbeb05e5e..7ff2eae37 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt @@ -185,7 +185,7 @@ class Nip96Uploader { } else if (result.status == "success" && result.nip94Event != null) { return convertToMediaResult(result.nip94Event) } else { - throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, result.message)) + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), result.message)) } } } else { @@ -207,16 +207,18 @@ class Nip96Uploader { val explanation = HttpStatusMessages.resourceIdFor(response.code) if (errorMessage != null) { - throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, errorMessage)) + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), errorMessage)) } else if (explanation != null) { - throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, stringRes(context, explanation))) + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), stringRes(context, explanation))) } else { - throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, response.code)) + throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), response.code.toString())) } } } } + fun String.displayUrl() = this.removeSuffix("/").removePrefix("https://") + fun convertToMediaResult(nip96: PartialEvent): MediaUploadResult { // Images don't seem to be ready immediately after upload val imageUrl = nip96.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1) @@ -225,9 +227,9 @@ class Nip96Uploader { ?.firstOrNull { it.size > 1 && it[0] == "m" } ?.get(1) ?.ifBlank { null } - val originalHash = + val hash = nip96.tags - ?.firstOrNull { it.size > 1 && it[0] == "ox" } + ?.firstOrNull { it.size > 1 && it[0] == "x" } ?.get(1) ?.ifBlank { null } val dim = @@ -245,7 +247,7 @@ class Nip96Uploader { return MediaUploadResult( url = imageUrl, type = remoteMimeType, - sha256 = originalHash, + sha256 = hash, dimension = dim, magnet = magnet, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt index a83f4e1e7..6aa25517e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt @@ -114,7 +114,6 @@ import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.amethyst.ui.theme.subtleBorder -import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -328,13 +327,13 @@ fun EditPostView( } } - if (postViewModel.mediaToUpload.isNotEmpty()) { + postViewModel.multiOrchestrator?.let { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), ) { ImageVideoDescription( - postViewModel.mediaToUpload, + it, accountViewModel.account.settings.defaultFileServer, onAdd = { alt, server, sensitiveContent, mediaQuality -> postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context) @@ -343,7 +342,7 @@ fun EditPostView( } }, onDelete = postViewModel::deleteMediaToUpload, - onCancel = { postViewModel.mediaToUpload = persistentListOf() }, + onCancel = { postViewModel.multiOrchestrator = null }, onError = { scope.launch { Toast.makeText(context, context.resources.getText(it), Toast.LENGTH_SHORT).show() } }, accountViewModel = accountViewModel, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt index 48d0e828a..fdf1eda15 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.Account @@ -36,23 +37,21 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing -import com.vitorpamplona.amethyst.ui.actions.uploads.UploadOrchestrator -import com.vitorpamplona.amethyst.ui.actions.uploads.UploadingState -import com.vitorpamplona.amethyst.ui.components.MediaCompressor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.ammolite.relays.RelaySetupInfo import com.vitorpamplona.quartz.encoders.IMetaTag import com.vitorpamplona.quartz.encoders.IMetaTagBuilder import com.vitorpamplona.quartz.events.FileStorageEvent import com.vitorpamplona.quartz.events.FileStorageHeaderEvent import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch @Stable @@ -77,7 +76,7 @@ open class EditPostViewModel : ViewModel() { var userSuggestionsMainMessage: UserSuggestionAnchor? = null // Images and Videos - var mediaToUpload by mutableStateOf>(persistentListOf()) + var multiOrchestrator by mutableStateOf(null) // Invoices var canAddInvoice by mutableStateOf(false) @@ -102,7 +101,7 @@ open class EditPostViewModel : ViewModel() { this.account = accountViewModel.account canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null - mediaToUpload = persistentListOf() + multiOrchestrator = null message = TextFieldValue(versionLookingAt?.event?.content() ?: edit.event?.content() ?: "") urlPreview = findUrlInMessage() @@ -156,38 +155,27 @@ open class EditPostViewModel : ViewModel() { onError: (String, String) -> Unit, context: Context, ) { - val myAccount = account ?: return - viewModelScope.launch { + val myAccount = account ?: return@launch + val myMultiOrchestrator = multiOrchestrator ?: return@launch + isUploadingImage = true - val jobs = - mediaToUpload.map { myGalleryUri -> - viewModelScope.launch(Dispatchers.IO) { - myGalleryUri.orchestrator.upload( - myGalleryUri.media.uri, - myGalleryUri.media.mimeType, - alt, - sensitiveContent, - MediaCompressor.intToCompressorQuality(mediaQuality), - server, - myAccount, - context, - ) - } - } + val results = + myMultiOrchestrator.upload( + viewModelScope, + alt, + sensitiveContent, + MediaCompressor.intToCompressorQuality(mediaQuality), + server, + myAccount, + context, + ) - jobs.joinAll() - - val allGood = - mediaToUpload.mapNotNull { - it.orchestrator.progressState.value as? UploadingState.Finished - } - - if (allGood.size == mediaToUpload.size) { - allGood.forEach { - if (it.result is UploadOrchestrator.OrchestratorResult.NIP95Result) { - account?.createNip95(it.result.bytes, headerInfo = it.result.fileHeader, alt, sensitiveContent) { nip95 -> + if (results.allGood) { + results.successful.forEach { state -> + if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) { + account?.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, sensitiveContent) { nip95 -> nip95attachments = nip95attachments + nip95 val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } @@ -197,33 +185,36 @@ open class EditPostViewModel : ViewModel() { urlPreview = findUrlInMessage() } - } else if (it.result is UploadOrchestrator.OrchestratorResult.ServerResult) { + } else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { val iMeta = - IMetaTagBuilder(it.result.url) + IMetaTagBuilder(state.result.url) .apply { - hash(it.result.fileHeader.hash) - size(it.result.fileHeader.size) - it.result.fileHeader.mimeType + hash(state.result.fileHeader.hash) + size(state.result.fileHeader.size) + state.result.fileHeader.mimeType ?.let { mimeType(it) } - it.result.fileHeader.dim + state.result.fileHeader.dim ?.let { dims(it) } - it.result.fileHeader.blurHash + state.result.fileHeader.blurHash ?.let { blurhash(it.blurhash) } - it.result.magnet?.let { magnet(it) } - it.result.originalHash?.let { originalHash(it) } + state.result.magnet?.let { magnet(it) } + state.result.uploadedHash?.let { originalHash(it) } alt?.let { alt(it) } - // TODO: Support Reasons on images if (sensitiveContent) sensitiveContent("") }.build() iMetaAttachments = iMetaAttachments.filter { it.url != iMeta.url } + iMeta - message = message.insertUrlAtCursor(it.result.url) + message = message.insertUrlAtCursor(state.result.url) urlPreview = findUrlInMessage() } } - mediaToUpload = persistentListOf() + this@EditPostViewModel.multiOrchestrator = null + } else { + val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct() + + onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) } isUploadingImage = false @@ -236,7 +227,7 @@ open class EditPostViewModel : ViewModel() { editedFromNote = null - mediaToUpload = persistentListOf() + multiOrchestrator = null urlPreview = null isUploadingImage = false @@ -304,13 +295,13 @@ open class EditPostViewModel : ViewModel() { } } - fun canPost() = message.text.isNotBlank() && !isUploadingImage && !wantsInvoice && mediaToUpload.isEmpty() + fun canPost() = message.text.isNotBlank() && !isUploadingImage && !wantsInvoice && multiOrchestrator == null fun selectImage(uris: ImmutableList) { - mediaToUpload = uris.map { SelectedMediaProcessing(it) }.toImmutableList() + multiOrchestrator = MultiOrchestrator(uris) } fun deleteMediaToUpload(selected: SelectedMediaProcessing) { - this.mediaToUpload = mediaToUpload.filter { it != selected }.toImmutableList() + this.multiOrchestrator?.remove(selected) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt index a18456d8f..325a18809 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt @@ -28,19 +28,19 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing -import com.vitorpamplona.amethyst.ui.actions.uploads.UploadOrchestrator -import com.vitorpamplona.amethyst.ui.actions.uploads.UploadingState -import com.vitorpamplona.amethyst.ui.components.MediaCompressor +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.ammolite.relays.RelaySetupInfo import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch @@ -59,7 +59,7 @@ open class NewMediaModel : ViewModel() { var sensitiveContent by mutableStateOf(false) // Images and Videos - var mediaToUpload by mutableStateOf>(persistentListOf()) + var multiOrchestrator by mutableStateOf(null) var onceUploaded: () -> Unit = {} // 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED @@ -71,7 +71,7 @@ open class NewMediaModel : ViewModel() { ) { this.caption = "" this.account = account - this.mediaToUpload = uris.map { SelectedMediaProcessing(it) }.toImmutableList() + this.multiOrchestrator = MultiOrchestrator(uris) this.selectedServer = defaultServer() } @@ -83,46 +83,37 @@ open class NewMediaModel : ViewModel() { fun upload( context: Context, relayList: List, + onError: (String, String) -> Unit, ) { - val myAccount = account ?: return - if (relayList.isEmpty()) return - val serverToUse = selectedServer ?: return - viewModelScope.launch { + val myAccount = account ?: return@launch + if (relayList.isEmpty()) return@launch + val serverToUse = selectedServer ?: return@launch + + val myMultiOrchestrator = multiOrchestrator ?: return@launch + isUploadingImage = true - val jobs = - mediaToUpload.map { myGalleryUri -> - viewModelScope.launch(Dispatchers.IO) { - myGalleryUri.orchestrator.upload( - myGalleryUri.media.uri, - myGalleryUri.media.mimeType, - caption, - sensitiveContent, - MediaCompressor.intToCompressorQuality(mediaQualitySlider), - serverToUse, - myAccount, - context, - ) - } - } + val results = + myMultiOrchestrator.upload( + viewModelScope, + caption, + sensitiveContent, + MediaCompressor.intToCompressorQuality(mediaQualitySlider), + serverToUse, + myAccount, + context, + ) - jobs.joinAll() - - val allGood = - mediaToUpload.mapNotNull { - it.orchestrator.progressState.value as? UploadingState.Finished - } - - if (allGood.size == mediaToUpload.size) { + if (results.allGood) { // It all finished successfully val nip95s = - allGood.mapNotNull { + results.successful.mapNotNull { it.result as? UploadOrchestrator.OrchestratorResult.NIP95Result } val videosAndOthers = - allGood.mapNotNull { + results.successful.mapNotNull { val map = it.result as? UploadOrchestrator.OrchestratorResult.ServerResult if (map != null && !isImage(map.url, map.fileHeader.mimeType)) { map @@ -132,7 +123,7 @@ open class NewMediaModel : ViewModel() { } val imageUrls = - allGood + results.successful .mapNotNull { val map = it.result as? UploadOrchestrator.OrchestratorResult.ServerResult if (map != null && isImage(map.url, map.fileHeader.mimeType)) { @@ -169,7 +160,7 @@ open class NewMediaModel : ViewModel() { it.fileHeader, caption, sensitiveContent, - it.originalHash, + it.uploadedHash, relayList, ) { continuation.resume(true) @@ -203,22 +194,26 @@ open class NewMediaModel : ViewModel() { onceUploaded() cancelModel() + } else { + val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct() + + onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) } } } open fun cancelModel() { - mediaToUpload = persistentListOf() + multiOrchestrator = null isUploadingImage = false caption = "" selectedServer = defaultServer() } fun deleteMediaToUpload(selected: SelectedMediaProcessing) { - this.mediaToUpload = mediaToUpload.filter { it != selected }.toImmutableList() + multiOrchestrator?.remove(selected) } - fun canPost(): Boolean = !isUploadingImage && mediaToUpload.isNotEmpty() && selectedServer != null + fun canPost(): Boolean = !isUploadingImage && multiOrchestrator != null && selectedServer != null fun defaultServer() = account?.settings?.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0] diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt index 9fd5bebca..47f35746c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt @@ -141,8 +141,7 @@ fun NewMediaView( PostButton( onPost = { onClose() - postViewModel.upload(context, relayList) - // accountViewModel.toast(stringRes(context, R.string.failed_to_upload_media_no_details), it) + postViewModel.upload(context, relayList, onError = accountViewModel::toast) postViewModel.selectedServer?.let { if (it.type != ServerType.NIP95) { account.settings.changeDefaultFileServer(it) @@ -218,11 +217,13 @@ fun ImageVideoPost( }.toImmutableList() } - ShowImageUploadGallery( - postViewModel.mediaToUpload, - postViewModel::deleteMediaToUpload, - accountViewModel, - ) + postViewModel.multiOrchestrator?.let { + ShowImageUploadGallery( + it, + postViewModel::deleteMediaToUpload, + accountViewModel, + ) + } OutlinedTextField( label = { Text(text = stringRes(R.string.add_caption)) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index e47427a74..9bf038c5a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.Account @@ -42,15 +43,17 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.LocationState import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing -import com.vitorpamplona.amethyst.ui.actions.uploads.UploadOrchestrator -import com.vitorpamplona.amethyst.ui.actions.uploads.UploadingState -import com.vitorpamplona.amethyst.ui.components.MediaCompressor import com.vitorpamplona.amethyst.ui.components.Split import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.ammolite.relays.RelaySetupInfo +import com.vitorpamplona.quartz.crypto.nip17.AESGCM import com.vitorpamplona.quartz.encoders.Hex import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.encoders.IMetaTag @@ -59,7 +62,6 @@ import com.vitorpamplona.quartz.encoders.toNpub import com.vitorpamplona.quartz.events.AddressableEvent import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent import com.vitorpamplona.quartz.events.BaseTextNoteEvent -import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.CommentEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent @@ -68,6 +70,7 @@ import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.FileStorageEvent import com.vitorpamplona.quartz.events.FileStorageHeaderEvent import com.vitorpamplona.quartz.events.GitIssueEvent +import com.vitorpamplona.quartz.events.NIP17Group import com.vitorpamplona.quartz.events.Price import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.RootScope @@ -77,13 +80,10 @@ import com.vitorpamplona.quartz.events.TorrentEvent import com.vitorpamplona.quartz.events.ZapSplitSetup import com.vitorpamplona.quartz.events.findURLs import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.UUID @@ -127,7 +127,7 @@ open class NewPostViewModel : ViewModel() { var subject by mutableStateOf(TextFieldValue("")) // Images and Videos - var mediaToUpload by mutableStateOf>(persistentListOf()) + var multiOrchestrator by mutableStateOf(null) // Polls var canUsePoll by mutableStateOf(false) @@ -247,7 +247,7 @@ open class NewPostViewModel : ViewModel() { canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null - mediaToUpload = persistentListOf() + multiOrchestrator = null quote?.let { message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}") @@ -331,7 +331,7 @@ open class NewPostViewModel : ViewModel() { canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null - mediaToUpload = persistentListOf() + multiOrchestrator = null val localfowardZapTo = draftEvent.tags().filter { it.size > 1 && it[0] == "zap" } forwardZapTo = Split() @@ -364,7 +364,7 @@ open class NewPostViewModel : ViewModel() { note } - if (draftEvent !is PrivateDmEvent && draftEvent !is ChatMessageEvent) { + if (draftEvent !is PrivateDmEvent && draftEvent !is NIP17Group) { pTags = draftEvent.tags().filter { it.size > 1 && it[0] == "p" }.map { LocalCache.getOrCreateUser(it[1]) @@ -459,7 +459,7 @@ open class NewPostViewModel : ViewModel() { .firstOrNull() } ?: ClassifiedsEvent.CONDITION.USED_LIKE_NEW - wantsDirectMessage = draftEvent is PrivateDmEvent || draftEvent is ChatMessageEvent + wantsDirectMessage = draftEvent is PrivateDmEvent || draftEvent is NIP17Group draftEvent.subject()?.let { subject = TextFieldValue() @@ -474,13 +474,13 @@ open class NewPostViewModel : ViewModel() { TextFieldValue(draftEvent.content()) } - requiresNIP17 = draftEvent is ChatMessageEvent - nip17 = draftEvent is ChatMessageEvent + requiresNIP17 = draftEvent is NIP17Group + nip17 = draftEvent is NIP17Group - if (draftEvent is ChatMessageEvent) { + if (draftEvent is NIP17Group) { toUsers = TextFieldValue( - draftEvent.recipientsPubKey().mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, + draftEvent.groupMembers().mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, ) } @@ -627,18 +627,10 @@ open class NewPostViewModel : ViewModel() { imetas = usedAttachments, draftTag = localDraft, ) - } else if (originalNote?.event is ChatMessageEvent) { - val receivers = - (originalNote?.event as ChatMessageEvent) - .recipientsPubKey() - .plus(originalNote?.author?.pubkeyHex) - .filterNotNull() - .toSet() - .toList() - + } else if (originalNote?.event is NIP17Group) { account?.sendNIP17PrivateMessage( message = tagger.message, - toUsers = receivers, + toUsers = (originalNote?.event as NIP17Group).groupMembers().toList(), subject = subject.text.ifBlank { null }, replyingTo = originalNote!!, mentions = tagger.pTags, @@ -865,6 +857,70 @@ open class NewPostViewModel : ViewModel() { } } + fun uploadAsSeparatePrivateEvent( + toUsers: Set, + alt: String?, + sensitiveContent: Boolean, + mediaQuality: Int, + server: ServerName, + onError: (title: String, message: String) -> Unit, + context: Context, + ) { + val myAccount = account ?: return + + viewModelScope.launch(Dispatchers.Default) { + isUploadingImage = true + + val cipher = AESGCM() + val myMultiOrchestrator = multiOrchestrator ?: return@launch + + val results = + myMultiOrchestrator.uploadEncrypted( + viewModelScope, + alt, + sensitiveContent, + MediaCompressor.intToCompressorQuality(mediaQuality), + cipher, + server, + myAccount, + context, + ) + + if (results.allGood) { + results.successful.forEach { state -> + if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { + account?.sendNIP17EncryptedFile( + url = state.result.url, + toUsers = toUsers.toList(), + replyingTo = originalNote, + contentType = state.result.mimeTypeBeforeEncryption, + algo = cipher.name(), + key = cipher.keyBytes, + nonce = cipher.nonce, + originalHash = state.result.hashBeforeEncryption, + hash = state.result.fileHeader.hash, + size = state.result.fileHeader.size, + dimensions = state.result.fileHeader.dim, + blurhash = + state.result.fileHeader.blurHash + ?.blurhash, + alt = alt, + sensitiveContent = sensitiveContent, + ) + } + } + + multiOrchestrator = null + } else { + val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct() + + onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) + } + + isUploadingImage = false + } + } + fun upload( alt: String?, sensitiveContent: Boolean, @@ -874,36 +930,26 @@ open class NewPostViewModel : ViewModel() { onError: (title: String, message: String) -> Unit, context: Context, ) { - val myAccount = account ?: return + viewModelScope.launch(Dispatchers.Default) { + val myAccount = account ?: return@launch + + val myMultiOrchestrator = multiOrchestrator ?: return@launch - viewModelScope.launch { isUploadingImage = true - val jobs = - mediaToUpload.map { myGalleryUri -> - viewModelScope.launch(Dispatchers.IO) { - myGalleryUri.orchestrator.upload( - myGalleryUri.media.uri, - myGalleryUri.media.mimeType, - alt, - sensitiveContent, - MediaCompressor.intToCompressorQuality(mediaQuality), - server, - myAccount, - context, - ) - } - } + val results = + myMultiOrchestrator.upload( + viewModelScope, + alt, + sensitiveContent, + MediaCompressor.intToCompressorQuality(mediaQuality), + server, + myAccount, + context, + ) - jobs.joinAll() - - val allGood = - mediaToUpload.mapNotNull { - it.orchestrator.progressState.value as? UploadingState.Finished - } - - if (allGood.size == mediaToUpload.size) { - allGood.forEach { + if (results.allGood) { + results.successful.forEach { if (it.result is UploadOrchestrator.OrchestratorResult.NIP95Result) { account?.createNip95(it.result.bytes, headerInfo = it.result.fileHeader, alt, sensitiveContent) { nip95 -> nip95attachments = nip95attachments + nip95 @@ -928,7 +974,7 @@ open class NewPostViewModel : ViewModel() { it.result.fileHeader.blurHash ?.let { blurhash(it.blurhash) } it.result.magnet?.let { magnet(it) } - it.result.originalHash?.let { originalHash(it) } + it.result.uploadedHash?.let { originalHash(it) } alt?.let { alt(it) } // TODO: Support Reasons on images if (sensitiveContent) sensitiveContent("") @@ -941,7 +987,11 @@ open class NewPostViewModel : ViewModel() { } } - mediaToUpload = persistentListOf() + multiOrchestrator = null + } else { + val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct() + + onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) } isUploadingImage = false @@ -955,7 +1005,7 @@ open class NewPostViewModel : ViewModel() { forkedFromNote = null - mediaToUpload = persistentListOf() + multiOrchestrator = null urlPreview = null isUploadingImage = false pTags = null @@ -1004,7 +1054,7 @@ open class NewPostViewModel : ViewModel() { } fun deleteMediaToUpload(selected: SelectedMediaProcessing) { - this.mediaToUpload = mediaToUpload.filter { it != selected }.toImmutableList() + this.multiOrchestrator?.remove(selected) } open fun findUrlInMessage(): String? = RichTextParser().parseValidUrls(message.text).firstOrNull() @@ -1170,14 +1220,14 @@ open class NewPostViewModel : ViewModel() { !category.text.isNullOrBlank() ) ) && - mediaToUpload.isEmpty() + multiOrchestrator == null fun insertAtCursor(newElement: String) { message = message.insertUrlAtCursor(newElement) } fun selectImage(uris: ImmutableList) { - mediaToUpload = uris.map { SelectedMediaProcessing(it) }.toImmutableList() + multiOrchestrator = MultiOrchestrator(uris) } fun locationFlow(): StateFlow { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt index 1ae7a9ef0..3f38cd28d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -28,12 +28,12 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.uploads.CompressorQuality +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia -import com.vitorpamplona.amethyst.ui.components.CompressorQuality -import com.vitorpamplona.amethyst.ui.components.MediaCompressor import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.events.GitHubIdentity import com.vitorpamplona.quartz.events.MastodonIdentity diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectedMediaProcessing.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectedMediaProcessing.kt index 0ebb21287..b9b22e1cf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectedMediaProcessing.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectedMediaProcessing.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.actions.uploads +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator + class SelectedMediaProcessing( val media: SelectedMedia, val orchestrator: UploadOrchestrator = UploadOrchestrator(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ShowImageUploadItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ShowImageUploadItem.kt index 4f13aedeb..66fcc1f95 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ShowImageUploadItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ShowImageUploadItem.kt @@ -59,6 +59,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.service.uploads.UploadingState import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid import com.vitorpamplona.amethyst.ui.components.VideoView import com.vitorpamplona.amethyst.ui.note.CloseIcon @@ -67,19 +70,18 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size40Modifier import com.vitorpamplona.amethyst.ui.theme.Size55Modifier -import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @Composable fun ShowImageUploadGallery( - list: ImmutableList, + list: MultiOrchestrator, onDelete: (SelectedMediaProcessing) -> Unit, accountViewModel: AccountViewModel, ) { - AutoNonlazyGrid(list.size) { - ShowImageUploadItem(list[it], onDelete, accountViewModel) + AutoNonlazyGrid(list.size()) { + ShowImageUploadItem(list.get(it), onDelete, accountViewModel) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 32b23c23d..e50cb178f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -95,6 +95,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack import com.vitorpamplona.amethyst.ui.note.types.RenderBadgeAward import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessage +import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessageEncryptedFile import com.vitorpamplona.amethyst.ui.note.types.RenderClassifieds import com.vitorpamplona.amethyst.ui.note.types.RenderCommunity import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack @@ -158,6 +159,7 @@ import com.vitorpamplona.quartz.events.BaseTextNoteEvent import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent +import com.vitorpamplona.quartz.events.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent @@ -671,6 +673,18 @@ private fun RenderNoteRow( nav, ) } + is ChatMessageEncryptedFileHeaderEvent -> { + RenderChatMessageEncryptedFile( + baseNote, + makeItShort, + canPreview, + quotesLeft, + backgroundColor, + editState, + accountViewModel, + nav, + ) + } is ClassifiedsEvent -> { RenderClassifieds( noteEvent, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessageEncryptedFile.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessageEncryptedFile.kt new file mode 100644 index 000000000..7adc1a7de --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessageEncryptedFile.kt @@ -0,0 +1,175 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent +import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlImage +import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlVideo +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager +import com.vitorpamplona.amethyst.ui.components.GenericLoadable +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChatroomHeader +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.crypto.nip17.AESGCM +import com.vitorpamplona.quartz.events.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.events.ChatroomKeyable +import com.vitorpamplona.quartz.events.EmptyTagList +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun RenderChatMessageEncryptedFile( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + quotesLeft: Int, + backgroundColor: MutableState, + editState: State>, + accountViewModel: AccountViewModel, + nav: INav, +) { + val userRoom by + remember(note) { + derivedStateOf { + (note.event as? ChatroomKeyable)?.chatroomKey(accountViewModel.userProfile().pubkeyHex) + } + } + + userRoom?.let { + if (it.users.size > 1 || (it.users.size == 1 && note.author == accountViewModel.account.userProfile())) { + ChatroomHeader(it, MaterialTheme.colorScheme.replyModifier.padding(10.dp), accountViewModel) { + routeFor(note, accountViewModel.userProfile())?.let { + nav.nav(it) + } + } + Spacer(modifier = StdVertSpacer) + } + } + + SensitivityWarning( + note = note, + accountViewModel = accountViewModel, + ) { + Box(modifier = HalfVertPadding) { + RenderEncryptedFile(note, backgroundColor, accountViewModel, nav) + } + } +} + +@Composable +fun RenderEncryptedFile( + note: Note, + backgroundBubbleColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = note.event as? ChatMessageEncryptedFileHeaderEvent ?: return + + val algo = noteEvent.algo() + val key = noteEvent.key() + val nonce = noteEvent.nonce() + + if (algo == AESGCM.NAME && key != null && nonce != null) { + HttpClientManager.addCipherToCache(noteEvent.content, AESGCM(key, nonce)) + + val content by remember(noteEvent) { + val isImage = noteEvent.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(noteEvent.content) + val mimeType = noteEvent.mimeType() + + mutableStateOf( + if (isImage) { + EncryptedMediaUrlImage( + url = noteEvent.content, + description = noteEvent.alt(), + hash = noteEvent.originalHash(), + blurhash = noteEvent.blurhash(), + dim = noteEvent.dimensions(), + uri = noteEvent.toNostrUri(), + mimeType = mimeType, + encryptionAlgo = algo, + encryptionKey = key, + encryptionNonce = nonce, + ) + } else { + EncryptedMediaUrlVideo( + url = noteEvent.content, + description = noteEvent.alt(), + hash = noteEvent.originalHash(), + blurhash = noteEvent.blurhash(), + dim = noteEvent.dimensions(), + uri = note.toNostrUri(), + authorName = note.author?.toBestDisplayName(), + mimeType = mimeType, + encryptionAlgo = algo, + encryptionKey = key, + encryptionNonce = nonce, + ) + }, + ) + } + + ZoomableContentView( + content, + persistentListOf(content), + roundedCorner = true, + contentScale = ContentScale.FillWidth, + accountViewModel, + ) + } else { + TranslatableRichTextViewer( + content = stringRes(id = R.string.could_not_decrypt_the_message), + canPreview = true, + quotesLeft = 0, + modifier = Modifier, + tags = EmptyTagList, + backgroundColor = backgroundBubbleColor, + id = note.idHex, + callbackUri = note.toNostrUri(), + accountViewModel = accountViewModel, + nav = nav, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt index ad49ac299..de986ffac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt @@ -128,6 +128,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.LocationState import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource +import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator import com.vitorpamplona.amethyst.ui.actions.NewPollOption import com.vitorpamplona.amethyst.ui.actions.NewPollVoteValueRange import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel @@ -506,13 +507,13 @@ fun NewPostScreen( } } - if (postViewModel.mediaToUpload.isNotEmpty()) { + postViewModel.multiOrchestrator?.let { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), ) { ImageVideoDescription( - postViewModel.mediaToUpload, + it, accountViewModel.account.settings.defaultFileServer, onAdd = { alt, server, sensitiveContent, mediaQuality -> postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context) @@ -521,7 +522,7 @@ fun NewPostScreen( } }, onDelete = postViewModel::deleteMediaToUpload, - onCancel = { postViewModel.mediaToUpload = persistentListOf() }, + onCancel = { postViewModel.multiOrchestrator = null }, onError = { scope.launch { Toast.makeText(context, context.resources.getText(it), Toast.LENGTH_SHORT).show() } }, accountViewModel = accountViewModel, ) @@ -1666,7 +1667,7 @@ fun CreateButton( @Composable fun ImageVideoDescription( - uris: ImmutableList, + uris: MultiOrchestrator, defaultServer: ServerName, onAdd: (String, ServerName, Boolean, Int) -> Unit, onDelete: (SelectedMediaProcessing) -> Unit, @@ -1727,7 +1728,7 @@ fun ImageVideoDescription( .padding(bottom = 10.dp), ) { val text = - if (uris.size == 1) { + if (uris.size() == 1) { if (uris.first().media.isImage() == true) { R.string.content_description_add_image } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChannelScreen.kt index bafce3983..6393e50a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChannelScreen.kt @@ -110,14 +110,14 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.PublicChatChannel import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.NostrChannelDataSource +import com.vitorpamplona.amethyst.service.uploads.CompressorQuality +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor 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.UrlUserTagTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery -import com.vitorpamplona.amethyst.ui.components.CompressorQuality import com.vitorpamplona.amethyst.ui.components.LoadNote -import com.vitorpamplona.amethyst.ui.components.MediaCompressor import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer @@ -395,7 +395,7 @@ private suspend fun innerSendPost( tagger.run() val urls = findURLs(tagger.message) - val usedAttachments = newPostModel.iMetaAttachments.filter { it.url !in urls.toSet() } + val usedAttachments = newPostModel.iMetaAttachments.filter { it.url in urls.toSet() } if (channel is PublicChatChannel) { accountViewModel.account.sendChannelMessage( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomMessageCompose.kt index e593cdef8..0f4f00342 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomMessageCompose.kt @@ -77,6 +77,7 @@ import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent import com.vitorpamplona.amethyst.ui.note.WatchUserFollows import com.vitorpamplona.amethyst.ui.note.ZapReaction import com.vitorpamplona.amethyst.ui.note.timeAgoShort +import com.vitorpamplona.amethyst.ui.note.types.RenderEncryptedFile import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ChatBubbleMaxSizeModifier @@ -102,10 +103,12 @@ import com.vitorpamplona.amethyst.ui.theme.messageBubbleLimits import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent -import com.vitorpamplona.quartz.events.ChatMessageEvent +import com.vitorpamplona.quartz.events.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.events.ChatroomKeyable import com.vitorpamplona.quartz.events.DraftEvent import com.vitorpamplona.quartz.events.EmptyTagList import com.vitorpamplona.quartz.events.ImmutableListOfLists +import com.vitorpamplona.quartz.events.NIP17Group import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.toImmutableListOfLists @@ -174,7 +177,7 @@ fun NormalChatNote( false // never shows the user's pictures } else if (noteEvent is PrivateDmEvent) { false // one-on-one, never shows it. - } else if (noteEvent is ChatMessageEvent) { + } else if (noteEvent is ChatroomKeyable) { // only shows in a group chat. noteEvent.chatroomKey(accountViewModel.userProfile().pubkeyHex).users.size > 1 } else { @@ -581,6 +584,13 @@ private fun NoteRow( accountViewModel, nav, ) + is ChatMessageEncryptedFileHeaderEvent -> + RenderEncryptedFile( + note, + backgroundBubbleColor, + accountViewModel, + nav, + ) else -> RenderRegularTextNote( note, @@ -631,16 +641,9 @@ private fun RenderDraftEvent( } } -@Composable -private fun ConstrainedStatusRow( - firstColumn: @Composable () -> Unit, - secondColumn: @Composable () -> Unit, -) { -} - @Composable fun IncognitoBadge(baseNote: Note) { - if (baseNote.event is ChatMessageEvent) { + if (baseNote.event is NIP17Group) { Icon( painter = painterResource(id = R.drawable.incognito), null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomScreen.kt index ea6deb6f3..b700227f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomScreen.kt @@ -85,12 +85,13 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.NostrChatroomDataSource +import com.vitorpamplona.amethyst.service.uploads.CompressorQuality +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery -import com.vitorpamplona.amethyst.ui.components.CompressorQuality -import com.vitorpamplona.amethyst.ui.components.MediaCompressor +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture @@ -121,9 +122,10 @@ import com.vitorpamplona.amethyst.ui.theme.Size34dp import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.ZeroPadding import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.ChatroomKey +import com.vitorpamplona.quartz.events.NIP17Group import com.vitorpamplona.quartz.events.findURLs +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.Dispatchers @@ -482,18 +484,34 @@ fun ChatroomScreen( } // LAST ROW - PrivateMessageEditFieldRow(newPostModel, accountViewModel) { - scope.launch(Dispatchers.IO) { - innerSendPost(newPostModel, room, replyTo, accountViewModel, null) + PrivateMessageEditFieldRow( + newPostModel, + accountViewModel, + onSendNewMessage = { + scope.launch(Dispatchers.IO) { + innerSendPost(newPostModel, room, replyTo, accountViewModel, null) - accountViewModel.deleteDraft(newPostModel.draftTag) + accountViewModel.deleteDraft(newPostModel.draftTag) - newPostModel.message = TextFieldValue("") + newPostModel.message = TextFieldValue("") - replyTo.value = null - feedViewModel.sendToTop() - } - } + replyTo.value = null + feedViewModel.sendToTop() + } + }, + onSendNewMedia = { + newPostModel.selectImage(it) + newPostModel.uploadAsSeparatePrivateEvent( + toUsers = room.users, + alt = null, + sensitiveContent = false, + mediaQuality = MediaCompressor.compressorQualityToInt(CompressorQuality.MEDIUM), + server = accountViewModel.account.settings.defaultFileServer, + onError = accountViewModel::toast, + context = context, + ) + }, + ) } } @@ -507,7 +525,7 @@ private fun innerSendPost( val urls = findURLs(newPostModel.message.text) val usedAttachments = newPostModel.iMetaAttachments.filter { it.url !in urls.toSet() } - if (newPostModel.nip17 || room.users.size > 1 || replyTo.value?.event is ChatMessageEvent) { + if (newPostModel.nip17 || room.users.size > 1 || replyTo.value?.event is NIP17Group) { accountViewModel.account.sendNIP17PrivateMessage( message = newPostModel.message.text, toUsers = room.users.toList(), @@ -535,6 +553,7 @@ fun PrivateMessageEditFieldRow( channelScreenModel: NewPostViewModel, accountViewModel: AccountViewModel, onSendNewMessage: () -> Unit, + onSendNewMedia: (ImmutableList) -> Unit, ) { Column( modifier = EditFieldModifier, @@ -579,19 +598,8 @@ fun PrivateMessageEditFieldRow( Modifier .size(30.dp) .padding(start = 2.dp), - ) { - channelScreenModel.selectImage(it) - channelScreenModel.upload( - alt = null, - sensitiveContent = false, - // use MEDIUM quality - mediaQuality = MediaCompressor.compressorQualityToInt(CompressorQuality.MEDIUM), - isPrivate = true, - server = accountViewModel.account.settings.defaultFileServer, - onError = accountViewModel::toast, - context = context, - ) - } + onImageChosen = onSendNewMedia, + ) var wantsToActivateNIP17 by remember { mutableStateOf(false) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt index 02600d693..b08832eb1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt @@ -41,9 +41,9 @@ import com.vitorpamplona.ammolite.relays.BundledUpdate import com.vitorpamplona.quartz.events.BadgeAwardEvent import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent -import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.LnZapEvent +import com.vitorpamplona.quartz.events.NIP17Group import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.ReactionEvent import com.vitorpamplona.quartz.events.RepostEvent @@ -285,7 +285,7 @@ class CardFeedContentState( it.event !is GenericRepostEvent && it.event !is LnZapEvent }.map { - if (it.event is PrivateDmEvent || it.event is ChatMessageEvent) { + if (it.event is PrivateDmEvent || it.event is NIP17Group) { MessageSetCard(it) } else if (it.event is BadgeAwardEvent) { BadgeCard(it) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d57a1bebd..cd766d436 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -884,6 +884,7 @@ Could not download uploaded media from the server Could not check downloaded file after upload: %1$s Could not prepare local file to upload: %1$s + Failed to upload to %1$s: %2$s Failed to upload: %1$s Failed to delete: %1$s Media is too big for NIP-95 diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/MediaCompressorTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/MediaCompressorTest.kt index 2c47d3f19..94e894073 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/MediaCompressorTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/MediaCompressorTest.kt @@ -24,6 +24,8 @@ import android.content.Context import android.net.Uri import android.os.Looper import com.abedelazizshe.lightcompressorlibrary.VideoCompressor +import com.vitorpamplona.amethyst.service.uploads.CompressorQuality +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor import com.vitorpamplona.amethyst.ui.components.util.MediaCompressorFileUtils import id.zelory.compressor.Compressor import io.mockk.MockKAnnotations diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt index cf7f13bf2..9b8da0989 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt @@ -133,6 +133,7 @@ class Relay( lastConnectTentative = TimeUtils.now() socket = socketBuilder.build(url, false, RelayListener(onConnected)) + socket?.connect() } catch (e: Exception) { if (e is CancellationException) throw e diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/sockets/WebSocket.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/sockets/WebSocket.kt index 509e603cc..47a6430a4 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/sockets/WebSocket.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/sockets/WebSocket.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.ammolite.sockets interface WebSocket { + fun connect() + fun cancel() fun send(msg: String): Boolean diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt index fbff4758e..e7dee4dba 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt @@ -43,7 +43,7 @@ abstract class MediaUrlContent( ) : BaseMediaContent(description, dim, blurhash) @Immutable -class MediaUrlImage( +open class MediaUrlImage( url: String, description: String? = null, hash: String? = null, @@ -54,8 +54,22 @@ class MediaUrlImage( mimeType: String? = null, ) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType) +class EncryptedMediaUrlImage( + url: String, + description: String? = null, + hash: String? = null, + blurhash: String? = null, + dim: Dimension? = null, + uri: String? = null, + contentWarning: String? = null, + mimeType: String? = null, + val encryptionAlgo: String, + val encryptionKey: ByteArray, + val encryptionNonce: ByteArray, +) : MediaUrlImage(url, description, hash, blurhash, dim, uri, contentWarning, mimeType) + @Immutable -class MediaUrlVideo( +open class MediaUrlVideo( url: String, description: String? = null, hash: String? = null, @@ -68,6 +82,23 @@ class MediaUrlVideo( mimeType: String? = null, ) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType) +@Immutable +class EncryptedMediaUrlVideo( + url: String, + description: String? = null, + hash: String? = null, + dim: Dimension? = null, + uri: String? = null, + artworkUri: String? = null, + authorName: String? = null, + blurhash: String? = null, + contentWarning: String? = null, + mimeType: String? = null, + val encryptionAlgo: String, + val encryptionKey: ByteArray, + val encryptionNonce: ByteArray, +) : MediaUrlVideo(url, description, hash, dim, uri, artworkUri, authorName, blurhash, contentWarning, mimeType) + @Immutable abstract class MediaPreloadedContent( val localFile: File?, diff --git a/quartz/src/androidTest/assets/ovxxk2vz.jpg b/quartz/src/androidTest/assets/ovxxk2vz.jpg new file mode 100644 index 000000000..bc96672af Binary files /dev/null and b/quartz/src/androidTest/assets/ovxxk2vz.jpg differ diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/crypto/nip17/AESGCMTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/crypto/nip17/AESGCMTest.kt new file mode 100644 index 000000000..a53868404 --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/crypto/nip17/AESGCMTest.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.crypto.nip17 + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation +import com.vitorpamplona.quartz.crypto.CryptoUtils.decrypt +import com.vitorpamplona.quartz.encoders.hexToByteArray +import junit.framework.TestCase.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AESGCMTest { + val decryptionNonce = "01e77c94bd5aba3e3cbb69594e7ba07c" + val decryptionKey = "c128ecffab90ee7810e3df08e7fb2cc39a8d40f24201f48b2b36e23b34ac50ee" + + val cipher = AESGCM(decryptionKey.hexToByteArray(), decryptionNonce.toByteArray(Charsets.UTF_8)) + + @Test + fun encryptDecrypt() { + val encrypted = cipher.encrypt("Testing".toByteArray(Charsets.UTF_8)) + val decrypted = cipher.decrypt(encrypted) + + assertEquals("Testing", String(decrypted)) + } + + @Test + fun imageTest() { + val image = + getInstrumentation().context.assets.open("ovxxk2vz.jpg").use { + it.readAllBytes() + } + + val decrypted = cipher.decrypt(image) + + assertEquals(44201, decrypted.size) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/nip17/AESGCM.kt b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/nip17/AESGCM.kt new file mode 100644 index 000000000..bea2684d0 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/nip17/AESGCM.kt @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.crypto.nip17 + +import com.vitorpamplona.quartz.crypto.CryptoUtils +import com.vitorpamplona.quartz.encoders.toHexKey +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +interface NostrCipher { + fun name(): String + + fun encrypt(bytesToEncrypt: ByteArray): ByteArray + + fun decrypt(bytesToDecrypt: ByteArray): ByteArray +} + +class AESGCM( + val keyBytes: ByteArray = CryptoUtils.random(32), + val nonce: ByteArray = CryptoUtils.random(16), +) : NostrCipher { + private fun newCipher() = Cipher.getInstance("AES/GCM/NoPadding") + + private fun keySpec() = SecretKeySpec(keyBytes, "AES") + + private fun param() = GCMParameterSpec(128, nonce) + + override fun name() = NAME + + fun copyUsingUTF8Nonce(): AESGCM = + AESGCM( + keyBytes, + nonce.toHexKey().toByteArray(Charsets.UTF_8), + ) + + override fun encrypt(bytesToEncrypt: ByteArray): ByteArray = + with(newCipher()) { + init(Cipher.ENCRYPT_MODE, keySpec(), param()) + doFinal(bytesToEncrypt) + } + + override fun decrypt(bytesToDecrypt: ByteArray): ByteArray = + with(newCipher()) { + init(Cipher.DECRYPT_MODE, keySpec(), param()) + doFinal(bytesToDecrypt) + } + + companion object { + const val NAME = "aes-gcm" + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEncryptedFileHeaderEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEncryptedFileHeaderEvent.kt new file mode 100644 index 000000000..fd5dcabb7 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEncryptedFileHeaderEvent.kt @@ -0,0 +1,189 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.Dimension +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.hexToByteArray +import com.vitorpamplona.quartz.encoders.toHexKey +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.collections.immutable.toImmutableSet + +@Immutable +class ChatMessageEncryptedFileHeaderEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : WrappedEvent(id, pubKey, createdAt, KIND, tags, content, sig), + ChatroomKeyable, + NIP17Group { + /** Recipients intended to receive this conversation */ + fun recipientsPubKey() = tags.mapNotNull { if (it.size > 1 && it[0] == "p") it[1] else null } + + override fun groupMembers() = recipientsPubKey().plus(pubKey).toSet() + + fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) + + fun talkingWith(oneSideHex: String): Set { + val listedPubKeys = recipientsPubKey() + + val result = + if (pubKey == oneSideHex) { + listedPubKeys.toSet().minus(oneSideHex) + } else { + listedPubKeys.plus(pubKey).toSet().minus(oneSideHex) + } + + if (result.isEmpty()) { + // talking to myself + return setOf(pubKey) + } + + return result + } + + override fun chatroomKey(toRemove: String): ChatroomKey = ChatroomKey(talkingWith(toRemove).toImmutableSet()) + + fun url() = content + + fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1) + + fun alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1) + + fun algo() = tags.firstOrNull { it.size > 1 && it[0] == ENCRYPTION_ALGORITHM }?.get(1) + + fun key() = + tags + .firstOrNull { it.size > 1 && it[0] == ENCRYPTION_KEY } + ?.get(1) + ?.runCatching { this.hexToByteArray() } + ?.getOrNull() + + fun nonce() = + tags + .firstOrNull { it.size > 1 && it[0] == ENCRYPTION_NONCE } + ?.get(1) + ?.runCatching { this.hexToByteArray() } + ?.getOrNull() + + fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1) + + fun originalHash() = tags.firstOrNull { it.size > 1 && it[0] == ORIGINAL_HASH }?.get(1) + + fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1) + + fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)?.let { Dimension.parse(it) } + + fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1) + + companion object { + const val KIND = 15 + const val ALT_DESCRIPTION = "Encrypted file in chat" + + const val MIME_TYPE = "file-type" + + const val ENCRYPTION_ALGORITHM = "encryption-algorithm" + const val ENCRYPTION_KEY = "decryption-key" + const val ENCRYPTION_NONCE = "decryption-nonce" + + const val FILE_SIZE = "size" + const val DIMENSION = "dim" + const val BLUR_HASH = "blurhash" + const val HASH = "x" + const val ORIGINAL_HASH = "ox" + + const val ALT = "alt" + + fun buildTags( + to: List, + repliesTo: List? = null, + contentType: String?, + algo: String, + key: ByteArray, + nonce: ByteArray? = null, + originalHash: String? = null, + hash: String? = null, + size: Int? = null, + dimensions: Dimension? = null, + blurhash: String? = null, + sensitiveContent: Boolean? = null, + alt: String?, + ): Array> { + val repliesHex = repliesTo?.map { arrayOf("e", it) } ?: emptyList() + + return ( + to.map { arrayOf("p", it) } + repliesHex + + listOfNotNull( + contentType?.let { arrayOf(MIME_TYPE, it) }, + arrayOf(ENCRYPTION_ALGORITHM, algo), + arrayOf(ENCRYPTION_KEY, key.toHexKey()), + nonce?.let { arrayOf(ENCRYPTION_NONCE, it.toHexKey()) }, + alt?.ifBlank { null }?.let { arrayOf(ALT, it) } ?: arrayOf(ALT, ALT_DESCRIPTION), + originalHash?.let { arrayOf(ORIGINAL_HASH, it) }, + hash?.let { arrayOf(HASH, it) }, + size?.let { arrayOf(FILE_SIZE, it.toString()) }, + dimensions?.let { arrayOf(DIMENSION, it.toString()) }, + blurhash?.let { arrayOf(BLUR_HASH, it) }, + sensitiveContent?.let { + if (it) { + arrayOf("content-warning", "") + } else { + null + } + }, + ) + ).toTypedArray() + } + + fun create( + url: String, + to: List, + repliesTo: List? = null, + contentType: String?, + algo: String, + key: ByteArray, + nonce: ByteArray? = null, + originalHash: String? = null, + hash: String? = null, + size: Int? = null, + dimensions: Dimension? = null, + blurhash: String? = null, + sensitiveContent: Boolean? = null, + alt: String?, + signer: NostrSigner, + isDraft: Boolean, + createdAt: Long = TimeUtils.now(), + onReady: (ChatMessageEncryptedFileHeaderEvent) -> Unit, + ) { + val tags = buildTags(to, repliesTo, contentType, algo, key, nonce, originalHash, hash, size, dimensions, blurhash, sensitiveContent, alt) + if (isDraft) { + signer.assembleRumor(createdAt, KIND, tags, url, onReady) + } else { + signer.sign(createdAt, KIND, tags, url, onReady) + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt index b991e06f2..73925dda5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt @@ -38,7 +38,8 @@ class ChatMessageEvent( content: String, sig: HexKey, ) : WrappedEvent(id, pubKey, createdAt, KIND, tags, content, sig), - ChatroomKeyable { + ChatroomKeyable, + NIP17Group { /** Recipients intended to receive this conversation */ fun recipientsPubKey() = tags.mapNotNull { if (it.size > 1 && it[0] == "p") it[1] else null } @@ -62,6 +63,8 @@ class ChatMessageEvent( return result } + override fun groupMembers() = recipientsPubKey().plus(pubKey).toSet() + override fun chatroomKey(toRemove: String): ChatroomKey = ChatroomKey(talkingWith(toRemove).toImmutableSet()) companion object { @@ -111,6 +114,10 @@ class ChatMessageEvent( } } +interface NIP17Group { + fun groupMembers(): Set +} + interface ChatroomKeyable { fun chatroomKey(toRemove: HexKey): ChatroomKey } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt index 3d9b0e58e..bbc731b65 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt @@ -59,6 +59,20 @@ class EventFactory { ChannelMessageEvent.KIND -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig) ChannelMetadataEvent.KIND -> ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig) ChannelMuteUserEvent.KIND -> ChannelMuteUserEvent(id, pubKey, createdAt, tags, content, sig) + ChatMessageEncryptedFileHeaderEvent.KIND -> { + if (id.isBlank()) { + ChatMessageEncryptedFileHeaderEvent( + Event.generateId(pubKey, createdAt, kind, tags, content).toHexKey(), + pubKey, + createdAt, + tags, + content, + sig, + ) + } else { + ChatMessageEncryptedFileHeaderEvent(id, pubKey, createdAt, tags, content, sig) + } + } ChatMessageEvent.KIND -> { if (id.isBlank()) { ChatMessageEvent( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP17Factory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP17Factory.kt index 172d02891..f3d8886c8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP17Factory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP17Factory.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.events +import com.vitorpamplona.quartz.encoders.Dimension import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.encoders.IMetaTag import com.vitorpamplona.quartz.signers.NostrSigner @@ -120,6 +121,65 @@ class NIP17Factory { } } + fun createEncryptedFileNIP17( + url: String, + to: List, + repliesToHex: List? = null, + contentType: String?, + algo: String, + key: ByteArray, + nonce: ByteArray? = null, + originalHash: String? = null, + hash: String? = null, + size: Int? = null, + dimensions: Dimension? = null, + blurhash: String? = null, + sensitiveContent: Boolean? = null, + alt: String?, + draftTag: String? = null, + signer: NostrSigner, + onReady: (Result) -> Unit, + ) { + val senderPublicKey = signer.pubKey + + ChatMessageEncryptedFileHeaderEvent.create( + url = url, + to = to, + repliesTo = repliesToHex, + contentType = contentType, + algo = algo, + key = key, + nonce = nonce, + originalHash = originalHash, + hash = hash, + size = size, + dimensions = dimensions, + blurhash = blurhash, + sensitiveContent = sensitiveContent, + alt = alt, + signer = signer, + isDraft = draftTag != null, + ) { senderMessage -> + if (draftTag != null) { + onReady( + Result( + msg = senderMessage, + wraps = listOf(), + ), + ) + } else { + createWraps(senderMessage, to.plus(senderPublicKey).toSet(), signer) { wraps -> + onReady( + Result( + msg = senderMessage, + wraps = wraps, + ), + ) + } + } + } + } + fun createReactionWithinGroup( content: String, originalNote: EventInterface,