diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt index 53988d943..5c91fcc80 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt @@ -48,7 +48,7 @@ class FileHeaderEvent( privateKey: ByteArray, createdAt: Long = Date().time / 1000 ): FileHeaderEvent { - var tags = listOfNotNull( + val tags = listOfNotNull( listOf(URL, url), mimeType?.let { listOf(MIME_TYPE, mimeType) }, hash?.let { listOf(HASH, it) }, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageEvent.kt new file mode 100644 index 000000000..af25b4152 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageEvent.kt @@ -0,0 +1,58 @@ +package com.vitorpamplona.amethyst.service.model + +import android.util.Log +import com.vitorpamplona.amethyst.model.HexKey +import com.vitorpamplona.amethyst.model.toHexKey +import nostr.postr.Utils +import java.util.Base64 +import java.util.Date + +class FileStorageEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + + fun type() = tags.firstOrNull { it.size > 1 && it[0] == TYPE }?.get(1) + fun decryptKey() = tags.firstOrNull { it.size > 2 && it[0] == DECRYPT }?.let { AESGCM(it[1], it[2]) } + + fun decode(): ByteArray? { + return try { + Base64.getDecoder().decode(content) + } catch (e: Exception) { + Log.e("FileStorageEvent", "Unable to decode base 64 ${e.message} $content") + null + } + } + + companion object { + const val kind = 1064 + + private const val TYPE = "type" + private const val DECRYPT = "decrypt" + + fun encode(bytes: ByteArray): String { + return Base64.getEncoder().encodeToString(bytes) + } + + fun create( + mimeType: String, + data: ByteArray, + privateKey: ByteArray, + createdAt: Long = Date().time / 1000 + ): FileStorageEvent { + val tags = listOfNotNull( + listOf(TYPE, mimeType) + ) + + val content = encode(data) + val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + val id = generateId(pubKey, createdAt, kind, tags, content) + val sig = Utils.sign(id, privateKey) + return FileStorageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageHeaderEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageHeaderEvent.kt new file mode 100644 index 000000000..f7d7f88a8 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageHeaderEvent.kt @@ -0,0 +1,67 @@ +package com.vitorpamplona.amethyst.service.model + +import com.vitorpamplona.amethyst.model.HexKey +import com.vitorpamplona.amethyst.model.toHexKey +import nostr.postr.Utils +import java.util.Date + +class FileStorageHeaderEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + + fun encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) } + fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1) + fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1) + fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1) + fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1) + fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1) + fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1) + + companion object { + const val kind = 1065 + + private const val ENCRYPTION_KEY = "aes-256-gcm" + private const val MIME_TYPE = "m" + private const val FILE_SIZE = "size" + private const val HASH = "x" + private const val MAGNET_URI = "magnet" + private const val TORRENT_INFOHASH = "i" + private const val BLUR_HASH = "blurhash" + + fun create( + storageEvent: FileStorageEvent, + mimeType: String? = null, + description: String? = null, + hash: String? = null, + size: String? = null, + blurhash: String? = null, + magnetURI: String? = null, + torrentInfoHash: String? = null, + encryptionKey: AESGCM? = null, + privateKey: ByteArray, + createdAt: Long = Date().time / 1000 + ): FileStorageHeaderEvent { + val tags = listOfNotNull( + listOf("e", storageEvent.id), + mimeType?.let { listOf(MIME_TYPE, mimeType) }, + hash?.let { listOf(HASH, it) }, + size?.let { listOf(FILE_SIZE, it) }, + blurhash?.let { listOf(BLUR_HASH, it) }, + magnetURI?.let { listOf(MAGNET_URI, it) }, + torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) }, + encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) } + ) + + val content = description ?: "" + val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + val id = generateId(pubKey, createdAt, kind, tags, content) + val sig = Utils.sign(id, privateKey) + return FileStorageHeaderEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt index e8dbca2cf..1da278cbc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt @@ -83,3 +83,63 @@ fun SaveToGallery(url: String) { Text(text = stringResource(id = R.string.save), color = Color.White) } } + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun SaveToGallery(byteArray: ByteArray, mimeType: String?) { + val localContext = LocalContext.current + val scope = rememberCoroutineScope() + + fun saveImage() { + ImageSaver.saveImage( + context = localContext, + byteArray = byteArray, + mimeType = mimeType, + onSuccess = { + scope.launch { + Toast.makeText( + localContext, + localContext.getString(R.string.image_saved_to_the_gallery), + Toast.LENGTH_SHORT + ) + .show() + } + }, + onError = { + scope.launch { + Toast.makeText( + localContext, + localContext.getString(R.string.failed_to_save_the_image), + Toast.LENGTH_SHORT + ) + .show() + } + } + ) + } + + val writeStoragePermissionState = rememberPermissionState( + Manifest.permission.WRITE_EXTERNAL_STORAGE + ) { isGranted -> + if (isGranted) { + saveImage() + } + } + + Button( + onClick = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || writeStoragePermissionState.status.isGranted) { + saveImage() + } else { + writeStoragePermissionState.launchPermissionRequest() + } + }, + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults + .buttonColors( + backgroundColor = Color.Gray + ) + ) { + Text(text = stringResource(id = R.string.save), color = Color.White) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index c2ed2dfb0..ee78c5bee 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -142,9 +142,9 @@ fun RichTextViewer( val imagesForPager = urlSet.mapNotNull { fullUrl -> val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { - ZoomableImage(fullUrl) + ZoomableUrlImage(fullUrl) } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { - ZoomableVideo(fullUrl) + ZoomableUrlVideo(fullUrl) } else { null } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt index 21b0efb53..59c7ec844 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt @@ -95,3 +95,7 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) - } } } + +@Composable +fun VideoView(videoBytes: ByteArray, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) { +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index cc840bf07..55292064d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -6,6 +6,7 @@ import android.widget.Toast import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -73,23 +74,47 @@ import net.engawapg.lib.zoomable.zoomable import java.security.MessageDigest abstract class ZoomableContent( - val url: String, - val description: String? = null, - val hash: String? = null + val description: String? = null ) -class ZoomableImage( +abstract class ZoomableUrlContent( + val url: String, + description: String? = null, + val hash: String? = null +) : ZoomableContent(description) + +class ZoomableUrlImage( url: String, description: String? = null, hash: String? = null, val bluehash: String? = null -) : ZoomableContent(url, description, hash) +) : ZoomableUrlContent(url, description, hash) -class ZoomableVideo( +class ZoomableUrlVideo( url: String, description: String? = null, hash: String? = null -) : ZoomableContent(url, description, hash) +) : ZoomableUrlContent(url, description, hash) + +abstract class ZoomablePreloadedContent( + description: String? = null, + val isVerified: Boolean? = null +) : ZoomableContent(description) + +class ZoomableBitmapImage( + val byteArray: ByteArray?, + val mimeType: String? = null, + description: String? = null, + val bluehash: String? = null, + isVerified: Boolean? = null +) : ZoomablePreloadedContent(description, isVerified) + +class ZoomableBytesVideo( + val byteArray: ByteArray, + val mimeType: String? = null, + description: String? = null, + isVerified: Boolean? = null +) : ZoomablePreloadedContent(description, isVerified) fun figureOutMimeType(fullUrl: String): ZoomableContent { val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() @@ -97,11 +122,11 @@ fun figureOutMimeType(fullUrl: String): ZoomableContent { val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } return if (isImage) { - ZoomableImage(fullUrl) + ZoomableUrlImage(fullUrl) } else if (isVideo) { - ZoomableVideo(fullUrl) + ZoomableUrlVideo(fullUrl) } else { - ZoomableImage(fullUrl) + ZoomableUrlImage(fullUrl) } } @@ -126,15 +151,33 @@ fun ZoomableContentView(content: ZoomableContent, images: List mutableStateOf(null) } - LaunchedEffect(key1 = content.url, key2 = imageState) { - if (imageState is AsyncImagePainter.State.Success) { - scope.launch(Dispatchers.IO) { - verifiedHash = verifyHash(content, context) + if (content is ZoomableUrlContent) { + LaunchedEffect(key1 = content.url, key2 = imageState) { + if (imageState is AsyncImagePainter.State.Success) { + scope.launch(Dispatchers.IO) { + verifiedHash = verifyHash(content, context) + } + } + } + } else if (content is ZoomableBitmapImage) { + LaunchedEffect(key1 = content.byteArray, key2 = imageState) { + if (imageState is AsyncImagePainter.State.Success) { + scope.launch(Dispatchers.IO) { + verifiedHash = content.isVerified + } + } + } + } else if (content is ZoomableBytesVideo) { + LaunchedEffect(key1 = content.byteArray, key2 = imageState) { + if (imageState is AsyncImagePainter.State.Success) { + scope.launch(Dispatchers.IO) { + verifiedHash = content.isVerified + } } } } - val mainImageModifier = Modifier + var mainImageModifier = Modifier .fillMaxWidth() .clip(shape = RoundedCornerShape(15.dp)) .border( @@ -142,12 +185,19 @@ fun ZoomableContentView(content: ZoomableContent, images: List MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp) ) - .combinedClickable( + + if (content is ZoomableUrlContent) { + mainImageModifier = mainImageModifier.combinedClickable( onClick = { dialogOpen = true }, onLongClick = { clipboardManager.setText(AnnotatedString(content.url)) } ) + } else { + mainImageModifier = mainImageModifier.clickable { + dialogOpen = true + } + } - if (content is ZoomableImage) { + if (content is ZoomableUrlImage) { Box() { AsyncImage( model = content.url, @@ -174,8 +224,36 @@ fun ZoomableContentView(content: ZoomableContent, images: List DisplayUrlWithLoadingSymbol(content) } } - } else { + } else if (content is ZoomableUrlVideo) { VideoView(content.url, content.description) { dialogOpen = true } + } else if (content is ZoomableBitmapImage) { + Box() { + AsyncImage( + model = content.byteArray, + contentDescription = content.description, + contentScale = ContentScale.FillWidth, + modifier = mainImageModifier, + onLoading = { + imageState = it + }, + onSuccess = { + imageState = it + } + ) + + if (imageState is AsyncImagePainter.State.Success) { + HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd)) + } + } + + if (imageState !is AsyncImagePainter.State.Success) { + if (content.bluehash != null) { + DisplayBlueHash(content, mainImageModifier) + } else { + DisplayUrlWithLoadingSymbol(content) + } + } + } else if (content is ZoomableBytesVideo) { } if (dialogOpen) { @@ -185,7 +263,11 @@ fun ZoomableContentView(content: ZoomableContent, images: List @Composable private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) { - ClickableUrl(urlText = "${content.url} ", url = content.url) + if (content is ZoomableUrlContent) { + ClickableUrl(urlText = "${content.url} ", url = content.url) + } else { + Text("Loading content... ") + } val myId = "inlineContent" val emptytext = buildAnnotatedString { @@ -220,7 +302,26 @@ private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) { @Composable private fun DisplayBlueHash( - content: ZoomableImage, + content: ZoomableUrlImage, + modifier: Modifier +) { + if (content.bluehash == null) return + + val context = LocalContext.current + AsyncImage( + model = BlurHashRequester.imageRequest( + context, + content.bluehash + ), + contentDescription = content.description, + contentScale = ContentScale.FillWidth, + modifier = modifier + ) +} + +@Composable +private fun DisplayBlueHash( + content: ZoomableBitmapImage, modifier: Modifier ) { if (content.bluehash == null) return @@ -264,7 +365,12 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List 1) { @@ -297,15 +403,33 @@ private fun RenderImageOrVideo(content: ZoomableContent) { mutableStateOf(null) } - LaunchedEffect(key1 = content.url, key2 = imageState) { - if (imageState is AsyncImagePainter.State.Success) { - scope.launch(Dispatchers.IO) { - verifiedHash = verifyHash(content, context) + if (content is ZoomableUrlContent) { + LaunchedEffect(key1 = content.url, key2 = imageState) { + if (imageState is AsyncImagePainter.State.Success) { + scope.launch(Dispatchers.IO) { + verifiedHash = verifyHash(content, context) + } + } + } + } else if (content is ZoomableBitmapImage) { + LaunchedEffect(key1 = content.byteArray, key2 = imageState) { + if (imageState is AsyncImagePainter.State.Success) { + scope.launch(Dispatchers.IO) { + verifiedHash = content.isVerified + } + } + } + } else if (content is ZoomableBytesVideo) { + LaunchedEffect(key1 = content.byteArray, key2 = imageState) { + if (imageState is AsyncImagePainter.State.Success) { + scope.launch(Dispatchers.IO) { + verifiedHash = content.isVerified + } } } } - if (content is ZoomableImage) { + if (content is ZoomableUrlImage) { Box() { AsyncImage( model = content.url, @@ -327,15 +451,42 @@ private fun RenderImageOrVideo(content: ZoomableContent) { HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd)) } } - } else { + } else if (content is ZoomableUrlVideo) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { VideoView(content.url, content.description) } + } else if (content is ZoomableBitmapImage) { + Box() { + AsyncImage( + model = content.byteArray, + contentDescription = content.description, + contentScale = ContentScale.FillWidth, + modifier = Modifier + .fillMaxSize() + .zoomable(rememberZoomState()), + onLoading = { + imageState = it + }, + onSuccess = { + imageState = it + } + ) + + if (imageState !is AsyncImagePainter.State.Success) { + DisplayBlueHash(content = content, modifier = Modifier.fillMaxWidth()) + } else { + HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd)) + } + } + } else if (content is ZoomableBytesVideo) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { + VideoView(content.byteArray, content.description) + } } } @OptIn(ExperimentalCoilApi::class) -private suspend fun verifyHash(content: ZoomableContent, context: Context): Boolean? { +private suspend fun verifyHash(content: ZoomableUrlContent, context: Context): Boolean? { if (content.hash == null) return null context.imageLoader.diskCache?.get(content.url)?.use { snapshot -> diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index cb53c7175..7f463a827 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -169,6 +169,8 @@ fun NoteComposeInner( BadgeDisplay(baseNote = note) } else if (noteEvent is FileHeaderEvent) { FileHeaderDisplay(note) + } else if (noteEvent is FileStorageHeaderEvent) { + FileStorageHeaderDisplay(note) } else { var isNew by remember { mutableStateOf(false) } @@ -794,9 +796,9 @@ fun FileHeaderDisplay(note: Note) { val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } content = if (isImage) { - ZoomableImage(fullUrl, description, hash, blurHash) + ZoomableUrlImage(fullUrl, description, hash, blurHash) } else { - ZoomableVideo(fullUrl, description, hash) + ZoomableUrlVideo(fullUrl, description, hash) } } } @@ -806,6 +808,42 @@ fun FileHeaderDisplay(note: Note) { } ?: UrlPreview(fullUrl, "$fullUrl ") } +@Composable +fun FileStorageHeaderDisplay(baseNote: Note) { + val fileNote = baseNote.replyTo?.firstOrNull() ?: return + + val noteState by fileNote.live().metadata.observeAsState() + val note = noteState?.note + + val eventBytes = (note?.event as? FileStorageEvent) + val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return + + var content by remember { mutableStateOf(null) } + + LaunchedEffect(key1 = eventHeader.id) { + withContext(Dispatchers.IO) { + val bytes = eventBytes?.decode() + val blurHash = eventHeader.blurhash() + val description = eventHeader.content + val mimeType = eventHeader.mimeType() + + content = if (mimeType?.startsWith("image") == true) { + ZoomableBitmapImage(bytes, mimeType, description, blurHash, true) + } else { + if (bytes != null) { + ZoomableBytesVideo(bytes, mimeType, description, true) + } else { + null + } + } + } + } + + content?.let { + ZoomableContentView(content = it, listOf(it)) + } ?: BlankNote() +} + @Composable private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: User) { Row(