Adds support for encrypted media uploads on NIP-17 DMs

This commit is contained in:
Vitor Pamplona
2024-12-23 20:30:22 -05:00
parent f839565152
commit 94c74a1e0c
45 changed files with 1556 additions and 332 deletions
@@ -27,13 +27,13 @@ import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.service.uploads.FileHeader 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.blossom.BlossomUploader
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
import com.vitorpamplona.amethyst.service.uploads.nip96.ServerInfoRetriever 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.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType 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.CryptoUtils
import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.encoders.toHexKey
@@ -67,7 +67,6 @@ import com.vitorpamplona.quartz.events.BookmarkListEvent
import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommentEvent 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.MetadataEvent
import com.vitorpamplona.quartz.events.MuteListEvent import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.NIP17Factory import com.vitorpamplona.quartz.events.NIP17Factory
import com.vitorpamplona.quartz.events.NIP17Group
import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryRequestEvent import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryRequestEvent
import com.vitorpamplona.quartz.events.OtsEvent import com.vitorpamplona.quartz.events.OtsEvent
import com.vitorpamplona.quartz.events.PeopleListEvent import com.vitorpamplona.quartz.events.PeopleListEvent
@@ -1233,14 +1233,9 @@ class Account(
return return
} }
if (note.event is ChatMessageEvent) { val noteEvent = note.event
val event = note.event as ChatMessageEvent if (noteEvent is NIP17Group) {
val users = val users = noteEvent.groupMembers().toList()
event
.recipientsPubKey()
.plus(event.pubKey)
.toSet()
.toList()
if (reaction.startsWith(":")) { if (reaction.startsWith(":")) {
val emojiUrl = EmojiUrl.decode(reaction) val emojiUrl = EmojiUrl.decode(reaction)
@@ -2959,6 +2954,48 @@ class Account(
} }
} }
fun sendNIP17EncryptedFile(
url: String,
toUsers: List<HexKey>,
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( fun sendNIP17PrivateMessage(
message: String, message: String,
toUsers: List<HexKey>, toUsers: List<HexKey>,
@@ -60,6 +60,7 @@ import com.vitorpamplona.quartz.events.ChannelListEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.ChannelMuteUserEvent import com.vitorpamplona.quartz.events.ChannelMuteUserEvent
import com.vitorpamplona.quartz.events.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.events.ChatroomKey import com.vitorpamplona.quartz.events.ChatroomKey
@@ -625,6 +626,8 @@ object LocalCache {
is CommentEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } is CommentEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
is ChatMessageEvent -> event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) } is ChatMessageEvent -> event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) }
is ChatMessageEncryptedFileHeaderEvent -> event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) }
is LnZapEvent -> is LnZapEvent ->
event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } + event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) } + event.taggedAddresses().map { getOrCreateAddressableNote(it) } +
@@ -1621,7 +1624,50 @@ object LocalCache {
// Already processed this event. // Already processed this event.
if (note.event != null) return 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() val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet()
// Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}") // Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
@@ -2264,7 +2310,27 @@ object LocalCache {
} }
} }
is ChatMessageEvent -> { 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() val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet()
if (recipients.isNotEmpty()) { 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 -> { is ChannelMessageEvent -> {
draft.channel()?.let { channelId -> draft.channel()?.let { channelId ->
checkGetOrCreateChannel(channelId)?.let { channel -> checkGetOrCreateChannel(channelId)?.let { channel ->
@@ -2398,6 +2484,7 @@ object LocalCache {
is ChannelMessageEvent -> consume(event, relay) is ChannelMessageEvent -> consume(event, relay)
is ChannelMetadataEvent -> consume(event) is ChannelMetadataEvent -> consume(event)
is ChannelMuteUserEvent -> consume(event) is ChannelMuteUserEvent -> consume(event)
is ChatMessageEncryptedFileHeaderEvent -> consume(event, relay)
is ChatMessageEvent -> consume(event, relay) is ChatMessageEvent -> consume(event, relay)
is ChatMessageRelayListEvent -> consume(event, relay) is ChatMessageRelayListEvent -> consume(event, relay)
is ClassifiedsEvent -> consume(event, relay) is ClassifiedsEvent -> consume(event, relay)
@@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZa
import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.encoders.toNpub import com.vitorpamplona.quartz.encoders.toNpub
import com.vitorpamplona.quartz.events.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.DraftEvent import com.vitorpamplona.quartz.events.DraftEvent
import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.Event
@@ -121,6 +122,9 @@ class EventNotificationConsumer(
} else if (innerEvent is ChatMessageEvent) { } else if (innerEvent is ChatMessageEvent) {
Log.d(TAG, "New ChatMessage to Notify") Log.d(TAG, "New ChatMessage to Notify")
notify(innerEvent, signer, account) 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( private fun notify(
event: ChatMessageEvent, event: ChatMessageEvent,
signer: NostrSigner, signer: NostrSigner,
@@ -18,6 +18,22 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * 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)
}
}
@@ -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
}
}
@@ -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<String, NostrCipher>(100)
fun add(
url: String?,
cipher: NostrCipher,
) {
if (cache.get(url) == null) {
cache.put(url, cipher)
}
}
fun get(url: String): NostrCipher? = cache.get(url)
}
@@ -22,18 +22,13 @@ package com.vitorpamplona.amethyst.service.okhttp
import android.util.Log import android.util.Log
import com.vitorpamplona.quartz.crypto.nip17.NostrCipher import com.vitorpamplona.quartz.crypto.nip17.NostrCipher
import okhttp3.Interceptor
import okhttp3.OkHttpClient 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.InetSocketAddress
import java.net.Proxy import java.net.Proxy
import java.time.Duration import java.time.Duration
object HttpClientManager { object HttpClientManager {
val rootClient = private val rootClient =
OkHttpClient OkHttpClient
.Builder() .Builder()
.followRedirects(true) .followRedirects(true)
@@ -50,6 +45,8 @@ object HttpClientManager {
private var currentProxy: Proxy? = null private var currentProxy: Proxy? = null
private val cache = EncryptionKeyCache()
fun setDefaultProxy(proxy: Proxy?) { fun setDefaultProxy(proxy: Proxy?) {
if (currentProxy != proxy) { if (currentProxy != proxy) {
Log.d("HttpClient", "Changing proxy to: ${proxy != null}") Log.d("HttpClient", "Changing proxy to: ${proxy != null}")
@@ -96,67 +93,10 @@ object HttpClientManager {
.writeTimeout(duration) .writeTimeout(duration)
.addInterceptor(DefaultContentTypeInterceptor(userAgent)) .addInterceptor(DefaultContentTypeInterceptor(userAgent))
.addNetworkInterceptor(LoggingInterceptor()) .addNetworkInterceptor(LoggingInterceptor())
.addNetworkInterceptor(EncryptedBlobInterceptor()) .addNetworkInterceptor(EncryptedBlobInterceptor(cache))
.build() .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? = fun getCurrentProxyPort(useProxy: Boolean): Int? =
if (useProxy) { if (useProxy) {
(currentProxy?.address() as? InetSocketAddress)?.port (currentProxy?.address() as? InetSocketAddress)?.port
@@ -180,4 +120,9 @@ object HttpClientManager {
fun setDefaultProxyOnPort(port: Int) { fun setDefaultProxyOnPort(port: Int) {
setDefaultProxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port))) setDefaultProxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port)))
} }
fun addCipherToCache(
url: String,
cipher: NostrCipher,
) = cache.add(url, cipher)
} }
@@ -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
}
}
@@ -91,7 +91,7 @@ class MultiPlayerPlaybackManager(
val player = val player =
ExoPlayer.Builder(context).run { ExoPlayer.Builder(context).run {
dataSourceFactory?.let { setMediaSourceFactory(it) } setMediaSourceFactory(dataSourceFactory)
build() build()
} }
@@ -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(),
)
}
}
}
@@ -30,7 +30,6 @@ import android.os.Build
import android.util.Log import android.util.Log
import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash
import com.vitorpamplona.amethyst.service.Blurhash import com.vitorpamplona.amethyst.service.Blurhash
import com.vitorpamplona.amethyst.ui.actions.uploads.ImageDownloader
import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.encoders.Dimension import com.vitorpamplona.quartz.encoders.Dimension
import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.encoders.toHexKey
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * 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 com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * 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.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
@@ -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<SelectedMedia>,
) {
private var list: List<SelectedMediaProcessing> = uris.map { SelectedMediaProcessing(it) }
@Stable
class Result(
val allGood: Boolean,
val successful: List<UploadingState.Finished>,
val errors: List<UploadingState.Error>,
)
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<UploadingState.Finished>()
val errors = resultsByState.filterIsInstance<UploadingState.Error>()
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)
}
@@ -18,22 +18,18 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * 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.content.Context
import android.net.Uri import android.net.Uri
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.FileHeader import com.vitorpamplona.amethyst.service.uploads.UploadingState.UploadingFinalState
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadingState.UploadingFinalState import com.vitorpamplona.quartz.crypto.nip17.NostrCipher
import com.vitorpamplona.amethyst.ui.components.CompressorQuality
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import com.vitorpamplona.amethyst.ui.components.MediaCompressorResult
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
@@ -64,8 +60,6 @@ sealed class UploadingState {
} }
class UploadOrchestrator { class UploadOrchestrator {
private val compressor = MediaCompressor()
val progress = MutableStateFlow(0.0) val progress = MutableStateFlow(0.0)
val progressState = MutableStateFlow<UploadingState>(UploadingState.Ready) val progressState = MutableStateFlow<UploadingState>(UploadingState.Ready)
@@ -79,7 +73,10 @@ class UploadOrchestrator {
vararg params: String, vararg params: String,
) = UploadingState.Error(resId, params).also { updateState(0.0, it) } ) = 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( fun updateState(
newProgress: Double, newProgress: Double,
@@ -92,6 +89,8 @@ class UploadOrchestrator {
private fun uploadNIP95( private fun uploadNIP95(
fileUri: Uri, fileUri: Uri,
contentType: String?, contentType: String?,
originalContentType: String?,
originalHash: String?,
context: Context, context: Context,
): UploadingFinalState { ): UploadingFinalState {
updateState(0.4, UploadingState.Uploading) updateState(0.4, UploadingState.Uploading)
@@ -117,7 +116,7 @@ class UploadOrchestrator {
result.fold( result.fold(
onSuccess = { onSuccess = {
return finish(OrchestratorResult.NIP95Result(it, bytes)) return finish(OrchestratorResult.NIP95Result(it, bytes, originalContentType, originalHash))
}, },
onFailure = { onFailure = {
return error(R.string.could_not_check_downloaded_file, it.message ?: it.javaClass.simpleName) return error(R.string.could_not_check_downloaded_file, it.message ?: it.javaClass.simpleName)
@@ -135,6 +134,8 @@ class UploadOrchestrator {
alt: String?, alt: String?,
sensitiveContent: Boolean, sensitiveContent: Boolean,
serverBaseUrl: String, serverBaseUrl: String,
contentTypeForResult: String?,
originalHash: String?,
account: Account, account: Account,
context: Context, context: Context,
): UploadingFinalState { ): UploadingFinalState {
@@ -159,6 +160,8 @@ class UploadOrchestrator {
verifyHeader( verifyHeader(
uploadResult = result, uploadResult = result,
localContentType = contentType, localContentType = contentType,
originalContentType = contentTypeForResult,
originalHash = originalHash,
forceProxy = account::shouldUseTorForNIP96, forceProxy = account::shouldUseTorForNIP96,
) )
} catch (e: Exception) { } catch (e: Exception) {
@@ -174,6 +177,8 @@ class UploadOrchestrator {
alt: String?, alt: String?,
sensitiveContent: Boolean, sensitiveContent: Boolean,
serverBaseUrl: String, serverBaseUrl: String,
contentTypeForResult: String?,
originalHash: String?,
account: Account, account: Account,
context: Context, context: Context,
): UploadingFinalState { ): UploadingFinalState {
@@ -197,6 +202,8 @@ class UploadOrchestrator {
uploadResult = result, uploadResult = result,
localContentType = contentType, localContentType = contentType,
forceProxy = account::shouldUseTorForNIP96, forceProxy = account::shouldUseTorForNIP96,
originalHash = originalHash,
originalContentType = contentTypeForResult,
) )
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
@@ -207,6 +214,8 @@ class UploadOrchestrator {
private suspend fun verifyHeader( private suspend fun verifyHeader(
uploadResult: MediaUploadResult, uploadResult: MediaUploadResult,
localContentType: String?, localContentType: String?,
originalContentType: String?,
originalHash: String?,
forceProxy: (String) -> Boolean, forceProxy: (String) -> Boolean,
): UploadingFinalState { ): UploadingFinalState {
if (uploadResult.url.isNullOrBlank()) { if (uploadResult.url.isNullOrBlank()) {
@@ -229,7 +238,16 @@ class UploadOrchestrator {
result.fold( result.fold(
onSuccess = { 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 = { onFailure = {
return error(R.string.could_not_prepare_local_file_to_upload, it.message ?: it.javaClass.simpleName) return error(R.string.could_not_prepare_local_file_to_upload, it.message ?: it.javaClass.simpleName)
@@ -244,16 +262,32 @@ class UploadOrchestrator {
class NIP95Result( class NIP95Result(
val fileHeader: FileHeader, val fileHeader: FileHeader,
val bytes: ByteArray, val bytes: ByteArray,
val mimeTypeBeforeEncryption: String?,
val hashBeforeEncryption: String?,
) : OrchestratorResult() ) : OrchestratorResult()
class ServerResult( class ServerResult(
val fileHeader: FileHeader, val fileHeader: FileHeader,
val url: String, val url: String,
val magnet: String?, val magnet: String?,
val originalHash: String?, val uploadedHash: String?,
val mimeTypeBeforeEncryption: String?,
val hashBeforeEncryption: String?,
) : OrchestratorResult() ) : 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( suspend fun upload(
uri: Uri, uri: Uri,
mimeType: String?, mimeType: String?,
@@ -264,18 +298,33 @@ class UploadOrchestrator {
account: Account, account: Account,
context: Context, context: Context,
): UploadingFinalState { ): UploadingFinalState {
val result = val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context)
if (compressionQuality != CompressorQuality.UNCOMPRESSED) {
updateState(0.02, UploadingState.Compressing)
compressor.compress(uri, mimeType, compressionQuality, context.applicationContext)
} else {
MediaCompressorResult(uri, mimeType, null)
}
return when (server.type) { return when (server.type) {
ServerType.NIP95 -> uploadNIP95(result.uri, result.contentType, context) ServerType.NIP95 -> uploadNIP95(compressed.uri, compressed.contentType, null, null, context)
ServerType.NIP96 -> uploadNIP96(result.uri, result.contentType, result.size, alt, sensitiveContent, server.baseUrl, account, context) ServerType.NIP96 -> uploadNIP96(compressed.uri, compressed.contentType, compressed.size, alt, sensitiveContent, server.baseUrl, null, null, account, context)
ServerType.Blossom -> uploadBlossom(result.uri, result.contentType, result.size, alt, sensitiveContent, server.baseUrl, 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)
} }
} }
} }
@@ -175,16 +175,18 @@ class BlossomUploader {
val explanation = HttpStatusMessages.resourceIdFor(response.code) val explanation = HttpStatusMessages.resourceIdFor(response.code)
if (errorMessage != null) { 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) { } 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 { } 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( suspend fun delete(
hash: String, hash: String,
contentType: String?, contentType: String?,
@@ -185,7 +185,7 @@ class Nip96Uploader {
} else if (result.status == "success" && result.nip94Event != null) { } else if (result.status == "success" && result.nip94Event != null) {
return convertToMediaResult(result.nip94Event) return convertToMediaResult(result.nip94Event)
} else { } 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 { } else {
@@ -207,16 +207,18 @@ class Nip96Uploader {
val explanation = HttpStatusMessages.resourceIdFor(response.code) val explanation = HttpStatusMessages.resourceIdFor(response.code)
if (errorMessage != null) { 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) { } 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 { } 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 { fun convertToMediaResult(nip96: PartialEvent): MediaUploadResult {
// Images don't seem to be ready immediately after upload // Images don't seem to be ready immediately after upload
val imageUrl = nip96.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1) 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" } ?.firstOrNull { it.size > 1 && it[0] == "m" }
?.get(1) ?.get(1)
?.ifBlank { null } ?.ifBlank { null }
val originalHash = val hash =
nip96.tags nip96.tags
?.firstOrNull { it.size > 1 && it[0] == "ox" } ?.firstOrNull { it.size > 1 && it[0] == "x" }
?.get(1) ?.get(1)
?.ifBlank { null } ?.ifBlank { null }
val dim = val dim =
@@ -245,7 +247,7 @@ class Nip96Uploader {
return MediaUploadResult( return MediaUploadResult(
url = imageUrl, url = imageUrl,
type = remoteMimeType, type = remoteMimeType,
sha256 = originalHash, sha256 = hash,
dimension = dim, dimension = dim,
magnet = magnet, magnet = magnet,
) )
@@ -114,7 +114,6 @@ import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.amethyst.ui.theme.subtleBorder import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -328,13 +327,13 @@ fun EditPostView(
} }
} }
if (postViewModel.mediaToUpload.isNotEmpty()) { postViewModel.multiOrchestrator?.let {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
) { ) {
ImageVideoDescription( ImageVideoDescription(
postViewModel.mediaToUpload, it,
accountViewModel.account.settings.defaultFileServer, accountViewModel.account.settings.defaultFileServer,
onAdd = { alt, server, sensitiveContent, mediaQuality -> onAdd = { alt, server, sensitiveContent, mediaQuality ->
postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context) postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context)
@@ -343,7 +342,7 @@ fun EditPostView(
} }
}, },
onDelete = postViewModel::deleteMediaToUpload, 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() } }, onError = { scope.launch { Toast.makeText(context, context.resources.getText(it), Toast.LENGTH_SHORT).show() } },
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
) )
@@ -29,6 +29,7 @@ import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Account 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.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource 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.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing 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.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.ammolite.relays.RelaySetupInfo import com.vitorpamplona.ammolite.relays.RelaySetupInfo
import com.vitorpamplona.quartz.encoders.IMetaTag import com.vitorpamplona.quartz.encoders.IMetaTag
import com.vitorpamplona.quartz.encoders.IMetaTagBuilder import com.vitorpamplona.quartz.encoders.IMetaTagBuilder
import com.vitorpamplona.quartz.events.FileStorageEvent import com.vitorpamplona.quartz.events.FileStorageEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Stable @Stable
@@ -77,7 +76,7 @@ open class EditPostViewModel : ViewModel() {
var userSuggestionsMainMessage: UserSuggestionAnchor? = null var userSuggestionsMainMessage: UserSuggestionAnchor? = null
// Images and Videos // Images and Videos
var mediaToUpload by mutableStateOf<ImmutableList<SelectedMediaProcessing>>(persistentListOf()) var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
// Invoices // Invoices
var canAddInvoice by mutableStateOf(false) var canAddInvoice by mutableStateOf(false)
@@ -102,7 +101,7 @@ open class EditPostViewModel : ViewModel() {
this.account = accountViewModel.account this.account = accountViewModel.account
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
mediaToUpload = persistentListOf() multiOrchestrator = null
message = TextFieldValue(versionLookingAt?.event?.content() ?: edit.event?.content() ?: "") message = TextFieldValue(versionLookingAt?.event?.content() ?: edit.event?.content() ?: "")
urlPreview = findUrlInMessage() urlPreview = findUrlInMessage()
@@ -156,38 +155,27 @@ open class EditPostViewModel : ViewModel() {
onError: (String, String) -> Unit, onError: (String, String) -> Unit,
context: Context, context: Context,
) { ) {
val myAccount = account ?: return
viewModelScope.launch { viewModelScope.launch {
val myAccount = account ?: return@launch
val myMultiOrchestrator = multiOrchestrator ?: return@launch
isUploadingImage = true isUploadingImage = true
val jobs = val results =
mediaToUpload.map { myGalleryUri -> myMultiOrchestrator.upload(
viewModelScope.launch(Dispatchers.IO) { viewModelScope,
myGalleryUri.orchestrator.upload( alt,
myGalleryUri.media.uri, sensitiveContent,
myGalleryUri.media.mimeType, MediaCompressor.intToCompressorQuality(mediaQuality),
alt, server,
sensitiveContent, myAccount,
MediaCompressor.intToCompressorQuality(mediaQuality), context,
server, )
myAccount,
context,
)
}
}
jobs.joinAll() if (results.allGood) {
results.successful.forEach { state ->
val allGood = if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
mediaToUpload.mapNotNull { account?.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, sensitiveContent) { nip95 ->
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 ->
nip95attachments = nip95attachments + nip95 nip95attachments = nip95attachments + nip95
val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) }
@@ -197,33 +185,36 @@ open class EditPostViewModel : ViewModel() {
urlPreview = findUrlInMessage() urlPreview = findUrlInMessage()
} }
} else if (it.result is UploadOrchestrator.OrchestratorResult.ServerResult) { } else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
val iMeta = val iMeta =
IMetaTagBuilder(it.result.url) IMetaTagBuilder(state.result.url)
.apply { .apply {
hash(it.result.fileHeader.hash) hash(state.result.fileHeader.hash)
size(it.result.fileHeader.size) size(state.result.fileHeader.size)
it.result.fileHeader.mimeType state.result.fileHeader.mimeType
?.let { mimeType(it) } ?.let { mimeType(it) }
it.result.fileHeader.dim state.result.fileHeader.dim
?.let { dims(it) } ?.let { dims(it) }
it.result.fileHeader.blurHash state.result.fileHeader.blurHash
?.let { blurhash(it.blurhash) } ?.let { blurhash(it.blurhash) }
it.result.magnet?.let { magnet(it) } state.result.magnet?.let { magnet(it) }
it.result.originalHash?.let { originalHash(it) } state.result.uploadedHash?.let { originalHash(it) }
alt?.let { alt(it) } alt?.let { alt(it) }
// TODO: Support Reasons on images
if (sensitiveContent) sensitiveContent("") if (sensitiveContent) sensitiveContent("")
}.build() }.build()
iMetaAttachments = iMetaAttachments.filter { it.url != iMeta.url } + iMeta iMetaAttachments = iMetaAttachments.filter { it.url != iMeta.url } + iMeta
message = message.insertUrlAtCursor(it.result.url) message = message.insertUrlAtCursor(state.result.url)
urlPreview = findUrlInMessage() 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 isUploadingImage = false
@@ -236,7 +227,7 @@ open class EditPostViewModel : ViewModel() {
editedFromNote = null editedFromNote = null
mediaToUpload = persistentListOf() multiOrchestrator = null
urlPreview = null urlPreview = null
isUploadingImage = false 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<SelectedMedia>) { fun selectImage(uris: ImmutableList<SelectedMedia>) {
mediaToUpload = uris.map { SelectedMediaProcessing(it) }.toImmutableList() multiOrchestrator = MultiOrchestrator(uris)
} }
fun deleteMediaToUpload(selected: SelectedMediaProcessing) { fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
this.mediaToUpload = mediaToUpload.filter { it != selected }.toImmutableList() this.multiOrchestrator?.remove(selected)
} }
} }
@@ -28,19 +28,19 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Account 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.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadingState
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import com.vitorpamplona.ammolite.relays.RelaySetupInfo import com.vitorpamplona.ammolite.relays.RelaySetupInfo
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.joinAll import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -59,7 +59,7 @@ open class NewMediaModel : ViewModel() {
var sensitiveContent by mutableStateOf(false) var sensitiveContent by mutableStateOf(false)
// Images and Videos // Images and Videos
var mediaToUpload by mutableStateOf<ImmutableList<SelectedMediaProcessing>>(persistentListOf()) var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
var onceUploaded: () -> Unit = {} var onceUploaded: () -> Unit = {}
// 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED // 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED
@@ -71,7 +71,7 @@ open class NewMediaModel : ViewModel() {
) { ) {
this.caption = "" this.caption = ""
this.account = account this.account = account
this.mediaToUpload = uris.map { SelectedMediaProcessing(it) }.toImmutableList() this.multiOrchestrator = MultiOrchestrator(uris)
this.selectedServer = defaultServer() this.selectedServer = defaultServer()
} }
@@ -83,46 +83,37 @@ open class NewMediaModel : ViewModel() {
fun upload( fun upload(
context: Context, context: Context,
relayList: List<RelaySetupInfo>, relayList: List<RelaySetupInfo>,
onError: (String, String) -> Unit,
) { ) {
val myAccount = account ?: return
if (relayList.isEmpty()) return
val serverToUse = selectedServer ?: return
viewModelScope.launch { viewModelScope.launch {
val myAccount = account ?: return@launch
if (relayList.isEmpty()) return@launch
val serverToUse = selectedServer ?: return@launch
val myMultiOrchestrator = multiOrchestrator ?: return@launch
isUploadingImage = true isUploadingImage = true
val jobs = val results =
mediaToUpload.map { myGalleryUri -> myMultiOrchestrator.upload(
viewModelScope.launch(Dispatchers.IO) { viewModelScope,
myGalleryUri.orchestrator.upload( caption,
myGalleryUri.media.uri, sensitiveContent,
myGalleryUri.media.mimeType, MediaCompressor.intToCompressorQuality(mediaQualitySlider),
caption, serverToUse,
sensitiveContent, myAccount,
MediaCompressor.intToCompressorQuality(mediaQualitySlider), context,
serverToUse, )
myAccount,
context,
)
}
}
jobs.joinAll() if (results.allGood) {
val allGood =
mediaToUpload.mapNotNull {
it.orchestrator.progressState.value as? UploadingState.Finished
}
if (allGood.size == mediaToUpload.size) {
// It all finished successfully // It all finished successfully
val nip95s = val nip95s =
allGood.mapNotNull { results.successful.mapNotNull {
it.result as? UploadOrchestrator.OrchestratorResult.NIP95Result it.result as? UploadOrchestrator.OrchestratorResult.NIP95Result
} }
val videosAndOthers = val videosAndOthers =
allGood.mapNotNull { results.successful.mapNotNull {
val map = it.result as? UploadOrchestrator.OrchestratorResult.ServerResult val map = it.result as? UploadOrchestrator.OrchestratorResult.ServerResult
if (map != null && !isImage(map.url, map.fileHeader.mimeType)) { if (map != null && !isImage(map.url, map.fileHeader.mimeType)) {
map map
@@ -132,7 +123,7 @@ open class NewMediaModel : ViewModel() {
} }
val imageUrls = val imageUrls =
allGood results.successful
.mapNotNull { .mapNotNull {
val map = it.result as? UploadOrchestrator.OrchestratorResult.ServerResult val map = it.result as? UploadOrchestrator.OrchestratorResult.ServerResult
if (map != null && isImage(map.url, map.fileHeader.mimeType)) { if (map != null && isImage(map.url, map.fileHeader.mimeType)) {
@@ -169,7 +160,7 @@ open class NewMediaModel : ViewModel() {
it.fileHeader, it.fileHeader,
caption, caption,
sensitiveContent, sensitiveContent,
it.originalHash, it.uploadedHash,
relayList, relayList,
) { ) {
continuation.resume(true) continuation.resume(true)
@@ -203,22 +194,26 @@ open class NewMediaModel : ViewModel() {
onceUploaded() onceUploaded()
cancelModel() 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() { open fun cancelModel() {
mediaToUpload = persistentListOf() multiOrchestrator = null
isUploadingImage = false isUploadingImage = false
caption = "" caption = ""
selectedServer = defaultServer() selectedServer = defaultServer()
} }
fun deleteMediaToUpload(selected: SelectedMediaProcessing) { 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] fun defaultServer() = account?.settings?.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0]
@@ -141,8 +141,7 @@ fun NewMediaView(
PostButton( PostButton(
onPost = { onPost = {
onClose() onClose()
postViewModel.upload(context, relayList) postViewModel.upload(context, relayList, onError = accountViewModel::toast)
// accountViewModel.toast(stringRes(context, R.string.failed_to_upload_media_no_details), it)
postViewModel.selectedServer?.let { postViewModel.selectedServer?.let {
if (it.type != ServerType.NIP95) { if (it.type != ServerType.NIP95) {
account.settings.changeDefaultFileServer(it) account.settings.changeDefaultFileServer(it)
@@ -218,11 +217,13 @@ fun ImageVideoPost(
}.toImmutableList() }.toImmutableList()
} }
ShowImageUploadGallery( postViewModel.multiOrchestrator?.let {
postViewModel.mediaToUpload, ShowImageUploadGallery(
postViewModel::deleteMediaToUpload, it,
accountViewModel, postViewModel::deleteMediaToUpload,
) accountViewModel,
)
}
OutlinedTextField( OutlinedTextField(
label = { Text(text = stringRes(R.string.add_caption)) }, label = { Text(text = stringRes(R.string.add_caption)) },
@@ -34,6 +34,7 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Account 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.model.User
import com.vitorpamplona.amethyst.service.LocationState import com.vitorpamplona.amethyst.service.LocationState
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource 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.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing 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.components.Split
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.ammolite.relays.RelaySetupInfo import com.vitorpamplona.ammolite.relays.RelaySetupInfo
import com.vitorpamplona.quartz.crypto.nip17.AESGCM
import com.vitorpamplona.quartz.encoders.Hex import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.IMetaTag 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.AddressableEvent
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.BaseTextNoteEvent import com.vitorpamplona.quartz.events.BaseTextNoteEvent
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommentEvent import com.vitorpamplona.quartz.events.CommentEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent 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.FileStorageEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import com.vitorpamplona.quartz.events.GitIssueEvent import com.vitorpamplona.quartz.events.GitIssueEvent
import com.vitorpamplona.quartz.events.NIP17Group
import com.vitorpamplona.quartz.events.Price import com.vitorpamplona.quartz.events.Price
import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.RootScope 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.ZapSplitSetup
import com.vitorpamplona.quartz.events.findURLs import com.vitorpamplona.quartz.events.findURLs
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.UUID import java.util.UUID
@@ -127,7 +127,7 @@ open class NewPostViewModel : ViewModel() {
var subject by mutableStateOf(TextFieldValue("")) var subject by mutableStateOf(TextFieldValue(""))
// Images and Videos // Images and Videos
var mediaToUpload by mutableStateOf<ImmutableList<SelectedMediaProcessing>>(persistentListOf()) var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
// Polls // Polls
var canUsePoll by mutableStateOf(false) var canUsePoll by mutableStateOf(false)
@@ -247,7 +247,7 @@ open class NewPostViewModel : ViewModel() {
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null
mediaToUpload = persistentListOf() multiOrchestrator = null
quote?.let { quote?.let {
message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}") message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}")
@@ -331,7 +331,7 @@ open class NewPostViewModel : ViewModel() {
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
canAddZapRaiser = 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" } val localfowardZapTo = draftEvent.tags().filter { it.size > 1 && it[0] == "zap" }
forwardZapTo = Split() forwardZapTo = Split()
@@ -364,7 +364,7 @@ open class NewPostViewModel : ViewModel() {
note note
} }
if (draftEvent !is PrivateDmEvent && draftEvent !is ChatMessageEvent) { if (draftEvent !is PrivateDmEvent && draftEvent !is NIP17Group) {
pTags = pTags =
draftEvent.tags().filter { it.size > 1 && it[0] == "p" }.map { draftEvent.tags().filter { it.size > 1 && it[0] == "p" }.map {
LocalCache.getOrCreateUser(it[1]) LocalCache.getOrCreateUser(it[1])
@@ -459,7 +459,7 @@ open class NewPostViewModel : ViewModel() {
.firstOrNull() .firstOrNull()
} ?: ClassifiedsEvent.CONDITION.USED_LIKE_NEW } ?: ClassifiedsEvent.CONDITION.USED_LIKE_NEW
wantsDirectMessage = draftEvent is PrivateDmEvent || draftEvent is ChatMessageEvent wantsDirectMessage = draftEvent is PrivateDmEvent || draftEvent is NIP17Group
draftEvent.subject()?.let { draftEvent.subject()?.let {
subject = TextFieldValue() subject = TextFieldValue()
@@ -474,13 +474,13 @@ open class NewPostViewModel : ViewModel() {
TextFieldValue(draftEvent.content()) TextFieldValue(draftEvent.content())
} }
requiresNIP17 = draftEvent is ChatMessageEvent requiresNIP17 = draftEvent is NIP17Group
nip17 = draftEvent is ChatMessageEvent nip17 = draftEvent is NIP17Group
if (draftEvent is ChatMessageEvent) { if (draftEvent is NIP17Group) {
toUsers = toUsers =
TextFieldValue( 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, imetas = usedAttachments,
draftTag = localDraft, draftTag = localDraft,
) )
} else if (originalNote?.event is ChatMessageEvent) { } else if (originalNote?.event is NIP17Group) {
val receivers =
(originalNote?.event as ChatMessageEvent)
.recipientsPubKey()
.plus(originalNote?.author?.pubkeyHex)
.filterNotNull()
.toSet()
.toList()
account?.sendNIP17PrivateMessage( account?.sendNIP17PrivateMessage(
message = tagger.message, message = tagger.message,
toUsers = receivers, toUsers = (originalNote?.event as NIP17Group).groupMembers().toList(),
subject = subject.text.ifBlank { null }, subject = subject.text.ifBlank { null },
replyingTo = originalNote!!, replyingTo = originalNote!!,
mentions = tagger.pTags, mentions = tagger.pTags,
@@ -865,6 +857,70 @@ open class NewPostViewModel : ViewModel() {
} }
} }
fun uploadAsSeparatePrivateEvent(
toUsers: Set<HexKey>,
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( fun upload(
alt: String?, alt: String?,
sensitiveContent: Boolean, sensitiveContent: Boolean,
@@ -874,36 +930,26 @@ open class NewPostViewModel : ViewModel() {
onError: (title: String, message: String) -> Unit, onError: (title: String, message: String) -> Unit,
context: Context, context: Context,
) { ) {
val myAccount = account ?: return viewModelScope.launch(Dispatchers.Default) {
val myAccount = account ?: return@launch
val myMultiOrchestrator = multiOrchestrator ?: return@launch
viewModelScope.launch {
isUploadingImage = true isUploadingImage = true
val jobs = val results =
mediaToUpload.map { myGalleryUri -> myMultiOrchestrator.upload(
viewModelScope.launch(Dispatchers.IO) { viewModelScope,
myGalleryUri.orchestrator.upload( alt,
myGalleryUri.media.uri, sensitiveContent,
myGalleryUri.media.mimeType, MediaCompressor.intToCompressorQuality(mediaQuality),
alt, server,
sensitiveContent, myAccount,
MediaCompressor.intToCompressorQuality(mediaQuality), context,
server, )
myAccount,
context,
)
}
}
jobs.joinAll() if (results.allGood) {
results.successful.forEach {
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) { if (it.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
account?.createNip95(it.result.bytes, headerInfo = it.result.fileHeader, alt, sensitiveContent) { nip95 -> account?.createNip95(it.result.bytes, headerInfo = it.result.fileHeader, alt, sensitiveContent) { nip95 ->
nip95attachments = nip95attachments + nip95 nip95attachments = nip95attachments + nip95
@@ -928,7 +974,7 @@ open class NewPostViewModel : ViewModel() {
it.result.fileHeader.blurHash it.result.fileHeader.blurHash
?.let { blurhash(it.blurhash) } ?.let { blurhash(it.blurhash) }
it.result.magnet?.let { magnet(it) } it.result.magnet?.let { magnet(it) }
it.result.originalHash?.let { originalHash(it) } it.result.uploadedHash?.let { originalHash(it) }
alt?.let { alt(it) } alt?.let { alt(it) }
// TODO: Support Reasons on images // TODO: Support Reasons on images
if (sensitiveContent) sensitiveContent("") 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 isUploadingImage = false
@@ -955,7 +1005,7 @@ open class NewPostViewModel : ViewModel() {
forkedFromNote = null forkedFromNote = null
mediaToUpload = persistentListOf() multiOrchestrator = null
urlPreview = null urlPreview = null
isUploadingImage = false isUploadingImage = false
pTags = null pTags = null
@@ -1004,7 +1054,7 @@ open class NewPostViewModel : ViewModel() {
} }
fun deleteMediaToUpload(selected: SelectedMediaProcessing) { fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
this.mediaToUpload = mediaToUpload.filter { it != selected }.toImmutableList() this.multiOrchestrator?.remove(selected)
} }
open fun findUrlInMessage(): String? = RichTextParser().parseValidUrls(message.text).firstOrNull() open fun findUrlInMessage(): String? = RichTextParser().parseValidUrls(message.text).firstOrNull()
@@ -1170,14 +1220,14 @@ open class NewPostViewModel : ViewModel() {
!category.text.isNullOrBlank() !category.text.isNullOrBlank()
) )
) && ) &&
mediaToUpload.isEmpty() multiOrchestrator == null
fun insertAtCursor(newElement: String) { fun insertAtCursor(newElement: String) {
message = message.insertUrlAtCursor(newElement) message = message.insertUrlAtCursor(newElement)
} }
fun selectImage(uris: ImmutableList<SelectedMedia>) { fun selectImage(uris: ImmutableList<SelectedMedia>) {
mediaToUpload = uris.map { SelectedMediaProcessing(it) }.toImmutableList() multiOrchestrator = MultiOrchestrator(uris)
} }
fun locationFlow(): StateFlow<LocationState.LocationResult> { fun locationFlow(): StateFlow<LocationState.LocationResult> {
@@ -28,12 +28,12 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account 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.blossom.BlossomUploader
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia 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.amethyst.ui.stringRes
import com.vitorpamplona.quartz.events.GitHubIdentity import com.vitorpamplona.quartz.events.GitHubIdentity
import com.vitorpamplona.quartz.events.MastodonIdentity import com.vitorpamplona.quartz.events.MastodonIdentity
@@ -20,6 +20,8 @@
*/ */
package com.vitorpamplona.amethyst.ui.actions.uploads package com.vitorpamplona.amethyst.ui.actions.uploads
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
class SelectedMediaProcessing( class SelectedMediaProcessing(
val media: SelectedMedia, val media: SelectedMedia,
val orchestrator: UploadOrchestrator = UploadOrchestrator(), val orchestrator: UploadOrchestrator = UploadOrchestrator(),
@@ -59,6 +59,9 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R 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.AutoNonlazyGrid
import com.vitorpamplona.amethyst.ui.components.VideoView import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.note.CloseIcon 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.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size40Modifier import com.vitorpamplona.amethyst.ui.theme.Size40Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
fun ShowImageUploadGallery( fun ShowImageUploadGallery(
list: ImmutableList<SelectedMediaProcessing>, list: MultiOrchestrator,
onDelete: (SelectedMediaProcessing) -> Unit, onDelete: (SelectedMediaProcessing) -> Unit,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
AutoNonlazyGrid(list.size) { AutoNonlazyGrid(list.size()) {
ShowImageUploadItem(list[it], onDelete, accountViewModel) ShowImageUploadItem(list.get(it), onDelete, accountViewModel)
} }
} }
@@ -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.RenderBadgeAward
import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage
import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessage 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.RenderClassifieds
import com.vitorpamplona.amethyst.ui.note.types.RenderCommunity import com.vitorpamplona.amethyst.ui.note.types.RenderCommunity
import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack 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.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent
@@ -671,6 +673,18 @@ private fun RenderNoteRow(
nav, nav,
) )
} }
is ChatMessageEncryptedFileHeaderEvent -> {
RenderChatMessageEncryptedFile(
baseNote,
makeItShort,
canPreview,
quotesLeft,
backgroundColor,
editState,
accountViewModel,
nav,
)
}
is ClassifiedsEvent -> { is ClassifiedsEvent -> {
RenderClassifieds( RenderClassifieds(
noteEvent, noteEvent,
@@ -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<Color>,
editState: State<GenericLoadable<EditState>>,
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<Color>,
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<BaseMediaContent>(
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,
)
}
}
@@ -128,6 +128,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.LocationState import com.vitorpamplona.amethyst.service.LocationState
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource 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.NewPollOption
import com.vitorpamplona.amethyst.ui.actions.NewPollVoteValueRange import com.vitorpamplona.amethyst.ui.actions.NewPollVoteValueRange
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
@@ -506,13 +507,13 @@ fun NewPostScreen(
} }
} }
if (postViewModel.mediaToUpload.isNotEmpty()) { postViewModel.multiOrchestrator?.let {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
) { ) {
ImageVideoDescription( ImageVideoDescription(
postViewModel.mediaToUpload, it,
accountViewModel.account.settings.defaultFileServer, accountViewModel.account.settings.defaultFileServer,
onAdd = { alt, server, sensitiveContent, mediaQuality -> onAdd = { alt, server, sensitiveContent, mediaQuality ->
postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context) postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context)
@@ -521,7 +522,7 @@ fun NewPostScreen(
} }
}, },
onDelete = postViewModel::deleteMediaToUpload, 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() } }, onError = { scope.launch { Toast.makeText(context, context.resources.getText(it), Toast.LENGTH_SHORT).show() } },
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
) )
@@ -1666,7 +1667,7 @@ fun CreateButton(
@Composable @Composable
fun ImageVideoDescription( fun ImageVideoDescription(
uris: ImmutableList<SelectedMediaProcessing>, uris: MultiOrchestrator,
defaultServer: ServerName, defaultServer: ServerName,
onAdd: (String, ServerName, Boolean, Int) -> Unit, onAdd: (String, ServerName, Boolean, Int) -> Unit,
onDelete: (SelectedMediaProcessing) -> Unit, onDelete: (SelectedMediaProcessing) -> Unit,
@@ -1727,7 +1728,7 @@ fun ImageVideoDescription(
.padding(bottom = 10.dp), .padding(bottom = 10.dp),
) { ) {
val text = val text =
if (uris.size == 1) { if (uris.size() == 1) {
if (uris.first().media.isImage() == true) { if (uris.first().media.isImage() == true) {
R.string.content_description_add_image R.string.content_description_add_image
} else { } else {
@@ -110,14 +110,14 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.PublicChatChannel import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrChannelDataSource 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.NewChannelView
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery 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.LoadNote
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
@@ -395,7 +395,7 @@ private suspend fun innerSendPost(
tagger.run() tagger.run()
val urls = findURLs(tagger.message) 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) { if (channel is PublicChatChannel) {
accountViewModel.account.sendChannelMessage( accountViewModel.account.sendChannelMessage(
@@ -77,6 +77,7 @@ import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent
import com.vitorpamplona.amethyst.ui.note.WatchUserFollows import com.vitorpamplona.amethyst.ui.note.WatchUserFollows
import com.vitorpamplona.amethyst.ui.note.ZapReaction import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.note.timeAgoShort 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.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleMaxSizeModifier 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.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent 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.DraftEvent
import com.vitorpamplona.quartz.events.EmptyTagList import com.vitorpamplona.quartz.events.EmptyTagList
import com.vitorpamplona.quartz.events.ImmutableListOfLists import com.vitorpamplona.quartz.events.ImmutableListOfLists
import com.vitorpamplona.quartz.events.NIP17Group
import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.toImmutableListOfLists import com.vitorpamplona.quartz.events.toImmutableListOfLists
@@ -174,7 +177,7 @@ fun NormalChatNote(
false // never shows the user's pictures false // never shows the user's pictures
} else if (noteEvent is PrivateDmEvent) { } else if (noteEvent is PrivateDmEvent) {
false // one-on-one, never shows it. false // one-on-one, never shows it.
} else if (noteEvent is ChatMessageEvent) { } else if (noteEvent is ChatroomKeyable) {
// only shows in a group chat. // only shows in a group chat.
noteEvent.chatroomKey(accountViewModel.userProfile().pubkeyHex).users.size > 1 noteEvent.chatroomKey(accountViewModel.userProfile().pubkeyHex).users.size > 1
} else { } else {
@@ -581,6 +584,13 @@ private fun NoteRow(
accountViewModel, accountViewModel,
nav, nav,
) )
is ChatMessageEncryptedFileHeaderEvent ->
RenderEncryptedFile(
note,
backgroundBubbleColor,
accountViewModel,
nav,
)
else -> else ->
RenderRegularTextNote( RenderRegularTextNote(
note, note,
@@ -631,16 +641,9 @@ private fun RenderDraftEvent(
} }
} }
@Composable
private fun ConstrainedStatusRow(
firstColumn: @Composable () -> Unit,
secondColumn: @Composable () -> Unit,
) {
}
@Composable @Composable
fun IncognitoBadge(baseNote: Note) { fun IncognitoBadge(baseNote: Note) {
if (baseNote.event is ChatMessageEvent) { if (baseNote.event is NIP17Group) {
Icon( Icon(
painter = painterResource(id = R.drawable.incognito), painter = painterResource(id = R.drawable.incognito),
null, null,
@@ -85,12 +85,13 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource 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.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.components.CompressorQuality import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture 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.StdPadding
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ChatroomKey import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.NIP17Group
import com.vitorpamplona.quartz.events.findURLs import com.vitorpamplona.quartz.events.findURLs
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -482,18 +484,34 @@ fun ChatroomScreen(
} }
// LAST ROW // LAST ROW
PrivateMessageEditFieldRow(newPostModel, accountViewModel) { PrivateMessageEditFieldRow(
scope.launch(Dispatchers.IO) { newPostModel,
innerSendPost(newPostModel, room, replyTo, accountViewModel, null) 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 replyTo.value = null
feedViewModel.sendToTop() 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 urls = findURLs(newPostModel.message.text)
val usedAttachments = newPostModel.iMetaAttachments.filter { it.url !in urls.toSet() } 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( accountViewModel.account.sendNIP17PrivateMessage(
message = newPostModel.message.text, message = newPostModel.message.text,
toUsers = room.users.toList(), toUsers = room.users.toList(),
@@ -535,6 +553,7 @@ fun PrivateMessageEditFieldRow(
channelScreenModel: NewPostViewModel, channelScreenModel: NewPostViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
onSendNewMessage: () -> Unit, onSendNewMessage: () -> Unit,
onSendNewMedia: (ImmutableList<SelectedMedia>) -> Unit,
) { ) {
Column( Column(
modifier = EditFieldModifier, modifier = EditFieldModifier,
@@ -579,19 +598,8 @@ fun PrivateMessageEditFieldRow(
Modifier Modifier
.size(30.dp) .size(30.dp)
.padding(start = 2.dp), .padding(start = 2.dp),
) { onImageChosen = onSendNewMedia,
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,
)
}
var wantsToActivateNIP17 by remember { mutableStateOf(false) } var wantsToActivateNIP17 by remember { mutableStateOf(false) }
@@ -41,9 +41,9 @@ import com.vitorpamplona.ammolite.relays.BundledUpdate
import com.vitorpamplona.quartz.events.BadgeAwardEvent import com.vitorpamplona.quartz.events.BadgeAwardEvent
import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.NIP17Group
import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.ReactionEvent import com.vitorpamplona.quartz.events.ReactionEvent
import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.RepostEvent
@@ -285,7 +285,7 @@ class CardFeedContentState(
it.event !is GenericRepostEvent && it.event !is GenericRepostEvent &&
it.event !is LnZapEvent it.event !is LnZapEvent
}.map { }.map {
if (it.event is PrivateDmEvent || it.event is ChatMessageEvent) { if (it.event is PrivateDmEvent || it.event is NIP17Group) {
MessageSetCard(it) MessageSetCard(it)
} else if (it.event is BadgeAwardEvent) { } else if (it.event is BadgeAwardEvent) {
BadgeCard(it) BadgeCard(it)
+1
View File
@@ -884,6 +884,7 @@
<string name="could_not_download_from_the_server">Could not download uploaded media from the server</string> <string name="could_not_download_from_the_server">Could not download uploaded media from the server</string>
<string name="could_not_check_downloaded_file">Could not check downloaded file after upload: %1$s</string> <string name="could_not_check_downloaded_file">Could not check downloaded file after upload: %1$s</string>
<string name="could_not_prepare_local_file_to_upload">Could not prepare local file to upload: %1$s</string> <string name="could_not_prepare_local_file_to_upload">Could not prepare local file to upload: %1$s</string>
<string name="failed_to_upload_to_server_with_message">Failed to upload to %1$s: %2$s</string>
<string name="failed_to_upload_with_message">Failed to upload: %1$s</string> <string name="failed_to_upload_with_message">Failed to upload: %1$s</string>
<string name="failed_to_delete_with_message">Failed to delete: %1$s</string> <string name="failed_to_delete_with_message">Failed to delete: %1$s</string>
<string name="media_too_big_for_nip95">Media is too big for NIP-95</string> <string name="media_too_big_for_nip95">Media is too big for NIP-95</string>
@@ -24,6 +24,8 @@ import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Looper import android.os.Looper
import com.abedelazizshe.lightcompressorlibrary.VideoCompressor 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 com.vitorpamplona.amethyst.ui.components.util.MediaCompressorFileUtils
import id.zelory.compressor.Compressor import id.zelory.compressor.Compressor
import io.mockk.MockKAnnotations import io.mockk.MockKAnnotations
@@ -133,6 +133,7 @@ class Relay(
lastConnectTentative = TimeUtils.now() lastConnectTentative = TimeUtils.now()
socket = socketBuilder.build(url, false, RelayListener(onConnected)) socket = socketBuilder.build(url, false, RelayListener(onConnected))
socket?.connect()
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
@@ -21,6 +21,8 @@
package com.vitorpamplona.ammolite.sockets package com.vitorpamplona.ammolite.sockets
interface WebSocket { interface WebSocket {
fun connect()
fun cancel() fun cancel()
fun send(msg: String): Boolean fun send(msg: String): Boolean
@@ -43,7 +43,7 @@ abstract class MediaUrlContent(
) : BaseMediaContent(description, dim, blurhash) ) : BaseMediaContent(description, dim, blurhash)
@Immutable @Immutable
class MediaUrlImage( open class MediaUrlImage(
url: String, url: String,
description: String? = null, description: String? = null,
hash: String? = null, hash: String? = null,
@@ -54,8 +54,22 @@ class MediaUrlImage(
mimeType: String? = null, mimeType: String? = null,
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType) ) : 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 @Immutable
class MediaUrlVideo( open class MediaUrlVideo(
url: String, url: String,
description: String? = null, description: String? = null,
hash: String? = null, hash: String? = null,
@@ -68,6 +82,23 @@ class MediaUrlVideo(
mimeType: String? = null, mimeType: String? = null,
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType) ) : 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 @Immutable
abstract class MediaPreloadedContent( abstract class MediaPreloadedContent(
val localFile: File?, val localFile: File?,
Binary file not shown.
@@ -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)
}
}
@@ -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"
}
}
@@ -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<Array<String>>,
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<HexKey> {
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<HexKey>,
repliesTo: List<HexKey>? = 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<Array<String>> {
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<HexKey>,
repliesTo: List<HexKey>? = 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)
}
}
}
}
@@ -38,7 +38,8 @@ class ChatMessageEvent(
content: String, content: String,
sig: HexKey, sig: HexKey,
) : WrappedEvent(id, pubKey, createdAt, KIND, tags, content, sig), ) : WrappedEvent(id, pubKey, createdAt, KIND, tags, content, sig),
ChatroomKeyable { ChatroomKeyable,
NIP17Group {
/** Recipients intended to receive this conversation */ /** Recipients intended to receive this conversation */
fun recipientsPubKey() = tags.mapNotNull { if (it.size > 1 && it[0] == "p") it[1] else null } fun recipientsPubKey() = tags.mapNotNull { if (it.size > 1 && it[0] == "p") it[1] else null }
@@ -62,6 +63,8 @@ class ChatMessageEvent(
return result return result
} }
override fun groupMembers() = recipientsPubKey().plus(pubKey).toSet()
override fun chatroomKey(toRemove: String): ChatroomKey = ChatroomKey(talkingWith(toRemove).toImmutableSet()) override fun chatroomKey(toRemove: String): ChatroomKey = ChatroomKey(talkingWith(toRemove).toImmutableSet())
companion object { companion object {
@@ -111,6 +114,10 @@ class ChatMessageEvent(
} }
} }
interface NIP17Group {
fun groupMembers(): Set<HexKey>
}
interface ChatroomKeyable { interface ChatroomKeyable {
fun chatroomKey(toRemove: HexKey): ChatroomKey fun chatroomKey(toRemove: HexKey): ChatroomKey
} }
@@ -59,6 +59,20 @@ class EventFactory {
ChannelMessageEvent.KIND -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig) ChannelMessageEvent.KIND -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig)
ChannelMetadataEvent.KIND -> ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig) ChannelMetadataEvent.KIND -> ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig)
ChannelMuteUserEvent.KIND -> ChannelMuteUserEvent(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 -> { ChatMessageEvent.KIND -> {
if (id.isBlank()) { if (id.isBlank()) {
ChatMessageEvent( ChatMessageEvent(
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.quartz.events package com.vitorpamplona.quartz.events
import com.vitorpamplona.quartz.encoders.Dimension
import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.IMetaTag import com.vitorpamplona.quartz.encoders.IMetaTag
import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.signers.NostrSigner
@@ -120,6 +121,65 @@ class NIP17Factory {
} }
} }
fun createEncryptedFileNIP17(
url: String,
to: List<HexKey>,
repliesToHex: List<HexKey>? = 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( fun createReactionWithinGroup(
content: String, content: String,
originalNote: EventInterface, originalNote: EventInterface,