From 94c74a1e0cd050ce9d8e2cc2be37029e3d7ea484 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 23 Dec 2024 20:30:22 -0500 Subject: [PATCH] Adds support for encrypted media uploads on NIP-17 DMs --- .../amethyst/ImageUploadTesting.kt | 2 +- .../vitorpamplona/amethyst/model/Account.kt | 55 ++++- .../amethyst/model/LocalCache.kt | 91 ++++++++- .../EventNotificationConsumer.kt | 49 +++++ .../DefaultContentTypeInterceptor.kt} | 20 +- .../okhttp/EncryptedBlobInterceptor.kt | 72 +++++++ .../service/okhttp/EncryptionKeyCache.kt | 45 +++++ .../service/okhttp/HttpClientManager.kt | 73 +------ .../service/okhttp/LoggingInterceptor.kt | 48 +++++ .../playback/MultiPlayerPlaybackManager.kt | 2 +- .../amethyst/service/uploads/EncryptFiles.kt | 68 +++++++ .../amethyst/service/uploads/FileHeader.kt | 1 - .../uploads/ImageDownloader.kt | 2 +- .../uploads}/MediaCompressor.kt | 2 +- .../service/uploads/MultiOrchestrator.kt | 130 ++++++++++++ .../uploads/UploadOrchestrator.kt | 95 ++++++--- .../uploads/blossom/BlossomUploader.kt | 8 +- .../service/uploads/nip96/Nip96Uploader.kt | 16 +- .../amethyst/ui/actions/EditPostView.kt | 7 +- .../amethyst/ui/actions/EditPostViewModel.kt | 95 ++++----- .../amethyst/ui/actions/NewMediaModel.kt | 77 ++++--- .../amethyst/ui/actions/NewMediaView.kt | 15 +- .../amethyst/ui/actions/NewPostViewModel.kt | 166 +++++++++------ .../ui/actions/NewUserMetadataViewModel.kt | 4 +- .../uploads/SelectedMediaProcessing.kt | 2 + .../ui/actions/uploads/ShowImageUploadItem.kt | 10 +- .../amethyst/ui/note/NoteCompose.kt | 14 ++ .../ui/note/types/ChatMessageEncryptedFile.kt | 175 ++++++++++++++++ .../ui/screen/loggedIn/NewPostScreen.kt | 11 +- .../loggedIn/chatrooms/ChannelScreen.kt | 6 +- .../chatrooms/ChatroomMessageCompose.kt | 23 ++- .../loggedIn/chatrooms/ChatroomScreen.kt | 60 +++--- .../notifications/CardFeedContentState.kt | 4 +- amethyst/src/main/res/values/strings.xml | 1 + .../ui/components/MediaCompressorTest.kt | 2 + .../vitorpamplona/ammolite/relays/Relay.kt | 1 + .../ammolite/sockets/WebSocket.kt | 2 + .../commons/richtext/MediaContentModels.kt | 35 +++- quartz/src/androidTest/assets/ovxxk2vz.jpg | Bin 0 -> 44217 bytes .../quartz/crypto/nip17/AESGCMTest.kt | 57 ++++++ .../quartz/crypto/nip17/AESGCM.kt | 70 +++++++ .../ChatMessageEncryptedFileHeaderEvent.kt | 189 ++++++++++++++++++ .../quartz/events/ChatMessageEvent.kt | 9 +- .../quartz/events/EventFactory.kt | 14 ++ .../quartz/events/NIP17Factory.kt | 60 ++++++ 45 files changed, 1556 insertions(+), 332 deletions(-) rename amethyst/src/main/java/com/vitorpamplona/amethyst/service/{uploads/CombinedUploader.kt => okhttp/DefaultContentTypeInterceptor.kt} (65%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptedBlobInterceptor.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptionKeyCache.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LoggingInterceptor.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/EncryptFiles.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/{ui/actions => service}/uploads/ImageDownloader.kt (98%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/{ui/components => service/uploads}/MediaCompressor.kt (99%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/{ui/actions => service}/uploads/UploadOrchestrator.kt (70%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessageEncryptedFile.kt create mode 100644 quartz/src/androidTest/assets/ovxxk2vz.jpg create mode 100644 quartz/src/androidTest/java/com/vitorpamplona/quartz/crypto/nip17/AESGCMTest.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/crypto/nip17/AESGCM.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEncryptedFileHeaderEvent.kt 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 0000000000000000000000000000000000000000..bc96672afea90df491e274f7388a1fcf9e6881af GIT binary patch literal 44217 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 l=O>!u8tZtO2pShTNG}-BT0UiJV literal 0 HcmV?d00001 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,