session-android/libsession/src/main/java/org/session/libsession/messaging/opengroups/OpenGroupMessage.kt

243 lines
10 KiB
Kotlin

package org.session.libsession.messaging.opengroups
import org.session.libsession.messaging.Configuration
import org.session.libsession.messaging.messages.visible.VisibleMessage
import org.session.libsignal.libsignal.logging.Log
import org.session.libsignal.service.internal.util.Hex
import org.session.libsignal.service.loki.utilities.removing05PrefixIfNeeded
import org.whispersystems.curve25519.Curve25519
public data class OpenGroupMessage(
public val serverID: Long?,
public val senderPublicKey: String,
public val displayName: String,
public val body: String,
public val timestamp: Long,
public val type: String,
public val quote: Quote?,
public val attachments: List<Attachment>,
public val profilePicture: ProfilePicture?,
public val signature: Signature?,
public val serverTimestamp: Long,
) {
// region Settings
companion object {
fun from(message: VisibleMessage, server: String): OpenGroupMessage? {
val storage = Configuration.shared.storage
val userPublicKey = storage.getUserPublicKey() ?: return null
// Validation
if (!message.isValid) { return null } // Should be valid at this point
// Quote
val quote: OpenGroupMessage.Quote? = {
val quote = message.quote
if (quote != null && quote.isValid) {
val quotedMessageServerID = storage.getQuoteServerID(quote.id, quote.publicKey)
OpenGroupMessage.Quote(quote.timestamp, quote.publicKey, quote.text, quotedMessageServerID)
} else {
null
}
}()
// Message
val displayname = storage.getUserDisplayName() ?: "Anonymous"
val body = message.text ?: message.sentTimestamp.toString() // The back-end doesn't accept messages without a body so we use this as a workaround
val result = OpenGroupMessage(null, userPublicKey, displayname, body, message.sentTimestamp!!, OpenGroupAPI.openGroupMessageType, quote, mutableListOf(), null, null, 0)
// Link preview
val linkPreview = message.linkPreview
linkPreview?.let {
if (!linkPreview.isValid) { return@let }
val attachment = linkPreview.getImage() ?: return@let
val openGroupLinkPreview = OpenGroupMessage.Attachment(
OpenGroupMessage.Attachment.Kind.LinkPreview,
server,
attachment.getId(),
attachment.getContentType(),
attachment.getSize(),
attachment.getFileName(),
attachment.getFlags(),
attachment.getWidth(),
attachment.getHeight(),
attachment.getCaption(),
attachment.getUrl(),
linkPreview.getUrl(),
linkPreview.getTitle())
result.attachments.add(openGroupLinkPreview)
}
// Attachments
val attachments = message.getAttachemnts().forEach {
val attachement = OpenGroupMessage.Attachment(
OpenGroupMessage.Attachment.Kind.Attachment,
server,
it.getId(),
it.getContentType(),
it.getSize(),
it.getFileName(),
it.getFlags(),
it.getWidth(),
it.getHeight(),
it.getCaption(),
it.getUrl(),
linkPreview.getUrl(),
linkPreview.getTitle())
result.attachments.add(attachement)
}
// Return
return result
}
private val curve = Curve25519.getInstance(Curve25519.BEST)
private val signatureVersion: Long = 1
private val attachmentType = "net.app.core.oembed"
}
// endregion
// region Types
public data class ProfilePicture(
public val profileKey: ByteArray,
public val url: String,
)
public data class Quote(
public val quotedMessageTimestamp: Long,
public val quoteePublicKey: String,
public val quotedMessageBody: String,
public val quotedMessageServerID: Long? = null,
)
public data class Signature(
public val data: ByteArray,
public val version: Long,
)
public data class Attachment(
public val kind: Kind,
public val server: String,
public val serverID: Long,
public val contentType: String,
public val size: Int,
public val fileName: String,
public val flags: Int,
public val width: Int,
public val height: Int,
public val caption: String?,
public val url: String,
/**
Guaranteed to be non-`nil` if `kind` is `LinkPreview`.
*/
public val linkPreviewURL: String?,
/**
Guaranteed to be non-`nil` if `kind` is `LinkPreview`.
*/
public val linkPreviewTitle: String?,
) {
public val dotNetAPIType = when {
contentType.startsWith("image") -> "photo"
contentType.startsWith("video") -> "video"
contentType.startsWith("audio") -> "audio"
else -> "other"
}
public enum class Kind(val rawValue: String) {
Attachment("attachment"), LinkPreview("preview")
}
}
// endregion
// region Initialization
constructor(hexEncodedPublicKey: String, displayName: String, body: String, timestamp: Long, type: String, quote: Quote?, attachments: List<Attachment>)
: this(null, hexEncodedPublicKey, displayName, body, timestamp, type, quote, attachments, null, null, 0)
// endregion
// region Crypto
internal fun sign(privateKey: ByteArray): OpenGroupMessage? {
val data = getValidationData(signatureVersion)
if (data == null) {
Log.d("Loki", "Failed to sign public chat message.")
return null
}
try {
val signatureData = curve.calculateSignature(privateKey, data)
val signature = Signature(signatureData, signatureVersion)
return copy(signature = signature)
} catch (e: Exception) {
Log.d("Loki", "Failed to sign public chat message due to error: ${e.message}.")
return null
}
}
internal fun hasValidSignature(): Boolean {
if (signature == null) { return false }
val data = getValidationData(signature.version) ?: return false
val publicKey = Hex.fromStringCondensed(senderPublicKey.removing05PrefixIfNeeded())
try {
return curve.verifySignature(publicKey, data, signature.data)
} catch (e: Exception) {
Log.d("Loki", "Failed to verify public chat message due to error: ${e.message}.")
return false
}
}
// endregion
// region Parsing
internal fun toJSON(): Map<String, Any> {
val value = mutableMapOf<String, Any>("timestamp" to timestamp)
if (quote != null) {
value["quote"] = mapOf("id" to quote.quotedMessageTimestamp, "author" to quote.quoteePublicKey, "text" to quote.quotedMessageBody)
}
if (signature != null) {
value["sig"] = Hex.toStringCondensed(signature.data)
value["sigver"] = signature.version
}
val annotation = mapOf("type" to type, "value" to value)
val annotations = mutableListOf(annotation)
attachments.forEach { attachment ->
val attachmentValue = mutableMapOf(
// Fields required by the .NET API
"version" to 1,
"type" to attachment.dotNetAPIType,
// Custom fields
"lokiType" to attachment.kind.rawValue,
"server" to attachment.server,
"id" to attachment.serverID,
"contentType" to attachment.contentType,
"size" to attachment.size,
"fileName" to attachment.fileName,
"flags" to attachment.flags,
"width" to attachment.width,
"height" to attachment.height,
"url" to attachment.url
)
if (attachment.caption != null) { attachmentValue["caption"] = attachment.caption }
if (attachment.linkPreviewURL != null) { attachmentValue["linkPreviewUrl"] = attachment.linkPreviewURL }
if (attachment.linkPreviewTitle != null) { attachmentValue["linkPreviewTitle"] = attachment.linkPreviewTitle }
val attachmentAnnotation = mapOf("type" to attachmentType, "value" to attachmentValue)
annotations.add(attachmentAnnotation)
}
val result = mutableMapOf("text" to body, "annotations" to annotations)
if (quote?.quotedMessageServerID != null) {
result["reply_to"] = quote.quotedMessageServerID
}
return result
}
// endregion
// region Convenience
private fun getValidationData(signatureVersion: Long): ByteArray? {
var string = "${body.trim()}$timestamp"
if (quote != null) {
string += "${quote.quotedMessageTimestamp}${quote.quoteePublicKey}${quote.quotedMessageBody.trim()}"
if (quote.quotedMessageServerID != null) {
string += "${quote.quotedMessageServerID}"
}
}
string += attachments.sortedBy { it.serverID }.map { it.serverID }.joinToString("")
string += "$signatureVersion"
try {
return string.toByteArray(Charsets.UTF_8)
} catch (exception: Exception) {
return null
}
}
// endregion
}