- Adds support for multiple media uploads at the same time.

- Adds support to display PictureEvents with multiple images at the same time
- Improves Uploading feedback for the NewPost screen
- 10x better performance on Blurhash generation
- Fixes cosine caching on Blurhash
- Removes troublesome dependency on blurhash encoder liberary
- Restructures contentScale for Images and Video dialogs
- Refactors Media Uploaders to improve code reuse
- Refactors iMeta usage on Quartz to move away from NIP-94
This commit is contained in:
Vitor Pamplona
2024-12-18 16:52:50 -05:00
parent 728768632c
commit 579f63935a
100 changed files with 3260 additions and 2503 deletions
-3
View File
@@ -233,9 +233,6 @@ dependencies {
// enables network for coil
implementation libs.coil.okhttp
// create blurhash
implementation libs.trbl.blurhash
// Permission to upload pictures:
implementation libs.accompanist.permissions
@@ -26,14 +26,14 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.service.BlossomUploader
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.Nip96Retriever
import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
import com.vitorpamplona.amethyst.service.uploads.FileHeader
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
@@ -84,7 +84,7 @@ class ImageUploadTesting {
val initialHash = CryptoUtils.sha256(paylod).toHexKey()
val inputStream = paylod.inputStream()
val result =
BlossomUploader(account)
BlossomUploader()
.uploadImage(
inputStream,
initialHash,
@@ -95,6 +95,7 @@ class ImageUploadTesting {
sensitiveContent = null,
server,
forceProxy = { false },
createBlossomUploadAuth = account::createBlossomUploadAuth,
context = InstrumentationRegistry.getInstrumentation().targetContext,
)
@@ -116,7 +117,7 @@ class ImageUploadTesting {
private suspend fun testNip96(server: ServerName) {
val serverInfo =
Nip96Retriever()
ServerInfoRetriever()
.loadInfo(
server.baseUrl,
false,
@@ -125,7 +126,7 @@ class ImageUploadTesting {
val paylod = getBitmap()
val inputStream = paylod.inputStream()
val result =
Nip96Uploader(account)
Nip96Uploader()
.uploadImage(
inputStream,
paylod.size.toLong(),
@@ -135,6 +136,7 @@ class ImageUploadTesting {
serverInfo,
forceProxy = { false },
onProgress = {},
createHTTPAuthorization = account::createHTTPAuthorization,
context = InstrumentationRegistry.getInstrumentation().targetContext,
)
@@ -20,13 +20,13 @@
*/
package com.vitorpamplona.amethyst
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
/**
* Launches an async coroutine for each item, runs the
@@ -54,9 +54,12 @@ suspend fun <T> launchAndWaitAll(
/**
* Runs the function and waits for 10 seconds for any result.
*/
suspend inline fun <T> tryAndWait(crossinline asyncFunc: (CancellableContinuation<T>) -> Unit): T? =
withTimeoutOrNull(10000) {
suspendCancellableCoroutine { continuation ->
suspend inline fun <T> tryAndWait(
timeoutMillis: Long = 10000,
crossinline asyncFunc: (Continuation<T>) -> Unit,
): T? =
withTimeoutOrNull(timeoutMillis) {
suspendCoroutine { continuation ->
asyncFunc(continuation)
}
}
@@ -31,10 +31,10 @@ import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.LocationState
import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.uploads.FileHeader
import com.vitorpamplona.amethyst.tryAndWait
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
@@ -51,9 +51,11 @@ import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.ammolite.service.HttpClientManager
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.Dimension
import com.vitorpamplona.quartz.encoders.ETag
import com.vitorpamplona.quartz.encoders.EventHint
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.IMetaTag
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
import com.vitorpamplona.quartz.encoders.PTag
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
@@ -73,7 +75,6 @@ import com.vitorpamplona.quartz.events.CommentEvent
import com.vitorpamplona.quartz.events.Contact
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.DeletionEvent
import com.vitorpamplona.quartz.events.Dimension
import com.vitorpamplona.quartz.events.DraftEvent
import com.vitorpamplona.quartz.events.EmojiPackEvent
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
@@ -105,6 +106,7 @@ import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryRequestEvent
import com.vitorpamplona.quartz.events.OtsEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PictureEvent
import com.vitorpamplona.quartz.events.PictureMeta
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.Price
import com.vitorpamplona.quartz.events.PrivateDmEvent
@@ -1495,36 +1497,45 @@ class Account(
}
}
fun createHTTPAuthorization(
suspend fun createHTTPAuthorization(
url: String,
method: String,
body: ByteArray? = null,
onReady: (HTTPAuthorizationEvent) -> Unit,
) {
if (!isWriteable()) return
): HTTPAuthorizationEvent? {
if (!isWriteable()) return null
HTTPAuthorizationEvent.create(url, method, body, signer, onReady = onReady)
return tryAndWait { continuation ->
HTTPAuthorizationEvent.create(url, method, body, signer) {
continuation.resume(it)
}
}
}
fun createBlossomUploadAuth(
suspend fun createBlossomUploadAuth(
hash: HexKey,
size: Long,
alt: String,
onReady: (BlossomAuthorizationEvent) -> Unit,
) {
if (!isWriteable()) return
): BlossomAuthorizationEvent? {
if (!isWriteable()) return null
BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer, onReady = onReady)
return tryAndWait { continuation ->
BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer) {
continuation.resume(it)
}
}
}
fun createBlossomDeleteAuth(
suspend fun createBlossomDeleteAuth(
hash: HexKey,
alt: String,
onReady: (BlossomAuthorizationEvent) -> Unit,
) {
if (!isWriteable()) return
): BlossomAuthorizationEvent? {
if (!isWriteable()) return null
BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer, onReady = onReady)
return tryAndWait { continuation ->
BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer) {
continuation.resume(it)
}
}
}
suspend fun boost(note: Note) {
@@ -1858,7 +1869,7 @@ class Account(
hash = headerInfo.hash,
size = headerInfo.size.toString(),
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
blurhash = headerInfo.blurHash?.blurhash,
alt = alt,
sensitiveContent = sensitiveContent,
signer = signer,
@@ -1934,7 +1945,7 @@ class Account(
hash = headerInfo.hash,
size = headerInfo.size.toString(),
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
blurhash = headerInfo.blurHash?.blurhash,
alt = alt,
originalHash = originalHash,
sensitiveContent = sensitiveContent,
@@ -1944,6 +1955,38 @@ class Account(
}
}
fun sendAllAsOnePictureEvent(
urlHeaderInfo: Map<String, FileHeader>,
caption: String?,
sensitiveContent: Boolean,
relayList: List<RelaySetupInfo>,
onReady: (Note) -> Unit,
) {
val iMetas =
urlHeaderInfo.map {
PictureMeta(
it.key,
it.value.mimeType,
it.value.blurHash?.blurhash,
it.value.dim,
caption,
it.value.hash,
it.value.size.toLong(),
emptyList(),
emptyList(),
)
}
PictureEvent.create(
images = iMetas,
msg = caption,
markAsSensitive = sensitiveContent,
signer = signer,
) { event ->
sendHeader(event, relayList = relayList, onReady)
}
}
fun sendHeader(
url: String,
magnetUri: String?,
@@ -1967,7 +2010,8 @@ class Account(
hash = headerInfo.hash,
size = headerInfo.size.toLong(),
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
blurhash = headerInfo.blurHash?.blurhash,
markAsSensitive = sensitiveContent,
alt = alt,
signer = signer,
) { event ->
@@ -1981,7 +2025,7 @@ class Account(
hash = headerInfo.hash,
size = headerInfo.size,
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
blurhash = headerInfo.blurHash?.blurhash,
alt = alt,
sensitiveContent = sensitiveContent,
signer = signer,
@@ -1995,7 +2039,7 @@ class Account(
hash = headerInfo.hash,
size = headerInfo.size,
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
blurhash = headerInfo.blurHash?.blurhash,
alt = alt,
sensitiveContent = sensitiveContent,
signer = signer,
@@ -2011,7 +2055,7 @@ class Account(
hash = headerInfo.hash,
size = headerInfo.size.toString(),
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
blurhash = headerInfo.blurHash?.blurhash,
alt = alt,
originalHash = originalHash,
sensitiveContent = sensitiveContent,
@@ -2037,7 +2081,7 @@ class Account(
zapRaiserAmount: Long? = null,
relayList: List<RelaySetupInfo>,
geohash: String? = null,
nip94attachments: List<Event>? = null,
imetas: List<IMetaTag>? = null,
draftTag: String?,
) {
if (!isWriteable()) return
@@ -2064,7 +2108,7 @@ class Account(
zapRaiserAmount = zapRaiserAmount,
directMentions = directMentions,
geohash = geohash,
nip94attachments = nip94attachments,
imetas = imetas,
signer = signer,
isDraft = draftTag != null,
) {
@@ -2104,7 +2148,7 @@ class Account(
forkedFrom: Event?,
relayList: List<RelaySetupInfo>,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
draftTag: String?,
) {
if (!isWriteable()) return
@@ -2126,7 +2170,7 @@ class Account(
root = root,
directMentions = directMentions,
geohash = geohash,
nip94attachments = nip94attachments,
imetas = imetas,
forkedFrom = forkedFrom,
signer = signer,
isDraft = draftTag != null,
@@ -2172,7 +2216,7 @@ class Account(
forkedFrom: Event?,
relayList: List<RelaySetupInfo>,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
draftTag: String?,
) {
if (!isWriteable()) return
@@ -2192,7 +2236,7 @@ class Account(
torrent = root,
directMentions = directMentions,
geohash = geohash,
nip94attachments = nip94attachments,
imetas = imetas,
forkedFrom = forkedFrom,
signer = signer,
isDraft = draftTag != null,
@@ -2255,7 +2299,7 @@ class Account(
replyingTo: Note,
directMentionsUsers: Set<User> = emptySet(),
directMentionsNotes: Set<Note> = emptySet(),
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
geohash: String? = null,
zapReceiver: List<ZapSplitSetup>? = null,
wantsToMarkAsSensitive: Boolean = false,
@@ -2298,7 +2342,7 @@ class Account(
usersMentioned = usersMentioned,
addressesMentioned = addressesMentioned,
eventsMentioned = eventsMentioned,
nip94attachments = nip94attachments,
imetas = imetas,
geohash = geohash,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
@@ -2330,7 +2374,7 @@ class Account(
usersMentioned = usersMentioned,
addressesMentioned = addressesMentioned,
eventsMentioned = eventsMentioned,
nip94attachments = nip94attachments,
imetas = imetas,
geohash = geohash,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
@@ -2364,7 +2408,7 @@ class Account(
replyingTo: Note? = null,
directMentionsUsers: Set<User> = emptySet(),
directMentionsNotes: Set<Note> = emptySet(),
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
zapReceiver: List<ZapSplitSetup>? = null,
wantsToMarkAsSensitive: Boolean = false,
zapRaiserAmount: Long? = null,
@@ -2406,7 +2450,7 @@ class Account(
usersMentioned = usersMentioned,
addressesMentioned = addressesMentioned,
eventsMentioned = eventsMentioned,
nip94attachments = nip94attachments,
imetas = imetas,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
@@ -2437,7 +2481,7 @@ class Account(
usersMentioned = usersMentioned,
addressesMentioned = addressesMentioned,
eventsMentioned = eventsMentioned,
nip94attachments = nip94attachments,
imetas = imetas,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
@@ -2520,7 +2564,7 @@ class Account(
zapReceiver: List<ZapSplitSetup>? = null,
wantsToMarkAsSensitive: Boolean = false,
zapRaiserAmount: Long? = null,
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
draftTag: String? = null,
relayList: List<RelaySetupInfo>,
) {
@@ -2536,7 +2580,7 @@ class Account(
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
nip94attachments = nip94attachments,
imetas = imetas,
signer = signer,
isDraft = draftTag != null,
) {
@@ -2563,7 +2607,7 @@ class Account(
zapReceiver: List<ZapSplitSetup>? = null,
wantsToMarkAsSensitive: Boolean = false,
zapRaiserAmount: Long? = null,
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
draftTag: String? = null,
relayList: List<RelaySetupInfo>,
) {
@@ -2577,7 +2621,7 @@ class Account(
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
nip94attachments = nip94attachments,
imetas = imetas,
signer = signer,
isDraft = draftTag != null,
) {
@@ -2610,7 +2654,7 @@ class Account(
forkedFrom: Event?,
relayList: List<RelaySetupInfo>,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
draftTag: String?,
) {
if (!isWriteable()) return
@@ -2632,7 +2676,7 @@ class Account(
root = root,
directMentions = directMentions,
geohash = geohash,
nip94attachments = nip94attachments,
imetas = imetas,
forkedFrom = forkedFrom,
signer = signer,
isDraft = draftTag != null,
@@ -2702,7 +2746,7 @@ class Account(
zapRaiserAmount: Long? = null,
relayList: List<RelaySetupInfo>,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
draftTag: String?,
) {
if (!isWriteable()) return
@@ -2726,7 +2770,7 @@ class Account(
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
geohash = geohash,
nip94attachments = nip94attachments,
imetas = imetas,
isDraft = draftTag != null,
) {
if (draftTag != null) {
@@ -2761,7 +2805,7 @@ class Account(
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
draftTag: String?,
) {
if (!isWriteable()) return
@@ -2778,7 +2822,7 @@ class Account(
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
geohash = geohash,
nip94attachments = nip94attachments,
imetas = imetas,
signer = signer,
isDraft = draftTag != null,
) {
@@ -2806,7 +2850,7 @@ class Account(
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
draftTag: String?,
) {
if (!isWriteable()) return
@@ -2824,7 +2868,7 @@ class Account(
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
geohash = geohash,
nip94attachments = nip94attachments,
imetas = imetas,
signer = signer,
isDraft = draftTag != null,
) {
@@ -2852,7 +2896,7 @@ class Account(
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
draftTag: String?,
) {
sendPrivateMessage(
@@ -2864,7 +2908,7 @@ class Account(
wantsToMarkAsSensitive,
zapRaiserAmount,
geohash,
nip94attachments,
imetas,
draftTag,
)
}
@@ -2878,7 +2922,7 @@ class Account(
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
draftTag: String?,
) {
if (!isWriteable()) return
@@ -2896,7 +2940,7 @@ class Account(
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
geohash = geohash,
nip94attachments = nip94attachments,
imetas = imetas,
signer = signer,
advertiseNip18 = false,
isDraft = draftTag != null,
@@ -2926,7 +2970,7 @@ class Account(
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
draftTag: String? = null,
) {
if (!isWriteable()) return
@@ -2944,7 +2988,7 @@ class Account(
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
geohash = geohash,
nip94attachments = nip94attachments,
imetas = imetas,
draftTag = draftTag,
signer = signer,
) {
@@ -66,13 +66,13 @@ import com.vitorpamplona.quartz.events.WrappedEvent
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.containsAny
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flatMapLatest
import java.math.BigDecimal
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
@Stable
@@ -618,7 +618,7 @@ open class Note(
private fun processZapAmountFromResponse(
paymentRequest: Note,
paymentResponse: Note?,
continuation: CancellableContinuation<InvoiceAmount?>,
continuation: Continuation<InvoiceAmount?>,
signer: NostrSigner,
) {
val nwcRequest = paymentRequest.event as? LnZapPaymentRequestEvent
@@ -644,7 +644,7 @@ open class Note(
private fun processZapAmountFromResponse(
nwcRequest: LnZapPaymentRequestEvent,
nwcResponse: LnZapPaymentResponseEvent,
continuation: CancellableContinuation<InvoiceAmount?>,
continuation: Continuation<InvoiceAmount?>,
signer: NostrSigner,
) {
// if we can decrypt the reply
@@ -29,7 +29,7 @@ import coil3.fetch.Fetcher
import coil3.fetch.ImageFetchResult
import coil3.key.Keyer
import coil3.request.Options
import com.vitorpamplona.amethyst.commons.preview.BlurHashDecoder
import com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder
class Blurhash(
val blurhash: String,
@@ -154,7 +154,7 @@ class LightningAddressResolver {
} else {
onError(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(context, R.string.could_not_fetch_invoice_from, lnCallback),
stringRes(context, R.string.could_not_fetch_invoice_from, urlBinder),
)
}
}
@@ -0,0 +1,23 @@
/**
* 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
class CombinedUploader
@@ -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.service
package com.vitorpamplona.amethyst.service.uploads
import android.graphics.Bitmap
import android.graphics.BitmapFactory
@@ -28,53 +28,52 @@ import android.media.MediaMetadataRetriever
import android.media.MediaMetadataRetriever.BitmapParams
import android.os.Build
import android.util.Log
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
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
import com.vitorpamplona.quartz.events.Dimension
import io.trbl.blurhash.BlurHash
import kotlinx.coroutines.CancellationException
import java.io.IOException
import kotlin.math.roundToInt
class FileHeader(
val mimeType: String?,
val hash: String,
val size: Int,
val dim: Dimension?,
val blurHash: String?,
val blurHash: Blurhash?,
) {
class UnableToDownload(
val fileUrl: String,
) : Exception()
companion object {
suspend fun prepare(
fileUrl: String,
mimeType: String?,
dimPrecomputed: Dimension?,
forceProxy: Boolean,
onReady: (FileHeader) -> Unit,
onError: (String) -> Unit,
) {
): Result<FileHeader> =
try {
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl, forceProxy)
if (imageData != null) {
prepare(imageData, mimeType, dimPrecomputed, onReady, onError)
prepare(imageData, mimeType, dimPrecomputed)
} else {
onError("Unable to download image from $fileUrl")
Result.failure(UnableToDownload(fileUrl))
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
onError(e.message ?: e.javaClass.simpleName)
Result.failure(e)
}
}
fun prepare(
data: ByteArray,
mimeType: String?,
dimPrecomputed: Dimension?,
onReady: (FileHeader) -> Unit,
onError: (String) -> Unit,
) {
): Result<FileHeader> =
try {
val hash = CryptoUtils.sha256(data).toHexKey()
val size = data.size
@@ -84,88 +83,13 @@ class FileHeader(
val opt = BitmapFactory.Options()
opt.inPreferredConfig = Bitmap.Config.ARGB_8888
val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt)
val intArray = IntArray(mBitmap.width * mBitmap.height)
mBitmap.getPixels(
intArray,
0,
mBitmap.width,
0,
0,
mBitmap.width,
mBitmap.height,
)
val dim = Dimension(mBitmap.width, mBitmap.height)
val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat()
if (aspectRatio > 1) {
Pair(
BlurHash.encode(
intArray,
mBitmap.width,
mBitmap.height,
9,
(9 * (1 / aspectRatio)).roundToInt(),
),
dim,
)
} else if (aspectRatio < 1) {
Pair(
BlurHash.encode(
intArray,
mBitmap.width,
mBitmap.height,
(9 * aspectRatio).roundToInt(),
9,
),
dim,
)
} else {
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim)
}
Pair(Blurhash(mBitmap.toBlurhash()), Dimension(mBitmap.width, mBitmap.height))
} else if (mimeType?.startsWith("video/") == true) {
val mediaMetadataRetriever = MediaMetadataRetriever()
mediaMetadataRetriever.setDataSource(ByteArrayMediaDataSource(data))
val newDim = mediaMetadataRetriever.prepareDimFromVideo() ?: dimPrecomputed
val blurhash =
mediaMetadataRetriever.getThumbnail()?.let { thumbnail ->
val aspectRatio = (thumbnail.width).toFloat() / (thumbnail.height).toFloat()
val intArray = IntArray(thumbnail.width * thumbnail.height)
thumbnail.getPixels(
intArray,
0,
thumbnail.width,
0,
0,
thumbnail.width,
thumbnail.height,
)
if (aspectRatio > 1) {
BlurHash.encode(
intArray,
thumbnail.width,
thumbnail.height,
9,
(9 * (1 / aspectRatio)).roundToInt(),
)
} else if (aspectRatio < 1) {
BlurHash.encode(
intArray,
thumbnail.width,
thumbnail.height,
(9 * aspectRatio).roundToInt(),
9,
)
} else {
BlurHash.encode(intArray, thumbnail.width, thumbnail.height, 4, 4)
}
}
val blurhash = mediaMetadataRetriever.getThumbnail()?.toBlurhash()?.let { Blurhash(it) }
if (newDim?.hasSize() == true) {
Pair(blurhash, newDim)
@@ -176,13 +100,12 @@ class FileHeader(
Pair(null, null)
}
onReady(FileHeader(mimeType, hash, size, dim, blurHash))
Result.success(FileHeader(mimeType, hash, size, dim, blurHash))
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
onError(e.message ?: e.javaClass.simpleName)
Result.failure(e)
}
}
}
}
@@ -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.uploads
import com.vitorpamplona.quartz.encoders.Dimension
import com.vitorpamplona.quartz.encoders.HexKey
data class MediaUploadResult(
// A publicly accessible URL to the BUD-01 GET /<sha256> endpoint (optionally with a file extension)
val url: String?,
// The sha256 hash of the blob
val sha256: HexKey? = null,
// The size of the blob in bytes
val size: Long? = null,
// (optional) The MIME type of the blob
val type: String? = null,
// upload time
val uploaded: Long? = null,
// dimensions
val dimension: Dimension? = null,
// magnet link
val magnet: String? = null,
// info hash
val infohash: String? = null,
// ipfs link
val ipfs: String? = null,
)
@@ -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.service
package com.vitorpamplona.amethyst.service.uploads.blossom
import android.content.ContentResolver
import android.content.Context
@@ -29,15 +29,16 @@ import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.tryAndWait
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.service.HttpStatusMessages
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
import com.vitorpamplona.amethyst.service.uploads.nip96.randomChars
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.ammolite.service.HttpClientManager
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.Dimension
import com.vitorpamplona.quartz.events.BlossomAuthorizationEvent
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody
@@ -46,11 +47,8 @@ import okio.source
import java.io.File
import java.io.InputStream
import java.util.Base64
import kotlin.coroutines.resume
class BlossomUploader(
val account: Account?,
) {
class BlossomUploader {
fun Context.getFileName(uri: Uri): String? =
when (uri.scheme) {
ContentResolver.SCHEME_CONTENT -> getContentFileName(uri)
@@ -71,13 +69,14 @@ class BlossomUploader(
size: Long?,
alt: String?,
sensitiveContent: String?,
server: ServerName,
contentResolver: ContentResolver,
serverBaseUrl: String,
forceProxy: (String) -> Boolean,
httpAuth: suspend (hash: HexKey, size: Long, alt: String) -> BlossomAuthorizationEvent?,
context: Context,
): MediaUploadResult {
checkNotInMainThread()
val contentResolver = context.contentResolver
val myContentType = contentType ?: contentResolver.getType(uri)
val fileName = context.getFileName(uri)
@@ -103,31 +102,38 @@ class BlossomUploader(
myContentType,
alt,
sensitiveContent,
server,
serverBaseUrl,
forceProxy,
httpAuth,
context,
)
}
fun encodeAuth(event: BlossomAuthorizationEvent): String {
val encodedNIP98Event = Base64.getEncoder().encodeToString(event.toJson().toByteArray())
return "Nostr $encodedNIP98Event"
}
suspend fun uploadImage(
inputStream: InputStream,
hash: HexKey,
length: Int,
fileName: String?,
baseFileName: String?,
contentType: String?,
alt: String?,
sensitiveContent: String?,
server: ServerName,
serverBaseUrl: String,
forceProxy: (String) -> Boolean,
httpAuth: suspend (hash: HexKey, size: Long, alt: String) -> BlossomAuthorizationEvent?,
context: Context,
): MediaUploadResult {
checkNotInMainThread()
val fileName = fileName ?: randomChars()
val fileName = baseFileName ?: randomChars()
val extension =
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val apiUrl = server.baseUrl.removeSuffix("/") + "/upload"
val apiUrl = serverBaseUrl.removeSuffix("/") + "/upload"
val client = HttpClientManager.getHttpClient(forceProxy(apiUrl))
val requestBuilder = Request.Builder()
@@ -143,12 +149,8 @@ class BlossomUploader(
}
}
authUploadHeader(
hash,
length.toLong(),
alt?.let { "Uploading $it" } ?: "Uploading $fileName",
)?.let {
requestBuilder.addHeader("Authorization", it)
httpAuth(hash, length.toLong(), alt?.let { "Uploading $it" } ?: "Uploading $fileName")?.let {
requestBuilder.addHeader("Authorization", encodeAuth(it))
}
contentType?.let { requestBuilder.addHeader("Content-Type", it) }
@@ -186,23 +188,21 @@ class BlossomUploader(
suspend fun delete(
hash: String,
contentType: String?,
server: ServerName,
serverBaseUrl: String,
forceProxy: (String) -> Boolean,
httpAuth: (hash: HexKey, alt: String) -> BlossomAuthorizationEvent?,
context: Context,
): Boolean {
val extension =
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val apiUrl = server.baseUrl
val client = HttpClientManager.getHttpClient(forceProxy(apiUrl))
val apiUrl = serverBaseUrl
val requestBuilder = Request.Builder()
authDeleteHeader(
hash,
"Deleting $hash",
)?.let { requestBuilder.addHeader("Authorization", it) }
httpAuth(hash, "Deleting $hash")?.let {
requestBuilder.addHeader("Authorization", encodeAuth(it))
}
val request =
requestBuilder
@@ -211,7 +211,7 @@ class BlossomUploader(
.delete()
.build()
client.newCall(request).execute().use { response ->
HttpClientManager.getHttpClient(forceProxy(apiUrl)).newCall(request).execute().use { response ->
if (response.isSuccessful) {
return true
} else {
@@ -225,56 +225,8 @@ class BlossomUploader(
}
}
suspend fun authUploadHeader(
hash: String,
size: Long,
alt: String,
): String? {
val myAccount = account ?: return null
return tryAndWait { continuation ->
myAccount.createBlossomUploadAuth(hash, size, alt) {
val encodedNIP98Event = Base64.getEncoder().encodeToString(it.toJson().toByteArray())
continuation.resume("Nostr $encodedNIP98Event")
}
}
}
suspend fun authDeleteHeader(
hash: String,
alt: String,
): String? {
val myAccount = account ?: return null
return tryAndWait { continuation ->
myAccount.createBlossomDeleteAuth(hash, alt) {
val encodedNIP98Event = Base64.getEncoder().encodeToString(it.toJson().toByteArray())
continuation.resume("Nostr $encodedNIP98Event")
}
}
}
private fun parseResults(body: String): MediaUploadResult {
val mapper =
jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
return mapper.readValue(body, MediaUploadResult::class.java)
}
}
data class MediaUploadResult(
// A publicly accessible URL to the BUD-01 GET /<sha256> endpoint (optionally with a file extension)
val url: String?,
// The sha256 hash of the blob
val sha256: HexKey? = null,
// The size of the blob in bytes
val size: Long? = null,
// (optional) The MIME type of the blob
val type: String? = null,
// upload time
val uploaded: Long? = null,
// dimensions
val dimension: Dimension? = null,
// magnet link
val magnet: String? = null,
val infohash: String? = null,
// ipfs link
val ipfs: String? = null,
)
@@ -0,0 +1,43 @@
/**
* 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.nip96
import com.fasterxml.jackson.annotation.JsonProperty
data class Nip96Result(
val status: String? = null,
val message: String? = null,
@JsonProperty("processing_url")
val processingUrl: String? = null,
val percentage: Int? = null,
@JsonProperty("nip94_event")
val nip94Event: PartialEvent? = null,
)
class PartialEvent(
val tags: Array<Array<String>>? = null,
val content: String? = null,
)
data class DeleteResult(
val status: String?,
val message: String?,
)
@@ -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.service
package com.vitorpamplona.amethyst.service.uploads.nip96
import android.content.ContentResolver
import android.content.Context
@@ -26,17 +26,17 @@ import android.net.Uri
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import androidx.core.net.toFile
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.tryAndWait
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.service.HttpStatusMessages
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.ammolite.service.HttpClientManager
import com.vitorpamplona.quartz.events.Dimension
import com.vitorpamplona.quartz.encoders.Dimension
import com.vitorpamplona.quartz.events.HTTPAuthorizationEvent
import kotlinx.coroutines.delay
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
@@ -46,47 +46,44 @@ import okio.BufferedSink
import okio.source
import java.io.InputStream
import java.util.Base64
import kotlin.coroutines.resume
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
fun randomChars() = List(16) { charPool.random() }.joinToString("")
class Nip96Uploader(
val account: Account?,
) {
class Nip96Uploader {
suspend fun uploadImage(
uri: Uri,
contentType: String?,
size: Long?,
alt: String?,
sensitiveContent: String?,
server: ServerName,
contentResolver: ContentResolver,
serverBaseUrl: String,
forceProxy: (String) -> Boolean,
onProgress: (percentage: Float) -> Unit,
httpAuth: suspend (String, String, ByteArray?) -> HTTPAuthorizationEvent?,
context: Context,
): MediaUploadResult {
val serverInfo =
Nip96Retriever()
.loadInfo(
server.baseUrl,
forceProxy(server.baseUrl),
)
) = uploadImage(
uri,
contentType,
size,
alt,
sensitiveContent,
ServerInfoRetriever().loadInfo(serverBaseUrl, forceProxy(serverBaseUrl)),
forceProxy,
onProgress,
httpAuth,
context,
)
return uploadImage(
uri,
contentType,
size,
alt,
sensitiveContent,
serverInfo,
contentResolver,
forceProxy,
onProgress,
context,
)
}
fun ContentResolver.querySize(uri: Uri) =
query(uri, null, null, null, null)?.use {
it.moveToFirst()
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
it.getLong(sizeIndex)
}
fun fileSize(uri: Uri) = runCatching { uri.toFile().length() }.getOrNull()
suspend fun uploadImage(
uri: Uri,
@@ -94,25 +91,19 @@ class Nip96Uploader(
size: Long?,
alt: String?,
sensitiveContent: String?,
server: Nip96Retriever.ServerInfo,
contentResolver: ContentResolver,
server: ServerInfo,
forceProxy: (String) -> Boolean,
onProgress: (percentage: Float) -> Unit,
httpAuth: suspend (String, String, ByteArray?) -> HTTPAuthorizationEvent?,
context: Context,
): MediaUploadResult {
checkNotInMainThread()
val contentResolver = context.contentResolver
val myContentType = contentType ?: contentResolver.getType(uri)
val imageInputStream = contentResolver.openInputStream(uri)
val length = size ?: contentResolver.querySize(uri) ?: fileSize(uri) ?: 0
val length =
size
?: contentResolver.query(uri, null, null, null, null)?.use {
it.moveToFirst()
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
it.getLong(sizeIndex)
}
?: kotlin.runCatching { uri.toFile().length() }.getOrNull() ?: 0
val imageInputStream = contentResolver.openInputStream(uri)
checkNotNull(imageInputStream) { "Can't open the image input stream" }
@@ -125,6 +116,7 @@ class Nip96Uploader(
server,
forceProxy,
onProgress,
httpAuth,
context,
)
}
@@ -135,16 +127,16 @@ class Nip96Uploader(
contentType: String?,
alt: String?,
sensitiveContent: String?,
server: Nip96Retriever.ServerInfo,
server: ServerInfo,
forceProxy: (String) -> Boolean,
onProgress: (percentage: Float) -> Unit,
httpAuth: suspend (String, String, ByteArray?) -> HTTPAuthorizationEvent?,
context: Context,
): MediaUploadResult {
checkNotInMainThread()
val fileName = randomChars()
val extension =
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val client = HttpClientManager.getHttpClient(forceProxy(server.apiUrl))
val requestBuilder = Request.Builder()
@@ -173,7 +165,7 @@ class Nip96Uploader(
},
).build()
nip98Header(server.apiUrl)?.let { requestBuilder.addHeader("Authorization", it) }
httpAuth(server.apiUrl, "POST", null)?.let { requestBuilder.addHeader("Authorization", encodeAuth(it)) }
requestBuilder
.addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
@@ -259,11 +251,17 @@ class Nip96Uploader(
)
}
fun encodeAuth(event: HTTPAuthorizationEvent): String {
val encodedNIP98Event = Base64.getEncoder().encodeToString(event.toJson().toByteArray())
return "Nostr $encodedNIP98Event"
}
suspend fun delete(
hash: String,
contentType: String?,
server: Nip96Retriever.ServerInfo,
server: ServerInfo,
forceProxy: (String) -> Boolean,
httpAuth: (String, String, ByteArray?) -> HTTPAuthorizationEvent,
context: Context,
): Boolean {
val extension =
@@ -273,7 +271,7 @@ class Nip96Uploader(
val requestBuilder = Request.Builder()
nip98Header(server.apiUrl)?.let { requestBuilder.addHeader("Authorization", it) }
httpAuth(server.apiUrl, "DELETE", null)?.let { requestBuilder.addHeader("Authorization", encodeAuth(it)) }
val request =
requestBuilder
@@ -302,7 +300,7 @@ class Nip96Uploader(
private suspend fun waitProcessing(
result: Nip96Result,
server: Nip96Retriever.ServerInfo,
server: ServerInfo,
forceProxy: (String) -> Boolean,
onProgress: (percentage: Float) -> Unit,
): MediaUploadResult {
@@ -338,54 +336,11 @@ class Nip96Uploader(
}
}
suspend fun nip98Header(url: String): String? =
tryAndWait { continuation ->
nip98Header(url, "POST") { authorizationToken -> continuation.resume(authorizationToken) }
}
fun nip98Header(
url: String,
method: String,
file: ByteArray? = null,
onReady: (String?) -> Unit,
) {
val myAccount = account
if (myAccount == null) {
onReady(null)
return
}
myAccount.createHTTPAuthorization(url, method, file) {
val encodedNIP98Event = Base64.getEncoder().encodeToString(it.toJson().toByteArray())
onReady("Nostr $encodedNIP98Event")
}
}
data class DeleteResult(
val status: String?,
val message: String?,
)
private fun parseDeleteResults(body: String): DeleteResult {
val mapper =
jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
return mapper.readValue(body, DeleteResult::class.java)
}
data class Nip96Result(
val status: String? = null,
val message: String? = null,
@JsonProperty("processing_url") val processingUrl: String? = null,
val percentage: Int? = null,
@JsonProperty("nip94_event") val nip94Event: PartialEvent? = null,
)
class PartialEvent(
val tags: Array<Array<String>>? = null,
val content: String? = null,
)
private fun parseResults(body: String): Nip96Result {
val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
return mapper.readValue(body, Nip96Result::class.java)
@@ -0,0 +1,59 @@
/**
* 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.nip96
import com.fasterxml.jackson.annotation.JsonProperty
typealias PlanName = String
typealias MimeType = String
data class ServerInfo(
@JsonProperty("api_url")
val apiUrl: String,
@JsonProperty("download_url")
val downloadUrl: String? = null,
@JsonProperty("delegated_to_url")
val delegatedToUrl: String? = null,
@JsonProperty("supported_nips")
val supportedNips: ArrayList<Int> = arrayListOf(),
@JsonProperty("tos_url") val
tosUrl: String? = null,
@JsonProperty("content_types") val
contentTypes: ArrayList<MimeType> = arrayListOf(),
@JsonProperty("plans") val
plans: Map<PlanName, Plan> = mapOf(),
)
data class Plan(
@JsonProperty("name") val
name: String? = null,
@JsonProperty("is_nip98_required") val
isNip98Required: Boolean? = null,
@JsonProperty("url") val
url: String? = null,
@JsonProperty("max_byte_size") val
maxByteSize: Long? = null,
@JsonProperty("file_expiration") val
fileExpiration: ArrayList<Int> = arrayListOf(),
@JsonProperty("media_transformations")
val mediaTransformations: Map<MimeType, Array<String>> = emptyMap(),
)
@@ -18,55 +18,19 @@
* 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
package com.vitorpamplona.amethyst.service.uploads.nip96
import android.util.Log
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.ammolite.service.HttpClientManager
import kotlinx.coroutines.CancellationException
import okhttp3.Request
import java.net.URI
import java.net.URL
object Nip96MediaServers {
val cache: MutableMap<String, Nip96Retriever.ServerInfo> = mutableMapOf()
suspend fun load(
url: String,
forceProxy: Boolean,
): Nip96Retriever.ServerInfo {
val cached = cache[url]
if (cached != null) return cached
val fetched = Nip96Retriever().loadInfo(url, forceProxy)
cache[url] = fetched
return fetched
}
}
class Nip96Retriever {
data class ServerInfo(
@JsonProperty("api_url") val apiUrl: String,
@JsonProperty("download_url") val downloadUrl: String? = null,
@JsonProperty("delegated_to_url") val delegatedToUrl: String? = null,
@JsonProperty("supported_nips") val supportedNips: ArrayList<Int> = arrayListOf(),
@JsonProperty("tos_url") val tosUrl: String? = null,
@JsonProperty("content_types") val contentTypes: ArrayList<MimeType> = arrayListOf(),
@JsonProperty("plans") val plans: Map<PlanName, Plan> = mapOf(),
)
data class Plan(
@JsonProperty("name") val name: String? = null,
@JsonProperty("is_nip98_required") val isNip98Required: Boolean? = null,
@JsonProperty("url") val url: String? = null,
@JsonProperty("max_byte_size") val maxByteSize: Long? = null,
@JsonProperty("file_expiration") val fileExpiration: ArrayList<Int> = arrayListOf(),
@JsonProperty("media_transformations")
val mediaTransformations: Map<MimeType, Array<String>> = emptyMap(),
)
class ServerInfoRetriever {
fun parse(
baseUrl: String,
body: String,
@@ -138,7 +102,3 @@ class Nip96Retriever {
potentialyRelativeUrl
}
}
typealias PlanName = String
typealias MimeType = String
@@ -91,6 +91,7 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.components.BechLink
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
@@ -113,6 +114,7 @@ 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
@@ -302,7 +304,7 @@ fun EditPostView(
myUrlPreview,
mimeType = null,
roundedCorner = true,
isFiniteHeight = false,
contentScale = ContentScale.FillWidth,
accountViewModel = accountViewModel,
)
} else {
@@ -326,22 +328,22 @@ fun EditPostView(
}
}
val url = postViewModel.contentToAddUrl
if (url != null) {
if (postViewModel.mediaToUpload.isNotEmpty()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
) {
ImageVideoDescription(
url,
postViewModel.mediaToUpload,
accountViewModel.account.settings.defaultFileServer,
onAdd = { alt, server, sensitiveContent, mediaQuality ->
postViewModel.upload(url, alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context)
postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context)
if (server.type != ServerType.NIP95) {
accountViewModel.account.settings.changeDefaultFileServer(server)
}
},
onCancel = { postViewModel.contentToAddUrl = null },
onDelete = postViewModel::deleteMediaToUpload,
onCancel = { postViewModel.mediaToUpload = persistentListOf() },
onError = { scope.launch { Toast.makeText(context, context.resources.getText(it), Toast.LENGTH_SHORT).show() } },
accountViewModel = accountViewModel,
)
@@ -468,7 +470,7 @@ private fun BottomRowActions(postViewModel: EditPostViewModel) {
.height(50.dp),
verticalAlignment = CenterVertically,
) {
UploadFromGallery(
SelectFromGallery(
isUploading = postViewModel.isUploadingImage,
tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier,
@@ -21,8 +21,6 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -31,29 +29,30 @@ 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
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.BlossomUploader
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.MediaUploadResult
import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
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.events.FileHeaderEvent
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.coroutines.CancellationException
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
@@ -65,7 +64,7 @@ open class EditPostViewModel : ViewModel() {
var subject by mutableStateOf(TextFieldValue(""))
var nip94attachments by mutableStateOf<List<FileHeaderEvent>>(emptyList())
var iMetaAttachments by mutableStateOf<List<IMetaTag>>(emptyList())
var nip95attachments by
mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
@@ -78,7 +77,7 @@ open class EditPostViewModel : ViewModel() {
var userSuggestionsMainMessage: UserSuggestionAnchor? = null
// Images and Videos
var contentToAddUrl by mutableStateOf<Uri?>(null)
var mediaToUpload by mutableStateOf<ImmutableList<SelectedMediaProcessing>>(persistentListOf())
// Invoices
var canAddInvoice by mutableStateOf(false)
@@ -103,7 +102,7 @@ open class EditPostViewModel : ViewModel() {
this.account = accountViewModel.account
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
contentToAddUrl = null
mediaToUpload = persistentListOf()
message = TextFieldValue(versionLookingAt?.event?.content() ?: edit.event?.content() ?: "")
urlPreview = findUrlInMessage()
@@ -149,7 +148,6 @@ open class EditPostViewModel : ViewModel() {
}
fun upload(
galleryUri: Uri,
alt: String?,
sensitiveContent: Boolean,
mediaQuality: Int,
@@ -158,119 +156,77 @@ open class EditPostViewModel : ViewModel() {
onError: (String, String) -> Unit,
context: Context,
) {
isUploadingImage = true
contentToAddUrl = null
val myAccount = account ?: return
val contentResolver = context.contentResolver
val contentType = contentResolver.getType(galleryUri)
viewModelScope.launch {
isUploadingImage = true
viewModelScope.launch(Dispatchers.IO) {
MediaCompressor()
.compress(
galleryUri,
contentType,
context.applicationContext,
onReady = { fileUri, contentType, size ->
if (server.type == ServerType.NIP95) {
contentResolver.openInputStream(fileUri)?.use {
createNIP95Record(
it.readBytes(),
contentType,
alt,
sensitiveContent,
onError = {
onError(stringRes(context, R.string.failed_to_upload_media_no_details), it)
},
context,
)
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,
)
}
}
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 ->
nip95attachments = nip95attachments + nip95
val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) }
note?.let {
message = message.insertUrlAtCursor("nostr:" + it.toNEvent())
}
} else if (server.type == ServerType.NIP96) {
viewModelScope.launch(Dispatchers.IO) {
try {
val result =
Nip96Uploader(account)
.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
alt = alt,
sensitiveContent = if (sensitiveContent) "" else null,
server = server,
contentResolver = contentResolver,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
onProgress = {},
context = context,
)
createNIP94Record(
uploadingResult = result,
localContentType = contentType,
alt = alt,
sensitiveContent = sensitiveContent,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
onError = {
onError(stringRes(context, R.string.failed_to_upload_media_no_details), it)
},
context = context,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(
"ImageUploader",
"Failed to upload ${e.message}",
e,
)
isUploadingImage = false
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
}
}
} else {
viewModelScope.launch(Dispatchers.IO) {
try {
val result =
BlossomUploader(account)
.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
alt = alt,
sensitiveContent = if (sensitiveContent) "" else null,
server = server,
contentResolver = contentResolver,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
context = context,
)
createNIP94Record(
uploadingResult = result,
localContentType = contentType,
alt = alt,
sensitiveContent = sensitiveContent,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
onError = {
onError(stringRes(context, R.string.failed_to_upload_media_no_details), it)
},
context = context,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(
"ImageUploader",
"Failed to upload ${e.message}",
e,
)
isUploadingImage = false
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
}
}
urlPreview = findUrlInMessage()
}
},
onError = {
isUploadingImage = false
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, it))
},
mediaQuality = MediaCompressor().intToCompressorQuality(mediaQuality),
)
} else if (it.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
val iMeta =
IMetaTagBuilder(it.result.url)
.apply {
hash(it.result.fileHeader.hash)
size(it.result.fileHeader.size)
it.result.fileHeader.mimeType
?.let { mimeType(it) }
it.result.fileHeader.dim
?.let { dims(it) }
it.result.fileHeader.blurHash
?.let { blurhash(it.blurhash) }
it.result.magnet?.let { magnet(it) }
it.result.originalHash?.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)
urlPreview = findUrlInMessage()
}
}
mediaToUpload = persistentListOf()
}
isUploadingImage = false
}
}
@@ -280,7 +236,7 @@ open class EditPostViewModel : ViewModel() {
editedFromNote = null
contentToAddUrl = null
mediaToUpload = persistentListOf()
urlPreview = null
isUploadingImage = false
@@ -348,94 +304,13 @@ open class EditPostViewModel : ViewModel() {
}
}
fun canPost(): Boolean =
message.text.isNotBlank() &&
!isUploadingImage &&
!wantsInvoice &&
contentToAddUrl == null
fun canPost() = message.text.isNotBlank() && !isUploadingImage && !wantsInvoice && mediaToUpload.isEmpty()
suspend fun createNIP94Record(
uploadingResult: MediaUploadResult,
localContentType: String?,
alt: String?,
sensitiveContent: Boolean,
forceProxy: (String) -> Boolean,
onError: (String) -> Unit = {},
context: Context,
) {
if (uploadingResult.url.isNullOrBlank()) {
Log.e("ImageDownload", "Couldn't download image from server")
cancel()
isUploadingImage = false
onError(stringRes(context, R.string.server_did_not_provide_a_url_after_uploading))
return
}
FileHeader.prepare(
fileUrl = uploadingResult.url,
mimeType = uploadingResult.type ?: localContentType,
dimPrecomputed = uploadingResult.dimension,
forceProxy = forceProxy(uploadingResult.url),
onReady = { header: FileHeader ->
account?.createHeader(uploadingResult.url, uploadingResult.magnet, header, alt, sensitiveContent, uploadingResult.sha256) { event ->
isUploadingImage = false
nip94attachments = nip94attachments + event
message = message.insertUrlAtCursor(uploadingResult.url)
urlPreview = findUrlInMessage()
}
},
onError = {
isUploadingImage = false
onError(stringRes(context, R.string.could_not_prepare_header, it))
},
)
fun selectImage(uris: ImmutableList<SelectedMedia>) {
mediaToUpload = uris.map { SelectedMediaProcessing(it) }.toImmutableList()
}
fun createNIP95Record(
bytes: ByteArray,
mimeType: String?,
alt: String?,
sensitiveContent: Boolean,
onError: (String) -> Unit = {},
context: Context,
) {
if (bytes.size > 80000) {
viewModelScope.launch {
onError(stringRes(context, id = R.string.media_too_big_for_nip95))
isUploadingImage = false
}
return
}
viewModelScope.launch(Dispatchers.IO) {
FileHeader.prepare(
bytes,
mimeType,
null,
onReady = {
account?.createNip95(bytes, headerInfo = it, alt, sensitiveContent) { nip95 ->
nip95attachments = nip95attachments + nip95
val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) }
isUploadingImage = false
note?.let {
message = message.insertUrlAtCursor("nostr:" + it.toNEvent())
}
urlPreview = findUrlInMessage()
}
},
onError = {
isUploadingImage = false
onError(stringRes(context, R.string.could_not_prepare_header, it))
},
)
}
}
fun selectImage(uri: Uri) {
contentToAddUrl = uri
fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
this.mediaToUpload = mediaToUpload.filter { it != selected }.toImmutableList()
}
}
@@ -1,85 +0,0 @@
/**
* 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.actions
import com.vitorpamplona.ammolite.service.HttpClientManager
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import java.net.HttpURLConnection
import java.net.URL
class ImageDownloader {
suspend fun waitAndGetImage(
imageUrl: String,
forceProxy: Boolean,
): ByteArray? {
var imageData: ByteArray? = null
var tentatives = 0
// Servers are usually not ready.. so tries to download it for 15 times/seconds.
while (imageData == null && tentatives < 15) {
imageData =
try {
// TODO: Migrate to OkHttp
HttpURLConnection.setFollowRedirects(true)
var url = URL(imageUrl)
var huc =
if (forceProxy) {
url.openConnection(HttpClientManager.getCurrentProxy()) as HttpURLConnection
} else {
url.openConnection() as HttpURLConnection
}
huc.instanceFollowRedirects = true
var responseCode = huc.responseCode
if (responseCode in 300..400) {
val newUrl: String = huc.getHeaderField("Location")
// open the new connnection again
url = URL(newUrl)
huc =
if (forceProxy) {
url.openConnection(HttpClientManager.getCurrentProxy()) as HttpURLConnection
} else {
url.openConnection() as HttpURLConnection
}
responseCode = huc.responseCode
}
if (responseCode in 200..300) {
huc.inputStream.use { it.readBytes() }
} else {
tentatives++
delay(1000)
null
}
} catch (e: Exception) {
if (e is CancellationException) throw e
tentatives++
delay(1000)
null
}
}
return imageData
}
}
@@ -21,336 +21,204 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.BlossomUploader
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.MediaUploadResult
import com.vitorpamplona.amethyst.service.Nip96Uploader
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.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.coroutines.CancellationException
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
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@Stable
open class NewMediaModel : ViewModel() {
var account: Account? = null
var isUploadingImage by mutableStateOf(false)
var mediaType by mutableStateOf<String?>(null)
var selectedServer by mutableStateOf<ServerName?>(null)
var alt by mutableStateOf("")
var caption by mutableStateOf("")
var sensitiveContent by mutableStateOf(false)
// Images and Videos
var galleryUri by mutableStateOf<Uri?>(null)
var uploadingPercentage = mutableStateOf(0.0f)
var uploadingDescription = mutableStateOf<String?>(null)
var mediaToUpload by mutableStateOf<ImmutableList<SelectedMediaProcessing>>(persistentListOf())
var onceUploaded: () -> Unit = {}
// 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED
var mediaQualitySlider by mutableIntStateOf(1)
open fun load(
account: Account,
uri: Uri,
contentType: String?,
uris: ImmutableList<SelectedMedia>,
) {
this.caption = ""
this.account = account
this.galleryUri = uri
this.mediaType = contentType
this.mediaToUpload = uris.map { SelectedMediaProcessing(it) }.toImmutableList()
this.selectedServer = defaultServer()
}
fun isImage(
url: String,
mimeType: String?,
): Boolean = mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(url)
fun upload(
context: Context,
relayList: List<RelaySetupInfo>,
mediaQuality: Int,
onError: (String) -> Unit = {},
) {
isUploadingImage = true
val contentResolver = context.contentResolver
val myGalleryUri = galleryUri ?: return
val myAccount = account ?: return
if (relayList.isEmpty()) return
val serverToUse = selectedServer ?: return
val contentType = contentResolver.getType(myGalleryUri)
viewModelScope.launch {
isUploadingImage = true
viewModelScope.launch(Dispatchers.IO) {
uploadingPercentage.value = 0.1f
uploadingDescription.value = "Compress"
MediaCompressor()
.compress(
myGalleryUri,
contentType,
context.applicationContext,
onReady = { fileUri, contentType, size ->
if (serverToUse.type == ServerType.NIP95) {
uploadingPercentage.value = 0.2f
uploadingDescription.value = "Loading"
contentResolver.openInputStream(fileUri)?.use {
createNIP95Record(
it.readBytes(),
contentType,
alt,
sensitiveContent,
relayList = relayList,
onError = onError,
context,
)
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,
)
}
}
jobs.joinAll()
val allGood =
mediaToUpload.mapNotNull {
it.orchestrator.progressState.value as? UploadingState.Finished
}
if (allGood.size == mediaToUpload.size) {
// It all finished successfully
val nip95s =
allGood.mapNotNull {
it.result as? UploadOrchestrator.OrchestratorResult.NIP95Result
}
val videosAndOthers =
allGood.mapNotNull {
val map = it.result as? UploadOrchestrator.OrchestratorResult.ServerResult
if (map != null && !isImage(map.url, map.fileHeader.mimeType)) {
map
} else {
null
}
}
val imageUrls =
allGood
.mapNotNull {
val map = it.result as? UploadOrchestrator.OrchestratorResult.ServerResult
if (map != null && isImage(map.url, map.fileHeader.mimeType)) {
Pair(map.url, map.fileHeader)
} else {
null
}
?: run {
viewModelScope.launch {
onError(stringRes(context, R.string.could_not_open_the_compressed_file))
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
}.toMap()
val nip95jobs =
nip95s.map {
// upload each file as an individual nip95 event.
viewModelScope.launch(Dispatchers.IO) {
withTimeoutOrNull(30000) {
suspendCoroutine { continuation ->
account?.createNip95(it.bytes, headerInfo = it.fileHeader, caption, sensitiveContent) { nip95 ->
account?.consumeAndSendNip95(nip95.first, nip95.second, relayList)
continuation.resume(true)
}
}
} else if (serverToUse.type == ServerType.NIP96) {
uploadingPercentage.value = 0.2f
uploadingDescription.value = "Uploading"
viewModelScope.launch(Dispatchers.IO) {
try {
val result =
Nip96Uploader(account)
.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
alt = alt,
sensitiveContent = if (sensitiveContent) "" else null,
server = serverToUse,
contentResolver = contentResolver,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
onProgress = { percent: Float ->
uploadingPercentage.value = 0.2f + (0.2f * percent)
},
context = context,
)
createNIP94Record(
uploadingResult = result,
localContentType = contentType,
alt = alt,
sensitiveContent = sensitiveContent,
relayList = relayList,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
onError = onError,
context,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
onError(stringRes(context, R.string.failed_to_upload_media, e.message))
}
}
} else if (serverToUse.type == ServerType.Blossom) {
uploadingPercentage.value = 0.2f
uploadingDescription.value = "Uploading"
viewModelScope.launch(Dispatchers.IO) {
try {
val result =
BlossomUploader(account)
.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
alt = alt,
sensitiveContent = if (sensitiveContent) "" else null,
server = serverToUse,
contentResolver = contentResolver,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
context = context,
)
createNIP94Record(
uploadingResult = result,
localContentType = contentType,
alt = alt,
sensitiveContent = sensitiveContent,
relayList = relayList,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
onError = onError,
context,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
onError(stringRes(context, R.string.failed_to_upload_media, e.message))
}
}
}
},
onError = {
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
onError(stringRes(context, R.string.error_when_compressing_media, it))
},
mediaQuality = MediaCompressor().intToCompressorQuality(mediaQuality),
)
}
val videoJobs =
videosAndOthers.map {
// upload each file as an individual nip95 event.
viewModelScope.launch(Dispatchers.IO) {
withTimeoutOrNull(30000) {
suspendCoroutine { continuation ->
account?.sendHeader(
it.url,
it.magnet,
it.fileHeader,
caption,
sensitiveContent,
it.originalHash,
relayList,
) {
continuation.resume(true)
}
}
}
}
}
val imageJobs =
listOf(
viewModelScope.launch(Dispatchers.IO) {
withTimeoutOrNull(30000) {
suspendCoroutine { continuation ->
account?.sendAllAsOnePictureEvent(
imageUrls,
caption,
sensitiveContent,
relayList,
) {
continuation.resume(true)
}
}
}
},
)
nip95jobs.joinAll()
videoJobs.joinAll()
imageJobs.joinAll()
onceUploaded()
cancelModel()
}
}
}
open fun cancel() {
galleryUri = null
open fun cancelModel() {
mediaToUpload = persistentListOf()
isUploadingImage = false
mediaType = null
uploadingDescription.value = null
uploadingPercentage.value = 0.0f
alt = ""
caption = ""
selectedServer = defaultServer()
}
fun canPost(): Boolean = !isUploadingImage && galleryUri != null && selectedServer != null
suspend fun createNIP94Record(
uploadingResult: MediaUploadResult,
localContentType: String?,
alt: String,
sensitiveContent: Boolean,
relayList: List<RelaySetupInfo>,
forceProxy: (String) -> Boolean,
onError: (String) -> Unit = {},
context: Context,
) {
uploadingPercentage.value = 0.40f
uploadingDescription.value = "Server Processing"
// Images don't seem to be ready immediately after upload
if (uploadingResult.url.isNullOrBlank()) {
Log.e("ImageDownload", "Couldn't download image from server")
cancel()
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
isUploadingImage = false
onError(stringRes(context, R.string.server_did_not_provide_a_url_after_uploading))
return
}
uploadingDescription.value = "Downloading"
uploadingPercentage.value = 0.60f
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(uploadingResult.url, forceProxy(uploadingResult.url))
if (imageData != null) {
uploadingPercentage.value = 0.80f
uploadingDescription.value = "Hashing"
FileHeader.prepare(
data = imageData,
mimeType = uploadingResult.type ?: localContentType,
dimPrecomputed = uploadingResult.dimension,
onReady = {
uploadingPercentage.value = 0.90f
uploadingDescription.value = "Sending"
account?.sendHeader(
uploadingResult.url,
uploadingResult.magnet,
it,
alt,
sensitiveContent,
uploadingResult.sha256,
relayList,
) {
uploadingPercentage.value = 1.00f
isUploadingImage = false
onceUploaded()
cancel()
}
},
onError = {
cancel()
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
isUploadingImage = false
onError(stringRes(context, R.string.could_not_prepare_local_file_to_upload, it))
},
)
} else {
Log.e("ImageDownload", "Couldn't download image from server")
cancel()
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
isUploadingImage = false
onError(stringRes(context, R.string.could_not_download_from_the_server))
}
fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
this.mediaToUpload = mediaToUpload.filter { it != selected }.toImmutableList()
}
fun createNIP95Record(
bytes: ByteArray,
mimeType: String?,
alt: String,
sensitiveContent: Boolean,
relayList: List<RelaySetupInfo>,
onError: (String) -> Unit = {},
context: Context,
) {
if (bytes.size > 80000) {
viewModelScope.launch {
onError(stringRes(context, id = R.string.media_too_big_for_nip95))
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
}
return
}
uploadingPercentage.value = 0.30f
uploadingDescription.value = "Hashing"
viewModelScope.launch(Dispatchers.IO) {
FileHeader.prepare(
bytes,
mimeType,
null,
onReady = {
uploadingDescription.value = "Signing"
uploadingPercentage.value = 0.40f
account?.createNip95(bytes, headerInfo = it, alt, sensitiveContent) { nip95 ->
uploadingDescription.value = "Sending"
uploadingPercentage.value = 0.60f
account?.consumeAndSendNip95(nip95.first, nip95.second, relayList)
uploadingPercentage.value = 1.00f
isUploadingImage = false
onceUploaded()
cancel()
}
},
onError = {
uploadingDescription.value = null
uploadingPercentage.value = 0.00f
isUploadingImage = false
cancel()
onError(stringRes(context, R.string.could_not_prepare_local_file_to_upload, it))
},
)
}
}
fun isImage() = mediaType?.startsWith("image")
fun isVideo() = mediaType?.startsWith("video")
fun canPost(): Boolean = !isUploadingImage && mediaToUpload.isNotEmpty() && selectedServer != null
fun defaultServer() = account?.settings?.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0]
@@ -20,25 +20,17 @@
*/
package com.vitorpamplona.amethyst.ui.actions
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.util.Log
import android.util.Size
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
@@ -57,15 +49,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.KeyboardCapitalization
@@ -73,11 +62,11 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
@@ -85,41 +74,35 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.PostButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SettingSwitchItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewMediaView(
uri: Uri,
uris: ImmutableList<SelectedMedia>,
onClose: () -> Unit,
postViewModel: NewMediaModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val account = accountViewModel.account
val resolver = LocalContext.current.contentResolver
val context = LocalContext.current
val scrollState = rememberScrollState()
LaunchedEffect(uri) {
val mediaType = resolver.getType(uri) ?: ""
postViewModel.load(account, uri, mediaType)
LaunchedEffect(uris) {
postViewModel.load(account, uris)
}
var showRelaysDialog by remember { mutableStateOf(false) }
var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() }
// 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED
var mediaQualitySlider by remember { mutableIntStateOf(1) }
Dialog(
onDismissRequest = { onClose() },
properties =
@@ -158,9 +141,8 @@ fun NewMediaView(
PostButton(
onPost = {
onClose()
postViewModel.upload(context, relayList, mediaQualitySlider) {
accountViewModel.toast(stringRes(context, R.string.failed_to_upload_media_no_details), it)
}
postViewModel.upload(context, relayList)
// accountViewModel.toast(stringRes(context, R.string.failed_to_upload_media_no_details), it)
postViewModel.selectedServer?.let {
if (it.type != ServerType.NIP95) {
account.settings.changeDefaultFileServer(it)
@@ -176,7 +158,7 @@ fun NewMediaView(
Spacer(modifier = StdHorzSpacer)
CloseButton(
onPress = {
postViewModel.cancel()
postViewModel.cancelModel()
onClose()
},
)
@@ -206,69 +188,9 @@ fun NewMediaView(
.consumeWindowInsets(pad)
.imePadding(),
) {
Column(
modifier =
Modifier
.fillMaxSize()
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
) {
Column(
modifier = Modifier.fillMaxWidth().weight(1f).verticalScroll(scrollState),
) {
Column(Modifier.fillMaxSize().padding(start = 10.dp, end = 10.dp, bottom = 10.dp)) {
Column(Modifier.fillMaxWidth().verticalScroll(scrollState)) {
ImageVideoPost(postViewModel, accountViewModel)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
) {
Column(
modifier = Modifier.weight(1.0f),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
Text(
text = stringRes(context, R.string.media_compression_quality_label),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringRes(context, R.string.media_compression_quality_explainer),
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(modifier = Modifier.fillMaxWidth()) {
Text(
text =
when (mediaQualitySlider) {
0 -> stringRes(R.string.media_compression_quality_low)
1 -> stringRes(R.string.media_compression_quality_medium)
2 -> stringRes(R.string.media_compression_quality_high)
3 -> stringRes(R.string.media_compression_quality_uncompressed)
else -> stringRes(R.string.media_compression_quality_medium)
},
modifier = Modifier.align(Alignment.Center),
)
}
Slider(
value = mediaQualitySlider.toFloat(),
onValueChange = { mediaQualitySlider = it.toInt() },
valueRange = 0f..3f,
steps = 2,
)
}
}
}
}
}
@@ -296,70 +218,41 @@ fun ImageVideoPost(
}.toImmutableList()
}
val resolver = LocalContext.current.contentResolver
ShowImageUploadGallery(
postViewModel.mediaToUpload,
postViewModel::deleteMediaToUpload,
accountViewModel,
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.padding(bottom = 10.dp)
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
) {
if (postViewModel.isImage() == true) {
AsyncImage(
model = postViewModel.galleryUri.toString(),
contentDescription = postViewModel.galleryUri.toString(),
contentScale = ContentScale.FillWidth,
modifier =
Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
OutlinedTextField(
label = { Text(text = stringRes(R.string.add_caption)) },
modifier = Modifier.fillMaxWidth().padding(top = 3.dp).height(150.dp),
maxLines = 10,
value = postViewModel.caption,
onValueChange = { postViewModel.caption = it },
placeholder = {
Text(
text = stringRes(R.string.add_caption_example),
color = MaterialTheme.colorScheme.placeholderText,
)
} else if (postViewModel.isVideo() == true && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
)
LaunchedEffect(key1 = postViewModel.galleryUri) {
launch(Dispatchers.IO) {
postViewModel.galleryUri?.let {
try {
bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e)
}
}
}
}
SettingSwitchItem(
title = R.string.add_sensitive_content_label,
description = R.string.add_sensitive_content_description,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
checked = postViewModel.sensitiveContent,
onCheckedChange = { postViewModel.sensitiveContent = it },
)
bitmap?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = "some useful description",
contentScale = ContentScale.FillWidth,
modifier = Modifier.padding(top = 4.dp).fillMaxWidth(),
)
}
} else {
postViewModel.galleryUri?.let {
VideoView(
videoUri = it.toString(),
mimeType = postViewModel.mediaType,
roundedCorner = false,
isFiniteHeight = false,
accountViewModel = accountViewModel,
)
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
SettingsRow(R.string.file_server, R.string.file_server_description) {
TextSpinner(
label = stringRes(id = R.string.file_server),
label = "",
placeholder =
fileServers
.firstOrNull { it == accountViewModel.account.settings.defaultFileServer }
@@ -367,42 +260,47 @@ fun ImageVideoPost(
?: fileServers[0].name,
options = fileServerOptions,
onSelect = { postViewModel.selectedServer = fileServers[it] },
modifier = Modifier.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)).weight(1f),
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
SettingSwitchItem(
modifier = Modifier.fillMaxWidth().padding(8.dp),
checked = postViewModel.sensitiveContent,
onCheckedChange = { postViewModel.sensitiveContent = it },
title = R.string.add_sensitive_content_label,
description = R.string.add_sensitive_content_description,
Text(
text = stringRes(R.string.media_compression_quality_label),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringRes(R.string.media_compression_quality_explainer),
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.content_description)) },
modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
value = postViewModel.alt,
onValueChange = { postViewModel.alt = it },
placeholder = {
Text(
text = stringRes(R.string.content_description_example),
color = MaterialTheme.colorScheme.placeholderText,
)
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(modifier = Modifier.fillMaxWidth()) {
Text(
text =
when (postViewModel.mediaQualitySlider) {
0 -> stringRes(R.string.media_compression_quality_low)
1 -> stringRes(R.string.media_compression_quality_medium)
2 -> stringRes(R.string.media_compression_quality_high)
3 -> stringRes(R.string.media_compression_quality_uncompressed)
else -> stringRes(R.string.media_compression_quality_medium)
},
modifier = Modifier.align(Alignment.Center),
)
}
Slider(
value = postViewModel.mediaQualitySlider.toFloat(),
onValueChange = { postViewModel.mediaQualitySlider = it.toInt() },
valueRange = 0f..3f,
steps = 2,
)
}
}
@@ -21,7 +21,6 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
@@ -35,28 +34,27 @@ 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
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.BlossomUploader
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.LocationState
import com.vitorpamplona.amethyst.service.MediaUploadResult
import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
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.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.IMetaTag
import com.vitorpamplona.quartz.encoders.IMetaTagBuilder
import com.vitorpamplona.quartz.encoders.toNpub
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
@@ -67,7 +65,6 @@ import com.vitorpamplona.quartz.events.CommentEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.DraftEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import com.vitorpamplona.quartz.events.GitIssueEvent
@@ -79,10 +76,14 @@ import com.vitorpamplona.quartz.events.TorrentCommentEvent
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
@@ -108,7 +109,7 @@ open class NewPostViewModel : ViewModel() {
var pTags by mutableStateOf<List<User>?>(null)
var eTags by mutableStateOf<List<Note>?>(null)
var nip94attachments by mutableStateOf<List<FileHeaderEvent>>(emptyList())
var iMetaAttachments by mutableStateOf<List<IMetaTag>>(emptyList())
var nip95attachments by
mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
@@ -126,7 +127,7 @@ open class NewPostViewModel : ViewModel() {
var subject by mutableStateOf(TextFieldValue(""))
// Images and Videos
var contentToAddUrl by mutableStateOf<Uri?>(null)
var mediaToUpload by mutableStateOf<ImmutableList<SelectedMediaProcessing>>(persistentListOf())
// Polls
var canUsePoll by mutableStateOf(false)
@@ -246,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
contentToAddUrl = null
mediaToUpload = persistentListOf()
quote?.let {
message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}")
@@ -330,7 +331,7 @@ open class NewPostViewModel : ViewModel() {
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null
contentToAddUrl = null
mediaToUpload = persistentListOf()
val localfowardZapTo = draftEvent.tags().filter { it.size > 1 && it[0] == "zap" }
forwardZapTo = Split()
@@ -553,9 +554,7 @@ open class NewPostViewModel : ViewModel() {
}
val urls = findURLs(tagger.message)
val usedAttachments = nip94attachments.filter { it.urls().intersect(urls.toSet()).isNotEmpty() }
// Doesn't send as nip94 yet because we don't know if it makes sense.
// usedAttachments.forEach { account?.sendHeader(it, relayList, {}) }
val usedAttachments = iMetaAttachments.filter { it.url in urls.toSet() }
val replyingTo = originalNote
@@ -565,7 +564,7 @@ open class NewPostViewModel : ViewModel() {
replyingTo = replyingTo,
directMentionsUsers = tagger.directMentionsUsers,
directMentionsNotes = tagger.directMentionsNotes,
nip94attachments = usedAttachments,
imetas = usedAttachments,
geohash = geoHash,
zapReceiver = zapReceiver,
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
@@ -580,7 +579,7 @@ open class NewPostViewModel : ViewModel() {
replyingTo = originalNote,
directMentionsUsers = tagger.directMentionsUsers,
directMentionsNotes = tagger.directMentionsNotes,
nip94attachments = usedAttachments,
imetas = usedAttachments,
zapReceiver = zapReceiver,
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = localZapRaiserAmount,
@@ -598,7 +597,7 @@ open class NewPostViewModel : ViewModel() {
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
imetas = usedAttachments,
draftTag = localDraft,
)
} else {
@@ -611,7 +610,7 @@ open class NewPostViewModel : ViewModel() {
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
imetas = usedAttachments,
draftTag = localDraft,
)
}
@@ -625,7 +624,7 @@ open class NewPostViewModel : ViewModel() {
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
imetas = usedAttachments,
draftTag = localDraft,
)
} else if (originalNote?.event is ChatMessageEvent) {
@@ -647,7 +646,7 @@ open class NewPostViewModel : ViewModel() {
zapReceiver = zapReceiver,
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
imetas = usedAttachments,
draftTag = localDraft,
)
} else if (!dmUsers.isNullOrEmpty()) {
@@ -662,7 +661,7 @@ open class NewPostViewModel : ViewModel() {
zapReceiver = zapReceiver,
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
imetas = usedAttachments,
draftTag = localDraft,
)
} else {
@@ -675,7 +674,7 @@ open class NewPostViewModel : ViewModel() {
zapReceiver = zapReceiver,
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
imetas = usedAttachments,
draftTag = localDraft,
)
}
@@ -716,7 +715,7 @@ open class NewPostViewModel : ViewModel() {
forkedFrom = forkedFromNote?.event as? Event,
relayList = relayList,
geohash = geoHash,
nip94attachments = usedAttachments,
imetas = usedAttachments,
draftTag = localDraft,
)
} else if (originalNote?.event is TorrentCommentEvent) {
@@ -755,7 +754,7 @@ open class NewPostViewModel : ViewModel() {
forkedFrom = forkedFromNote?.event as? Event,
relayList = relayList,
geohash = geoHash,
nip94attachments = usedAttachments,
imetas = usedAttachments,
draftTag = localDraft,
)
}
@@ -784,7 +783,7 @@ open class NewPostViewModel : ViewModel() {
forkedFrom = forkedFromNote?.event as? Event,
relayList = relayList,
geohash = geoHash,
nip94attachments = usedAttachments,
imetas = usedAttachments,
draftTag = localDraft,
)
} else {
@@ -803,7 +802,7 @@ open class NewPostViewModel : ViewModel() {
zapRaiserAmount = localZapRaiserAmount,
relayList = relayList,
geohash = geoHash,
nip94attachments = usedAttachments,
imetas = usedAttachments,
draftTag = localDraft,
)
} else if (wantsProduct) {
@@ -822,7 +821,7 @@ open class NewPostViewModel : ViewModel() {
zapRaiserAmount = localZapRaiserAmount,
relayList = relayList,
geohash = geoHash,
nip94attachments = usedAttachments,
imetas = usedAttachments,
draftTag = localDraft,
)
} else {
@@ -859,7 +858,7 @@ open class NewPostViewModel : ViewModel() {
forkedFrom = forkedFromNote?.event as? Event,
relayList = relayList,
geohash = geoHash,
nip94attachments = usedAttachments,
imetas = usedAttachments,
draftTag = localDraft,
)
}
@@ -867,7 +866,6 @@ open class NewPostViewModel : ViewModel() {
}
fun upload(
galleryUri: Uri,
alt: String?,
sensitiveContent: Boolean,
mediaQuality: Int,
@@ -876,119 +874,77 @@ open class NewPostViewModel : ViewModel() {
onError: (title: String, message: String) -> Unit,
context: Context,
) {
isUploadingImage = true
contentToAddUrl = null
val myAccount = account ?: return
val contentResolver = context.contentResolver
val contentType = contentResolver.getType(galleryUri)
viewModelScope.launch {
isUploadingImage = true
viewModelScope.launch(Dispatchers.IO) {
MediaCompressor()
.compress(
galleryUri,
contentType,
context.applicationContext,
onReady = { fileUri, contentType, size ->
if (server.type == ServerType.NIP95) {
contentResolver.openInputStream(fileUri)?.use {
createNIP95Record(
it.readBytes(),
contentType,
alt,
sensitiveContent,
onError = {
onError(stringRes(context, R.string.failed_to_upload_media_no_details), it)
},
context,
)
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,
)
}
}
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 ->
nip95attachments = nip95attachments + nip95
val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) }
note?.let {
message = message.insertUrlAtCursor("nostr:" + it.toNEvent())
}
} else if (server.type == ServerType.NIP96) {
viewModelScope.launch(Dispatchers.IO) {
try {
val result =
Nip96Uploader(account)
.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
alt = alt,
sensitiveContent = if (sensitiveContent) "" else null,
server = server,
contentResolver = contentResolver,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
onProgress = {},
context = context,
)
createNIP94Record(
uploadingResult = result,
localContentType = contentType,
alt = alt,
sensitiveContent = sensitiveContent,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
onError = {
onError(stringRes(context, R.string.failed_to_upload_media_no_details), it)
},
context = context,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(
"ImageUploader",
"Failed to upload ${e.message}",
e,
)
isUploadingImage = false
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
}
}
} else if (server.type == ServerType.Blossom) {
viewModelScope.launch(Dispatchers.IO) {
try {
val result =
BlossomUploader(account)
.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
alt = alt,
sensitiveContent = if (sensitiveContent) "" else null,
server = server,
contentResolver = contentResolver,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
context = context,
)
createNIP94Record(
uploadingResult = result,
localContentType = contentType,
alt = alt,
sensitiveContent = sensitiveContent,
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
onError = {
onError(stringRes(context, R.string.failed_to_upload_media_no_details), it)
},
context = context,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(
"ImageUploader",
"Failed to upload ${e.message}",
e,
)
isUploadingImage = false
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
}
}
urlPreview = findUrlInMessage()
}
},
onError = {
isUploadingImage = false
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, it))
},
mediaQuality = MediaCompressor().intToCompressorQuality(mediaQuality),
)
} else if (it.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
val iMeta =
IMetaTagBuilder(it.result.url)
.apply {
hash(it.result.fileHeader.hash)
size(it.result.fileHeader.size)
it.result.fileHeader.mimeType
?.let { mimeType(it) }
it.result.fileHeader.dim
?.let { dims(it) }
it.result.fileHeader.blurHash
?.let { blurhash(it.blurhash) }
it.result.magnet?.let { magnet(it) }
it.result.originalHash?.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)
urlPreview = findUrlInMessage()
}
}
mediaToUpload = persistentListOf()
}
isUploadingImage = false
}
}
@@ -999,7 +955,7 @@ open class NewPostViewModel : ViewModel() {
forkedFromNote = null
contentToAddUrl = null
mediaToUpload = persistentListOf()
urlPreview = null
isUploadingImage = false
pTags = null
@@ -1047,6 +1003,10 @@ open class NewPostViewModel : ViewModel() {
}
}
fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
this.mediaToUpload = mediaToUpload.filter { it != selected }.toImmutableList()
}
open fun findUrlInMessage(): String? = RichTextParser().parseValidUrls(message.text).firstOrNull()
open fun removeFromReplyList(userToRemove: User) {
@@ -1210,97 +1170,14 @@ open class NewPostViewModel : ViewModel() {
!category.text.isNullOrBlank()
)
) &&
contentToAddUrl == null
suspend fun createNIP94Record(
uploadingResult: MediaUploadResult,
localContentType: String?,
alt: String?,
sensitiveContent: Boolean,
forceProxy: (String) -> Boolean,
onError: (message: String) -> Unit,
context: Context,
) {
if (uploadingResult.url.isNullOrBlank()) {
Log.e("ImageDownload", "Couldn't download image from server")
cancel()
isUploadingImage = false
onError(stringRes(context, R.string.server_did_not_provide_a_url_after_uploading))
return
}
FileHeader.prepare(
fileUrl = uploadingResult.url,
mimeType = uploadingResult.type ?: localContentType,
dimPrecomputed = uploadingResult.dimension,
forceProxy = forceProxy(uploadingResult.url),
onReady = { header: FileHeader ->
account?.createHeader(uploadingResult.url, uploadingResult.magnet, header, alt, sensitiveContent, uploadingResult.sha256) { event ->
isUploadingImage = false
nip94attachments = nip94attachments.filter { it.url() != event.url() } + event
message = message.insertUrlAtCursor(uploadingResult.url)
urlPreview = findUrlInMessage()
saveDraft()
}
},
onError = {
isUploadingImage = false
onError(stringRes(context, R.string.could_not_prepare_header, it))
},
)
}
mediaToUpload.isEmpty()
fun insertAtCursor(newElement: String) {
message = message.insertUrlAtCursor(newElement)
}
fun createNIP95Record(
bytes: ByteArray,
mimeType: String?,
alt: String?,
sensitiveContent: Boolean,
onError: (message: String) -> Unit,
context: Context,
) {
if (bytes.size > 80000) {
viewModelScope.launch {
onError(stringRes(context, id = R.string.media_too_big_for_nip95))
isUploadingImage = false
}
return
}
viewModelScope.launch(Dispatchers.IO) {
FileHeader.prepare(
bytes,
mimeType,
null,
onReady = {
account?.createNip95(bytes, headerInfo = it, alt, sensitiveContent) { nip95 ->
nip95attachments = nip95attachments + nip95
val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) }
isUploadingImage = false
note?.let {
message = message.insertUrlAtCursor("nostr:" + it.toNEvent())
}
urlPreview = findUrlInMessage()
saveDraft()
}
},
onError = {
isUploadingImage = false
onError(stringRes(context, R.string.could_not_prepare_header, it))
},
)
}
}
fun selectImage(uri: Uri) {
contentToAddUrl = uri
fun selectImage(uris: ImmutableList<SelectedMedia>) {
mediaToUpload = uris.map { SelectedMediaProcessing(it) }.toImmutableList()
}
fun locationFlow(): StateFlow<LocationState.LocationResult> {
@@ -45,6 +45,7 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectSingleFromGallery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SaveButton
@@ -151,7 +152,7 @@ fun NewUserMetadataView(
)
},
leadingIcon = {
UploadFromGallery(
SelectSingleFromGallery(
isUploading = postViewModel.isUploadingImageForPicture,
tint = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.padding(start = 5.dp),
@@ -176,7 +177,7 @@ fun NewUserMetadataView(
)
},
leadingIcon = {
UploadFromGallery(
SelectSingleFromGallery(
isUploading = postViewModel.isUploadingImageForBanner,
tint = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.padding(start = 5.dp),
@@ -21,7 +21,6 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -29,9 +28,10 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.BlossomUploader
import com.vitorpamplona.amethyst.service.Nip96Uploader
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
@@ -132,7 +132,7 @@ class NewUserMetadataViewModel : ViewModel() {
}
fun uploadForPicture(
uri: Uri,
uri: SelectedMedia,
context: Context,
onError: (String, String) -> Unit,
) {
@@ -148,7 +148,7 @@ class NewUserMetadataViewModel : ViewModel() {
}
fun uploadForBanner(
uri: Uri,
uri: SelectedMedia,
context: Context,
onError: (String, String) -> Unit,
) {
@@ -164,7 +164,7 @@ class NewUserMetadataViewModel : ViewModel() {
}
private suspend fun upload(
galleryUri: Uri,
galleryUri: SelectedMedia,
context: Context,
onUploading: (Boolean) -> Unit,
onUploaded: (String) -> Unit,
@@ -172,67 +172,48 @@ class NewUserMetadataViewModel : ViewModel() {
) {
onUploading(true)
val contentResolver = context.contentResolver
val compResult = MediaCompressor().compress(galleryUri.uri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
MediaCompressor()
.compress(
galleryUri,
contentResolver.getType(galleryUri),
context.applicationContext,
onReady = { fileUri, contentType, size ->
viewModelScope.launch(Dispatchers.IO) {
try {
val result =
if (account.settings.defaultFileServer.type == ServerType.NIP96) {
Nip96Uploader(account)
.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
alt = null,
sensitiveContent = null,
server = account.settings.defaultFileServer,
contentResolver = contentResolver,
forceProxy = account::shouldUseTorForNIP96,
onProgress = {},
context = context,
)
} else {
BlossomUploader(account)
.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
alt = null,
sensitiveContent = null,
server = account.settings.defaultFileServer,
contentResolver = contentResolver,
forceProxy = account::shouldUseTorForNIP96,
context = context,
)
}
try {
val result =
if (account.settings.defaultFileServer.type == ServerType.NIP96) {
Nip96Uploader().uploadImage(
uri = compResult.uri,
contentType = compResult.contentType,
size = compResult.size,
alt = null,
sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
forceProxy = account::shouldUseTorForNIP96,
onProgress = {},
httpAuth = account::createHTTPAuthorization,
context = context,
)
} else {
BlossomUploader().uploadImage(
uri = compResult.uri,
contentType = compResult.contentType,
size = compResult.size,
alt = null,
sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
forceProxy = account::shouldUseTorForNIP96,
httpAuth = account::createBlossomUploadAuth,
context = context,
)
}
if (result.url != null) {
onUploading(false)
onUploaded(result.url)
} else {
onUploading(false)
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.server_did_not_provide_a_url_after_uploading))
}
} catch (e: Exception) {
if (e is CancellationException) throw e
onUploading(false)
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
}
}
},
onError = {
onUploading(false)
onError(stringRes(context, R.string.error_when_compressing_media), stringRes(context, it))
},
// Use MEDIUM quality as default
mediaQuality = CompressorQuality.MEDIUM,
)
if (result.url != null) {
onUploading(false)
onUploaded(result.url)
} else {
onUploading(false)
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.server_did_not_provide_a_url_after_uploading))
}
} catch (e: Exception) {
if (e is CancellationException) throw e
onUploading(false)
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
}
}
}
@@ -0,0 +1,96 @@
/**
* 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.actions.uploads
import com.vitorpamplona.ammolite.service.HttpClientManager
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.net.HttpURLConnection
import java.net.URL
class ImageDownloader {
suspend fun waitAndGetImage(
imageUrl: String,
forceProxy: Boolean,
): ByteArray? =
withContext(Dispatchers.IO) {
var imageData: ByteArray? = null
var tentatives = 0
// Servers are usually not ready.. so tries to download it for 15 times/seconds.
while (imageData == null && tentatives < 15) {
imageData =
try {
tryGetTheImage(imageUrl, forceProxy)
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
if (imageData == null) {
tentatives++
delay(1000)
}
}
return@withContext imageData
}
private suspend fun tryGetTheImage(
imageUrl: String,
forceProxy: Boolean,
): ByteArray? =
withContext(Dispatchers.IO) {
// TODO: Migrate to OkHttp
HttpURLConnection.setFollowRedirects(true)
var url = URL(imageUrl)
var huc =
if (forceProxy) {
url.openConnection(HttpClientManager.getCurrentProxy()) as HttpURLConnection
} else {
url.openConnection() as HttpURLConnection
}
huc.instanceFollowRedirects = true
var responseCode = huc.responseCode
if (responseCode in 300..400) {
val newUrl: String = huc.getHeaderField("Location")
// open the new connnection again
url = URL(newUrl)
huc =
if (forceProxy) {
url.openConnection(HttpClientManager.getCurrentProxy()) as HttpURLConnection
} else {
url.openConnection() as HttpURLConnection
}
responseCode = huc.responseCode
}
return@withContext if (responseCode in 200..300) {
huc.inputStream.use { it.readBytes() }
} else {
null
}
}
}
@@ -18,78 +18,93 @@
* 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
package com.vitorpamplona.amethyst.ui.actions.uploads
import android.content.Context
import android.net.Uri
import android.os.Environment
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddPhotoAlternate
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.atomic.AtomicBoolean
@Stable
class SelectedMedia(
val uri: Uri,
val mimeType: String?,
) {
fun isImage() = mimeType?.startsWith("image")
fun isVideo() = mimeType?.startsWith("video")
}
@Composable
fun UploadFromGallery(
fun SelectFromGallery(
isUploading: Boolean,
tint: Color,
modifier: Modifier,
onImageChosen: (Uri) -> Unit,
onImageChosen: (ImmutableList<SelectedMedia>) -> Unit,
) {
var showGallerySelect by remember { mutableStateOf(false) }
if (showGallerySelect) {
GallerySelect(
onImageUri = { uri ->
showGallerySelect = false
if (uri != null) {
if (uri.isNotEmpty()) {
onImageChosen(uri)
}
},
)
}
UploadBoxButton(isUploading, tint, modifier) { showGallerySelect = true }
GallerySelectButton(isUploading, tint, modifier) { showGallerySelect = true }
}
@Composable
private fun UploadBoxButton(
fun SelectSingleFromGallery(
isUploading: Boolean,
tint: Color,
modifier: Modifier,
onImageChosen: (SelectedMedia) -> Unit,
) {
var showGallerySelect by remember { mutableStateOf(false) }
if (showGallerySelect) {
GallerySelectSingle(
onImageUri = { media ->
showGallerySelect = false
if (media != null) {
onImageChosen(media)
}
},
)
}
GallerySelectButton(isUploading, tint, modifier) { showGallerySelect = true }
}
@Composable
private fun GallerySelectButton(
isUploading: Boolean,
tint: Color,
modifier: Modifier,
@@ -115,84 +130,52 @@ private fun UploadBoxButton(
}
}
fun getPhotoUri(context: Context): Uri {
val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val storageDir: File? = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
return File
.createTempFile(
"JPEG_${timeStamp}_",
".jpg",
storageDir,
).let {
FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
it,
)
}
}
val DefaultAnimationColors =
listOf(
Color(0xFF5851D8),
Color(0xFF833AB4),
Color(0xFFC13584),
Color(0xFFE1306C),
Color(0xFFFD1D1D),
Color(0xFFF56040),
Color(0xFFF77737),
Color(0xFFFCAF45),
Color(0xFFFFDC80),
Color(0xFF5851D8),
).toImmutableList()
@Composable
fun LoadingAnimation(
indicatorSize: Dp = 20.dp,
circleColors: ImmutableList<Color> = DefaultAnimationColors,
animationDuration: Int = 1000,
) {
val infiniteTransition = rememberInfiniteTransition()
val rotateAnimation by
infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec =
infiniteRepeatable(
animation =
tween(
durationMillis = animationDuration,
easing = LinearEasing,
),
),
label = "UploadGalleryUploadingAnimation",
)
CircularProgressIndicator(
modifier =
Modifier
.size(size = indicatorSize)
.rotate(degrees = rotateAnimation)
.border(
width = 4.dp,
brush = Brush.sweepGradient(circleColors),
shape = CircleShape,
),
progress = 1f,
strokeWidth = 1.dp,
color = MaterialTheme.colorScheme.background,
)
}
@Composable
fun GallerySelect(onImageUri: (Uri?) -> Unit = {}) {
fun GallerySelect(onImageUri: (ImmutableList<SelectedMedia>) -> Unit = {}) {
val hasLaunched by remember { mutableStateOf(AtomicBoolean(false)) }
val resolver = LocalContext.current.contentResolver
val launcher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia(),
onResult = { uri: Uri? ->
onImageUri(uri)
contract = ActivityResultContracts.PickMultipleVisualMedia(10),
onResult = { uris: List<Uri> ->
onImageUri(
uris
.map {
SelectedMedia(it, resolver.getType(it))
}.toImmutableList(),
)
hasLaunched.set(false)
},
)
@Composable
fun LaunchGallery() {
SideEffect {
if (!hasLaunched.getAndSet(true)) {
launcher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo))
}
}
}
LaunchGallery()
}
@Composable
fun GallerySelectSingle(onImageUri: (SelectedMedia?) -> Unit = {}) {
val hasLaunched by remember { mutableStateOf(AtomicBoolean(false)) }
val resolver = LocalContext.current.contentResolver
val launcher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia(),
onResult = { uri: Uri? ->
if (uri != null) {
onImageUri(SelectedMedia(uri, resolver.getType(uri)))
} else {
onImageUri(null)
}
hasLaunched.set(false)
},
)
@@ -0,0 +1,26 @@
/**
* 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.actions.uploads
class SelectedMediaProcessing(
val media: SelectedMedia,
val orchestrator: UploadOrchestrator = UploadOrchestrator(),
)
@@ -0,0 +1,246 @@
/**
* 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.actions.uploads
import android.content.Context
import android.graphics.Bitmap
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
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.ui.components.AutoNonlazyGrid
import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.note.CloseIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
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>,
onDelete: (SelectedMediaProcessing) -> Unit,
accountViewModel: AccountViewModel,
) {
AutoNonlazyGrid(list.size) {
ShowImageUploadItem(list[it], onDelete, accountViewModel)
}
}
/**
* Creates a bitmap thumbnail from video uri of the scheme type content://
*/
fun createVideoThumb(
context: Context,
uri: Uri,
): Bitmap? {
try {
val mediaMetadataRetriever = MediaMetadataRetriever()
mediaMetadataRetriever.setDataSource(context, uri)
return mediaMetadataRetriever.frameAtTime
} catch (ex: Exception) {
Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", ex)
}
return null
}
@Composable
fun ShowImageUploadItem(
item: SelectedMediaProcessing,
onDelete: (SelectedMediaProcessing) -> Unit,
accountViewModel: AccountViewModel,
) {
if (item.media.isImage() == true) {
AsyncImage(
model = item.media.uri.toString(),
contentDescription = item.media.uri.toString(),
contentScale = ContentScale.Crop,
modifier =
Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
)
} else if (item.media.isVideo() == true && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
val context = LocalContext.current
LaunchedEffect(key1 = item) {
launch(Dispatchers.IO) {
try {
bitmap = createVideoThumb(context, item.media.uri)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e)
}
}
}
if (bitmap != null) {
bitmap?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = "some useful description",
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth(),
)
}
} else {
VideoView(
videoUri = item.media.uri.toString(),
mimeType = item.media.mimeType,
roundedCorner = false,
contentScale = ContentScale.Crop,
accountViewModel = accountViewModel,
)
}
} else {
VideoView(
videoUri = item.media.uri.toString(),
mimeType = item.media.mimeType,
roundedCorner = false,
contentScale = ContentScale.Crop,
accountViewModel = accountViewModel,
)
}
OrchestratorOverlay(item.orchestrator) {
onDelete(item)
}
}
@Composable
fun OrchestratorOverlay(
orchestrator: UploadOrchestrator,
onDelete: () -> Unit,
) {
val progress by orchestrator.progress.collectAsState()
val progressState by orchestrator.progressState.collectAsState()
if (progressState is UploadingState.Ready) {
DeleteButton(onDelete)
} else {
UploadingState(progress, progressState)
}
}
@Composable
fun DeleteButton(onDelete: () -> Unit) {
Box(
contentAlignment = Alignment.TopEnd,
modifier = Modifier.fillMaxSize(),
) {
Box(Size40Modifier, contentAlignment = Alignment.Center) {
Box(
Modifier
.clip(CircleShape)
.fillMaxSize(0.6f)
.align(Alignment.Center)
.background(MaterialTheme.colorScheme.background),
)
IconButton(
modifier = Size20Modifier,
onClick = onDelete,
) {
CloseIcon()
}
}
}
}
@Composable
fun UploadingState(
progress: Double,
progressState: UploadingState,
) {
Box(Modifier.size(55.dp), contentAlignment = Alignment.Center) {
val animatedProgress by animateFloatAsState(
targetValue = progress.toFloat(),
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
)
CircularProgressIndicator(
progress = { animatedProgress },
modifier =
Size55Modifier
.clip(CircleShape)
.background(MaterialTheme.colorScheme.background),
strokeWidth = 5.dp,
)
val txt =
when (progressState) {
is UploadingState.Ready -> stringRes(R.string.uploading_state_ready)
is UploadingState.Compressing -> stringRes(R.string.uploading_state_compressing)
is UploadingState.Uploading -> stringRes(R.string.uploading_state_uploading)
is UploadingState.ServerProcessing -> stringRes(R.string.uploading_state_server_processing)
is UploadingState.Downloading -> stringRes(R.string.uploading_state_downloading)
is UploadingState.Hashing -> stringRes(R.string.uploading_state_hashing)
is UploadingState.Finished -> stringRes(R.string.uploading_state_finished)
is UploadingState.Error -> stringRes(R.string.uploading_state_error)
}
Text(
txt,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 10.sp,
textAlign = TextAlign.Center,
)
}
}
@@ -0,0 +1,154 @@
/**
* 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.actions.uploads
import android.Manifest
import android.content.Context
import android.net.Uri
import android.os.Environment
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun TakePictureButton(onPictureTaken: (ImmutableList<SelectedMedia>) -> Unit) {
var showCamera by remember { mutableStateOf(false) }
if (showCamera) {
TakePicture(
onPictureTaken = { uri ->
showCamera = false
if (uri.isNotEmpty()) {
onPictureTaken(uri)
}
},
)
}
PictureButton { showCamera = true }
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun TakePicture(onPictureTaken: (ImmutableList<SelectedMedia>) -> Unit) {
val context = LocalContext.current
var cameraUri by remember { mutableStateOf<Uri?>(null) }
val scope = rememberCoroutineScope()
val launcher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicture(),
) { success ->
if (success) {
cameraUri?.let {
onPictureTaken(persistentListOf(SelectedMedia(it, "image/jpeg")))
}
} else {
onPictureTaken(persistentListOf())
}
cameraUri = null
}
val cameraPermissionState =
rememberPermissionState(
Manifest.permission.CAMERA,
onPermissionResult = {
if (it) {
scope.launch(Dispatchers.IO) {
cameraUri = getPhotoUri(context)
cameraUri?.let { launcher.launch(it) }
}
}
},
)
if (cameraPermissionState.status.isGranted) {
LaunchedEffect(key1 = Unit) {
launch(Dispatchers.IO) {
cameraUri = getPhotoUri(context)
cameraUri?.let { launcher.launch(it) }
}
}
} else {
LaunchedEffect(key1 = Unit) {
cameraPermissionState.launchPermissionRequest()
}
}
}
@Composable
fun PictureButton(onClick: () -> Unit) {
IconButton(
onClick = onClick,
) {
Icon(
imageVector = Icons.Default.CameraAlt,
contentDescription = stringRes(id = R.string.upload_image),
modifier = Modifier.height(25.dp),
tint = MaterialTheme.colorScheme.onBackground,
)
}
}
fun getPhotoUri(context: Context): Uri {
val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val storageDir: File? = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
return File
.createTempFile(
"JPEG_${timeStamp}_",
".jpg",
storageDir,
).let {
FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
it,
)
}
}
@@ -0,0 +1,281 @@
/**
* 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.actions.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.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 kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlin.coroutines.cancellation.CancellationException
sealed class UploadingState {
data object Ready : UploadingState()
data object Compressing : UploadingState()
data object Uploading : UploadingState()
data object ServerProcessing : UploadingState()
data object Downloading : UploadingState()
data object Hashing : UploadingState()
sealed class UploadingFinalState : UploadingState()
class Finished(
val result: UploadOrchestrator.OrchestratorResult,
) : UploadingFinalState()
class Error(
val errorResource: Int,
val params: Array<out String>,
) : UploadingFinalState()
}
class UploadOrchestrator {
private val compressor = MediaCompressor()
val progress = MutableStateFlow(0.0)
val progressState = MutableStateFlow<UploadingState>(UploadingState.Ready)
val isUploading =
progressState.map {
progressState.value !is UploadingState.Ready && progressState.value !is UploadingState.Error && progressState.value !is UploadingState.Finished
}
fun error(
resId: Int,
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 updateState(
newProgress: Double,
newState: UploadingState,
) {
progress.value = newProgress
progressState.value = newState
}
private fun uploadNIP95(
fileUri: Uri,
contentType: String?,
context: Context,
): UploadingFinalState {
updateState(0.4, UploadingState.Uploading)
val bytes =
context.contentResolver.openInputStream(fileUri)?.use {
it.readBytes()
}
if (bytes != null) {
if (bytes.size > 80000) {
return error(R.string.media_too_big_for_nip95)
}
updateState(0.8, UploadingState.Hashing)
val result =
FileHeader.prepare(
bytes,
contentType,
null,
)
result.fold(
onSuccess = {
return finish(OrchestratorResult.NIP95Result(it, bytes))
},
onFailure = {
return error(R.string.could_not_check_downloaded_file, it.message ?: it.javaClass.simpleName)
},
)
} else {
return error(R.string.could_not_open_the_compressed_file)
}
}
private suspend fun uploadNIP96(
fileUri: Uri,
contentType: String?,
size: Long?,
alt: String?,
sensitiveContent: Boolean,
serverBaseUrl: String,
account: Account,
context: Context,
): UploadingFinalState {
updateState(0.2, UploadingState.Uploading)
return try {
val result =
Nip96Uploader().uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
alt = alt,
sensitiveContent = if (sensitiveContent) "" else null,
serverBaseUrl = serverBaseUrl,
forceProxy = account::shouldUseTorForNIP96,
onProgress = { percent: Float ->
updateState(0.2 + (0.2 * percent), UploadingState.Uploading)
},
httpAuth = account::createHTTPAuthorization,
context = context,
)
verifyHeader(
uploadResult = result,
localContentType = contentType,
forceProxy = account::shouldUseTorForNIP96,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
error(R.string.failed_to_upload_media, e.message ?: e.javaClass.simpleName)
}
}
private suspend fun uploadBlossom(
fileUri: Uri,
contentType: String?,
size: Long?,
alt: String?,
sensitiveContent: Boolean,
serverBaseUrl: String,
account: Account,
context: Context,
): UploadingFinalState {
updateState(0.2, UploadingState.Uploading)
return try {
val result =
BlossomUploader()
.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
alt = alt,
sensitiveContent = if (sensitiveContent) "" else null,
serverBaseUrl = serverBaseUrl,
forceProxy = account::shouldUseTorForNIP96,
httpAuth = account::createBlossomUploadAuth,
context = context,
)
verifyHeader(
uploadResult = result,
localContentType = contentType,
forceProxy = account::shouldUseTorForNIP96,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
error(R.string.failed_to_upload_media, e.message ?: e.javaClass.simpleName)
}
}
private suspend fun verifyHeader(
uploadResult: MediaUploadResult,
localContentType: String?,
forceProxy: (String) -> Boolean,
): UploadingFinalState {
if (uploadResult.url.isNullOrBlank()) {
return error(R.string.server_did_not_provide_a_url_after_uploading)
}
updateState(0.6, UploadingState.Downloading)
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(uploadResult.url, forceProxy(uploadResult.url))
if (imageData != null) {
updateState(0.8, UploadingState.Hashing)
val result =
FileHeader.prepare(
imageData,
uploadResult.type ?: localContentType,
uploadResult.dimension,
)
result.fold(
onSuccess = {
return finish(OrchestratorResult.ServerResult(it, uploadResult.url, uploadResult.magnet, uploadResult.sha256))
},
onFailure = {
return error(R.string.could_not_prepare_local_file_to_upload, it.message ?: it.javaClass.simpleName)
},
)
} else {
return error(R.string.could_not_download_from_the_server)
}
}
sealed class OrchestratorResult {
class NIP95Result(
val fileHeader: FileHeader,
val bytes: ByteArray,
) : OrchestratorResult()
class ServerResult(
val fileHeader: FileHeader,
val url: String,
val magnet: String?,
val originalHash: String?,
) : OrchestratorResult()
}
suspend fun upload(
uri: Uri,
mimeType: String?,
alt: String?,
sensitiveContent: Boolean,
compressionQuality: CompressorQuality,
server: ServerName,
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)
}
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)
}
}
}
@@ -62,7 +62,6 @@ import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.service.CachedCashuProcessor
import com.vitorpamplona.amethyst.service.CashuToken
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.note.CopyIcon
import com.vitorpamplona.amethyst.ui.note.OpenInNewIcon
import com.vitorpamplona.amethyst.ui.note.ZapIcon
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.layout.ContentScale
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
@@ -81,7 +82,7 @@ fun RenderLoaded(
ZoomableContentView(
content = MediaUrlImage(url, uri = callbackUri),
roundedCorner = true,
isFiniteHeight = false,
contentScale = ContentScale.FillWidth,
accountViewModel = accountViewModel,
)
}
@@ -90,7 +91,7 @@ fun RenderLoaded(
ZoomableContentView(
content = MediaUrlVideo(url, uri = callbackUri),
roundedCorner = true,
isFiniteHeight = false,
contentScale = ContentScale.FillWidth,
accountViewModel = accountViewModel,
)
}
@@ -0,0 +1,97 @@
/**
* 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.components
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
val DefaultAnimationColors =
listOf(
Color(0xFF5851D8),
Color(0xFF833AB4),
Color(0xFFC13584),
Color(0xFFE1306C),
Color(0xFFFD1D1D),
Color(0xFFF56040),
Color(0xFFF77737),
Color(0xFFFCAF45),
Color(0xFFFFDC80),
Color(0xFF5851D8),
).toImmutableList()
@Composable
fun LoadingAnimation(
indicatorSize: Dp = 20.dp,
circleColors: ImmutableList<Color> = DefaultAnimationColors,
animationDuration: Int = 1000,
) {
val infiniteTransition = rememberInfiniteTransition()
val rotateAnimation by
infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec =
infiniteRepeatable(
animation =
tween(
durationMillis = animationDuration,
easing = LinearEasing,
),
),
label = "UploadGalleryUploadingAnimation",
)
CircularProgressIndicator(
progress = { 1f },
modifier =
Modifier
.size(size = indicatorSize)
.rotate(degrees = rotateAnimation)
.border(
width = 4.dp,
brush = Brush.sweepGradient(circleColors),
shape = CircleShape,
),
color = MaterialTheme.colorScheme.background,
strokeWidth = 1.dp,
trackColor = ProgressIndicatorDefaults.circularDeterminateTrackColor,
)
}
@@ -30,53 +30,56 @@ import com.abedelazizshe.lightcompressorlibrary.VideoCompressor
import com.abedelazizshe.lightcompressorlibrary.VideoQuality
import com.abedelazizshe.lightcompressorlibrary.config.AppSpecificStorageConfiguration
import com.abedelazizshe.lightcompressorlibrary.config.Configuration
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.components.util.MediaCompressorFileUtils
import id.zelory.compressor.Compressor
import id.zelory.compressor.constraint.default
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.withTimeoutOrNull
import java.io.File
import java.util.UUID
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class MediaCompressorResult(
val uri: Uri,
val contentType: String?,
val size: Long?,
)
class MediaCompressor {
// ALL ERRORS ARE IGNORED. The original file is returned.
suspend fun compress(
uri: Uri,
contentType: String?,
applicationContext: Context,
onReady: (Uri, String?, Long?) -> Unit,
onError: (Int) -> Unit,
mediaQuality: CompressorQuality,
) {
applicationContext: Context,
): MediaCompressorResult {
// Skip compression if user selected uncompressed
if (mediaQuality == CompressorQuality.UNCOMPRESSED) {
Log.d("MediaCompressor", "UNCOMPRESSED quality selected, skipping compression.")
onReady(uri, contentType, null)
return
return MediaCompressorResult(uri, contentType, null)
}
checkNotInMainThread()
// branch into compression based on content type
when {
contentType?.startsWith("video", ignoreCase = true) == true ->
compressVideo(uri, contentType, applicationContext, onReady, onError, mediaQuality)
return when {
contentType?.startsWith("video", ignoreCase = true) == true -> compressVideo(uri, contentType, applicationContext, mediaQuality)
contentType?.startsWith("image", ignoreCase = true) == true &&
!contentType.contains("gif") &&
!contentType.contains("svg") ->
compressImage(uri, contentType, applicationContext, onReady, onError, mediaQuality)
else -> onReady(uri, contentType, null)
compressImage(uri, contentType, applicationContext, mediaQuality)
else -> MediaCompressorResult(uri, contentType, null)
}
}
private fun compressVideo(
private suspend fun compressVideo(
uri: Uri,
contentType: String?,
applicationContext: Context,
onReady: (Uri, String?, Long?) -> Unit,
onError: (Int) -> Unit,
mediaQuality: CompressorQuality,
) {
): MediaCompressorResult {
val videoQuality =
when (mediaQuality) {
CompressorQuality.VERY_LOW -> VideoQuality.VERY_LOW
@@ -89,73 +92,77 @@ class MediaCompressor {
Log.d("MediaCompressor", "Using video compression $mediaQuality")
VideoCompressor.start(
// => This is required
context = applicationContext,
// => Source can be provided as content uris
uris = listOf(uri),
isStreamable = false,
// THIS STORAGE
// sharedStorageConfiguration = SharedStorageConfiguration(
// saveAt = SaveLocation.movies, // => default is movies
// videoName = "compressed_video" // => required name
// ),
// OR AND NOT BOTH
appSpecificStorageConfiguration = AppSpecificStorageConfiguration(),
configureWith =
Configuration(
quality = videoQuality,
// => required name
videoNames = listOf(UUID.randomUUID().toString()),
),
listener =
object : CompressionListener {
override fun onProgress(
index: Int,
percent: Float,
) {
}
val result =
withTimeoutOrNull(30000) {
suspendCoroutine { continuation ->
VideoCompressor.start(
// => This is required
context = applicationContext,
// => Source can be provided as content uris
uris = listOf(uri),
isStreamable = false,
// THIS STORAGE
// sharedStorageConfiguration = SharedStorageConfiguration(
// saveAt = SaveLocation.movies, // => default is movies
// videoName = "compressed_video" // => required name
// ),
// OR AND NOT BOTH
appSpecificStorageConfiguration = AppSpecificStorageConfiguration(),
configureWith =
Configuration(
quality = videoQuality,
// => required name
videoNames = listOf(UUID.randomUUID().toString()),
),
listener =
object : CompressionListener {
override fun onProgress(
index: Int,
percent: Float,
) {}
override fun onStart(index: Int) {}
override fun onStart(index: Int) {}
override fun onSuccess(
index: Int,
size: Long,
path: String?,
) {
if (path != null) {
Log.d("MediaCompressor", "Video compression success. Compressed size [$size]")
onReady(Uri.fromFile(File(path)), contentType, size)
} else {
Log.d("MediaCompressor", "Video compression successful, but returned null path")
onError(R.string.compression_returned_null)
}
}
override fun onSuccess(
index: Int,
size: Long,
path: String?,
) {
if (path != null) {
Log.d("MediaCompressor", "Video compression success. Compressed size [$size]")
continuation.resume(MediaCompressorResult(Uri.fromFile(File(path)), contentType, size))
} else {
Log.d("MediaCompressor", "Video compression successful, but returned null path")
continuation.resume(null)
}
}
override fun onFailure(
index: Int,
failureMessage: String,
) {
Log.d("MediaCompressor", "Video compression failed: $failureMessage")
// keeps going with original video
onReady(uri, contentType, null)
}
override fun onFailure(
index: Int,
failureMessage: String,
) {
Log.d("MediaCompressor", "Video compression failed: $failureMessage")
// keeps going with original video
continuation.resume(null)
}
override fun onCancelled(index: Int) {
onError(R.string.compression_cancelled)
}
},
)
override fun onCancelled(index: Int) {
continuation.resume(null)
}
},
)
}
}
return result ?: MediaCompressorResult(uri, contentType, null)
}
private suspend fun compressImage(
uri: Uri,
contentType: String?,
context: Context,
onReady: (Uri, String?, Long?) -> Unit,
onError: (Int) -> Unit,
mediaQuality: CompressorQuality,
) {
): MediaCompressorResult {
val imageQuality =
when (mediaQuality) {
CompressorQuality.VERY_LOW -> 40
@@ -166,7 +173,7 @@ class MediaCompressor {
else -> 60
}
try {
return try {
Log.d("MediaCompressor", "Using image compression $mediaQuality")
val tempFile = MediaCompressorFileUtils.from(uri, context)
val compressedImageFile =
@@ -174,32 +181,34 @@ class MediaCompressor {
default(width = 640, format = Bitmap.CompressFormat.JPEG, quality = imageQuality)
}
Log.d("MediaCompressor", "Image compression success. Original size [${tempFile.length()}], new size [${compressedImageFile.length()}]")
onReady(compressedImageFile.toUri(), contentType, compressedImageFile.length())
MediaCompressorResult(compressedImageFile.toUri(), contentType, compressedImageFile.length())
} catch (e: Exception) {
Log.d("MediaCompressor", "Image compression failed: ${e.message}")
if (e is CancellationException) throw e
e.printStackTrace()
onReady(uri, contentType, null)
MediaCompressorResult(uri, contentType, null)
}
}
fun intToCompressorQuality(mediaQualityFloat: Int): CompressorQuality =
when (mediaQualityFloat) {
0 -> CompressorQuality.LOW
1 -> CompressorQuality.MEDIUM
2 -> CompressorQuality.HIGH
3 -> CompressorQuality.UNCOMPRESSED
else -> CompressorQuality.MEDIUM
}
companion object {
fun intToCompressorQuality(mediaQualityFloat: Int): CompressorQuality =
when (mediaQualityFloat) {
0 -> CompressorQuality.LOW
1 -> CompressorQuality.MEDIUM
2 -> CompressorQuality.HIGH
3 -> CompressorQuality.UNCOMPRESSED
else -> CompressorQuality.MEDIUM
}
fun compressorQualityToInt(compressorQuality: CompressorQuality): Int =
when (compressorQuality) {
CompressorQuality.LOW -> 0
CompressorQuality.MEDIUM -> 1
CompressorQuality.HIGH -> 2
CompressorQuality.UNCOMPRESSED -> 3
else -> 1
}
fun compressorQualityToInt(compressorQuality: CompressorQuality): Int =
when (compressorQuality) {
CompressorQuality.LOW -> 0
CompressorQuality.MEDIUM -> 1
CompressorQuality.HIGH -> 2
CompressorQuality.UNCOMPRESSED -> 3
else -> 1
}
}
}
enum class CompressorQuality {
@@ -0,0 +1,143 @@
/**
* 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.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import com.google.common.math.IntMath.sqrt
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import java.math.RoundingMode
import kotlin.math.ceil
@Composable
fun GridPreviewTemplate(count: Int) {
AutoNonlazyGrid(count) { idx ->
Box(Modifier.background(Color.Green).fillMaxSize())
Text(idx.toString())
}
}
@Composable
@Preview(device = "spec:width=300px,height=300px,dpi=440")
fun Items1() = GridPreviewTemplate(1)
@Composable
@Preview(device = "spec:width=300px,height=300px,dpi=440")
fun Items2() = GridPreviewTemplate(2)
@Composable
@Preview(device = "spec:width=300px,height=300px,dpi=440")
fun Items3() = GridPreviewTemplate(3)
@Composable
@Preview(device = "spec:width=300px,height=300px,dpi=440")
fun Items4() = GridPreviewTemplate(4)
@Composable
@Preview(device = "spec:width=300px,height=300px,dpi=440")
fun Items5() = GridPreviewTemplate(5)
@Composable
@Preview(device = "spec:width=300px,height=300px,dpi=440")
fun Items6() = GridPreviewTemplate(6)
@Composable
@Preview(device = "spec:width=300px,height=300px,dpi=440")
fun Items7() = GridPreviewTemplate(7)
@Composable
@Preview(device = "spec:width=300px,height=300px,dpi=440")
fun Items8() = GridPreviewTemplate(8)
@Composable
@Preview(device = "spec:width=300px,height=300px,dpi=440")
fun Items9() = GridPreviewTemplate(9)
@Composable
@Preview(device = "spec:width=300px,height=300px,dpi=440")
fun Items10() = GridPreviewTemplate(10)
@Composable
fun AutoNonlazyGrid(
itemCount: Int,
modifier: Modifier = Modifier.aspectRatio(1f),
content: @Composable (Int) -> Unit,
) {
if (itemCount > 0) {
NonlazyGrid(sqrt(itemCount, RoundingMode.UP), itemCount, modifier, content)
}
}
@Composable
fun NonlazyGrid(
columns: Int,
itemCount: Int,
modifier: Modifier = Modifier.aspectRatio(1f),
content: @Composable (Int) -> Unit,
) {
val skipItems = ((columns * columns) - itemCount)
val skipInEveryRow = (skipItems / columns)
val shouldSkip = skipItems % columns
var addSkips = shouldSkip
val newColumns = columns - skipInEveryRow
Column(modifier = modifier, verticalArrangement = spacedBy(Size5dp)) {
val rows = ceil(itemCount.toDouble() / newColumns).toInt()
for (rowId in 0 until rows) {
val firstIndex = if (rowId == 0) 0 else (rowId * newColumns) - (shouldSkip - addSkips)
Row(Modifier.weight(1f), horizontalArrangement = spacedBy(Size5dp)) {
val thisRowColumns =
if (addSkips > 0) {
addSkips--
newColumns - 1
} else {
newColumns
}
for (columnId in 0 until thisRowColumns) {
val index = firstIndex + columnId
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.weight(1f),
) {
if (index < itemCount) {
content(index)
}
}
}
}
}
}
}
@@ -47,6 +47,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFontFamilyResolver
import androidx.compose.ui.platform.LocalLayoutDirection
@@ -421,7 +422,7 @@ private fun ZoomableContentView(
) {
state.imagesForPager[word]?.let {
Box(modifier = HalfVertPadding) {
ZoomableContentView(it, state.imageList, roundedCorner = true, isFiniteHeight = false, accountViewModel)
ZoomableContentView(it, state.imageList, roundedCorner = true, contentScale = ContentScale.FillWidth, accountViewModel)
}
}
}
@@ -118,7 +118,7 @@ import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.amethyst.ui.theme.videoGalleryModifier
import com.vitorpamplona.ammolite.service.HttpClientManager
import com.vitorpamplona.quartz.events.Dimension
import com.vitorpamplona.quartz.encoders.Dimension
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
@@ -141,7 +141,7 @@ fun LoadThumbAndThenVideoView(
thumbUri: String,
authorName: String? = null,
roundedCorner: Boolean,
isFiniteHeight: Boolean,
contentScale: ContentScale,
nostrUriCallback: String? = null,
accountViewModel: AccountViewModel,
onDialog: ((Boolean) -> Unit)? = null,
@@ -173,7 +173,7 @@ fun LoadThumbAndThenVideoView(
title = title,
thumb = VideoThumb(loadingFinished.second),
roundedCorner = roundedCorner,
isFiniteHeight = isFiniteHeight,
contentScale = contentScale,
artworkUri = thumbUri,
authorName = authorName,
nostrUriCallback = nostrUriCallback,
@@ -187,7 +187,7 @@ fun LoadThumbAndThenVideoView(
title = title,
thumb = null,
roundedCorner = roundedCorner,
isFiniteHeight = isFiniteHeight,
contentScale = contentScale,
artworkUri = thumbUri,
authorName = authorName,
nostrUriCallback = nostrUriCallback,
@@ -206,7 +206,7 @@ fun VideoView(
thumb: VideoThumb? = null,
roundedCorner: Boolean,
gallery: Boolean = false,
isFiniteHeight: Boolean,
contentScale: ContentScale,
waveform: ImmutableList<Int>? = null,
artworkUri: String? = null,
authorName: String? = null,
@@ -227,7 +227,7 @@ fun VideoView(
Modifier
}
VideoView(videoUri, mimeType, title, thumb, borderModifier, isFiniteHeight, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, onControllerVisibilityChanged, accountViewModel, alwaysShowVideo)
VideoView(videoUri, mimeType, title, thumb, borderModifier, contentScale, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, onControllerVisibilityChanged, accountViewModel, alwaysShowVideo)
}
@Composable
@@ -237,7 +237,7 @@ fun VideoView(
title: String? = null,
thumb: VideoThumb? = null,
borderModifier: Modifier,
isFiniteHeight: Boolean,
contentScale: ContentScale,
waveform: ImmutableList<Int>? = null,
artworkUri: String? = null,
authorName: String? = null,
@@ -281,7 +281,7 @@ fun VideoView(
title = title,
thumb = thumb,
borderModifier = borderModifier,
isFiniteHeight = isFiniteHeight,
contentScale = contentScale,
waveform = waveform,
artworkUri = artworkUri,
authorName = authorName,
@@ -308,7 +308,7 @@ fun VideoView(
DisplayBlurHash(
blurhash,
null,
if (isFiniteHeight) ContentScale.FillWidth else ContentScale.FillWidth,
contentScale,
if (ratio != null) borderModifier.aspectRatio(ratio) else borderModifier,
)
@@ -327,7 +327,7 @@ fun VideoView(
title = title,
thumb = thumb,
borderModifier = borderModifier,
isFiniteHeight = isFiniteHeight,
contentScale = contentScale,
waveform = waveform,
artworkUri = artworkUri,
authorName = authorName,
@@ -352,7 +352,7 @@ fun VideoViewInner(
title: String? = null,
thumb: VideoThumb? = null,
showControls: Boolean = true,
isFiniteHeight: Boolean,
contentScale: ContentScale,
borderModifier: Modifier,
waveform: ImmutableList<Int>? = null,
artworkUri: String? = null,
@@ -378,7 +378,7 @@ fun VideoViewInner(
controller = controller,
thumbData = thumb,
showControls = showControls,
isFiniteHeight = isFiniteHeight,
contentScale = contentScale,
nostrUriCallback = nostrUriCallback,
waveform = waveform,
keepPlaying = keepPlaying,
@@ -729,7 +729,7 @@ private fun RenderVideoPlayer(
controller: MediaController,
thumbData: VideoThumb?,
showControls: Boolean = true,
isFiniteHeight: Boolean,
contentScale: ContentScale,
nostrUriCallback: String?,
waveform: ImmutableList<Int>? = null,
keepPlaying: MutableState<Boolean>,
@@ -761,10 +761,13 @@ private fun RenderVideoPlayer(
hideController()
resizeMode =
if (isFiniteHeight) {
AspectRatioFrameLayout.RESIZE_MODE_FIT
} else {
AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
when (contentScale) {
ContentScale.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT
ContentScale.FillWidth -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
ContentScale.Crop -> AspectRatioFrameLayout.RESIZE_MODE_FILL
ContentScale.FillHeight -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT
ContentScale.Inside -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
else -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
}
if (showControls) {
@@ -410,7 +410,7 @@ private fun RenderImageOrVideo(
artworkUri = content.artworkUri,
authorName = content.authorName,
borderModifier = borderModifier,
isFiniteHeight = isFiniteHeight,
contentScale = contentScale,
automaticallyStartPlayback = automaticallyStartPlayback,
onControllerVisibilityChanged = onControllerVisibilityChanged,
accountViewModel = accountViewModel,
@@ -457,7 +457,7 @@ private fun RenderImageOrVideo(
artworkUri = content.artworkUri,
authorName = content.authorName,
borderModifier = borderModifier,
isFiniteHeight = isFiniteHeight,
contentScale = contentScale,
automaticallyStartPlayback = automaticallyStartPlayback,
onControllerVisibilityChanged = onControllerVisibilityChanged,
accountViewModel = accountViewModel,
@@ -78,7 +78,6 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.service.Blurhash
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.actions.InformationDialog
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.components.util.DeviceUtils
import com.vitorpamplona.amethyst.ui.navigation.getActivity
import com.vitorpamplona.amethyst.ui.note.BlankNote
@@ -95,9 +94,9 @@ import com.vitorpamplona.amethyst.ui.theme.Size75dp
import com.vitorpamplona.amethyst.ui.theme.hashVerifierMark
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.encoders.Dimension
import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.Dimension
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
@@ -110,7 +109,7 @@ fun ZoomableContentView(
content: BaseMediaContent,
images: ImmutableList<BaseMediaContent> = remember(content) { persistentListOf(content) },
roundedCorner: Boolean,
isFiniteHeight: Boolean,
contentScale: ContentScale,
accountViewModel: AccountViewModel,
) {
var dialogOpen by remember(content) { mutableStateOf(false) }
@@ -122,13 +121,6 @@ fun ZoomableContentView(
val isFoldableOrLarge = DeviceUtils.windowIsLarge(windowSize = currentWindowSize, isInLandscapeMode = isLandscapeMode)
val isOrientationLocked = DeviceUtils.screenOrientationIsLocked(LocalContext.current)
val contentScale =
if (isFiniteHeight) {
ContentScale.Fit
} else {
ContentScale.FillWidth
}
when (content) {
is MediaUrlImage ->
SensitivityWarning(content.contentWarning != null, accountViewModel) {
@@ -154,7 +146,7 @@ fun ZoomableContentView(
dimensions = content.dim,
blurhash = content.blurhash,
roundedCorner = roundedCorner,
isFiniteHeight = isFiniteHeight,
contentScale = contentScale,
nostrUriCallback = content.uri,
onDialog = {
dialogOpen = true
@@ -186,7 +178,7 @@ fun ZoomableContentView(
artworkUri = content.artworkUri,
authorName = content.authorName,
roundedCorner = roundedCorner,
isFiniteHeight = isFiniteHeight,
contentScale = contentScale,
nostrUriCallback = content.uri,
onDialog = { dialogOpen = true },
accountViewModel = accountViewModel,
@@ -355,11 +347,18 @@ fun UrlImageView(
-> {
if (content.blurhash != null) {
if (ratio != null) {
val modifier =
if (contentScale == ContentScale.Crop) {
loadedImageModifier.clickable { showImage.value = true }
} else {
loadedImageModifier.aspectRatio(ratio).clickable { showImage.value = true }
}
DisplayBlurHash(
content.blurhash,
content.description,
ContentScale.Crop,
loadedImageModifier.aspectRatio(ratio),
modifier,
)
} else {
DisplayBlurHash(
@@ -395,13 +394,18 @@ fun UrlImageView(
}
} else {
if (content.blurhash != null && ratio != null) {
val modifier =
if (contentScale == ContentScale.Crop) {
loadedImageModifier.clickable { showImage.value = true }
} else {
loadedImageModifier.aspectRatio(ratio).clickable { showImage.value = true }
}
DisplayBlurHash(
content.blurhash,
content.description,
ContentScale.Crop,
loadedImageModifier
.aspectRatio(ratio)
.clickable { showImage.value = true },
contentScale,
modifier,
)
IconButton(
modifier = Modifier.size(Size75dp),
@@ -27,6 +27,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
@@ -95,7 +96,7 @@ class MarkdownMediaRenderer(
ZoomableContentView(
content = content,
roundedCorner = true,
isFiniteHeight = false,
contentScale = ContentScale.FillWidth,
accountViewModel = accountViewModel,
)
}
@@ -117,7 +118,7 @@ class MarkdownMediaRenderer(
ZoomableContentView(
content = content,
roundedCorner = true,
isFiniteHeight = false,
contentScale = ContentScale.FillWidth,
accountViewModel = accountViewModel,
)
}
@@ -55,7 +55,7 @@ import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.LocationState
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.ui.note.LoadCityName
import com.vitorpamplona.amethyst.ui.screen.AroundMeFeedDefinition
import com.vitorpamplona.amethyst.ui.screen.CommunityName
@@ -25,6 +25,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -46,6 +47,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.distinctUntilChanged
@@ -319,9 +321,9 @@ fun AcceptableNote(
)
}
is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote)
is FileHeaderEvent -> FileHeaderDisplay(baseNote, false, false, accountViewModel)
is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, false, false, accountViewModel)
is VideoEvent -> JustVideoDisplay(baseNote, false, false, accountViewModel)
is FileHeaderEvent -> FileHeaderDisplay(baseNote, false, ContentScale.FillWidth, accountViewModel)
is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, false, ContentScale.FillWidth, accountViewModel)
is VideoEvent -> JustVideoDisplay(baseNote, false, ContentScale.FillWidth, accountViewModel)
else ->
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
CheckNewAndRenderNote(
@@ -604,8 +606,8 @@ private fun RenderNoteRow(
) {
when (val noteEvent = baseNote.event) {
is AppDefinitionEvent -> RenderAppDefinition(baseNote, accountViewModel, nav)
is AudioTrackEvent -> RenderAudioTrack(baseNote, false, accountViewModel, nav)
is AudioHeaderEvent -> RenderAudioHeader(baseNote, false, accountViewModel, nav)
is AudioTrackEvent -> RenderAudioTrack(baseNote, ContentScale.FillWidth, accountViewModel, nav)
is AudioHeaderEvent -> RenderAudioHeader(baseNote, ContentScale.FillWidth, accountViewModel, nav)
is DraftEvent -> RenderDraft(baseNote, quotesLeft, unPackReply, backgroundColor, accountViewModel, nav)
is ReactionEvent -> RenderReaction(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
is RepostEvent -> RenderRepost(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
@@ -731,12 +733,12 @@ private fun RenderNoteRow(
nav,
)
}
is FileHeaderEvent -> FileHeaderDisplay(baseNote, true, false, accountViewModel)
is VideoHorizontalEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, false, accountViewModel, nav)
is VideoVerticalEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, false, accountViewModel, nav)
is PictureEvent -> PictureDisplay(baseNote, true, false, backgroundColor, accountViewModel, nav)
is FileHeaderEvent -> FileHeaderDisplay(baseNote, true, ContentScale.FillWidth, accountViewModel)
is VideoHorizontalEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav)
is VideoVerticalEvent -> VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav)
is PictureEvent -> PictureDisplay(baseNote, true, ContentScale.FillWidth, PaddingValues(vertical = 5.dp), backgroundColor, accountViewModel, nav)
is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, true, false, accountViewModel)
is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, true, ContentScale.FillWidth, accountViewModel)
is CommunityPostApprovalEvent -> {
RenderPostApproval(
baseNote,
@@ -37,6 +37,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -61,20 +62,20 @@ import java.util.Locale
@Composable
fun RenderAudioTrack(
note: Note,
isFiniteHeight: Boolean,
contentScale: ContentScale,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = note.event as? AudioTrackEvent ?: return
AudioTrackHeader(noteEvent, note, isFiniteHeight, accountViewModel, nav)
AudioTrackHeader(noteEvent, note, contentScale, accountViewModel, nav)
}
@Composable
fun AudioTrackHeader(
noteEvent: AudioTrackEvent,
note: Note,
isFiniteHeight: Boolean,
contentScale: ContentScale,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -148,7 +149,7 @@ fun AudioTrackHeader(
thumbUri = cover,
authorName = note.author?.toBestDisplayName(),
roundedCorner = true,
isFiniteHeight = isFiniteHeight,
contentScale = contentScale,
nostrUriCallback = "nostr:${note.toNEvent()}",
accountViewModel = accountViewModel,
)
@@ -159,7 +160,7 @@ fun AudioTrackHeader(
title = noteEvent.subject(),
authorName = note.author?.toBestDisplayName(),
roundedCorner = true,
isFiniteHeight = isFiniteHeight,
contentScale = contentScale,
accountViewModel = accountViewModel,
)
}
@@ -172,20 +173,20 @@ fun AudioTrackHeader(
@Composable
fun RenderAudioHeader(
note: Note,
isFiniteHeight: Boolean,
contentScale: ContentScale,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = note.event as? AudioHeaderEvent ?: return
AudioHeader(noteEvent, note, isFiniteHeight, accountViewModel, nav)
AudioHeader(noteEvent, note, contentScale, accountViewModel, nav)
}
@Composable
fun AudioHeader(
noteEvent: AudioHeaderEvent,
note: Note,
isFiniteHeight: Boolean,
contentScale: ContentScale,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -210,7 +211,7 @@ fun AudioHeader(
title = noteEvent.subject(),
authorName = note.author?.toBestDisplayName(),
roundedCorner = true,
isFiniteHeight = isFiniteHeight,
contentScale = contentScale,
accountViewModel = accountViewModel,
nostrUriCallback = note.toNostrUri(),
)
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.layout.ContentScale
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
@@ -38,7 +39,7 @@ import com.vitorpamplona.quartz.events.FileHeaderEvent
fun FileHeaderDisplay(
note: Note,
roundedCorner: Boolean,
isFiniteHeight: Boolean,
contentScale: ContentScale,
accountViewModel: AccountViewModel,
) {
val event = (note.event as? FileHeaderEvent) ?: return
@@ -84,7 +85,7 @@ fun FileHeaderDisplay(
ZoomableContentView(
content = content,
roundedCorner = roundedCorner,
isFiniteHeight = isFiniteHeight,
contentScale = contentScale,
accountViewModel = accountViewModel,
)
}
@@ -25,6 +25,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
@@ -43,7 +44,7 @@ import java.io.File
fun FileStorageHeaderDisplay(
baseNote: Note,
roundedCorner: Boolean,
isFiniteHeight: Boolean,
contentScale: ContentScale,
accountViewModel: AccountViewModel,
) {
val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return
@@ -51,7 +52,7 @@ fun FileStorageHeaderDisplay(
LoadNote(baseNoteHex = dataEventId, accountViewModel) { contentNote ->
if (contentNote != null) {
ObserverAndRenderNIP95(baseNote, contentNote, roundedCorner, isFiniteHeight, accountViewModel)
ObserverAndRenderNIP95(baseNote, contentNote, roundedCorner, contentScale, accountViewModel)
}
}
}
@@ -61,7 +62,7 @@ private fun ObserverAndRenderNIP95(
header: Note,
content: Note,
roundedCorner: Boolean,
isFiniteHeight: Boolean,
contentScale: ContentScale,
accountViewModel: AccountViewModel,
) {
val eventHeader = (header.event as? FileStorageHeaderEvent) ?: return
@@ -113,7 +114,7 @@ private fun ObserverAndRenderNIP95(
ZoomableContentView(
content = it,
roundedCorner = roundedCorner,
isFiniteHeight = isFiniteHeight,
contentScale = contentScale,
accountViewModel = accountViewModel,
)
}
@@ -40,6 +40,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
@@ -183,7 +184,7 @@ fun RenderLiveActivityEventInner(
artworkUri = cover,
authorName = baseNote.author?.toBestDisplayName(),
roundedCorner = true,
isFiniteHeight = false,
contentScale = ContentScale.FillWidth,
accountViewModel = accountViewModel,
nostrUriCallback = "nostr:${baseNote.toNEvent()}",
)
@@ -21,7 +21,9 @@
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -29,17 +31,18 @@ import androidx.compose.runtime.MutableState
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.text.style.TextOverflow
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid
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.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.quartz.events.EmptyTagList
import com.vitorpamplona.quartz.events.PictureEvent
@@ -49,7 +52,8 @@ import kotlinx.collections.immutable.toImmutableList
fun PictureDisplay(
note: Note,
roundedCorner: Boolean,
isFiniteHeight: Boolean,
contentScale: ContentScale,
padding: PaddingValues,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
@@ -85,7 +89,7 @@ fun PictureDisplay(
Column {
if (title != null) {
Text(
modifier = if (isFiniteHeight) HalfPadding else HalfVertPadding,
modifier = Modifier.padding(padding),
text = title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
@@ -95,19 +99,31 @@ fun PictureDisplay(
Spacer(StdVertSpacer)
}
ZoomableContentView(
content = first,
images = images,
roundedCorner = roundedCorner,
isFiniteHeight = isFiniteHeight,
accountViewModel = accountViewModel,
)
if (images.size == 1) {
ZoomableContentView(
content = images.first(),
images = images,
roundedCorner = roundedCorner,
contentScale = ContentScale.FillWidth,
accountViewModel = accountViewModel,
)
} else {
AutoNonlazyGrid(images.size) {
ZoomableContentView(
content = images[it],
images = images,
roundedCorner = roundedCorner,
contentScale = ContentScale.Crop,
accountViewModel = accountViewModel,
)
}
}
TranslatableRichTextViewer(
content = event.content,
canPreview = false,
quotesLeft = 0,
modifier = if (isFiniteHeight) HalfPadding else HalfVertPadding,
modifier = Modifier.padding(padding),
tags = EmptyTagList,
backgroundColor = backgroundColor,
id = note.idHex,
@@ -115,20 +131,6 @@ fun PictureDisplay(
accountViewModel = accountViewModel,
nav = nav,
)
/*
TranslatableRichTextViewer(
content = ,
modifier = if (isFiniteHeight) HalfPadding else HalfVertPadding,
text = ,
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)*/
}
}
}
@@ -66,7 +66,7 @@ fun VideoDisplay(
makeItShort: Boolean,
canPreview: Boolean,
backgroundColor: MutableState<Color>,
isFiniteHeight: Boolean,
contentScale: ContentScale,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -144,7 +144,7 @@ fun VideoDisplay(
ZoomableContentView(
content = content,
roundedCorner = true,
isFiniteHeight = isFiniteHeight,
contentScale = contentScale,
accountViewModel = accountViewModel,
)
}
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.layout.ContentScale
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
@@ -38,7 +39,7 @@ import com.vitorpamplona.quartz.events.VideoEvent
fun JustVideoDisplay(
note: Note,
roundedCorner: Boolean,
isFiniteHeight: Boolean,
contentScale: ContentScale,
accountViewModel: AccountViewModel,
) {
val event = (note.event as? VideoEvent) ?: return
@@ -79,7 +80,7 @@ fun JustVideoDisplay(
ZoomableContentView(
content = content,
roundedCorner = roundedCorner,
isFiniteHeight = isFiniteHeight,
contentScale = contentScale,
accountViewModel = accountViewModel,
)
}
@@ -74,6 +74,7 @@ import com.vitorpamplona.amethyst.ui.tor.TorSettings
import com.vitorpamplona.ammolite.relays.BundledInsert
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.Dimension
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip11RelayInformation
import com.vitorpamplona.quartz.encoders.Nip19Bech32
@@ -83,7 +84,6 @@ import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.ChatroomKeyable
import com.vitorpamplona.quartz.events.Dimension
import com.vitorpamplona.quartz.events.DraftEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
@@ -22,15 +22,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.Manifest
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.os.Parcelable
import android.util.Log
import android.util.Size
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
@@ -60,7 +54,6 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.filled.CurrencyBitcoin
import androidx.compose.material.icons.filled.LocationOff
import androidx.compose.material.icons.filled.LocationOn
@@ -107,7 +100,6 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
@@ -136,20 +128,23 @@ 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.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.actions.NewPollOption
import com.vitorpamplona.amethyst.ui.actions.NewPollVoteValueRange
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.actions.RelaySelectionDialog
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.getPhotoUri
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
import com.vitorpamplona.amethyst.ui.components.BechLink
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.components.ZapRaiserRequest
import com.vitorpamplona.amethyst.ui.navigation.Nav
@@ -186,8 +181,8 @@ import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.quartz.events.ClassifiedsEvent
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.FlowPreview
import kotlinx.coroutines.delay
@@ -246,7 +241,8 @@ fun NewPostScreen(
postViewModel.updateMessage(TextFieldValue(it))
}
attachment?.let {
postViewModel.selectImage(it)
val mediaType = context.contentResolver.getType(it)
postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType)))
}
}
}
@@ -271,7 +267,8 @@ fun NewPostScreen(
}
(intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri)?.let {
postViewModel.selectImage(it)
val mediaType = context.contentResolver.getType(it)
postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType)))
}
}
}
@@ -458,7 +455,7 @@ fun NewPostScreen(
myUrlPreview,
mimeType = null,
roundedCorner = true,
isFiniteHeight = false,
contentScale = ContentScale.FillWidth,
accountViewModel = accountViewModel,
)
} else {
@@ -509,22 +506,22 @@ fun NewPostScreen(
}
}
val url = postViewModel.contentToAddUrl
if (url != null) {
if (postViewModel.mediaToUpload.isNotEmpty()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
) {
ImageVideoDescription(
url,
postViewModel.mediaToUpload,
accountViewModel.account.settings.defaultFileServer,
onAdd = { alt, server, sensitiveContent, mediaQuality ->
postViewModel.upload(url, alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context)
postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel::toast, context)
if (server.type != ServerType.NIP95) {
accountViewModel.account.settings.changeDefaultFileServer(server)
}
},
onCancel = { postViewModel.contentToAddUrl = null },
onDelete = postViewModel::deleteMediaToUpload,
onCancel = { postViewModel.mediaToUpload = persistentListOf() },
onError = { scope.launch { Toast.makeText(context, context.resources.getText(it), Toast.LENGTH_SHORT).show() } },
accountViewModel = accountViewModel,
)
@@ -608,7 +605,7 @@ private fun BottomRowActions(postViewModel: NewPostViewModel) {
.height(50.dp),
verticalAlignment = CenterVertically,
) {
UploadFromGallery(
SelectFromGallery(
isUploading = postViewModel.isUploadingImage,
tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier,
@@ -667,56 +664,6 @@ private fun BottomRowActions(postViewModel: NewPostViewModel) {
}
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun TakePictureButton(onPictureTaken: (Uri) -> Unit) {
var imageUri by remember { mutableStateOf<Uri?>(null) }
val scope = rememberCoroutineScope()
val launcher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicture(),
) { success ->
if (success) {
imageUri?.let {
onPictureTaken(it)
}
}
}
val context = LocalContext.current
val cameraPermissionState =
rememberPermissionState(
Manifest.permission.CAMERA,
onPermissionResult = {
if (it) {
scope.launch(Dispatchers.IO) {
imageUri = getPhotoUri(context)
imageUri?.let { uri -> launcher.launch(uri) }
}
}
},
)
IconButton(
onClick = {
if (cameraPermissionState.status.isGranted) {
scope.launch(Dispatchers.IO) {
imageUri = getPhotoUri(context)
imageUri?.let { uri -> launcher.launch(uri) }
}
} else {
cameraPermissionState.launchPermissionRequest()
}
},
) {
Icon(
imageVector = Icons.Default.CameraAlt,
contentDescription = stringRes(id = R.string.upload_image),
modifier = Modifier.height(25.dp),
tint = MaterialTheme.colorScheme.onBackground,
)
}
}
@Composable
private fun PollField(postViewModel: NewPostViewModel) {
val optionsList = postViewModel.pollOptions
@@ -1719,19 +1666,14 @@ fun CreateButton(
@Composable
fun ImageVideoDescription(
uri: Uri,
uris: ImmutableList<SelectedMediaProcessing>,
defaultServer: ServerName,
onAdd: (String, ServerName, Boolean, Int) -> Unit,
onDelete: (SelectedMediaProcessing) -> Unit,
onCancel: () -> Unit,
onError: (Int) -> Unit,
accountViewModel: AccountViewModel,
) {
val resolver = LocalContext.current.contentResolver
val mediaType = resolver.getType(uri) ?: ""
val isImage = mediaType.startsWith("image")
val isVideo = mediaType.startsWith("video")
val nip95description = stringRes(id = R.string.upload_server_relays_nip95)
val fileServers by accountViewModel.account.liveServerList.collectAsState()
@@ -1784,19 +1726,23 @@ fun ImageVideoDescription(
.fillMaxWidth()
.padding(bottom = 10.dp),
) {
Text(
text =
stringRes(
if (isImage) {
R.string.content_description_add_image
val text =
if (uris.size == 1) {
if (uris.first().media.isImage() == true) {
R.string.content_description_add_image
} else {
if (uris.first().media.isVideo() == true) {
R.string.content_description_add_video
} else {
if (isVideo) {
R.string.content_description_add_video
} else {
R.string.content_description_add_document
}
},
),
R.string.content_description_add_document
}
}
} else {
R.string.content_description_add_media
}
Text(
text = stringRes(text),
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier =
@@ -1827,48 +1773,7 @@ fun ImageVideoDescription(
.padding(bottom = 10.dp)
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
) {
if (mediaType.startsWith("image")) {
AsyncImage(
model = uri.toString(),
contentDescription = uri.toString(),
contentScale = ContentScale.FillWidth,
modifier =
Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
)
} else if (
mediaType.startsWith("video") && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
) {
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
LaunchedEffect(key1 = uri) {
launch(Dispatchers.IO) {
try {
bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null)
} catch (e: Exception) {
if (e is CancellationException) throw e
onError(R.string.unable_to_load_thumbnail)
Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e)
}
}
}
bitmap?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = "some useful description",
contentScale = ContentScale.FillWidth,
modifier =
Modifier
.padding(top = 4.dp)
.fillMaxWidth(),
)
}
} else {
VideoView(uri.toString(), roundedCorner = true, isFiniteHeight = false, mimeType = mediaType, accountViewModel = accountViewModel)
}
ShowImageUploadGallery(uris, onDelete, accountViewModel)
}
Row(
@@ -2030,10 +1935,11 @@ fun SettingSwitchItem(
onValueChange = onCheckedChange,
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
Column(
modifier = Modifier.weight(1.0f),
verticalArrangement = Arrangement.spacedBy(Size5dp),
modifier = Modifier.weight(2.0f),
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
Text(
text = stringRes(id = title),
@@ -2049,10 +1955,12 @@ fun SettingSwitchItem(
)
}
Switch(
checked = checked,
onCheckedChange = null,
enabled = enabled,
)
Column(Modifier.weight(1f), horizontalAlignment = Alignment.End) {
Switch(
checked = checked,
onCheckedChange = null,
enabled = enabled,
)
}
}
}
@@ -113,8 +113,8 @@ import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
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
@@ -341,7 +341,7 @@ fun ChannelScreen(
}
// LAST ROW
EditFieldRow(newPostModel, isPrivate = false, accountViewModel = accountViewModel) {
EditFieldRow(newPostModel, accountViewModel = accountViewModel) {
scope.launch(Dispatchers.IO) {
innerSendPost(replyTo, channel, newPostModel, accountViewModel, null)
newPostModel.message = TextFieldValue("")
@@ -395,7 +395,7 @@ private suspend fun innerSendPost(
tagger.run()
val urls = findURLs(tagger.message)
val usedAttachments = newPostModel.nip94attachments.filter { it.urls().intersect(urls.toSet()).isNotEmpty() }
val usedAttachments = newPostModel.iMetaAttachments.filter { it.url !in urls.toSet() }
if (channel is PublicChatChannel) {
accountViewModel.account.sendChannelMessage(
@@ -404,7 +404,7 @@ private suspend fun innerSendPost(
replyTo = tagger.eTags,
mentions = tagger.pTags,
wantsToMarkAsSensitive = false,
nip94attachments = usedAttachments,
imetas = usedAttachments,
draftTag = draftTag,
)
} else if (channel is LiveActivitiesChannel) {
@@ -414,7 +414,7 @@ private suspend fun innerSendPost(
replyTo = tagger.eTags,
mentions = tagger.pTags,
wantsToMarkAsSensitive = false,
nip94attachments = usedAttachments,
imetas = usedAttachments,
draftTag = draftTag,
)
}
@@ -469,7 +469,6 @@ fun DisplayReplyingToNote(
@Composable
fun EditFieldRow(
channelScreenModel: NewPostViewModel,
isPrivate: Boolean,
accountViewModel: AccountViewModel,
onSendNewMessage: () -> Unit,
) {
@@ -506,17 +505,17 @@ fun EditFieldRow(
}
},
leadingIcon = {
UploadFromGallery(
SelectFromGallery(
isUploading = channelScreenModel.isUploadingImage,
tint = MaterialTheme.colorScheme.placeholderText,
modifier = EditFieldLeadingIconModifier,
) {
channelScreenModel.selectImage(it)
channelScreenModel.upload(
galleryUri = it,
alt = null,
sensitiveContent = false,
// Use MEDIUM quality
mediaQuality = MediaCompressor().compressorQualityToInt(CompressorQuality.MEDIUM),
mediaQuality = MediaCompressor.compressorQualityToInt(CompressorQuality.MEDIUM),
server = accountViewModel.account.settings.defaultFileServer,
onError = accountViewModel::toast,
context = context,
@@ -793,7 +792,7 @@ fun ShowVideoStreaming(
ZoomableContentView(
content = zoomableUrlVideo,
roundedCorner = false,
isFiniteHeight = false,
contentScale = ContentScale.FillWidth,
accountViewModel = accountViewModel,
)
}
@@ -87,8 +87,8 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
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.navigation.INav
@@ -482,7 +482,7 @@ fun ChatroomScreen(
}
// LAST ROW
PrivateMessageEditFieldRow(newPostModel, isPrivate = true, accountViewModel) {
PrivateMessageEditFieldRow(newPostModel, accountViewModel) {
scope.launch(Dispatchers.IO) {
innerSendPost(newPostModel, room, replyTo, accountViewModel, null)
@@ -505,7 +505,7 @@ private fun innerSendPost(
dTag: String?,
) {
val urls = findURLs(newPostModel.message.text)
val usedAttachments = newPostModel.nip94attachments.filter { it.urls().intersect(urls.toSet()).isNotEmpty() }
val usedAttachments = newPostModel.iMetaAttachments.filter { it.url !in urls.toSet() }
if (newPostModel.nip17 || room.users.size > 1 || replyTo.value?.event is ChatMessageEvent) {
accountViewModel.account.sendNIP17PrivateMessage(
@@ -514,7 +514,7 @@ private fun innerSendPost(
replyingTo = replyTo.value,
mentions = null,
wantsToMarkAsSensitive = false,
nip94attachments = usedAttachments,
imetas = usedAttachments,
draftTag = dTag,
)
} else {
@@ -524,7 +524,7 @@ private fun innerSendPost(
replyingTo = replyTo.value,
mentions = null,
wantsToMarkAsSensitive = false,
nip94attachments = usedAttachments,
imetas = usedAttachments,
draftTag = dTag,
)
}
@@ -533,7 +533,6 @@ private fun innerSendPost(
@Composable
fun PrivateMessageEditFieldRow(
channelScreenModel: NewPostViewModel,
isPrivate: Boolean,
accountViewModel: AccountViewModel,
onSendNewMessage: () -> Unit,
) {
@@ -573,7 +572,7 @@ fun PrivateMessageEditFieldRow(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 6.dp),
) {
UploadFromGallery(
SelectFromGallery(
isUploading = channelScreenModel.isUploadingImage,
tint = MaterialTheme.colorScheme.placeholderText,
modifier =
@@ -581,13 +580,13 @@ fun PrivateMessageEditFieldRow(
.size(30.dp)
.padding(start = 2.dp),
) {
channelScreenModel.selectImage(it)
channelScreenModel.upload(
galleryUri = it,
alt = null,
sensitiveContent = false,
// use MEDIUM quality
mediaQuality = MediaCompressor().compressorQualityToInt(CompressorQuality.MEDIUM),
isPrivate = isPrivate,
mediaQuality = MediaCompressor.compressorQualityToInt(CompressorQuality.MEDIUM),
isPrivate = true,
server = accountViewModel.account.settings.defaultFileServer,
onError = accountViewModel::toast,
context = context,
@@ -25,6 +25,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -156,6 +157,7 @@ import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
@@ -512,19 +514,19 @@ private fun FullBleedNoteCompose(
nav = nav,
)
} else if (noteEvent is VideoEvent) {
VideoDisplay(baseNote, makeItShort = false, canPreview = true, backgroundColor = backgroundColor, isFiniteHeight = false, accountViewModel = accountViewModel, nav = nav)
VideoDisplay(baseNote, makeItShort = false, canPreview = true, backgroundColor = backgroundColor, ContentScale.FillWidth, accountViewModel = accountViewModel, nav = nav)
} else if (noteEvent is PictureEvent) {
PictureDisplay(baseNote, roundedCorner = true, isFiniteHeight = false, backgroundColor, accountViewModel = accountViewModel, nav)
PictureDisplay(baseNote, roundedCorner = true, ContentScale.FillWidth, PaddingValues(vertical = Size5dp), backgroundColor, accountViewModel = accountViewModel, nav)
} else if (noteEvent is FileHeaderEvent) {
FileHeaderDisplay(baseNote, roundedCorner = true, isFiniteHeight = false, accountViewModel = accountViewModel)
FileHeaderDisplay(baseNote, roundedCorner = true, ContentScale.FillWidth, accountViewModel = accountViewModel)
} else if (noteEvent is FileStorageHeaderEvent) {
FileStorageHeaderDisplay(baseNote, roundedCorner = true, isFiniteHeight = false, accountViewModel = accountViewModel)
FileStorageHeaderDisplay(baseNote, roundedCorner = true, ContentScale.FillWidth, accountViewModel = accountViewModel)
} else if (noteEvent is PeopleListEvent) {
DisplayPeopleList(baseNote, backgroundColor, accountViewModel, nav)
} else if (noteEvent is AudioTrackEvent) {
AudioTrackHeader(noteEvent, baseNote, false, accountViewModel, nav)
AudioTrackHeader(noteEvent, baseNote, ContentScale.FillWidth, accountViewModel, nav)
} else if (noteEvent is AudioHeaderEvent) {
AudioHeader(noteEvent, baseNote, false, accountViewModel, nav)
AudioHeader(noteEvent, baseNote, ContentScale.FillWidth, accountViewModel, nav)
} else if (noteEvent is CommunityPostApprovalEvent) {
RenderPostApproval(
baseNote,
@@ -20,18 +20,11 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.video
import android.Manifest
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
@@ -41,64 +34,50 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddPhotoAlternate
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.GallerySelect
import com.vitorpamplona.amethyst.ui.actions.NewMediaModel
import com.vitorpamplona.amethyst.ui.actions.NewMediaView
import com.vitorpamplona.amethyst.ui.actions.getPhotoUri
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePicture
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun NewImageButton(
accountViewModel: AccountViewModel,
nav: INav,
navScrollToTop: () -> Unit,
) {
val context = LocalContext.current
var isOpen by remember { mutableStateOf(false) }
var wantsToPostFromGallery by remember { mutableStateOf(false) }
var wantsToPostFromCamera by remember { mutableStateOf(false) }
var cameraUri by remember { mutableStateOf<Uri?>(null) }
var pickedURI by remember { mutableStateOf<Uri?>(null) }
var pickedURIs by remember { mutableStateOf<ImmutableList<SelectedMedia>>(persistentListOf()) }
val scope = rememberCoroutineScope()
@@ -111,180 +90,111 @@ fun NewImageButton(
}
if (wantsToPostFromCamera) {
val launcher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicture(),
) { success ->
if (success) {
cameraUri?.let {
pickedURI = it
}
}
cameraUri = null
wantsToPostFromCamera = false
}
val cameraPermissionState =
rememberPermissionState(
Manifest.permission.CAMERA,
onPermissionResult = {
if (it) {
scope.launch(Dispatchers.IO) {
cameraUri = getPhotoUri(context)
cameraUri?.let { launcher.launch(it) }
}
}
},
)
if (cameraPermissionState.status.isGranted) {
LaunchedEffect(key1 = accountViewModel) {
launch(Dispatchers.IO) {
cameraUri = getPhotoUri(context)
cameraUri?.let { launcher.launch(it) }
}
}
} else {
LaunchedEffect(key1 = accountViewModel) { cameraPermissionState.launchPermissionRequest() }
TakePicture { uri ->
wantsToPostFromCamera = false
pickedURIs = uri
}
}
if (wantsToPostFromGallery) {
var showGallerySelect by remember { mutableStateOf(false) }
if (showGallerySelect) {
GallerySelect(
onImageUri = { uri ->
wantsToPostFromGallery = false
showGallerySelect = false
pickedURI = uri
},
)
}
showGallerySelect = true
GallerySelect(
onImageUri = { uri ->
wantsToPostFromGallery = false
pickedURIs = uri
},
)
}
pickedURI?.let {
if (pickedURIs.isNotEmpty()) {
NewMediaView(
uri = it,
onClose = { pickedURI = null },
uris = pickedURIs,
onClose = { pickedURIs = persistentListOf() },
postViewModel = postViewModel,
accountViewModel = accountViewModel,
nav = nav,
)
}
if (postViewModel.isUploadingImage) {
ShowProgress(postViewModel)
} else {
Column {
// if (isOpen) {
Column {
AnimatedVisibility(
visible = isOpen,
enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(),
) {
Column {
FloatingActionButton(
onClick = {
wantsToPostFromCamera = true
isOpen = false
},
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(
imageVector = Icons.Default.CameraAlt,
contentDescription = stringRes(id = R.string.upload_image),
modifier = Modifier.size(26.dp),
tint = Color.White,
)
}
Spacer(modifier = Modifier.height(20.dp))
FloatingActionButton(
onClick = {
wantsToPostFromGallery = true
isOpen = false
},
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(
imageVector = Icons.Default.AddPhotoAlternate,
contentDescription = stringRes(id = R.string.upload_image),
modifier = Modifier.size(26.dp),
tint = Color.White,
)
}
Spacer(modifier = Modifier.height(20.dp))
}
}
FloatingActionButton(
onClick = {
isOpen = !isOpen
},
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
AnimatedVisibility(
visible = isOpen,
enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(),
enter = fadeIn(),
exit = fadeOut(),
) {
Column {
FloatingActionButton(
onClick = {
wantsToPostFromCamera = true
isOpen = false
},
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(
imageVector = Icons.Default.CameraAlt,
contentDescription = stringRes(id = R.string.upload_image),
modifier = Modifier.size(26.dp),
tint = Color.White,
)
}
Spacer(modifier = Modifier.height(20.dp))
FloatingActionButton(
onClick = {
wantsToPostFromGallery = true
isOpen = false
},
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(
imageVector = Icons.Default.AddPhotoAlternate,
contentDescription = stringRes(id = R.string.upload_image),
modifier = Modifier.size(26.dp),
tint = Color.White,
)
}
Spacer(modifier = Modifier.height(20.dp))
}
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = stringRes(id = R.string.new_short),
modifier = Modifier.size(26.dp),
tint = Color.White,
)
}
FloatingActionButton(
onClick = {
isOpen = !isOpen
},
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
AnimatedVisibility(
visible = !isOpen,
enter = fadeIn(),
exit = fadeOut(),
) {
AnimatedVisibility(
visible = isOpen,
enter = fadeIn(),
exit = fadeOut(),
) {
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = stringRes(id = R.string.new_short),
modifier = Modifier.size(26.dp),
tint = Color.White,
)
}
AnimatedVisibility(
visible = !isOpen,
enter = fadeIn(),
exit = fadeOut(),
) {
Icon(
painter = painterResource(R.drawable.ic_compose),
contentDescription = stringRes(id = R.string.new_short),
modifier = Modifier.size(26.dp),
tint = Color.White,
)
}
Icon(
painter = painterResource(R.drawable.ic_compose),
contentDescription = stringRes(id = R.string.new_short),
modifier = Modifier.size(26.dp),
tint = Color.White,
)
}
}
}
}
@Composable
private fun ShowProgress(postViewModel: NewMediaModel) {
Box(Modifier.size(55.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(
progress =
animateFloatAsState(
targetValue = postViewModel.uploadingPercentage.value,
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
).value,
modifier =
Size55Modifier
.clip(CircleShape)
.background(MaterialTheme.colorScheme.background),
strokeWidth = 5.dp,
)
postViewModel.uploadingDescription.value?.let {
Text(
it,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 10.sp,
textAlign = TextAlign.Center,
)
}
}
}
@@ -24,6 +24,7 @@ import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
@@ -46,6 +47,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
@@ -295,13 +297,13 @@ private fun RenderVideoOrPictureNote(
if (noteEvent is PictureEvent) {
val backgroundColor = remember { mutableStateOf(Color.Transparent) }
PictureDisplay(note, false, true, backgroundColor, accountViewModel, nav)
PictureDisplay(note, false, ContentScale.Fit, PaddingValues(5.dp), backgroundColor, accountViewModel, nav)
} else if (noteEvent is FileHeaderEvent) {
FileHeaderDisplay(note, false, true, accountViewModel)
FileHeaderDisplay(note, false, ContentScale.Fit, accountViewModel)
} else if (noteEvent is FileStorageHeaderEvent) {
FileStorageHeaderDisplay(note, false, true, accountViewModel)
FileStorageHeaderDisplay(note, false, ContentScale.Fit, accountViewModel)
} else if (noteEvent is VideoEvent) {
JustVideoDisplay(note, false, true, accountViewModel)
JustVideoDisplay(note, false, ContentScale.Fit, accountViewModel)
}
}
}
@@ -92,7 +92,7 @@ import com.vitorpamplona.amethyst.commons.hashtags.Amethyst
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.service.PackageUtils
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.navigation.getActivity
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
+15
View File
@@ -364,11 +364,15 @@
<string name="hash_verification_passed">This content is the same since the post</string>
<string name="hash_verification_failed">This content has changed. The author might not have seen or approved the change</string>
<string name="content_description_add_media">Add Media</string>
<string name="content_description_add_image">Add Image</string>
<string name="content_description_add_video">Add Video</string>
<string name="content_description_add_document">Add Document</string>
<string name="add_content">Add to Message</string>
<string name="add_caption">Add a Caption</string>
<string name="add_caption_example">My lovely friend</string>
<string name="content_description">Description of the contents</string>
<string name="content_description_example">A blue boat in a white sandy beach at sunset</string>
@@ -389,6 +393,8 @@
<string name="file_server">File Server</string>
<string name="file_server_description">Choose a server to upload this file to</string>
<string name="zap_forward_lnAddress">LnAddress or @User</string>
@@ -404,6 +410,14 @@
<string name="add_media_server">Add media server</string>
<string name="delete_media_server">Delete media server</string>
<string name="uploading_state_ready">Not Started</string>
<string name="uploading_state_compressing">Compressing</string>
<string name="uploading_state_uploading">Uploading</string>
<string name="uploading_state_server_processing">Processing</string>
<string name="uploading_state_downloading">Downloading</string>
<string name="uploading_state_hashing">Hashing</string>
<string name="uploading_state_finished">Done</string>
<string name="uploading_state_error">Error</string>
<string name="upload_server_relays_nip95">Your relays (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">Files are hosted by your relays. New NIP: check if they support</string>
@@ -866,6 +880,7 @@
<string name="failed_to_upload_media">Uploading error: %1$s</string>
<string name="server_did_not_provide_a_url_after_uploading">Server did not provide a URL after uploading</string>
<string name="could_not_download_from_the_server">Could not download uploaded media from the server</string>
<string name="could_not_check_downloaded_file">Could not check downloaded file after upload: %1$s</string>
<string name="could_not_prepare_local_file_to_upload">Could not prepare local file to upload: %1$s</string>
<string name="failed_to_upload_with_message">Failed to upload: %1$s</string>
<string name="failed_to_delete_with_message">Failed to delete: %1$s</string>
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.service.uploads.nip96.ServerInfoRetriever
import junit.framework.TestCase.assertEquals
import org.junit.Test
@@ -136,7 +137,7 @@ class Nip96Test {
@Test()
fun parseNostrBuild() {
val info = Nip96Retriever().parse("https://nostr.build", json)
val info = ServerInfoRetriever().parse("https://nostr.build", json)
assertEquals("https://nostr.build/api/v2/nip96/upload", info.apiUrl)
assertEquals("https://media.nostr.build", info.downloadUrl)
@@ -165,7 +166,7 @@ class Nip96Test {
@Test()
fun parseRelativeUrls() {
val info = Nip96Retriever().parse("https://test.com", relativeUrlTest)
val info = ServerInfoRetriever().parse("https://test.com", relativeUrlTest)
assertEquals("https://test.com/n96", info.apiUrl)
assertEquals("https://test.com/", info.downloadUrl)
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.benchmark
import androidx.benchmark.junit4.BenchmarkRule
import androidx.benchmark.junit4.measureRepeated
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.commons.preview.BlurHashDecoder
import com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -20,7 +20,13 @@
*/
package com.vitorpamplona.amethyst.commons.preview
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder
import com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoderOld
import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertTrue
import org.junit.Assert
import org.junit.Test
@@ -71,4 +77,30 @@ class BlurhashTest {
assertTrue(bmp1!!.sameAs(bmp2!!))
}
@Test
fun testBlack() {
assertEquals("U00000fQfQfQfQfQfQfQfQfQfQfQfQfQfQfQ", load("/black.png").toBlurhash())
}
@Test
fun test1x1() {
assertEquals("U~TSUA~q~q~q~q~q~q~q~q~q~q~q~q~q~q~q", load("/1x1.png").toBlurhash())
}
@Test
fun testWhite() {
assertEquals("U2TSUA~qfQ~q~qj[fQj[fQfQfQfQ~qj[fQj[", load("/white.png").toBlurhash())
}
@Test
fun testLorikeet() {
println("${load("/lorikeet.jpg").toBlurhash()}")
assertEquals("rFDcT@_LNs#p%Mt*nNM}E2VrIVX6VuV@WUo{xtjv9]RRw[OXS}rrWFX9w{OZxaxWNHX4n\$M}NGaK%0RkM}w{xto|jFs,Sh-Tj]bcwJnjXSxZs.NI", load("/lorikeet.jpg").toBlurhash())
}
private fun load(filename: String): Bitmap =
javaClass.getResourceAsStream(filename).use { inputStream ->
BitmapFactory.decodeStream(inputStream)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 B

@@ -0,0 +1,90 @@
/**
* 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.commons.blurhash
import kotlin.math.ceil
import kotlin.math.log
object Base83 {
fun encode(value: Long): String =
if (value > 82) {
encode(value, ceil(log((value + 1).toDouble(), 83.0)).toInt())
} else {
encode(value, 1)
}
fun encode(
value: Long,
length: Int,
): String {
val buffer = CharArray(length)
encode(value, length, buffer, 0)
return String(buffer)
}
fun encode(
value: Long,
length: Int,
buffer: CharArray,
offset: Int,
) {
var exp = 1L
for (i in 1..length) {
val digit = (value / exp % 83).toInt()
buffer[offset + length - i] = ALPHABET[digit]
exp *= 83
}
}
fun decodeAt(
str: String,
at: Int = 0,
): Int = charMap[str[at].code]
fun decodeFixed2(
str: String,
from: Int = 0,
): Int = charMap[str[from].code] * 83 + charMap[str[from + 1].code]
fun decode(
str: String,
from: Int = 0,
to: Int = str.length,
): Int {
var result = 0
for (i in from until to) {
result = result * 83 + charMap[str[i].code]
}
return result
}
val ALPHABET: CharArray = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".toCharArray()
private val charMap =
ALPHABET
.mapIndexed { i, c -> c.code to i }
.toMap()
.let { charMap ->
Array(255) {
charMap[it] ?: 0
}
}
}
@@ -0,0 +1,61 @@
/**
* 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.commons.blurhash
import android.graphics.Bitmap
import kotlin.math.roundToInt
fun Bitmap.toBlurhash(): String {
val aspectRatio = this.width.toFloat() / this.height.toFloat()
if (this.width > 100 && this.height > 100) {
return Bitmap.createScaledBitmap(this, 100, (100 / aspectRatio).toInt(), false).toBlurhash()
}
val intArray = IntArray(width * height)
this.getPixels(intArray, 0, width, 0, 0, width, height)
val numX =
if (aspectRatio > 1) {
9
} else if (aspectRatio < 1) {
(9 * aspectRatio).roundToInt()
} else {
4
}
val numY =
if (aspectRatio > 1) {
(9 * (1 / aspectRatio)).roundToInt()
} else if (aspectRatio < 1) {
9
} else {
4
}
return BlurHashEncoder().encode(
intArray,
width,
height,
numX,
numY,
)
}
@@ -18,38 +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.commons.preview
package com.vitorpamplona.amethyst.commons.blurhash
import android.graphics.Bitmap
import kotlin.math.cos
import com.vitorpamplona.amethyst.commons.blurhash.SRGB.Companion.linearToSrgb
import com.vitorpamplona.amethyst.commons.blurhash.SRGB.Companion.srgbToLinear
import kotlin.math.pow
import kotlin.math.roundToInt
import kotlin.math.withSign
object BlurHashDecoder {
// cache Math.cos() calculations to improve performance.
// The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY *
// 2 * nBitmaps
// the cache is enabled by default, it is recommended to disable it only when just a few images
// are displayed
private val cacheCosinesX = HashMap<Int, DoubleArray>()
private val cacheCosinesY = HashMap<Int, DoubleArray>()
/**
* Clear calculations stored in memory cache. The cache is not big, but will increase when many
* image sizes are used, if the app needs memory it is recommended to clear it.
*/
fun clearCache() {
cacheCosinesX.clear()
cacheCosinesY.clear()
}
/** Returns width/height */
fun aspectRatio(blurHash: String?): Float? {
if (blurHash == null || blurHash.length < 6) {
return null
}
val numCompEnc = decode83At(blurHash, 0)
val numCompEnc = Base83.decodeAt(blurHash, 0)
val numCompX = (numCompEnc % 9) + 1
val numCompY = (numCompEnc / 9) + 1
if (blurHash.length != 4 + 2 * numCompX * numCompY) {
@@ -64,18 +48,18 @@ object BlurHashDecoder {
numCompY: Int,
blurHash: String,
): Array<FloatArray> {
val maxAc = (decode83At(blurHash, 1) + 1) / 166f
val maxAc = (Base83.decodeAt(blurHash, 1) + 1) / 166f
return Array(numCompX * numCompY) { i ->
if (i == 0) {
decodeDc(decode83(blurHash, 2, 6))
decodeDc(Base83.decode(blurHash, 2, 6))
} else {
decodeAc(decode83Fixed2(blurHash, 4 + i * 2), maxAc)
decodeAc(Base83.decodeFixed2(blurHash, 4 + i * 2), maxAc)
}
}
}
fun computeNumComponets(blurHash: String): Pair<Int, Int> {
val numCompEnc = decode83At(blurHash, 0)
val numCompEnc = Base83.decodeAt(blurHash, 0)
val numCompX = (numCompEnc % 9) + 1
val numCompY = (numCompEnc / 9) + 1
return Pair(numCompX, numCompY)
@@ -106,28 +90,6 @@ object BlurHashDecoder {
return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888)
}
private fun decode83At(
str: String,
at: Int = 0,
): Int = charMap[str[at].code]
private fun decode83Fixed2(
str: String,
from: Int = 0,
): Int = charMap[str[from].code] * 83 + charMap[str[from + 1].code]
private fun decode83(
str: String,
from: Int = 0,
to: Int = str.length,
): Int {
var result = 0
for (i in from until to) {
result = result * 83 + charMap[str[i].code]
}
return result
}
private fun decodeDc(colorEnc: Int): FloatArray {
val r = colorEnc shr 16
val g = (colorEnc shr 8) and 255
@@ -135,15 +97,6 @@ object BlurHashDecoder {
return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b))
}
private fun srgbToLinear(colorEnc: Int): Float {
val v = colorEnc / 255f
return if (v <= 0.04045f) {
(v / 12.92f)
} else {
((v + 0.055f) / 1.055f).pow(2.4f)
}
}
private fun decodeAc(
value: Int,
maxAc: Float,
@@ -170,10 +123,10 @@ object BlurHashDecoder {
): IntArray {
// use an array for better performance when writing pixel colors
val imageArray = IntArray(width * height)
val calculateCosX = !useCache || !cacheCosinesX.containsKey(width * numCompX)
val cosinesX = getArrayForCosinesX(calculateCosX, width, numCompX)
val calculateCosY = !useCache || !cacheCosinesY.containsKey(height * numCompY)
val cosinesY = getArrayForCosinesY(calculateCosY, height, numCompY)
val calculateCosX = !useCache || !CosineCache.hasX(width * numCompX)
val cosinesX = CosineCache.getArrayForCosinesX(calculateCosX, width, numCompX)
val calculateCosY = !useCache || !CosineCache.hasY(height * numCompY)
val cosinesY = CosineCache.getArrayForCosinesY(calculateCosY, height, numCompY)
var r = 0.0f
var g = 0.0f
@@ -208,145 +161,4 @@ object BlurHashDecoder {
green: Int,
blue: Int,
): Int = -0x1000000 or (red shl 16) or (green shl 8) or blue
private fun getArrayForCosinesY(
calculate: Boolean,
height: Int,
numCompY: Int,
) = when {
calculate -> {
DoubleArray(height * numCompY) {
val y = it / numCompY
val j = it % numCompY
cos(Math.PI * y * j / height)
}.also {
cacheCosinesY[height * numCompY] = it
}
}
else -> {
cacheCosinesY[height * numCompY]!!
}
}
private fun getArrayForCosinesX(
calculate: Boolean,
width: Int,
numCompX: Int,
) = when {
calculate -> {
DoubleArray(width * numCompX) {
val x = it / numCompX
val i = it % numCompX
cos(Math.PI * x * i / width)
}.also { cacheCosinesX[width * numCompX] = it }
}
else -> cacheCosinesX[width * numCompX]!!
}
private fun linearToSrgb(value: Float): Int {
val v = value.coerceIn(0f, 1f)
return if (v <= 0.0031308f) {
(v * 12.92f * 255f + 0.5f).toInt()
} else {
((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt()
}
}
private val linToSrgbApproximation =
Array(255) {
linearToSrgb(it / 255f)
}
private val charMap =
listOf(
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'I',
'J',
'K',
'L',
'M',
'N',
'O',
'P',
'Q',
'R',
'S',
'T',
'U',
'V',
'W',
'X',
'Y',
'Z',
'a',
'b',
'c',
'd',
'e',
'f',
'g',
'h',
'i',
'j',
'k',
'l',
'm',
'n',
'o',
'p',
'q',
'r',
's',
't',
'u',
'v',
'w',
'x',
'y',
'z',
'#',
'$',
'%',
'*',
'+',
',',
'-',
'.',
':',
';',
'=',
'?',
'@',
'[',
']',
'^',
'_',
'{',
'|',
'}',
'~',
).mapIndexed { i, c -> c.code to i }
.toMap()
.let { charMap ->
Array(255) {
charMap[it] ?: 0
}
}
}
@@ -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.commons.preview
package com.vitorpamplona.amethyst.commons.blurhash
import android.graphics.Bitmap
import android.graphics.Color
@@ -0,0 +1,143 @@
/**
* 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.commons.blurhash
import com.vitorpamplona.amethyst.commons.blurhash.Base83.encode
import com.vitorpamplona.amethyst.commons.blurhash.SRGB.Companion.linearToSrgb
import kotlin.math.abs
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.withSign
class BlurHashEncoder {
fun signPow(
value: Double,
exp: Double,
): Double = abs(value).pow(exp).withSign(value)
private fun encodeAC(
value: DoubleArray,
maximumValue: Double,
): Long {
val quantR = floor(max(0.0, min(18.0, floor(signPow(value[0] / maximumValue, 0.5) * 9 + 9.5))))
val quantG = floor(max(0.0, min(18.0, floor(signPow(value[1] / maximumValue, 0.5) * 9 + 9.5))))
val quantB = floor(max(0.0, min(18.0, floor(signPow(value[2] / maximumValue, 0.5) * 9 + 9.5))))
return Math.round(quantR * 19 * 19 + quantG * 19 + quantB)
}
private fun encodeDC(value: DoubleArray): Long {
val r = linearToSrgb(value[0]).toLong()
val g = linearToSrgb(value[1]).toLong()
val b = linearToSrgb(value[2]).toLong()
return (r shl 16) + (g shl 8) + b
}
fun max(
values: Array<DoubleArray>,
from: Int,
endExclusive: Int,
): Double {
var result = Double.NEGATIVE_INFINITY
for (i in from until endExclusive) {
for (j in values[i].indices) {
val value = values[i][j]
if (value > result) {
result = value
}
}
}
return result
}
fun encode(
pixels: IntArray,
width: Int,
height: Int,
componentX: Int,
componentY: Int,
useCache: Boolean = true,
): String {
require(!(componentX < 1 || componentX > 9 || componentY < 1 || componentY > 9)) { "Blur hash must have between 1 and 9 components" }
require(width * height == pixels.size) { "Width and height must match the pixels array" }
val factors = Array(componentX * componentY) { DoubleArray(3) }
val calculateCosX = !useCache || !CosineCache.hasX(width * componentX)
val cosinesX = CosineCache.getArrayForCosinesX(calculateCosX, width, componentX)
val calculateCosY = !useCache || !CosineCache.hasY(height * componentY)
val cosinesY = CosineCache.getArrayForCosinesY(calculateCosY, height, componentY)
val scale = 1.0 / (width * height)
var r = 0.0
var g = 0.0
var b = 0.0
for (j in 0 until componentY) {
for (i in 0 until componentX) {
val normalisation = (if (i == 0 && j == 0) 1 else 2).toDouble()
r = 0.0
g = 0.0
b = 0.0
for (x in 0 until width) {
for (y in 0 until height) {
val basis = normalisation * cosinesY[j + componentY * y] * cosinesX[i + componentX * x]
val pixel = pixels[y * width + x]
r += basis * SRGB.srgbToLinear((pixel shr 16) and 0xff)
g += basis * SRGB.srgbToLinear((pixel shr 8) and 0xff)
b += basis * SRGB.srgbToLinear(pixel and 0xff)
}
}
val colors = factors[j * componentX + i]
colors[0] = r * scale
colors[1] = g * scale
colors[2] = b * scale
}
}
val hash = CharArray(1 + 1 + 4 + 2 * (factors.size - 1)) // size flag + max AC + DC + 2 * AC components
val sizeFlag = (componentX - 1 + (componentY - 1) * 9).toLong()
encode(sizeFlag, 1, hash, 0)
val maximumValue: Double
if (factors.size > 1) {
val actualMaximumValue = max(factors, 1, factors.size)
val quantisedMaximumValue = floor(max(0.0, min(82.0, floor(actualMaximumValue * 166 - 0.5))))
maximumValue = (quantisedMaximumValue + 1) / 166
encode(Math.round(quantisedMaximumValue), 1, hash, 1)
} else {
maximumValue = 1.0
encode(0, 1, hash, 1)
}
val dc = factors[0]
encode(encodeDC(dc), 4, hash, 2)
for (i in 1 until factors.size) {
encode(encodeAC(factors[i], maximumValue), 2, hash, 6 + 2 * (i - 1))
}
return String(hash)
}
}
@@ -0,0 +1,81 @@
/**
* 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.commons.blurhash
import androidx.collection.LruCache
import kotlin.math.cos
object CosineCache {
// cache Math.cos() calculations to improve performance.
// The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY *
// 2 * nBitmaps
// the cache is enabled by default, it is recommended to disable it only when just a few images
// are displayed
private val cacheCosinesX = LruCache<Int, DoubleArray>(20)
private val cacheCosinesY = LruCache<Int, DoubleArray>(20)
/**
* Clear calculations stored in memory cache. The cache is not big, but will increase when many
* image sizes are used, if the app needs memory it is recommended to clear it.
*/
fun clearCache() {
cacheCosinesX.evictAll()
cacheCosinesY.evictAll()
}
fun hasX(idx: Int) = cacheCosinesX.get(idx) != null
fun hasY(idx: Int) = cacheCosinesY.get(idx) != null
fun getArrayForCosinesY(
calculate: Boolean,
height: Int,
numCompY: Int,
) = when {
calculate -> {
DoubleArray(height * numCompY) {
val y = it / numCompY
val j = it % numCompY
cos(Math.PI * y * j / height)
}.also {
cacheCosinesY.put(height * numCompY, it)
}
}
else -> {
cacheCosinesY[height * numCompY]!!
}
}
fun getArrayForCosinesX(
calculate: Boolean,
width: Int,
numCompX: Int,
) = when {
calculate -> {
DoubleArray(width * numCompX) {
val x = it / numCompX
val i = it % numCompX
cos(Math.PI * x * i / width)
}.also { cacheCosinesX.put(width * numCompX, it) }
}
else -> cacheCosinesX[width * numCompX]!!
}
}
@@ -0,0 +1,56 @@
/**
* 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.commons.blurhash
import kotlin.math.pow
class SRGB {
companion object {
fun linearToSrgb(value: Float): Int {
val v = value.coerceIn(0.0f, 1.0f)
return if (v <= 0.0031308f) {
(v * 12.92f * 255f + 0.5f).toInt()
} else {
((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt()
}
}
fun linearToSrgb(value: Double): Int {
val v = value.coerceIn(0.0, 1.0)
return if (v <= 0.0031308f) {
(v * 12.92f * 255f + 0.5f).toInt()
} else {
((1.055f * v.pow(1 / 2.4) - 0.055f) * 255 + 0.5f).toInt()
}
}
fun srgbToLinear(value: Int): Float {
val valueCheck = value.coerceIn(0, 255)
val v = valueCheck / 255f
return if (v <= 0.04045f) {
v / 12.92f
} else {
((v + 0.055f) / 1.055f).pow(2.4f)
}
}
}
}
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.commons.richtext
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.events.Dimension
import com.vitorpamplona.quartz.encoders.Dimension
import java.io.File
@Immutable
@@ -24,10 +24,10 @@ import android.util.Log
import android.util.Patterns
import com.linkedin.urls.detection.UrlDetector
import com.linkedin.urls.detection.UrlDetectorOptions
import com.vitorpamplona.quartz.encoders.Dimension
import com.vitorpamplona.quartz.encoders.Nip30CustomEmoji
import com.vitorpamplona.quartz.encoders.Nip54InlineMetadata
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments
import com.vitorpamplona.quartz.events.Dimension
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import kotlinx.collections.immutable.ImmutableList
@@ -50,7 +50,7 @@ class RichTextParser {
callbackUri: String? = null,
): MediaUrlContent? {
val frags = Nip54InlineMetadata().parse(fullUrl)
val tags = Nip92MediaAttachments().parse(fullUrl, eventTags.lists)
val tags = Nip92MediaAttachments.parse(fullUrl, eventTags.lists)
val contentType = frags[FileHeaderEvent.MIME_TYPE] ?: tags[FileHeaderEvent.MIME_TYPE]
@@ -0,0 +1,82 @@
/**
* 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.commons
import com.vitorpamplona.amethyst.commons.blurhash.Base83
import junit.framework.TestCase.assertEquals
import org.junit.Test
class Base83Test {
@Test
fun testEncodeDecode() {
for (i in 0..820000) {
assertEquals("$i encode decode", i, Base83.decode(Base83.encode(i.toLong())))
}
}
@Test
fun testSingleDigits() {
for (i in 0..82) {
val expected: String = String(Base83.ALPHABET, i, 1)
assertEquals("$i encodes", expected, Base83.encode(i.toLong(), 1))
}
}
@Test
fun test0000() {
assertEquals("0000", Base83.encode(0, 4))
}
@Test
fun test0001() {
assertEquals("0001", Base83.encode(1, 4))
}
@Test
fun test0010() {
assertEquals("0010", Base83.encode(83, 4))
}
@Test
fun test0011() {
assertEquals("0011", Base83.encode(83 + 1, 4))
}
@Test
fun test00X0() {
assertEquals("00~0", Base83.encode(83 * 82, 4))
}
@Test
fun test0100() {
assertEquals("0100", Base83.encode(83 * 83, 4))
}
@Test
fun test00XXEncode() {
assertEquals("00~~", Base83.encode(83 * 82 + 82, 4))
}
@Test
fun test0XXXDecode() {
assertEquals(82 + 82 * 83 + 82 * 83 * 83, Base83.decode("0~~~"))
}
}
@@ -0,0 +1,42 @@
/**
* 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.commons
import com.vitorpamplona.amethyst.commons.blurhash.SRGB
import junit.framework.TestCase.assertEquals
import org.junit.Test
import kotlin.math.round
class SRGBTest {
@Test
fun testEncodeDecode() {
for (i in 0..255) {
assertEquals("$i encode decode", i, SRGB.linearToSrgb(SRGB.srgbToLinear(i)))
}
for (i in 0..100) {
val srgb = SRGB.linearToSrgb(i / 100.0f)
val linear = round(SRGB.srgbToLinear(srgb) * 100).toInt()
assertEquals("$i decode encode", i, linear)
}
}
}
-2
View File
@@ -12,7 +12,6 @@ audiowaveform = "1.1.1"
benchmark = "1.3.3"
benchmarkJunit4 = "1.3.3"
biometricKtx = "1.2.0-alpha05"
blurhash = "1.0.0"
coil = "3.0.4"
composeBom = "2024.12.01"
coreKtx = "1.15.0"
@@ -123,7 +122,6 @@ okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhtt
rfc3986-normalizer = { group = "org.czeal", name = "rfc3986", version.ref = "rfc3986" }
secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" }
tor-android = { module = "info.guardianproject:tor-android", version.ref = "torAndroid" }
trbl-blurhash = { group = "io.trbl", name = "blurhash", version.ref = "blurhash" }
unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" }
url-detector = { group = "io.github.url-detector", name = "url-detector", version.ref = "urlDetector" }
vico-charts-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico-charts" }
@@ -0,0 +1,50 @@
/**
* 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.encoders
class Dimension(
val width: Int,
val height: Int,
) {
fun aspectRatio() = width.toFloat() / height.toFloat()
fun hasSize() = width > 0 && height > 0
override fun toString() = "${width}x$height"
companion object {
fun parse(dim: String): Dimension? {
if (dim == "0x0") return null
val parts = dim.split("x")
if (parts.size != 2) return null
return try {
val width = parts[0].toInt()
val height = parts[1].toInt()
Dimension(width, height)
} catch (e: Exception) {
null
}
}
}
}
@@ -20,43 +20,36 @@
*/
package com.vitorpamplona.quartz.encoders
import com.vitorpamplona.quartz.events.FileHeaderEvent
import java.net.URI
import java.net.URLDecoder
import java.net.URLEncoder
import kotlin.coroutines.cancellation.CancellationException
class Nip54InlineMetadata {
fun convertFromFileHeader(header: FileHeaderEvent): String? {
val myUrl = header.url() ?: return null
return createUrl(
myUrl,
header.tags,
fun createUrl(header: IMetaTag): String =
createUrl(
header.url,
header.properties,
)
}
fun createUrl(
imageUrl: String,
tags: Array<Array<String>>,
url: String,
tags: Map<String, String>,
): String {
val extension =
tags
.mapNotNull {
if (it.isNotEmpty() && it[0] != "url") {
if (it.size > 1) {
"${it[0]}=${URLEncoder.encode(it[1], "utf-8")}"
} else {
"${it[0]}}="
}
if (it.key != "url") {
"${it.key}=${URLEncoder.encode(it.value, "utf-8")}"
} else {
null
}
}.joinToString("&")
return if (imageUrl.contains("#")) {
"$imageUrl&$extension"
return if (url.contains("#")) {
"$url&$extension"
} else {
"$imageUrl#$extension"
"$url#$extension"
}
}
@@ -20,65 +20,108 @@
*/
package com.vitorpamplona.quartz.encoders
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.ALT
import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.BLUR_HASH
import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.DIMENSION
import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.FILE_SIZE
import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.HASH
import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.MAGNET_URI
import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.MIME_TYPE
import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.ORIGINAL_HASH
import com.vitorpamplona.quartz.events.FileHeaderEvent.Companion.TORRENT_INFOHASH
class IMetaTag(
val url: String,
val properties: Map<String, String>,
)
class IMetaTagBuilder(
val url: String,
) {
val properties = mutableMapOf<String, String>()
fun add(
key: String,
value: String,
): IMetaTagBuilder {
properties.set(key, value)
return this
}
fun magnet(uri: String) = add(MAGNET_URI, uri)
fun mimeType(mime: String) = add(MIME_TYPE, mime)
fun alt(alt: String) = add(ALT, alt)
fun hash(hash: HexKey) = add(HASH, hash)
fun size(size: Int) = add(FILE_SIZE, size.toString())
fun dims(dims: Dimension) = add(DIMENSION, dims.toString())
fun blurhash(blurhash: String) = add(BLUR_HASH, blurhash)
fun originalHash(originalHash: String) = add(ORIGINAL_HASH, originalHash)
fun torrent(uri: String) = add(TORRENT_INFOHASH, uri)
fun sensitiveContent(reason: String) = add("content-warning", reason)
fun build() = IMetaTag(url, properties)
}
class Nip92MediaAttachments {
companion object {
const val IMETA = "imeta"
}
fun convertFromFileHeader(header: FileHeaderEvent): Array<String>? {
val myUrl = header.url() ?: return null
return createTag(
myUrl,
header.tags,
)
}
fun createTag(header: IMetaTag): Array<String> =
createTag(
header.url,
header.properties,
)
fun createTag(
imageUrl: String,
tags: Array<Array<String>>,
): Array<String> =
arrayOf(
IMETA,
"url $imageUrl",
) +
tags.mapNotNull {
if (it.isNotEmpty() && it[0] != "url") {
if (it.size > 1) {
"${it[0]} ${it[1]}"
fun createTag(
url: String,
tags: Map<String, String>,
): Array<String> =
arrayOf(
IMETA,
"url $url",
) +
tags.mapNotNull {
if (it.key != "url") {
"${it.key} ${it.value}"
} else {
"${it[0]}}"
null
}
} else {
null
}
fun parse(
url: String,
tags: Array<Array<String>>,
): Map<String, String> =
tags
.firstOrNull {
it.size > 1 && it[0] == IMETA && it[1] == "url $url"
}?.let { tagList ->
parseIMeta(tagList)
} ?: emptyMap()
fun parse(tags: Array<Array<String>>): Map<String, Map<String, String>> =
tags.filter { it.size > 1 && it[0] == IMETA }.associate {
val allTags = parseIMeta(it)
(allTags.get("url") ?: "") to allTags
}
private fun parseIMeta(tags: Array<String>): Map<String, String> =
tags.associate { tag ->
val parts = tag.split(" ", limit = 2)
when (parts.size) {
2 -> parts[0] to parts[1]
1 -> parts[0] to ""
else -> "" to ""
}
}
fun parse(
imageUrl: String,
tags: Array<Array<String>>,
): Map<String, String> =
tags
.firstOrNull {
it.size > 1 && it[0] == IMETA && it[1] == "url $imageUrl"
}?.let { tagList ->
parseIMeta(tagList)
} ?: emptyMap()
fun parse(tags: Array<Array<String>>): Map<String, Map<String, String>> =
tags.filter { it.size > 1 && it[0] == IMETA }.associate {
val allTags = parseIMeta(it)
(allTags.get("url") ?: "") to allTags
}
fun parseIMeta(tags: Array<String>): Map<String, String> =
tags.associate { tag ->
val parts = tag.split(" ", limit = 2)
when (parts.size) {
2 -> parts[0] to parts[1]
1 -> parts[0] to ""
else -> "" to ""
}
}
}
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.IMetaTag
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -62,7 +63,7 @@ class ChannelMessageEvent(
markAsSensitive: Boolean,
zapRaiserAmount: Long?,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
isDraft: Boolean,
onReady: (ChannelMessageEvent) -> Unit,
) {
@@ -80,12 +81,8 @@ class ChannelMessageEvent(
}
zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
geohash?.let { tags.addAll(geohashMipMap(it)) }
nip94attachments?.let {
it.forEach {
Nip92MediaAttachments().convertFromFileHeader(it)?.let {
tags.add(it)
}
}
imetas?.forEach {
tags.add(Nip92MediaAttachments.createTag(it))
}
tags.add(
arrayOf("alt", ALT),
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.events
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.IMetaTag
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -79,7 +80,7 @@ class ChatMessageEvent(
geohash: String? = null,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
isDraft: Boolean,
onReady: (ChatMessageEvent) -> Unit,
) {
@@ -96,12 +97,8 @@ class ChatMessageEvent(
zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
geohash?.let { tags.addAll(geohashMipMap(it)) }
subject?.let { tags.add(arrayOf("subject", it)) }
nip94attachments?.let {
it.forEach {
Nip92MediaAttachments().convertFromFileHeader(it)?.let {
tags.add(it)
}
}
imetas?.forEach {
tags.add(Nip92MediaAttachments.createTag(it))
}
// tags.add(arrayOf("alt", alt))
@@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.IMetaTag
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -112,7 +114,7 @@ class ClassifiedsEvent(
markAsSensitive: Boolean,
zapRaiserAmount: Long?,
geohash: String? = null,
nip94attachments: List<Event>? = null,
imetas: List<IMetaTag>? = null,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
isDraft: Boolean,
@@ -188,10 +190,8 @@ class ClassifiedsEvent(
}
zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
geohash?.let { tags.addAll(geohashMipMap(it)) }
nip94attachments?.let {
it.forEach {
// tags.add(arrayOf("nip94", it.toJson()))
}
imetas?.forEach {
tags.add(Nip92MediaAttachments.createTag(it))
}
tags.add(arrayOf("alt", ALT))
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.ETag
import com.vitorpamplona.quartz.encoders.EventHint
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.IMetaTag
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments
import com.vitorpamplona.quartz.encoders.PTag
import com.vitorpamplona.quartz.signers.NostrSigner
@@ -96,7 +97,7 @@ class CommentEvent(
usersMentioned: Set<PTag> = emptySet(),
addressesMentioned: Set<ATag> = emptySet(),
eventsMentioned: Set<ETag> = emptySet(),
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
geohash: String? = null,
zapReceiver: List<ZapSplitSetup>? = null,
markAsSensitive: Boolean = false,
@@ -119,7 +120,7 @@ class CommentEvent(
tags.add(removeTrailingNullsAndEmptyOthers("e", replyingTo.event.id, replyingTo.relay, replyingTo.event.pubKey))
tags.add(arrayOf("k", "${replyingTo.event.kind}"))
create(msg, tags, usersMentioned, addressesMentioned, eventsMentioned, nip94attachments, geohash, zapReceiver, markAsSensitive, zapRaiserAmount, isDraft, signer, createdAt, onReady)
create(msg, tags, usersMentioned, addressesMentioned, eventsMentioned, imetas, geohash, zapReceiver, markAsSensitive, zapRaiserAmount, isDraft, signer, createdAt, onReady)
}
fun replyComment(
@@ -128,7 +129,7 @@ class CommentEvent(
usersMentioned: Set<PTag> = emptySet(),
addressesMentioned: Set<ATag> = emptySet(),
eventsMentioned: Set<ETag> = emptySet(),
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
geohash: String? = null,
zapReceiver: List<ZapSplitSetup>? = null,
markAsSensitive: Boolean = false,
@@ -146,7 +147,7 @@ class CommentEvent(
tags.add(removeTrailingNullsAndEmptyOthers("e", replyingTo.event.id, replyingTo.relay, replyingTo.event.pubKey))
tags.add(arrayOf("k", "${replyingTo.event.kind}"))
create(msg, tags, usersMentioned, addressesMentioned, eventsMentioned, nip94attachments, geohash, zapReceiver, markAsSensitive, zapRaiserAmount, isDraft, signer, createdAt, onReady)
create(msg, tags, usersMentioned, addressesMentioned, eventsMentioned, imetas, geohash, zapReceiver, markAsSensitive, zapRaiserAmount, isDraft, signer, createdAt, onReady)
}
fun createGeoComment(
@@ -155,7 +156,7 @@ class CommentEvent(
usersMentioned: Set<PTag> = emptySet(),
addressesMentioned: Set<ATag> = emptySet(),
eventsMentioned: Set<ETag> = emptySet(),
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
zapReceiver: List<ZapSplitSetup>? = null,
markAsSensitive: Boolean = false,
zapRaiserAmount: Long? = null,
@@ -168,7 +169,7 @@ class CommentEvent(
geohash?.let { tags.addAll(rootGeohashMipMap(it)) }
tags.add(arrayOf("K", "geo"))
create(msg, tags, usersMentioned, addressesMentioned, eventsMentioned, nip94attachments, null, zapReceiver, markAsSensitive, zapRaiserAmount, isDraft, signer, createdAt, onReady)
create(msg, tags, usersMentioned, addressesMentioned, eventsMentioned, imetas, null, zapReceiver, markAsSensitive, zapRaiserAmount, isDraft, signer, createdAt, onReady)
}
private fun create(
@@ -177,7 +178,7 @@ class CommentEvent(
usersMentioned: Set<PTag> = emptySet(),
addressesMentioned: Set<ATag> = emptySet(),
eventsMentioned: Set<ETag> = emptySet(),
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
geohash: String? = null,
zapReceiver: List<ZapSplitSetup>? = null,
markAsSensitive: Boolean = false,
@@ -209,12 +210,8 @@ class CommentEvent(
}
zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
geohash?.let { tags.addAll(geohashMipMap(it)) }
nip94attachments?.let {
it.forEach {
Nip92MediaAttachments().convertFromFileHeader(it)?.let {
tags.add(it)
}
}
imetas?.forEach {
tags.add(Nip92MediaAttachments.createTag(it))
}
if (isDraft) {
@@ -21,6 +21,7 @@
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.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -74,6 +75,41 @@ class FileHeaderEvent(
const val ORIGINAL_HASH = "ox"
const val ALT = "alt"
fun buildTags(
url: String,
magnetUri: String? = null,
mimeType: String? = null,
alt: String? = null,
hash: String? = null,
size: String? = null,
dimensions: Dimension? = null,
blurhash: String? = null,
originalHash: String? = null,
magnetURI: String? = null,
torrentInfoHash: String? = null,
sensitiveContent: Boolean? = null,
): Array<Array<String>> =
listOfNotNull(
arrayOf(URL, url),
magnetUri?.let { arrayOf(MAGNET_URI, it) },
mimeType?.let { arrayOf(MIME_TYPE, it) },
alt?.ifBlank { null }?.let { arrayOf(ALT, it) } ?: arrayOf("alt", ALT_DESCRIPTION),
hash?.let { arrayOf(HASH, it) },
size?.let { arrayOf(FILE_SIZE, it) },
dimensions?.let { arrayOf(DIMENSION, it.toString()) },
blurhash?.let { arrayOf(BLUR_HASH, it) },
originalHash?.let { arrayOf(ORIGINAL_HASH, it) },
magnetURI?.let { arrayOf(MAGNET_URI, it) },
torrentInfoHash?.let { arrayOf(TORRENT_INFOHASH, it) },
sensitiveContent?.let {
if (it) {
arrayOf("content-warning", "")
} else {
null
}
},
).toTypedArray()
fun create(
url: String,
magnetUri: String? = null,
@@ -91,30 +127,10 @@ class FileHeaderEvent(
createdAt: Long = TimeUtils.now(),
onReady: (FileHeaderEvent) -> Unit,
) {
val tags =
listOfNotNull(
arrayOf(URL, url),
magnetUri?.let { arrayOf(MAGNET_URI, it) },
mimeType?.let { arrayOf(MIME_TYPE, it) },
alt?.ifBlank { null }?.let { arrayOf(ALT, it) } ?: arrayOf("alt", ALT_DESCRIPTION),
hash?.let { arrayOf(HASH, it) },
size?.let { arrayOf(FILE_SIZE, it) },
dimensions?.let { arrayOf(DIMENSION, it.toString()) },
blurhash?.let { arrayOf(BLUR_HASH, it) },
originalHash?.let { arrayOf(ORIGINAL_HASH, it) },
magnetURI?.let { arrayOf(MAGNET_URI, it) },
torrentInfoHash?.let { arrayOf(TORRENT_INFOHASH, it) },
sensitiveContent?.let {
if (it) {
arrayOf("content-warning", "")
} else {
null
}
},
)
val tags = buildTags(url, magnetUri, mimeType, alt, hash, size, dimensions, blurhash, originalHash, magnetURI, torrentInfoHash, sensitiveContent)
val content = alt ?: ""
signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady)
signer.sign(createdAt, KIND, tags, content, onReady)
}
}
}
@@ -21,6 +21,7 @@
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.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.IMetaTag
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -90,7 +91,7 @@ class GitReplyEvent(
root: String? = null,
directMentions: Set<HexKey> = emptySet(),
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
forkedFrom: Event? = null,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
@@ -148,12 +149,8 @@ class GitReplyEvent(
}
zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
geohash?.let { tags.addAll(geohashMipMap(it)) }
nip94attachments?.let {
it.forEach {
Nip92MediaAttachments().convertFromFileHeader(it)?.let {
tags.add(it)
}
}
imetas?.forEach {
tags.add(Nip92MediaAttachments.createTag(it))
}
tags.add(arrayOf("alt", "a git issue reply"))
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.events
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.IMetaTag
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments
open class InteractiveStoryBaseEvent(
@@ -51,7 +52,7 @@ open class InteractiveStoryBaseEvent(
markAsSensitive: Boolean = false,
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
): Array<Array<String>> {
val tags = mutableListOf<Array<String>>()
findHashtags(content).forEach {
@@ -71,12 +72,8 @@ open class InteractiveStoryBaseEvent(
}
zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
geohash?.let { tags.addAll(geohashMipMap(it)) }
nip94attachments?.let {
it.forEach {
Nip92MediaAttachments().convertFromFileHeader(it)?.let {
tags.add(it)
}
}
imetas?.forEach {
tags.add(Nip92MediaAttachments.createTag(it))
}
return tags.toTypedArray()
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.events
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.IMetaTag
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -59,7 +60,7 @@ class InteractiveStoryPrologueEvent(
markAsSensitive: Boolean = false,
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
isDraft: Boolean,
@@ -67,7 +68,7 @@ class InteractiveStoryPrologueEvent(
) {
val tags =
makeTags(baseId, ALT + title, title, summary, image, options) +
generalTags(content, zapReceiver, markAsSensitive, zapRaiserAmount, geohash, nip94attachments)
generalTags(content, zapReceiver, markAsSensitive, zapRaiserAmount, geohash, imetas)
if (isDraft) {
signer.assembleRumor(createdAt, KIND, tags, content, onReady)
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.events
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.IMetaTag
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -57,7 +58,7 @@ class InteractiveStorySceneEvent(
markAsSensitive: Boolean = false,
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
isDraft: Boolean,
@@ -65,7 +66,7 @@ class InteractiveStorySceneEvent(
) {
val tags =
makeTags(baseId, ALT + title, title, options = options) +
generalTags(content, zapReceiver, markAsSensitive, zapRaiserAmount, geohash, nip94attachments)
generalTags(content, zapReceiver, markAsSensitive, zapRaiserAmount, geohash, imetas)
if (isDraft) {
signer.assembleRumor(createdAt, KIND, tags, content, onReady)
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.IMetaTag
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -73,7 +74,7 @@ class LiveActivitiesChatMessageEvent(
markAsSensitive: Boolean,
zapRaiserAmount: Long?,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
isDraft: Boolean,
onReady: (LiveActivitiesChatMessageEvent) -> Unit,
) {
@@ -92,12 +93,8 @@ class LiveActivitiesChatMessageEvent(
}
zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
geohash?.let { tags.addAll(geohashMipMap(it)) }
nip94attachments?.let {
it.forEach {
Nip92MediaAttachments().convertFromFileHeader(it)?.let {
tags.add(it)
}
}
imetas?.forEach {
tags.add(Nip92MediaAttachments.createTag(it))
}
tags.add(arrayOf("alt", ALT))
@@ -21,6 +21,7 @@
package com.vitorpamplona.quartz.events
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.IMetaTag
import com.vitorpamplona.quartz.signers.NostrSigner
class NIP17Factory {
@@ -79,7 +80,7 @@ class NIP17Factory {
markAsSensitive: Boolean = false,
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
draftTag: String? = null,
onReady: (Result) -> Unit,
) {
@@ -97,7 +98,7 @@ class NIP17Factory {
zapRaiserAmount = zapRaiserAmount,
geohash = geohash,
isDraft = draftTag != null,
nip94attachments = nip94attachments,
imetas = imetas,
) { senderMessage ->
if (draftTag != null) {
onReady(
@@ -22,13 +22,13 @@ package com.vitorpamplona.quartz.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.Dimension
import com.vitorpamplona.quartz.encoders.ETag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments.Companion.IMETA
import com.vitorpamplona.quartz.encoders.PTag
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.coroutines.cancellation.CancellationException
@Immutable
class PictureEvent(
@@ -334,36 +334,6 @@ class PictureMeta(
}
}
class Dimension(
val width: Int,
val height: Int,
) {
fun aspectRatio() = width.toFloat() / height.toFloat()
fun hasSize() = width > 0 && height > 0
override fun toString() = "${width}x$height"
companion object {
fun parse(dim: String): Dimension? {
if (dim == "0x0") return null
val parts = dim.split("x")
if (parts.size != 2) return null
return try {
val width = parts[0].toInt()
val height = parts[1].toInt()
Dimension(width, height)
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
}
}
}
class UserAnnotation(
val pubkey: HexKey,
val x: Int,
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.IMetaTag
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -79,7 +80,7 @@ class PollNoteEvent(
markAsSensitive: Boolean,
zapRaiserAmount: Long?,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
isDraft: Boolean,
onReady: (PollNoteEvent) -> Unit,
) {
@@ -104,12 +105,8 @@ class PollNoteEvent(
}
zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
geohash?.let { tags.addAll(geohashMipMap(it)) }
nip94attachments?.let {
it.forEach {
Nip92MediaAttachments().convertFromFileHeader(it)?.let {
tags.add(it)
}
}
imetas?.forEach {
tags.add(Nip92MediaAttachments.createTag(it))
}
tags.add(arrayOf("alt", ALT))
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.HexValidator
import com.vitorpamplona.quartz.encoders.IMetaTag
import com.vitorpamplona.quartz.encoders.Nip54InlineMetadata
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -124,16 +125,13 @@ class PrivateDmEvent(
markAsSensitive: Boolean,
zapRaiserAmount: Long?,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
isDraft: Boolean,
onReady: (PrivateDmEvent) -> Unit,
) {
var message = msg
nip94attachments?.forEach {
val myUrl = it.url()
if (myUrl != null) {
message = message.replace(myUrl, Nip54InlineMetadata().createUrl(myUrl, it.tags))
}
imetas?.forEach {
message = message.replace(it.url, Nip54InlineMetadata().createUrl(it.url, it.properties))
}
message =
@@ -156,13 +154,10 @@ class PrivateDmEvent(
zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
geohash?.let { tags.addAll(geohashMipMap(it)) }
/* Privacy issue: DO NOT ADD THESE TO THE TAGS.
nip94attachments?.let {
it.forEach {
Nip92().convertFromFileHeader(it)?.let {
tags.add(it)
}
}
}*/
imetas?.forEach {
tags.add(Nip92MediaAttachments.createTag(it))
}
*/
tags.add(arrayOf("alt", ALT))
@@ -21,6 +21,7 @@
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.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -25,6 +25,7 @@ import com.linkedin.urls.detection.UrlDetector
import com.linkedin.urls.detection.UrlDetectorOptions
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.IMetaTag
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -56,7 +57,7 @@ class TextNoteEvent(
root: String? = null,
directMentions: Set<HexKey> = emptySet(),
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
forkedFrom: Event? = null,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
@@ -122,12 +123,8 @@ class TextNoteEvent(
}
zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
geohash?.let { tags.addAll(geohashMipMap(it)) }
nip94attachments?.let {
it.forEach {
Nip92MediaAttachments().convertFromFileHeader(it)?.let {
tags.add(it)
}
}
imetas?.forEach {
tags.add(Nip92MediaAttachments.createTag(it))
}
if (isDraft) {
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.IMetaTag
import com.vitorpamplona.quartz.encoders.Nip92MediaAttachments
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -61,7 +62,7 @@ class TorrentCommentEvent(
directMentions: Set<HexKey> = emptySet(),
zapRaiserAmount: Long?,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
imetas: List<IMetaTag>? = null,
forkedFrom: Event? = null,
isDraft: Boolean,
onReady: (TorrentCommentEvent) -> Unit,
@@ -122,12 +123,8 @@ class TorrentCommentEvent(
}
zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) }
geohash?.let { tags.addAll(geohashMipMap(it)) }
nip94attachments?.let {
it.forEach {
Nip92MediaAttachments().convertFromFileHeader(it)?.let {
tags.add(it)
}
}
imetas?.forEach {
tags.add(Nip92MediaAttachments.createTag(it))
}
tags.add(arrayOf("alt", ALT))
@@ -21,6 +21,7 @@
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.Nip92MediaAttachments.Companion.IMETA
import com.vitorpamplona.quartz.signers.NostrSigner
@@ -21,6 +21,7 @@
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.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -21,6 +21,7 @@
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.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils