Adds support for NIP-95 Images

This commit is contained in:
Vitor Pamplona 2023-04-26 14:22:49 -04:00
parent c6a0b0950a
commit 86fe9b4a65
8 changed files with 411 additions and 33 deletions

View File

@ -48,7 +48,7 @@ class FileHeaderEvent(
privateKey: ByteArray, privateKey: ByteArray,
createdAt: Long = Date().time / 1000 createdAt: Long = Date().time / 1000
): FileHeaderEvent { ): FileHeaderEvent {
var tags = listOfNotNull( val tags = listOfNotNull(
listOf(URL, url), listOf(URL, url),
mimeType?.let { listOf(MIME_TYPE, mimeType) }, mimeType?.let { listOf(MIME_TYPE, mimeType) },
hash?.let { listOf(HASH, it) }, hash?.let { listOf(HASH, it) },

View File

@ -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<List<String>>,
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())
}
}
}

View File

@ -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<List<String>>,
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())
}
}
}

View File

@ -83,3 +83,63 @@ fun SaveToGallery(url: String) {
Text(text = stringResource(id = R.string.save), color = Color.White) 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)
}
}

View File

@ -142,9 +142,9 @@ fun RichTextViewer(
val imagesForPager = urlSet.mapNotNull { fullUrl -> val imagesForPager = urlSet.mapNotNull { fullUrl ->
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
ZoomableImage(fullUrl) ZoomableUrlImage(fullUrl)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
ZoomableVideo(fullUrl) ZoomableUrlVideo(fullUrl)
} else { } else {
null null
} }

View File

@ -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) {
}

View File

