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