From 579f63935a52a6175359703091c336b8741e2fa9 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 18 Dec 2024 16:52:50 -0500 Subject: [PATCH] - 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 --- amethyst/build.gradle | 3 - .../amethyst/ImageUploadTesting.kt | 18 +- .../vitorpamplona/amethyst/ParallelUtils.kt | 13 +- .../vitorpamplona/amethyst/model/Account.kt | 150 ++++--- .../com/vitorpamplona/amethyst/model/Note.kt | 6 +- .../amethyst/service/BlurHashImage.kt | 2 +- .../service/lnurl/LightningAddressResolver.kt | 2 +- .../service/uploads/CombinedUploader.kt | 23 + .../service/{ => uploads}/FileHeader.kt | 115 +---- .../service/uploads/MediaUploadResult.kt | 45 ++ .../{ => uploads/blossom}/BlossomUploader.kt | 112 ++--- .../service/uploads/nip96/Nip96Result.kt | 43 ++ .../{ => uploads/nip96}/Nip96Uploader.kt | 143 ++---- .../service/uploads/nip96/ServerInfo.kt | 59 +++ .../nip96/ServerInfoRetriever.kt} | 46 +- .../amethyst/ui/actions/EditPostView.kt | 16 +- .../amethyst/ui/actions/EditPostViewModel.kt | 295 ++++-------- .../amethyst/ui/actions/ImageDownloader.kt | 85 ---- .../amethyst/ui/actions/NewMediaModel.kt | 420 ++++++------------ .../amethyst/ui/actions/NewMediaView.kt | 254 ++++------- .../amethyst/ui/actions/NewPostViewModel.kt | 329 +++++--------- .../ui/actions/NewUserMetadataView.kt | 5 +- .../ui/actions/NewUserMetadataViewModel.kt | 113 ++--- .../ui/actions/uploads/ImageDownloader.kt | 96 ++++ .../SelectFromGallery.kt} | 183 ++++---- .../uploads/SelectedMediaProcessing.kt | 26 ++ .../ui/actions/uploads/ShowImageUploadItem.kt | 246 ++++++++++ .../ui/actions/uploads/TakePicture.kt | 154 +++++++ .../ui/actions/uploads/UploadOrchestrator.kt | 281 ++++++++++++ .../amethyst/ui/components/CashuRedeem.kt | 1 - .../amethyst/ui/components/LoadUrlPreview.kt | 5 +- .../ui/components/LoadingAnimation.kt | 97 ++++ .../amethyst/ui/components/MediaCompressor.kt | 191 ++++---- .../ui/components/NonlazyGridPreview.kt | 143 ++++++ .../amethyst/ui/components/RichTextViewer.kt | 3 +- .../amethyst/ui/components/VideoView.kt | 37 +- .../ui/components/ZoomableContentDialog.kt | 4 +- .../ui/components/ZoomableContentView.kt | 38 +- .../markdown/MarkdownMediaRenderer.kt | 5 +- .../ui/navigation/FeedFilterSpinner.kt | 2 +- .../amethyst/ui/note/NoteCompose.kt | 22 +- .../amethyst/ui/note/types/AudioTrack.kt | 19 +- .../amethyst/ui/note/types/FileHeader.kt | 5 +- .../amethyst/ui/note/types/FileStorage.kt | 9 +- .../amethyst/ui/note/types/LiveActivity.kt | 3 +- .../amethyst/ui/note/types/PictureDisplay.kt | 54 +-- .../amethyst/ui/note/types/Video.kt | 4 +- .../amethyst/ui/note/types/VideoDisplay.kt | 5 +- .../ui/screen/loggedIn/AccountViewModel.kt | 2 +- .../ui/screen/loggedIn/NewPostScreen.kt | 186 ++------ .../loggedIn/chatrooms/ChannelScreen.kt | 19 +- .../loggedIn/chatrooms/ChatroomScreen.kt | 19 +- .../loggedIn/threadview/ThreadFeedView.kt | 14 +- .../screen/loggedIn/video/NewImageButton.kt | 272 ++++-------- .../ui/screen/loggedIn/video/VideoScreen.kt | 10 +- .../ui/screen/loggedOff/LoginScreen.kt | 2 +- amethyst/src/main/res/values/strings.xml | 15 + .../amethyst/service/Nip96Test.kt | 5 +- .../amethyst/benchmark/BlurhashBenchmark.kt | 2 +- .../amethyst/commons/preview/BlurhashTest.kt | 32 ++ commons/src/androidTest/resources/1x1.png | Bin 0 -> 90 bytes commons/src/androidTest/resources/black.png | Bin 0 -> 233 bytes .../src/androidTest/resources/lorikeet.jpg | Bin 0 -> 26369 bytes commons/src/androidTest/resources/white.png | Bin 0 -> 391 bytes .../amethyst/commons/blurhash/Base83.kt | 90 ++++ .../amethyst/commons/blurhash/BitmapUtils.kt | 61 +++ .../{preview => blurhash}/BlurHashDecoder.kt | 212 +-------- .../BlurHashDecoderOld.kt | 2 +- .../commons/blurhash/BlurHashEncoder.kt | 143 ++++++ .../amethyst/commons/blurhash/CosineCache.kt | 81 ++++ .../amethyst/commons/blurhash/SRGB.kt | 56 +++ .../commons/richtext/MediaContentModels.kt | 2 +- .../commons/richtext/RichTextParser.kt | 4 +- .../amethyst/commons/Base83Test.kt | 82 ++++ .../amethyst/commons/SRGBTest.kt | 42 ++ gradle/libs.versions.toml | 2 - .../quartz/encoders/Dimension.kt | 50 +++ .../quartz/encoders/Nip54InlineMetadata.kt | 29 +- .../quartz/encoders/Nip92MediaAttachments.kt | 145 +++--- .../quartz/events/ChannelMessageEvent.kt | 11 +- .../quartz/events/ChatMessageEvent.kt | 11 +- .../quartz/events/ClassifiedsEvent.kt | 10 +- .../quartz/events/CommentEvent.kt | 23 +- .../quartz/events/FileHeaderEvent.kt | 60 ++- .../quartz/events/FileStorageHeaderEvent.kt | 1 + .../quartz/events/GitReplyEvent.kt | 11 +- .../events/InteractiveStoryBaseEvent.kt | 11 +- .../events/InteractiveStoryPrologueEvent.kt | 5 +- .../events/InteractiveStorySceneEvent.kt | 5 +- .../events/LiveActivitiesChatMessageEvent.kt | 11 +- .../quartz/events/NIP17Factory.kt | 5 +- .../quartz/events/PictureEvent.kt | 32 +- .../quartz/events/PollNoteEvent.kt | 11 +- .../quartz/events/PrivateDmEvent.kt | 21 +- .../quartz/events/ProfileGalleryEntryEvent.kt | 1 + .../quartz/events/TextNoteEvent.kt | 11 +- .../quartz/events/TorrentCommentEvent.kt | 11 +- .../vitorpamplona/quartz/events/VideoEvent.kt | 1 + .../quartz/events/VideoHorizontalEvent.kt | 1 + .../quartz/events/VideoVerticalEvent.kt | 1 + 100 files changed, 3260 insertions(+), 2503 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/CombinedUploader.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/service/{ => uploads}/FileHeader.kt (58%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaUploadResult.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/service/{ => uploads/blossom}/BlossomUploader.kt (72%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Result.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/service/{ => uploads/nip96}/Nip96Uploader.kt (78%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/ServerInfo.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/service/{Nip96MediaServers.kt => uploads/nip96/ServerInfoRetriever.kt} (70%) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageDownloader.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ImageDownloader.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/{UploadFromGallery.kt => uploads/SelectFromGallery.kt} (56%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectedMediaProcessing.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ShowImageUploadItem.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TakePicture.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/UploadOrchestrator.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadingAnimation.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/NonlazyGridPreview.kt create mode 100644 commons/src/androidTest/resources/1x1.png create mode 100644 commons/src/androidTest/resources/black.png create mode 100644 commons/src/androidTest/resources/lorikeet.jpg create mode 100644 commons/src/androidTest/resources/white.png create mode 100644 commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt create mode 100644 commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt rename commons/src/main/java/com/vitorpamplona/amethyst/commons/{preview => blurhash}/BlurHashDecoder.kt (50%) rename commons/src/main/java/com/vitorpamplona/amethyst/commons/{preview => blurhash}/BlurHashDecoderOld.kt (99%) create mode 100644 commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt create mode 100644 commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt create mode 100644 commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/SRGB.kt create mode 100644 commons/src/test/java/com/vitorpamplona/amethyst/commons/Base83Test.kt create mode 100644 commons/src/test/java/com/vitorpamplona/amethyst/commons/SRGBTest.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/encoders/Dimension.kt diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 34bda279a..20bc1e955 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -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 diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt index 23361d8f0..304e8d7d4 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt @@ -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, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ParallelUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ParallelUtils.kt index 629b93706..a157eb033 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ParallelUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ParallelUtils.kt @@ -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 launchAndWaitAll( /** * Runs the function and waits for 10 seconds for any result. */ -suspend inline fun tryAndWait(crossinline asyncFunc: (CancellableContinuation) -> Unit): T? = - withTimeoutOrNull(10000) { - suspendCancellableCoroutine { continuation -> +suspend inline fun tryAndWait( + timeoutMillis: Long = 10000, + crossinline asyncFunc: (Continuation) -> Unit, +): T? = + withTimeoutOrNull(timeoutMillis) { + suspendCoroutine { continuation -> asyncFunc(continuation) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index edd4da899..f290d5cb0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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, + caption: String?, + sensitiveContent: Boolean, + relayList: List, + 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, geohash: String? = null, - nip94attachments: List? = null, + imetas: List? = 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, geohash: String? = null, - nip94attachments: List? = null, + imetas: List? = 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, geohash: String? = null, - nip94attachments: List? = null, + imetas: List? = 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 = emptySet(), directMentionsNotes: Set = emptySet(), - nip94attachments: List? = null, + imetas: List? = null, geohash: String? = null, zapReceiver: List? = 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 = emptySet(), directMentionsNotes: Set = emptySet(), - nip94attachments: List? = null, + imetas: List? = null, zapReceiver: List? = 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? = null, wantsToMarkAsSensitive: Boolean = false, zapRaiserAmount: Long? = null, - nip94attachments: List? = null, + imetas: List? = null, draftTag: String? = null, relayList: List, ) { @@ -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? = null, wantsToMarkAsSensitive: Boolean = false, zapRaiserAmount: Long? = null, - nip94attachments: List? = null, + imetas: List? = null, draftTag: String? = null, relayList: List, ) { @@ -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, geohash: String? = null, - nip94attachments: List? = null, + imetas: List? = 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, geohash: String? = null, - nip94attachments: List? = null, + imetas: List? = 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? = null, + imetas: List? = 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? = null, + imetas: List? = 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? = null, + imetas: List? = 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? = null, + imetas: List? = 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? = null, + imetas: List? = 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, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index 3ae7669db..5e05d37dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -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, + continuation: Continuation, signer: NostrSigner, ) { val nwcRequest = paymentRequest.event as? LnZapPaymentRequestEvent @@ -644,7 +644,7 @@ open class Note( private fun processZapAmountFromResponse( nwcRequest: LnZapPaymentRequestEvent, nwcResponse: LnZapPaymentResponseEvent, - continuation: CancellableContinuation, + continuation: Continuation, signer: NostrSigner, ) { // if we can decrypt the reply diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt index 8d795917d..96cc3044a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt index 468735a4a..f9c2103ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt @@ -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), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/CombinedUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/CombinedUploader.kt new file mode 100644 index 000000000..a7b553038 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/CombinedUploader.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt similarity index 58% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt index d091976dc..eb7813763 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.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 = 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 = 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) } - } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaUploadResult.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaUploadResult.kt new file mode 100644 index 000000000..b0d4eccf6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaUploadResult.kt @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.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 / 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, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/BlossomUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt similarity index 72% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/BlossomUploader.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt index 215f029f4..bcd603766 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/BlossomUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.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 / 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, -) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Result.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Result.kt new file mode 100644 index 000000000..9254d2b22 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Result.kt @@ -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>? = null, + val content: String? = null, +) + +data class DeleteResult( + val status: String?, + val message: String?, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt similarity index 78% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt index 589af0b1f..b26201195 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.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 = ('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>? = 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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/ServerInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/ServerInfo.kt new file mode 100644 index 000000000..5f811b4ec --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/ServerInfo.kt @@ -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 = arrayListOf(), + @JsonProperty("tos_url") val + tosUrl: String? = null, + @JsonProperty("content_types") val + contentTypes: ArrayList = arrayListOf(), + @JsonProperty("plans") val + plans: Map = 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 = arrayListOf(), + @JsonProperty("media_transformations") + val mediaTransformations: Map> = emptyMap(), +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96MediaServers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/ServerInfoRetriever.kt similarity index 70% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96MediaServers.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/ServerInfoRetriever.kt index be508d732..e2a00695e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96MediaServers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/ServerInfoRetriever.kt @@ -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 = 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 = arrayListOf(), - @JsonProperty("tos_url") val tosUrl: String? = null, - @JsonProperty("content_types") val contentTypes: ArrayList = arrayListOf(), - @JsonProperty("plans") val plans: Map = 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 = arrayListOf(), - @JsonProperty("media_transformations") - val mediaTransformations: Map> = emptyMap(), - ) - +class ServerInfoRetriever { fun parse( baseUrl: String, body: String, @@ -138,7 +102,3 @@ class Nip96Retriever { potentialyRelativeUrl } } - -typealias PlanName = String - -typealias MimeType = String diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt index b1b935718..a83f4e1e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt index 408c31a6a..48d0e828a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt @@ -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>(emptyList()) + var iMetaAttachments by mutableStateOf>(emptyList()) var nip95attachments by mutableStateOf>>(emptyList()) @@ -78,7 +77,7 @@ open class EditPostViewModel : ViewModel() { var userSuggestionsMainMessage: UserSuggestionAnchor? = null // Images and Videos - var contentToAddUrl by mutableStateOf(null) + var mediaToUpload by mutableStateOf>(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) { + 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() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageDownloader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageDownloader.kt deleted file mode 100644 index 6a2fb7408..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageDownloader.kt +++ /dev/null @@ -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 - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt index 73b1819fc..a18456d8f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt @@ -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(null) var selectedServer by mutableStateOf(null) - var alt by mutableStateOf("") + var caption by mutableStateOf("") var sensitiveContent by mutableStateOf(false) // Images and Videos - var galleryUri by mutableStateOf(null) - - var uploadingPercentage = mutableStateOf(0.0f) - var uploadingDescription = mutableStateOf(null) - + var mediaToUpload by mutableStateOf>(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, ) { + 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, - 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, - 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, - 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] diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt index c4dc90027..9fd5bebca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt @@ -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, 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(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, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 58d9292d7..e47427a74 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -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?>(null) var eTags by mutableStateOf?>(null) - var nip94attachments by mutableStateOf>(emptyList()) + var iMetaAttachments by mutableStateOf>(emptyList()) var nip95attachments by mutableStateOf>>(emptyList()) @@ -126,7 +127,7 @@ open class NewPostViewModel : ViewModel() { var subject by mutableStateOf(TextFieldValue("")) // Images and Videos - var contentToAddUrl by mutableStateOf(null) + var mediaToUpload by mutableStateOf>(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) { + mediaToUpload = uris.map { SelectedMediaProcessing(it) }.toImmutableList() } fun locationFlow(): StateFlow { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt index a7aeaeeb1..0772cf52f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataView.kt @@ -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), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt index 608b1e459..1ae7a9ef0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -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) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ImageDownloader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ImageDownloader.kt new file mode 100644 index 000000000..16ac11454 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ImageDownloader.kt @@ -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 + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt similarity index 56% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt index 66ac7d264..021dad965 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt @@ -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) -> 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 = 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) -> 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 -> + 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) }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectedMediaProcessing.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectedMediaProcessing.kt new file mode 100644 index 000000000..0ebb21287 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectedMediaProcessing.kt @@ -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(), +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ShowImageUploadItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ShowImageUploadItem.kt new file mode 100644 index 000000000..4f13aedeb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ShowImageUploadItem.kt @@ -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, + 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(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, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TakePicture.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TakePicture.kt new file mode 100644 index 000000000..e482f68fb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/TakePicture.kt @@ -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) -> 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) -> Unit) { + val context = LocalContext.current + var cameraUri by remember { mutableStateOf(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, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/UploadOrchestrator.kt new file mode 100644 index 000000000..6272cd39e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/UploadOrchestrator.kt @@ -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, + ) : UploadingFinalState() +} + +class UploadOrchestrator { + private val compressor = MediaCompressor() + + val progress = MutableStateFlow(0.0) + val progressState = MutableStateFlow(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) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt index 5123cbc39..47f87f4bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt index f1105390f..4fbd81863 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt @@ -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, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadingAnimation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadingAnimation.kt new file mode 100644 index 000000000..c1245cbff --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadingAnimation.kt @@ -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 = 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, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MediaCompressor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MediaCompressor.kt index 96b6c8ecc..64a3ee8a0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MediaCompressor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MediaCompressor.kt @@ -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 { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/NonlazyGridPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/NonlazyGridPreview.kt new file mode 100644 index 000000000..4613d550f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/NonlazyGridPreview.kt @@ -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) + } + } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index b79c1cb76..9c89ed4a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -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) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt index 409e90088..efb59a77b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt @@ -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? = 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? = 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? = 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? = null, keepPlaying: MutableState, @@ -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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index ba2c9e9bc..6413a3876 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 0cf857f8a..53b63b365 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -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 = 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), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt index b35f2e91d..273dbfc83 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/MarkdownMediaRenderer.kt @@ -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, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/FeedFilterSpinner.kt index f14e03041..0f7af14ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/FeedFilterSpinner.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index b3ee8c494..32b23c23d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt index d439a5285..a76052a01 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt @@ -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(), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt index 663f01d06..df65fde27 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt @@ -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, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt index 091424477..176d24d17 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt @@ -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, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt index 673a0a246..8ac38dccc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt @@ -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()}", ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt index 353d3b505..28f7520e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt @@ -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, 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, - )*/ } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt index 55ebe7201..e0a448a45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt @@ -66,7 +66,7 @@ fun VideoDisplay( makeItShort: Boolean, canPreview: Boolean, backgroundColor: MutableState, - 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, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt index 8264f42f6..545455b3b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt @@ -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, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 1085374db..9f06e9b26 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt index 2acdb1887..ad49ac299 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt @@ -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(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(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, 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(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, + ) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChannelScreen.kt index f83c751a2..bafce3983 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChannelScreen.kt @@ -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, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomScreen.kt index 05c9bbdd0..ea6deb6f3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomScreen.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 8fe28c409..6cba1dda9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/NewImageButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/NewImageButton.kt index ddef875df..f95a87de1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/NewImageButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/NewImageButton.kt @@ -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(null) } - - var pickedURI by remember { mutableStateOf(null) } + var pickedURIs by remember { mutableStateOf>(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, - ) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt index 66e272c3c..2d1ef7488 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt @@ -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) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt index 00e3d2ece..cc030afbf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt @@ -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 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 139e40195..d20ebd1e7 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -364,11 +364,15 @@ This content is the same since the post This content has changed. The author might not have seen or approved the change + Add Media Add Image Add Video Add Document Add to Message + Add a Caption + My lovely friend + Description of the contents A blue boat in a white sandy beach at sunset @@ -389,6 +393,8 @@ File Server + Choose a server to upload this file to + LnAddress or @User @@ -404,6 +410,14 @@ Add media server Delete media server + Not Started + Compressing + Uploading + Processing + Downloading + Hashing + Done + Error Your relays (NIP-95) Files are hosted by your relays. New NIP: check if they support @@ -866,6 +880,7 @@ Uploading error: %1$s Server did not provide a URL after uploading Could not download uploaded media from the server + Could not check downloaded file after upload: %1$s Could not prepare local file to upload: %1$s Failed to upload: %1$s Failed to delete: %1$s diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/Nip96Test.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/Nip96Test.kt index b80877c3d..f4d6d0658 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/Nip96Test.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/Nip96Test.kt @@ -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) diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/BlurhashBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/BlurhashBenchmark.kt index a0dbf7394..8210d8ea1 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/BlurhashBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/BlurhashBenchmark.kt @@ -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 diff --git a/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/preview/BlurhashTest.kt b/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/preview/BlurhashTest.kt index d2e14fdc8..cc4b1c848 100644 --- a/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/preview/BlurhashTest.kt +++ b/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/preview/BlurhashTest.kt @@ -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) + } } diff --git a/commons/src/androidTest/resources/1x1.png b/commons/src/androidTest/resources/1x1.png new file mode 100644 index 0000000000000000000000000000000000000000..2ce4ee021abb68c511f2788d7dfce16216fe5b5c GIT binary patch literal 90 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1SBVv2j2ryoCO|{#S9F3${@^GvDCf{D9GdK j;uyjqoBZSde|rYje@y@8pVAcuiZgh+`njxgN@xNA@I(}? literal 0 HcmV?d00001 diff --git a/commons/src/androidTest/resources/black.png b/commons/src/androidTest/resources/black.png new file mode 100644 index 0000000000000000000000000000000000000000..9862a47d293db9ae1ccc821e6aab61b9944f727d GIT binary patch literal 233 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 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 + } + } +} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt new file mode 100644 index 000000000..0457e7091 --- /dev/null +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt @@ -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, + ) +} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/preview/BlurHashDecoder.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt similarity index 50% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/preview/BlurHashDecoder.kt rename to commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt index 18ba0a61c..0548d763f 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/preview/BlurHashDecoder.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt @@ -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() - private val cacheCosinesY = HashMap() - - /** - * 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 { - 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 { - 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 - } - } } diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/preview/BlurHashDecoderOld.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoderOld.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/preview/BlurHashDecoderOld.kt rename to commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoderOld.kt index ee2429ce2..81fda02c1 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/preview/BlurHashDecoderOld.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoderOld.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.preview +package com.vitorpamplona.amethyst.commons.blurhash import android.graphics.Bitmap import android.graphics.Color diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt new file mode 100644 index 000000000..c48b5e0a5 --- /dev/null +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt @@ -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, + 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) + } +} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt new file mode 100644 index 000000000..5f18cbaf5 --- /dev/null +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt @@ -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(20) + private val cacheCosinesY = LruCache(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]!! + } +} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/SRGB.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/SRGB.kt new file mode 100644 index 000000000..e2cb5df15 --- /dev/null +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/SRGB.kt @@ -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) + } + } + } +} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt index 16d564099..fbff4758e 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index 88afa739f..36ac18a77 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -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] diff --git a/commons/src/test/java/com/vitorpamplona/amethyst/commons/Base83Test.kt b/commons/src/test/java/com/vitorpamplona/amethyst/commons/Base83Test.kt new file mode 100644 index 000000000..8692361f8 --- /dev/null +++ b/commons/src/test/java/com/vitorpamplona/amethyst/commons/Base83Test.kt @@ -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~~~")) + } +} diff --git a/commons/src/test/java/com/vitorpamplona/amethyst/commons/SRGBTest.kt b/commons/src/test/java/com/vitorpamplona/amethyst/commons/SRGBTest.kt new file mode 100644 index 000000000..0fe89dd49 --- /dev/null +++ b/commons/src/test/java/com/vitorpamplona/amethyst/commons/SRGBTest.kt @@ -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) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c876d2939..960b6fa3e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Dimension.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Dimension.kt new file mode 100644 index 000000000..99bcbb980 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Dimension.kt @@ -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 + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip54InlineMetadata.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip54InlineMetadata.kt index f49529869..d2bd7d9d5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip54InlineMetadata.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip54InlineMetadata.kt @@ -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>, + url: String, + tags: Map, ): 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" } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip92MediaAttachments.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip92MediaAttachments.kt index f98f1e9c4..52a13e456 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip92MediaAttachments.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip92MediaAttachments.kt @@ -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, +) + +class IMetaTagBuilder( + val url: String, +) { + val properties = mutableMapOf() + + 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? { - val myUrl = header.url() ?: return null - return createTag( - myUrl, - header.tags, - ) - } + fun createTag(header: IMetaTag): Array = + createTag( + header.url, + header.properties, + ) - fun createTag( - imageUrl: String, - tags: Array>, - ): Array = - 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, + ): Array = + 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>, + ): Map = + tags + .firstOrNull { + it.size > 1 && it[0] == IMETA && it[1] == "url $url" + }?.let { tagList -> + parseIMeta(tagList) + } ?: emptyMap() + + fun parse(tags: Array>): Map> = + tags.filter { it.size > 1 && it[0] == IMETA }.associate { + val allTags = parseIMeta(it) + (allTags.get("url") ?: "") to allTags + } + + private fun parseIMeta(tags: Array): Map = + 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>, - ): Map = - tags - .firstOrNull { - it.size > 1 && it[0] == IMETA && it[1] == "url $imageUrl" - }?.let { tagList -> - parseIMeta(tagList) - } ?: emptyMap() - - fun parse(tags: Array>): Map> = - tags.filter { it.size > 1 && it[0] == IMETA }.associate { - val allTags = parseIMeta(it) - (allTags.get("url") ?: "") to allTags - } - - fun parseIMeta(tags: Array): Map = - tags.associate { tag -> - val parts = tag.split(" ", limit = 2) - when (parts.size) { - 2 -> parts[0] to parts[1] - 1 -> parts[0] to "" - else -> "" to "" - } - } + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt index c4facbcbb..5ec8b05ba 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt @@ -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? = null, + imetas: List? = 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), diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt index 0ca280e80..b991e06f2 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt @@ -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? = null, + imetas: List? = 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)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt index bcd9def65..5a1aadf6b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt @@ -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? = null, + imetas: List? = 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)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/CommentEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/CommentEvent.kt index bac10e590..2e8e4c509 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/CommentEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/CommentEvent.kt @@ -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 = emptySet(), addressesMentioned: Set = emptySet(), eventsMentioned: Set = emptySet(), - nip94attachments: List? = null, + imetas: List? = null, geohash: String? = null, zapReceiver: List? = 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 = emptySet(), addressesMentioned: Set = emptySet(), eventsMentioned: Set = emptySet(), - nip94attachments: List? = null, + imetas: List? = null, geohash: String? = null, zapReceiver: List? = 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 = emptySet(), addressesMentioned: Set = emptySet(), eventsMentioned: Set = emptySet(), - nip94attachments: List? = null, + imetas: List? = null, zapReceiver: List? = 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 = emptySet(), addressesMentioned: Set = emptySet(), eventsMentioned: Set = emptySet(), - nip94attachments: List? = null, + imetas: List? = null, geohash: String? = null, zapReceiver: List? = 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) { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt index 6e9b6dc45..ef136101a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt @@ -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> = + 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) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageHeaderEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageHeaderEvent.kt index c58bbed08..2b3185b3a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageHeaderEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageHeaderEvent.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GitReplyEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GitReplyEvent.kt index ed1236899..1757be562 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/GitReplyEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GitReplyEvent.kt @@ -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 = emptySet(), geohash: String? = null, - nip94attachments: List? = null, + imetas: List? = 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")) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStoryBaseEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStoryBaseEvent.kt index 0388713ee..bda994aed 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStoryBaseEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStoryBaseEvent.kt @@ -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? = null, + imetas: List? = null, ): Array> { val tags = mutableListOf>() 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() } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStoryPrologueEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStoryPrologueEvent.kt index 704969ca6..f75f19a6e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStoryPrologueEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStoryPrologueEvent.kt @@ -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? = null, + imetas: List? = 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) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStorySceneEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStorySceneEvent.kt index cc6961df7..45807eeee 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStorySceneEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/InteractiveStorySceneEvent.kt @@ -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? = null, + imetas: List? = 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) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt index fef2cbfdd..e600a6b87 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt @@ -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? = null, + imetas: List? = 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)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP17Factory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP17Factory.kt index 370df42b7..172d02891 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP17Factory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP17Factory.kt @@ -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? = null, + imetas: List? = 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( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/PictureEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/PictureEvent.kt index 26651b879..97ac47063 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/PictureEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/PictureEvent.kt @@ -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, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt index 0e9039fab..5be47b30d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt @@ -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? = null, + imetas: List? = 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)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateDmEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateDmEvent.kt index b0584c8d4..cdaf38d46 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateDmEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateDmEvent.kt @@ -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? = null, + imetas: List? = 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)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ProfileGalleryEntryEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ProfileGalleryEntryEvent.kt index 841edb19b..5024b5115 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ProfileGalleryEntryEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ProfileGalleryEntryEvent.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt index 363cce07a..212307010 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt @@ -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 = emptySet(), geohash: String? = null, - nip94attachments: List? = null, + imetas: List? = 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) { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/TorrentCommentEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/TorrentCommentEvent.kt index cf73407af..21b751b50 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/TorrentCommentEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/TorrentCommentEvent.kt @@ -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 = emptySet(), zapRaiserAmount: Long?, geohash: String? = null, - nip94attachments: List? = null, + imetas: List? = 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)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoEvent.kt index 1855869fa..dab687808 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoEvent.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoHorizontalEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoHorizontalEvent.kt index 0e35d856e..f9a5c38dd 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoHorizontalEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoHorizontalEvent.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoVerticalEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoVerticalEvent.kt index 6b4473495..5abc2a128 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoVerticalEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoVerticalEvent.kt @@ -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