@ -6,6 +6,7 @@ import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -73,23 +74,47 @@ import net.engawapg.lib.zoomable.zoomable
import java.security.MessageDigest import java.security.MessageDigest
abstract class ZoomableContent( abstract class ZoomableContent(
val url: String, val description: String? = null
val description: String? = null,
val hash: String? = null
) )
class ZoomableImage( abstract class ZoomableUrlContent(
val url: String,
description: String? = null,
val hash: String? = null
) : ZoomableContent(description)
class ZoomableUrlImage(
url: String, url: String,
description: String? = null, description: String? = null,
hash: String? = null, hash: String? = null,
val bluehash: String? = null val bluehash: String? = null
) : ZoomableContent(url, description, hash) ) : ZoomableUrlContent(url, description, hash)
class ZoomableVideo( class ZoomableUrlVideo(
url: String, url: String,
description: String? = null, description: String? = null,
hash: 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 { fun figureOutMimeType(fullUrl: String): ZoomableContent {
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
@ -97,11 +122,11 @@ fun figureOutMimeType(fullUrl: String): ZoomableContent {
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
return if (isImage) { return if (isImage) {
ZoomableImage(fullUrl) ZoomableUrlImage(fullUrl)
} else if (isVideo) { } else if (isVideo) {
ZoomableVideo(fullUrl) ZoomableUrlVideo(fullUrl)
} else { } else {
ZoomableImage(fullUrl) ZoomableUrlImage(fullUrl)
} }
} }
@ -126,6 +151,7 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
mutableStateOf<Boolean?>(null) mutableStateOf<Boolean?>(null)
} }
if (content is ZoomableUrlContent) {
LaunchedEffect(key1 = content.url, key2 = imageState) { LaunchedEffect(key1 = content.url, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) { if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
@ -133,8 +159,25 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
} }
} }
} }
} 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() .fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp)) .clip(shape = RoundedCornerShape(15.dp))
.border( .border(
@ -142,12 +185,19 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
MaterialTheme.colors.onSurface.copy(alpha = 0.12f), MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp) RoundedCornerShape(15.dp)
) )
.combinedClickable(
if (content is ZoomableUrlContent) {
mainImageModifier = mainImageModifier.combinedClickable(
onClick = { dialogOpen = true }, onClick = { dialogOpen = true },
onLongClick = { clipboardManager.setText(AnnotatedString(content.url)) } onLongClick = { clipboardManager.setText(AnnotatedString(content.url)) }
) )
} else {
mainImageModifier = mainImageModifier.clickable {
dialogOpen = true
}
}
if (content is ZoomableImage) { if (content is ZoomableUrlImage) {
Box() { Box() {
AsyncImage( AsyncImage(
model = content.url, model = content.url,
@ -174,8 +224,36 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
DisplayUrlWithLoadingSymbol(content) DisplayUrlWithLoadingSymbol(content)
} }
} }
} else { } else if (content is ZoomableUrlVideo) {
VideoView(content.url, content.description) { dialogOpen = true } 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) { if (dialogOpen) {
@ -185,7 +263,11 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
@Composable @Composable
private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) { private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
if (content is ZoomableUrlContent) {
ClickableUrl(urlText = "${content.url} ", url = content.url) ClickableUrl(urlText = "${content.url} ", url = content.url)
} else {
Text("Loading content... ")
}
val myId = "inlineContent" val myId = "inlineContent"
val emptytext = buildAnnotatedString { val emptytext = buildAnnotatedString {
@ -220,7 +302,26 @@ private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
@Composable @Composable
private fun DisplayBlueHash( 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 modifier: Modifier
) { ) {
if (content.bluehash == null) return if (content.bluehash == null) return
@ -264,7 +365,12 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableConte
) { ) {
CloseButton(onCancel = onDismiss) CloseButton(onCancel = onDismiss)
SaveToGallery(url = allImages[pagerState.currentPage].url) val myContent = allImages[pagerState.currentPage]
if (myContent is ZoomableUrlContent) {
SaveToGallery(url = myContent.url)
} else if (myContent is ZoomableBitmapImage && myContent.byteArray != null) {
SaveToGallery(byteArray = myContent.byteArray, mimeType = myContent.mimeType)
}
} }
if (allImages.size > 1) { if (allImages.size > 1) {
@ -297,6 +403,7 @@ private fun RenderImageOrVideo(content: ZoomableContent) {
mutableStateOf<Boolean?>(null) mutableStateOf<Boolean?>(null)
} }
if (content is ZoomableUrlContent) {
LaunchedEffect(key1 = content.url, key2 = imageState) { LaunchedEffect(key1 = content.url, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) { if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
@ -304,8 +411,25 @@ private fun RenderImageOrVideo(content: ZoomableContent) {
} }
} }
} }
} 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() { Box() {
AsyncImage( AsyncImage(
model = content.url, model = content.url,
@ -327,15 +451,42 @@ private fun RenderImageOrVideo(content: ZoomableContent) {
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd)) HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
} }
} }
} else { } else if (content is ZoomableUrlVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
VideoView(content.url, content.description) 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) @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 if (content.hash == null) return null
context.imageLoader.diskCache?.get(content.url)?.use { snapshot -> context.imageLoader.diskCache?.get(content.url)?.use { snapshot ->

View File

@ -169,6 +169,8 @@ fun NoteComposeInner(
BadgeDisplay(baseNote = note) BadgeDisplay(baseNote = note)
} else if (noteEvent is FileHeaderEvent) { } else if (noteEvent is FileHeaderEvent) {
FileHeaderDisplay(note) FileHeaderDisplay(note)
} else if (noteEvent is FileStorageHeaderEvent) {
FileStorageHeaderDisplay(note)
} else { } else {
var isNew by remember { mutableStateOf<Boolean>(false) } var isNew by remember { mutableStateOf<Boolean>(false) }
@ -794,9 +796,9 @@ fun FileHeaderDisplay(note: Note) {
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
content = if (isImage) { content = if (isImage) {
ZoomableImage(fullUrl, description, hash, blurHash) ZoomableUrlImage(fullUrl, description, hash, blurHash)
} else { } else {
ZoomableVideo(fullUrl, description, hash) ZoomableUrlVideo(fullUrl, description, hash)
} }
} }
} }
@ -806,6 +808,42 @@ fun FileHeaderDisplay(note: Note) {
} ?: UrlPreview(fullUrl, "$fullUrl ") } ?: 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<ZoomableContent?>(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 @Composable
private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: User) { private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: User) {
Row( Row(