Merge pull request #20 from ouchadam/support-image-messages

Support image messages
This commit is contained in:
Adam Brown 2022-04-01 20:53:08 +01:00 committed by GitHub
commit 77efb58562
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 611 additions and 220 deletions

View File

@ -0,0 +1,64 @@
package app.dapk.st.messenger
import android.util.Base64
import app.dapk.st.matrix.sync.RoomEvent
import coil.bitmap.BitmapPool
import coil.decode.DataSource
import coil.decode.Options
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.size.Size
import okhttp3.OkHttpClient
import okhttp3.Request
import okio.Buffer
import java.security.MessageDigest
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
private const val CRYPTO_BUFFER_SIZE = 32 * 1024
private const val CIPHER_ALGORITHM = "AES/CTR/NoPadding"
private const val SECRET_KEY_SPEC_ALGORITHM = "AES"
private const val MESSAGE_DIGEST_ALGORITHM = "SHA-256"
class DecryptingFetcher : Fetcher<RoomEvent.Image> {
private val http = OkHttpClient()
override suspend fun fetch(pool: BitmapPool, data: RoomEvent.Image, size: Size, options: Options): FetchResult {
val keys = data.imageMeta.keys!!
val key = Base64.decode(keys.k.replace('-', '+').replace('_', '/'), Base64.DEFAULT)
val initVectorBytes = Base64.decode(keys.iv, Base64.DEFAULT)
val decryptCipher = Cipher.getInstance(CIPHER_ALGORITHM)
val secretKeySpec = SecretKeySpec(key, SECRET_KEY_SPEC_ALGORITHM)
val ivParameterSpec = IvParameterSpec(initVectorBytes)
decryptCipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec)
val messageDigest = MessageDigest.getInstance(MESSAGE_DIGEST_ALGORITHM)
var read: Int
val d = ByteArray(CRYPTO_BUFFER_SIZE)
var decodedBytes: ByteArray
val response = http.newCall(Request.Builder().url(data.imageMeta.url).build()).execute()
val outputStream = Buffer()
response.body()?.let {
it.byteStream().use {
read = it.read(d)
while (read != -1) {
messageDigest.update(d, 0, read)
decodedBytes = decryptCipher.update(d, 0, read)
outputStream.write(decodedBytes)
read = it.read(d)
}
}
}
return SourceResult(outputStream, null, DataSource.NETWORK)
}
override fun key(data: RoomEvent.Image) = data.imageMeta.url
}

View File

@ -22,9 +22,10 @@ internal class LocalEchoMapper(private val metaMapper: MetaMapper) {
}
}
fun RoomEvent.mergeWith(echo: MessageService.LocalEcho) = when (this) {
fun RoomEvent.mergeWith(echo: MessageService.LocalEcho): RoomEvent = when (this) {
is RoomEvent.Message -> this.copy(meta = metaMapper.toMeta(echo))
is RoomEvent.Reply -> this.copy(message = this.message.copy(meta = metaMapper.toMeta(echo)))
is RoomEvent.Reply -> this.copy(message = this.message.mergeWith(echo))
is RoomEvent.Image -> this.copy(meta = metaMapper.toMeta(echo))
}
}

View File

@ -9,10 +9,10 @@ import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.Surface
import androidx.compose.ui.Modifier
import app.dapk.st.design.components.SmallTalkTheme
import app.dapk.st.core.DapkActivity
import app.dapk.st.core.module
import app.dapk.st.core.viewModel
import app.dapk.st.design.components.SmallTalkTheme
import app.dapk.st.matrix.common.RoomId
import kotlinx.parcelize.Parcelize

View File

@ -1,5 +1,6 @@
package app.dapk.st.messenger
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
@ -36,6 +37,7 @@ import app.dapk.st.matrix.sync.RoomEvent
import app.dapk.st.matrix.sync.RoomEvent.Message
import app.dapk.st.matrix.sync.RoomState
import app.dapk.st.navigator.Navigator
import coil.compose.rememberImagePainter
import kotlinx.coroutines.launch
@Composable
@ -155,30 +157,33 @@ private fun ColumnScope.RoomContent(self: UserId, state: RoomState) {
items = state.events,
key = { _, item -> item.eventId.value },
) { index, item ->
val previousEvent = if (index != 0) state.events[index - 1] else null
val wasPreviousMessageSameSender = previousEvent?.author?.id == item.author.id
AlignedBubble(item, self, wasPreviousMessageSameSender) {
when (item) {
is Message -> {
val wasPreviousMessageSameSender = when (val previousEvent = if (index != 0) state.events[index - 1] else null) {
null -> false
is Message -> previousEvent.author.id == item.author.id
is RoomEvent.Reply -> previousEvent.message.author.id == item.author.id
}
Message(self, item, wasPreviousMessageSameSender)
}
is RoomEvent.Reply -> {
val wasPreviousMessageSameSender = when (val previousEvent = if (index != 0) state.events[index - 1] else null) {
null -> false
is Message -> previousEvent.author.id == item.message.author.id
is RoomEvent.Reply -> previousEvent.message.author.id == item.message.author.id
}
Reply(self, item, wasPreviousMessageSameSender)
is RoomEvent.Image -> MessageImage(it as BubbleContent<RoomEvent.Image>)
is Message -> TextBubbleContent(it as BubbleContent<RoomEvent.Message>)
is RoomEvent.Reply -> ReplyBubbleContent(it as BubbleContent<RoomEvent.Reply>)
}
}
}
}
}
private data class BubbleContent<T : RoomEvent>(
val shape: RoundedCornerShape,
val background: Color,
val isNotSelf: Boolean,
val message: T
)
@Composable
private fun LazyItemScope.Message(self: UserId, message: Message, wasPreviousMessageSameSender: Boolean) {
private fun <T : RoomEvent> LazyItemScope.AlignedBubble(
message: T,
self: UserId,
wasPreviousMessageSameSender: Boolean,
content: @Composable (BubbleContent<T>) -> Unit
) {
when (message.author.id == self) {
true -> {
Box(modifier = Modifier.fillParentMaxWidth(), contentAlignment = Alignment.TopEnd) {
@ -188,7 +193,7 @@ private fun LazyItemScope.Message(self: UserId, message: Message, wasPreviousMes
isNotSelf = false,
wasPreviousMessageSameSender = wasPreviousMessageSameSender
) {
TextBubbleContent(selfBackgroundShape, SmallTalkTheme.extendedColors.selfBubble, false, message)
content(BubbleContent(selfBackgroundShape, SmallTalkTheme.extendedColors.selfBubble, false, message))
}
}
}
@ -200,7 +205,7 @@ private fun LazyItemScope.Message(self: UserId, message: Message, wasPreviousMes
isNotSelf = true,
wasPreviousMessageSameSender = wasPreviousMessageSameSender
) {
TextBubbleContent(othersBackgroundShape, SmallTalkTheme.extendedColors.othersBubble, true, message)
content(BubbleContent(othersBackgroundShape, SmallTalkTheme.extendedColors.othersBubble, true, message))
}
}
}
@ -208,42 +213,67 @@ private fun LazyItemScope.Message(self: UserId, message: Message, wasPreviousMes
}
@Composable
private fun LazyItemScope.Reply(self: UserId, message: RoomEvent.Reply, wasPreviousMessageSameSender: Boolean) {
when (message.message.author.id == self) {
true -> {
Box(modifier = Modifier.fillParentMaxWidth(), contentAlignment = Alignment.TopEnd) {
Box(modifier = Modifier.fillParentMaxWidth(0.85f), contentAlignment = Alignment.TopEnd) {
Bubble(
message = message.message,
isNotSelf = false,
wasPreviousMessageSameSender = wasPreviousMessageSameSender
private fun MessageImage(content: BubbleContent<RoomEvent.Image>) {
Box(modifier = Modifier.padding(start = 6.dp)) {
Box(
Modifier
.padding(4.dp)
.clip(content.shape)
.background(content.background)
.height(IntrinsicSize.Max),
) {
ReplyBubbleContent(selfBackgroundShape, SmallTalkTheme.extendedColors.selfBubble, false, message)
}
}
}
}
false -> {
Box(modifier = Modifier.fillParentMaxWidth(0.95f), contentAlignment = Alignment.TopStart) {
Bubble(
message = message.message,
isNotSelf = true,
wasPreviousMessageSameSender = wasPreviousMessageSameSender
Column(
Modifier
.padding(8.dp)
.width(IntrinsicSize.Max)
.defaultMinSize(minWidth = 50.dp)
) {
ReplyBubbleContent(othersBackgroundShape, SmallTalkTheme.extendedColors.othersBubble, true, message)
if (content.isNotSelf) {
Text(
fontSize = 11.sp,
text = content.message.author.displayName ?: content.message.author.id.value,
maxLines = 1,
color = MaterialTheme.colors.onPrimary
)
}
val width = with(LocalDensity.current) { content.message.imageMeta.width.toDp() }
val height = with(LocalDensity.current) { content.message.imageMeta.height.toDp() }
Spacer(modifier = Modifier.height(4.dp))
Image(
modifier = Modifier.size(width, height),
painter = rememberImagePainter(
data = content.message,
builder = { fetcher(DecryptingFetcher()) }
),
contentDescription = null,
)
Spacer(modifier = Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
val editedPrefix = if (content.message.edited) "(edited) " else null
Text(
fontSize = 9.sp,
text = "${editedPrefix ?: ""}${content.message.time}",
textAlign = TextAlign.End,
color = MaterialTheme.colors.onPrimary,
modifier = Modifier.wrapContentSize()
)
SendStatus(content.message)
}
}
}
}
}
private val selfBackgroundShape = RoundedCornerShape(12.dp, 0.dp, 12.dp, 12.dp)
private val othersBackgroundShape = RoundedCornerShape(0.dp, 12.dp, 12.dp, 12.dp)
@Composable
private fun Bubble(
message: Message,
message: RoomEvent,
isNotSelf: Boolean,
wasPreviousMessageSameSender: Boolean,
content: @Composable () -> Unit
@ -270,13 +300,13 @@ private fun Bubble(
}
@Composable
private fun TextBubbleContent(shape: RoundedCornerShape, background: Color, isNotSelf: Boolean, message: Message) {
private fun TextBubbleContent(content: BubbleContent<RoomEvent.Message>) {
Box(modifier = Modifier.padding(start = 6.dp)) {
Box(
Modifier
.padding(4.dp)
.clip(shape)
.background(background)
.clip(content.shape)
.background(content.background)
.height(IntrinsicSize.Max),
) {
Column(
@ -285,14 +315,107 @@ private fun TextBubbleContent(shape: RoundedCornerShape, background: Color, isNo
.width(IntrinsicSize.Max)
.defaultMinSize(minWidth = 50.dp)
) {
if (isNotSelf) {
if (content.isNotSelf) {
Text(
fontSize = 11.sp,
text = message.author.displayName ?: message.author.id.value,
text = content.message.author.displayName ?: content.message.author.id.value,
maxLines = 1,
color = MaterialTheme.colors.onPrimary
)
}
Text(
text = content.message.content,
color = MaterialTheme.colors.onPrimary,
fontSize = 15.sp,
modifier = Modifier.wrapContentSize(),
textAlign = TextAlign.Start,
)
Spacer(modifier = Modifier.height(2.dp))
Row(horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
val editedPrefix = if (content.message.edited) "(edited) " else null
Text(
fontSize = 9.sp,
text = "${editedPrefix ?: ""}${content.message.time}",
textAlign = TextAlign.End,
color = MaterialTheme.colors.onPrimary,
modifier = Modifier.wrapContentSize()
)
SendStatus(content.message)
}
}
}
}
}
@Composable
private fun ReplyBubbleContent(content: BubbleContent<RoomEvent.Reply>) {
Box(modifier = Modifier.padding(start = 6.dp)) {
Box(
Modifier
.padding(4.dp)
.clip(content.shape)
.background(content.background)
.height(IntrinsicSize.Max),
) {
Column(
Modifier
.padding(8.dp)
.width(IntrinsicSize.Max)
.defaultMinSize(minWidth = 50.dp)
) {
Column(
Modifier
.background(if (content.isNotSelf) SmallTalkTheme.extendedColors.otherBubbleReplyBackground else SmallTalkTheme.extendedColors.selfBubbleReplyBackground)
.padding(4.dp)
) {
val replyName = if (!content.isNotSelf && content.message.replyingToSelf) "You" else content.message.replyingTo.author.displayName
?: content.message.replyingTo.author.id.value
Text(
fontSize = 11.sp,
text = replyName,
maxLines = 1,
color = MaterialTheme.colors.onPrimary
)
when (val replyingTo = content.message.replyingTo) {
is Message -> {
Text(
text = replyingTo.content,
color = MaterialTheme.colors.onPrimary,
fontSize = 15.sp,
modifier = Modifier.wrapContentSize(),
textAlign = TextAlign.Start,
)
}
is RoomEvent.Image -> {
val width = with(LocalDensity.current) { replyingTo.imageMeta.width.toDp() }
val height = with(LocalDensity.current) { replyingTo.imageMeta.height.toDp() }
Spacer(modifier = Modifier.height(4.dp))
Image(
modifier = Modifier.size(width, height),
painter = rememberImagePainter(
data = replyingTo,
builder = { fetcher(DecryptingFetcher()) }
),
contentDescription = null,
)
Spacer(modifier = Modifier.height(4.dp))
}
}
}
Spacer(modifier = Modifier.height(12.dp))
if (content.isNotSelf) {
Text(
fontSize = 11.sp,
text = content.message.message.author.displayName ?: content.message.message.author.id.value,
maxLines = 1,
color = MaterialTheme.colors.onPrimary
)
}
when (val message = content.message.message) {
is Message -> {
Text(
text = message.content,
color = MaterialTheme.colors.onPrimary,
@ -300,89 +423,33 @@ private fun TextBubbleContent(shape: RoundedCornerShape, background: Color, isNo
modifier = Modifier.wrapContentSize(),
textAlign = TextAlign.Start,
)
Spacer(modifier = Modifier.height(2.dp))
Row(horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
val editedPrefix = if (message.edited) "(edited) " else null
Text(
fontSize = 9.sp,
text = "${editedPrefix ?: ""}${message.time}",
textAlign = TextAlign.End,
color = MaterialTheme.colors.onPrimary,
modifier = Modifier.wrapContentSize()
}
is RoomEvent.Image -> {
val width = with(LocalDensity.current) { message.imageMeta.width.toDp() }
val height = with(LocalDensity.current) { message.imageMeta.height.toDp() }
Spacer(modifier = Modifier.height(4.dp))
Image(
modifier = Modifier.size(width, height),
painter = rememberImagePainter(
data = content.message,
builder = { fetcher(DecryptingFetcher()) }
),
contentDescription = null,
)
SendStatus(message)
Spacer(modifier = Modifier.height(4.dp))
}
}
}
}
}
@Composable
private fun ReplyBubbleContent(shape: RoundedCornerShape, background: Color, isNotSelf: Boolean, reply: RoomEvent.Reply) {
Box(modifier = Modifier.padding(start = 6.dp)) {
Box(
Modifier
.padding(4.dp)
.clip(shape)
.background(background)
.height(IntrinsicSize.Max),
) {
Column(
Modifier
.padding(8.dp)
.width(IntrinsicSize.Max)
.defaultMinSize(minWidth = 50.dp)
) {
Column(
Modifier
.background(if (isNotSelf) SmallTalkTheme.extendedColors.otherBubbleReplyBackground else SmallTalkTheme.extendedColors.selfBubbleReplyBackground)
.padding(4.dp)
) {
val replyName = if (!isNotSelf && reply.replyingToSelf) "You" else reply.replyingTo.author.displayName ?: reply.replyingTo.author.id.value
Text(
fontSize = 11.sp,
text = replyName,
maxLines = 1,
color = MaterialTheme.colors.onPrimary
)
Text(
text = reply.replyingTo.content,
color = MaterialTheme.colors.onPrimary,
fontSize = 15.sp,
modifier = Modifier.wrapContentSize(),
textAlign = TextAlign.Start,
)
}
Spacer(modifier = Modifier.height(12.dp))
if (isNotSelf) {
Text(
fontSize = 11.sp,
text = reply.message.author.displayName ?: reply.message.author.id.value,
maxLines = 1,
color = MaterialTheme.colors.onPrimary
)
}
Text(
text = reply.message.content,
color = MaterialTheme.colors.onPrimary,
fontSize = 15.sp,
modifier = Modifier.wrapContentSize(),
textAlign = TextAlign.Start,
)
Spacer(modifier = Modifier.height(2.dp))
Row(horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
fontSize = 9.sp,
text = reply.time,
text = content.message.time,
textAlign = TextAlign.End,
color = MaterialTheme.colors.onPrimary,
modifier = Modifier.wrapContentSize()
)
SendStatus(reply.message)
SendStatus(content.message.message)
}
}
}
@ -391,7 +458,7 @@ private fun ReplyBubbleContent(shape: RoundedCornerShape, background: Color, isN
@Composable
private fun RowScope.SendStatus(message: Message) {
private fun RowScope.SendStatus(message: RoomEvent) {
when (val meta = message.meta) {
MessageMeta.FromServer -> {
// last message is self

View File

@ -4,7 +4,6 @@ import app.dapk.st.core.extensions.unsafeLazy
import app.dapk.st.matrix.common.*
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime
@ -23,6 +22,8 @@ sealed class RoomEvent {
abstract val eventId: EventId
abstract val utcTimestamp: Long
abstract val author: RoomMember
abstract val meta: MessageMeta
@Serializable
@SerialName("message")
@ -30,8 +31,8 @@ sealed class RoomEvent {
@SerialName("event_id") override val eventId: EventId,
@SerialName("timestamp") override val utcTimestamp: Long,
@SerialName("content") val content: String,
@SerialName("author") val author: RoomMember,
@SerialName("meta") val meta: MessageMeta,
@SerialName("author") override val author: RoomMember,
@SerialName("meta") override val meta: MessageMeta,
@SerialName("encrypted_content") val encryptedContent: MegOlmV1? = null,
@SerialName("edited") val edited: Boolean = false,
) : RoomEvent() {
@ -44,7 +45,6 @@ sealed class RoomEvent {
@SerialName("session_id") val sessionId: SessionId,
)
@Transient
val time: String by unsafeLazy {
val instant = Instant.ofEpochMilli(utcTimestamp)
ZonedDateTime.ofInstant(instant, DEFAULT_ZONE).toLocalTime().format(MESSAGE_TIME_FORMAT)
@ -54,22 +54,59 @@ sealed class RoomEvent {
@Serializable
@SerialName("reply")
data class Reply(
@SerialName("message") val message: Message,
@SerialName("in_reply_to") val replyingTo: Message,
@SerialName("message") val message: RoomEvent,
@SerialName("in_reply_to") val replyingTo: RoomEvent,
) : RoomEvent() {
override val eventId: EventId = message.eventId
override val utcTimestamp: Long = message.utcTimestamp
override val author: RoomMember = message.author
override val meta: MessageMeta = message.meta
val replyingToSelf = replyingTo.author == message.author
@Transient
val time: String by unsafeLazy {
val instant = Instant.ofEpochMilli(utcTimestamp)
ZonedDateTime.ofInstant(instant, DEFAULT_ZONE).toLocalTime().format(MESSAGE_TIME_FORMAT)
}
}
@Serializable
@SerialName("image")
data class Image(
@SerialName("event_id") override val eventId: EventId,
@SerialName("timestamp") override val utcTimestamp: Long,
@SerialName("image_meta") val imageMeta: ImageMeta,
@SerialName("author") override val author: RoomMember,
@SerialName("meta") override val meta: MessageMeta,
@SerialName("encrypted_content") val encryptedContent: Message.MegOlmV1? = null,
@SerialName("edited") val edited: Boolean = false,
) : RoomEvent() {
val time: String by unsafeLazy {
val instant = Instant.ofEpochMilli(utcTimestamp)
ZonedDateTime.ofInstant(instant, DEFAULT_ZONE).toLocalTime().format(MESSAGE_TIME_FORMAT)
}
@Serializable
data class ImageMeta(
@SerialName("width") val width: Int,
@SerialName("height") val height: Int,
@SerialName("url") val url: String,
@SerialName("keys") val keys: Keys?,
) {
@Serializable
data class Keys(
@SerialName("k") val k: String,
@SerialName("iv") val iv: String,
@SerialName("v") val v: String,
@SerialName("hashes") val hashes: Map<String, String>,
)
}
}
}
@Serializable

View File

@ -35,7 +35,7 @@ internal class DefaultSyncService(
json: Json,
oneTimeKeyProducer: MaybeCreateMoreKeys,
scope: CoroutineScope,
credentialsStore: CredentialsStore,
private val credentialsStore: CredentialsStore,
roomMembersService: RoomMembersService,
logger: MatrixLogger,
errorTracker: ErrorTracker,
@ -57,7 +57,7 @@ internal class DefaultSyncService(
roomMembersService,
roomDataSource,
TimelineEventsProcessor(
RoomEventCreator(roomMembersService, logger, errorTracker),
RoomEventCreator(roomMembersService, errorTracker, RoomEventFactory(roomMembersService)),
roomEventsDecrypter,
eventDecrypter,
EventLookupUseCase(roomStore)
@ -69,6 +69,7 @@ internal class DefaultSyncService(
roomRefresher,
roomDataSource,
logger,
errorTracker,
coroutineDispatchers,
)
SyncUseCase(
@ -114,7 +115,7 @@ internal class DefaultSyncService(
coroutineDispatchers.withIoContext {
roomIds.map {
async {
roomRefresher.refreshRoomContent(it)?.also {
roomRefresher.refreshRoomContent(it, credentialsStore.credentials()!!)?.also {
overviewStore.persist(listOf(it.roomOverview))
}
}

View File

@ -445,14 +445,56 @@ internal sealed class ApiTimelineEvent {
@SerialName("st.decryption_status") val decryptionStatus: DecryptionStatus? = null
) : ApiTimelineEvent() {
@Serializable(with = ApiTimelineMessageContentDeserializer::class)
internal sealed interface Content {
val relation: Relation?
@Serializable
internal data class Content(
data class Text(
@SerialName("body") val body: String? = null,
@SerialName("formatted_body") val formattedBody: String? = null,
@SerialName("msgtype") val type: String? = null,
@SerialName("m.relates_to") val relation: Relation? = null,
@SerialName("m.relates_to") override val relation: Relation? = null,
@SerialName("msgtype") val messageType: String = "m.text",
) : Content
@Serializable
data class Image(
@SerialName("url") val url: MxUrl? = null,
@SerialName("file") val file: File? = null,
@SerialName("info") val info: Info,
@SerialName("m.relates_to") override val relation: Relation? = null,
@SerialName("msgtype") val messageType: String = "m.image",
) : Content {
@Serializable
data class File(
@SerialName("url") val url: MxUrl,
@SerialName("iv") val iv: String,
@SerialName("v") val v: String,
@SerialName("hashes") val hashes: Map<String, String>,
@SerialName("key") val key: Key,
) {
@Serializable
data class Key(
@SerialName("k") val k: String,
)
}
@Serializable
internal data class Info(
@SerialName("h") val height: Int,
@SerialName("w") val width: Int,
)
}
@Serializable
object Ignored : Content {
override val relation: Relation? = null
}
}
@Serializable
data class Relation(
@SerialName("m.in_reply_to") val inReplyTo: InReplyTo? = null,
@ -512,3 +554,28 @@ internal object EncryptedContentDeserializer : KSerializer<ApiEncryptedContent>
override fun serialize(encoder: Encoder, value: ApiEncryptedContent) = TODO("Not yet implemented")
}
internal object ApiTimelineMessageContentDeserializer : KSerializer<ApiTimelineEvent.TimelineMessage.Content> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("messageContent")
override fun deserialize(decoder: Decoder): ApiTimelineEvent.TimelineMessage.Content {
require(decoder is JsonDecoder)
val element = decoder.decodeJsonElement()
return when (element.jsonObject["msgtype"]?.jsonPrimitive?.content) {
"m.text" -> ApiTimelineEvent.TimelineMessage.Content.Text.serializer().deserialize(decoder)
"m.image" -> ApiTimelineEvent.TimelineMessage.Content.Image.serializer().deserialize(decoder)
else -> {
println(element)
ApiTimelineEvent.TimelineMessage.Content.Ignored
}
}
}
override fun serialize(encoder: Encoder, value: ApiTimelineEvent.TimelineMessage.Content) = when (value) {
ApiTimelineEvent.TimelineMessage.Content.Ignored -> {}
is ApiTimelineEvent.TimelineMessage.Content.Image -> ApiTimelineEvent.TimelineMessage.Content.Image.serializer().serialize(encoder, value)
is ApiTimelineEvent.TimelineMessage.Content.Text -> ApiTimelineEvent.TimelineMessage.Content.Text.serializer().serialize(encoder, value)
}
}

View File

@ -2,6 +2,7 @@ package app.dapk.st.matrix.sync.internal.room
import app.dapk.st.matrix.common.*
import app.dapk.st.matrix.sync.RoomEvent
import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent
import app.dapk.st.matrix.sync.internal.request.DecryptedContent
import kotlinx.serialization.json.Json
@ -11,13 +12,38 @@ internal class RoomEventsDecrypter(
private val logger: MatrixLogger,
) {
suspend fun decryptRoomEvents(events: List<RoomEvent>) = events.map { event ->
when (event) {
suspend fun decryptRoomEvents(userCredentials: UserCredentials, events: List<RoomEvent>) = events.map { event ->
decryptEvent(event, userCredentials)
}
private suspend fun decryptEvent(event: RoomEvent, userCredentials: UserCredentials): RoomEvent = when (event) {
is RoomEvent.Message -> event.decrypt()
is RoomEvent.Reply -> RoomEvent.Reply(
message = event.message.decrypt(),
replyingTo = event.replyingTo.decrypt(),
message = decryptEvent(event.message, userCredentials),
replyingTo = decryptEvent(event.replyingTo, userCredentials),
)
is RoomEvent.Image -> event.decrypt(userCredentials)
}
private suspend fun RoomEvent.Image.decrypt(userCredentials: UserCredentials) = when (this.encryptedContent) {
null -> this
else -> when (val result = messageDecrypter.decrypt(this.encryptedContent.toModel())) {
is DecryptionResult.Failed -> this.also { logger.crypto("Failed to decrypt ${it.eventId}") }
is DecryptionResult.Success -> when (val model = result.payload.toModel()) {
DecryptedContent.Ignored -> this
is DecryptedContent.TimelineText -> {
val content = model.content as ApiTimelineEvent.TimelineMessage.Content.Image
this.copy(
imageMeta = RoomEvent.Image.ImageMeta(
width = content.info.width,
height = content.info.height,
url = content.file?.url?.convertMxUrToUrl(userCredentials.homeServer) ?: content.url!!.convertMxUrToUrl(userCredentials.homeServer),
keys = content.file?.let { RoomEvent.Image.ImageMeta.Keys(it.key.k, it.iv, it.v, it.hashes) }
),
encryptedContent = null,
)
}
}
}
}
@ -35,10 +61,9 @@ internal class RoomEventsDecrypter(
private fun JsonString.toModel() = json.decodeFromString(DecryptedContent.serializer(), this.value)
private fun RoomEvent.Message.copyWithDecryptedContent(decryptedContent: DecryptedContent.TimelineText) = this.copy(
content = decryptedContent.content.body ?: "",
content = (decryptedContent.content as ApiTimelineEvent.TimelineMessage.Content.Text).body ?: "",
encryptedContent = null
)
}
private fun RoomEvent.Message.MegOlmV1.toModel() = EncryptedMessageContent.MegOlmV1(

View File

@ -39,7 +39,11 @@ internal class SyncEventDecrypter(
is DecryptedContent.TimelineText -> ApiTimelineEvent.TimelineMessage(
event.eventId,
event.senderId,
it.content.copy(relation = relation),
when (it.content) {
is ApiTimelineEvent.TimelineMessage.Content.Image -> it.content.copy(relation = relation)
is ApiTimelineEvent.TimelineMessage.Content.Text -> it.content.copy(relation = relation)
ApiTimelineEvent.TimelineMessage.Content.Ignored -> it.content
},
event.utcTimestamp,
).also { logger.matrixLog("decrypted to timeline text: $it") }
DecryptedContent.Ignored -> event

View File

@ -4,9 +4,8 @@ import app.dapk.st.core.extensions.ErrorTracker
import app.dapk.st.core.extensions.ifOrNull
import app.dapk.st.core.extensions.nullAndTrack
import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.MatrixLogger
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.common.matrixLog
import app.dapk.st.matrix.common.UserCredentials
import app.dapk.st.matrix.sync.MessageMeta
import app.dapk.st.matrix.sync.RoomEvent
import app.dapk.st.matrix.sync.RoomMembersService
@ -18,8 +17,8 @@ private typealias Lookup = suspend (EventId) -> LookupResult
internal class RoomEventCreator(
private val roomMembersService: RoomMembersService,
private val logger: MatrixLogger,
private val errorTracker: ErrorTracker,
private val roomEventFactory: RoomEventFactory,
) {
suspend fun ApiTimelineEvent.Encrypted.toRoomEvent(roomId: RoomId): RoomEvent? {
@ -44,82 +43,122 @@ internal class RoomEventCreator(
}
}
suspend fun ApiTimelineEvent.TimelineMessage.toRoomEvent(roomId: RoomId, lookup: Lookup): RoomEvent? {
suspend fun ApiTimelineEvent.TimelineMessage.toRoomEvent(userCredentials: UserCredentials, roomId: RoomId, lookup: Lookup): RoomEvent? {
return TimelineEventMapper(userCredentials, roomId, roomEventFactory).mapToRoomEvent(this, lookup)
}
}
internal class TimelineEventMapper(
private val userCredentials: UserCredentials,
private val roomId: RoomId,
private val roomEventFactory: RoomEventFactory,
) {
suspend fun mapToRoomEvent(event: ApiTimelineEvent.TimelineMessage, lookup: Lookup): RoomEvent? {
return when {
this.isEdit() -> handleEdit(roomId, this.content.relation!!.eventId!!, lookup)
this.isReply() -> handleReply(roomId, lookup)
else -> this.toMessage(roomId)
event.content == ApiTimelineEvent.TimelineMessage.Content.Ignored -> null
event.isEdit() -> event.handleEdit(editedEventId = event.content.relation!!.eventId!!, lookup)
event.isReply() -> event.handleReply(replyToId = event.content.relation!!.inReplyTo!!.eventId, lookup)
else -> roomEventFactory.mapToRoomEvent(event)
}
}
private suspend fun ApiTimelineEvent.TimelineMessage.handleEdit(roomId: RoomId, editedEventId: EventId, lookup: Lookup): RoomEvent? {
return lookup(editedEventId).fold(
onApiTimelineEvent = {
ifOrNull(this.utcTimestamp > it.utcTimestamp) {
it.toMessage(
roomId,
utcTimestamp = this.utcTimestamp,
content = this.content.body?.removePrefix(" * ")?.trim() ?: "redacted",
edited = true,
)
}
},
onRoomEvent = {
ifOrNull(this.utcTimestamp > it.utcTimestamp) {
when (it) {
is RoomEvent.Message -> it.edited(this)
is RoomEvent.Reply -> it.copy(message = it.message.edited(this))
}
}
},
onEmpty = { this.toMessage(roomId, edited = true) }
)
}
private fun RoomEvent.Message.edited(edit: ApiTimelineEvent.TimelineMessage) = this.copy(
content = edit.content.body?.removePrefix(" * ")?.trim() ?: "redacted",
utcTimestamp = edit.utcTimestamp,
edited = true,
)
private suspend fun ApiTimelineEvent.TimelineMessage.handleReply(roomId: RoomId, lookup: Lookup): RoomEvent {
val replyTo = this.content.relation!!.inReplyTo!!
val relationEvent = lookup(replyTo.eventId).fold(
onApiTimelineEvent = { it.toMessage(roomId) },
private suspend fun ApiTimelineEvent.TimelineMessage.handleReply(replyToId: EventId, lookup: Lookup): RoomEvent {
val relationEvent = lookup(replyToId).fold(
onApiTimelineEvent = { it.toTextMessage() },
onRoomEvent = { it },
onEmpty = { null }
)
logger.matrixLog("found relation: $relationEvent")
return when (relationEvent) {
null -> this.toMessage(roomId)
null -> when (this.content) {
is ApiTimelineEvent.TimelineMessage.Content.Image -> this.toImageMessage()
is ApiTimelineEvent.TimelineMessage.Content.Text -> this.toFallbackTextMessage()
ApiTimelineEvent.TimelineMessage.Content.Ignored -> throw IllegalStateException()
}
else -> {
RoomEvent.Reply(
message = this.toMessage(roomId, content = this.content.formattedBody?.stripTags() ?: "redacted"),
message = roomEventFactory.mapToRoomEvent(this),
replyingTo = when (relationEvent) {
is RoomEvent.Message -> relationEvent
is RoomEvent.Reply -> relationEvent.message
is RoomEvent.Image -> relationEvent
}
)
}
}
}
private suspend fun ApiTimelineEvent.TimelineMessage.toMessage(
roomId: RoomId,
content: String = this.content.body ?: "redacted",
private suspend fun ApiTimelineEvent.TimelineMessage.toFallbackTextMessage() = this.toTextMessage(content = this.asTextContent().body ?: "redacted")
private suspend fun ApiTimelineEvent.TimelineMessage.handleEdit(editedEventId: EventId, lookup: Lookup): RoomEvent? {
return lookup(editedEventId).fold(
onApiTimelineEvent = { editApiEvent(original = it, incomingEdit = this) },
onRoomEvent = { editRoomEvent(original = it, incomingEdit = this) },
onEmpty = { this.toTextMessage(edited = true) }
)
}
private fun editRoomEvent(original: RoomEvent, incomingEdit: ApiTimelineEvent.TimelineMessage): RoomEvent? {
return ifOrNull(incomingEdit.utcTimestamp > original.utcTimestamp) {
when (original) {
is RoomEvent.Message -> original.edited(incomingEdit)
is RoomEvent.Reply -> original.copy(
message = when (original.message) {
is RoomEvent.Image -> original.message
is RoomEvent.Message -> original.message.edited(incomingEdit)
is RoomEvent.Reply -> original.message
}
)
is RoomEvent.Image -> {
// can't edit images
null
}
}
}
}
private suspend fun editApiEvent(original: ApiTimelineEvent.TimelineMessage, incomingEdit: ApiTimelineEvent.TimelineMessage): RoomEvent? {
return ifOrNull(incomingEdit.utcTimestamp > original.utcTimestamp) {
when (original.content) {
is ApiTimelineEvent.TimelineMessage.Content.Image -> original.toImageMessage(
utcTimestamp = incomingEdit.utcTimestamp,
edited = true,
)
is ApiTimelineEvent.TimelineMessage.Content.Text -> original.toTextMessage(
utcTimestamp = incomingEdit.utcTimestamp,
content = incomingEdit.asTextContent().body?.removePrefix(" * ")?.trim() ?: "redacted",
edited = true,
)
ApiTimelineEvent.TimelineMessage.Content.Ignored -> null
}
}
}
private fun RoomEvent.Message.edited(edit: ApiTimelineEvent.TimelineMessage) = this.copy(
content = edit.asTextContent().body?.removePrefix(" * ")?.trim() ?: "redacted",
utcTimestamp = edit.utcTimestamp,
edited = true,
)
private suspend fun RoomEventFactory.mapToRoomEvent(source: ApiTimelineEvent.TimelineMessage): RoomEvent {
return when (source.content) {
is ApiTimelineEvent.TimelineMessage.Content.Image -> source.toImageMessage(userCredentials, roomId)
is ApiTimelineEvent.TimelineMessage.Content.Text -> source.toTextMessage(roomId)
ApiTimelineEvent.TimelineMessage.Content.Ignored -> throw IllegalStateException()
}
}
private suspend fun ApiTimelineEvent.TimelineMessage.toTextMessage(
content: String = this.asTextContent().formattedBody?.stripTags() ?: this.asTextContent().body ?: "redacted",
edited: Boolean = false,
utcTimestamp: Long = this.utcTimestamp,
) = RoomEvent.Message(
eventId = this.id,
content = content,
author = roomMembersService.find(roomId, this.senderId)!!,
utcTimestamp = utcTimestamp,
meta = MessageMeta.FromServer,
edited = edited,
)
) = with(roomEventFactory) { toTextMessage(roomId, content, edited, utcTimestamp) }
private suspend fun ApiTimelineEvent.TimelineMessage.toImageMessage(
edited: Boolean = false,
utcTimestamp: Long = this.utcTimestamp,
) = with(roomEventFactory) { toImageMessage(userCredentials, roomId, edited, utcTimestamp) }
}
@ -128,5 +167,6 @@ private fun String.stripTags() = this.substring(this.indexOf("</mx-reply>") + "<
.replace("<em>", "")
.replace("</em>", "")
private fun ApiTimelineEvent.TimelineMessage.isEdit() = this.content.relation?.relationType == "m.replace" && this.content.relation.eventId != null
private fun ApiTimelineEvent.TimelineMessage.isEdit() = this.content.relation?.relationType == "m.replace" && this.content.relation?.eventId != null
private fun ApiTimelineEvent.TimelineMessage.isReply() = this.content.relation?.inReplyTo != null
private fun ApiTimelineEvent.TimelineMessage.asTextContent() = this.content as ApiTimelineEvent.TimelineMessage.Content.Text

View File

@ -0,0 +1,61 @@
package app.dapk.st.matrix.sync.internal.sync
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.common.UserCredentials
import app.dapk.st.matrix.common.convertMxUrToUrl
import app.dapk.st.matrix.sync.MessageMeta
import app.dapk.st.matrix.sync.RoomEvent
import app.dapk.st.matrix.sync.RoomMembersService
import app.dapk.st.matrix.sync.find
import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent
internal class RoomEventFactory(
private val roomMembersService: RoomMembersService
) {
suspend fun ApiTimelineEvent.TimelineMessage.toTextMessage(
roomId: RoomId,
content: String = this.asTextContent().formattedBody?.stripTags() ?: this.asTextContent().body ?: "redacted",
edited: Boolean = false,
utcTimestamp: Long = this.utcTimestamp,
) = RoomEvent.Message(
eventId = this.id,
content = content,
author = roomMembersService.find(roomId, this.senderId)!!,
utcTimestamp = utcTimestamp,
meta = MessageMeta.FromServer,
edited = edited,
)
suspend fun ApiTimelineEvent.TimelineMessage.toImageMessage(
userCredentials: UserCredentials,
roomId: RoomId,
edited: Boolean = false,
utcTimestamp: Long = this.utcTimestamp,
imageMeta: RoomEvent.Image.ImageMeta = this.readImageMeta(userCredentials)
) = RoomEvent.Image(
eventId = this.id,
imageMeta = imageMeta,
author = roomMembersService.find(roomId, this.senderId)!!,
utcTimestamp = utcTimestamp,
meta = MessageMeta.FromServer,
edited = edited,
)
private fun ApiTimelineEvent.TimelineMessage.readImageMeta(userCredentials: UserCredentials): RoomEvent.Image.ImageMeta {
val content = this.content as ApiTimelineEvent.TimelineMessage.Content.Image
return RoomEvent.Image.ImageMeta(
content.info.width,
content.info.height,
content.file?.url?.convertMxUrToUrl(userCredentials.homeServer) ?: content.url!!.convertMxUrToUrl(userCredentials.homeServer),
keys = content.file?.let { RoomEvent.Image.ImageMeta.Keys(it.key.k, it.iv, it.v, it.hashes) }
)
}
}
private fun String.stripTags() = this.substring(this.indexOf("</mx-reply>") + "</mx-reply>".length)
.trim()
.replace("<em>", "")
.replace("</em>", "")
private fun ApiTimelineEvent.TimelineMessage.asTextContent() = this.content as ApiTimelineEvent.TimelineMessage.Content.Text

View File

@ -1,9 +1,6 @@
package app.dapk.st.matrix.sync.internal.sync
import app.dapk.st.matrix.common.MatrixLogTag
import app.dapk.st.matrix.common.MatrixLogger
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.common.matrixLog
import app.dapk.st.matrix.common.*
import app.dapk.st.matrix.sync.RoomEvent
import app.dapk.st.matrix.sync.RoomState
import app.dapk.st.matrix.sync.internal.room.RoomEventsDecrypter
@ -14,13 +11,13 @@ internal class RoomRefresher(
private val logger: MatrixLogger
) {
suspend fun refreshRoomContent(roomId: RoomId): RoomState? {
suspend fun refreshRoomContent(roomId: RoomId, userCredentials: UserCredentials): RoomState? {
logger.matrixLog(MatrixLogTag.SYNC, "reducing side effect: $roomId")
return when (val previousState = roomDataSource.read(roomId)) {
null -> null.also { logger.matrixLog(MatrixLogTag.SYNC, "no previous state to update") }
else -> {
logger.matrixLog(MatrixLogTag.SYNC, "previous state updated")
val decryptedEvents = previousState.events.decryptEvents()
val decryptedEvents = previousState.events.decryptEvents(userCredentials)
val lastMessage = decryptedEvents.sortedByDescending { it.utcTimestamp }.findLastMessage()
previousState.copy(events = decryptedEvents, roomOverview = previousState.roomOverview.copy(lastMessage = lastMessage)).also {
@ -30,6 +27,6 @@ internal class RoomRefresher(
}
}
private suspend fun List<RoomEvent>.decryptEvents() = roomEventsDecrypter.decryptRoomEvents(this)
private suspend fun List<RoomEvent>.decryptEvents(userCredentials: UserCredentials) = roomEventsDecrypter.decryptRoomEvents(userCredentials, this)
}

View File

@ -1,6 +1,7 @@
package app.dapk.st.matrix.sync.internal.sync
import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.extensions.ErrorTracker
import app.dapk.st.core.withIoContextAsync
import app.dapk.st.matrix.common.*
import app.dapk.st.matrix.common.MatrixLogTag.SYNC
@ -16,6 +17,7 @@ internal class SyncReducer(
private val roomRefresher: RoomRefresher,
private val roomDataSource: RoomDataSource,
private val logger: MatrixLogger,
private val errorTracker: ErrorTracker,
private val coroutineDispatchers: CoroutineDispatchers,
) {
@ -48,14 +50,14 @@ internal class SyncReducer(
isInitialSync = isInitialSync
)
}
.onFailure { logger.matrixLog(SYNC, "failed to reduce: $roomId, skipping") }
.onFailure { errorTracker.track(it, "failed to reduce: $roomId, skipping") }
.getOrNull()
}
} ?: emptyList()
val roomsWithSideEffects = sideEffects.roomsToRefresh(alreadyHandledRooms = apiUpdatedRooms?.keys ?: emptySet()).map { roomId ->
coroutineDispatchers.withIoContextAsync {
roomRefresher.refreshRoomContent(roomId)
roomRefresher.refreshRoomContent(roomId, userCredentials)
}
}

View File

@ -1,5 +1,6 @@
package app.dapk.st.matrix.sync.internal.sync
import app.dapk.st.matrix.common.UserCredentials
import app.dapk.st.matrix.sync.RoomEvent
import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent
import app.dapk.st.matrix.sync.internal.room.RoomEventsDecrypter
@ -22,13 +23,13 @@ internal class TimelineEventsProcessor(
private suspend fun processNewEvents(roomToProcess: RoomToProcess, previousEvents: List<RoomEvent>): List<RoomEvent> {
val decryptedTimeline = roomToProcess.apiSyncRoom.timeline.apiTimelineEvents.decryptEvents()
val decryptedPreviousEvents = previousEvents.decryptEvents()
val decryptedPreviousEvents = previousEvents.decryptEvents(roomToProcess.userCredentials)
val newEvents = with(roomEventCreator) {
decryptedTimeline.value.mapNotNull { event ->
val roomEvent = when (event) {
is ApiTimelineEvent.Encrypted -> event.toRoomEvent(roomToProcess.roomId)
is ApiTimelineEvent.TimelineMessage -> event.toRoomEvent(roomToProcess.roomId) { eventId ->
is ApiTimelineEvent.TimelineMessage -> event.toRoomEvent(roomToProcess.userCredentials, roomToProcess.roomId) { eventId ->
eventLookupUseCase.lookup(eventId, decryptedTimeline, decryptedPreviousEvents)
}
is ApiTimelineEvent.Encryption -> null
@ -46,7 +47,8 @@ internal class TimelineEventsProcessor(
}
private suspend fun List<ApiTimelineEvent>.decryptEvents() = DecryptedTimeline(eventDecrypter.decryptTimelineEvents(this))
private suspend fun List<RoomEvent>.decryptEvents() = DecryptedRoomEvents(roomEventsDecrypter.decryptRoomEvents(this))
private suspend fun List<RoomEvent>.decryptEvents(userCredentials: UserCredentials) =
DecryptedRoomEvents(roomEventsDecrypter.decryptRoomEvents(userCredentials, this))
}

View File

@ -42,6 +42,7 @@ internal class UnreadEventsUseCase(
when (it) {
is RoomEvent.Message -> it.author.id == selfId
is RoomEvent.Reply -> it.message.author.id == selfId
is RoomEvent.Image -> it.author.id == selfId
}
}.map { it.eventId }
roomStore.insertUnread(overview.roomId, eventsFromOthers)

View File

@ -21,6 +21,7 @@ private val AN_ENCRYPTED_ROOM_REPLY = aRoomReplyMessageEvent(
replyingTo = AN_ENCRYPTED_ROOM_MESSAGE.copy(eventId = anEventId("other-event"))
)
private val A_DECRYPTED_CONTENT = DecryptedContent.TimelineText(aTimelineTextEventContent(body = A_DECRYPTED_MESSAGE_CONTENT))
private val A_USER_CREDENTIALS = aUserCredentials()
class RoomEventsDecrypterTest {
@ -35,7 +36,7 @@ class RoomEventsDecrypterTest {
@Test
fun `given clear message event, when decrypting, then does nothing`() = runTest {
val aClearMessageEvent = aRoomMessageEvent(encryptedContent = null)
val result = roomEventsDecrypter.decryptRoomEvents(listOf(aClearMessageEvent))
val result = roomEventsDecrypter.decryptRoomEvents(A_USER_CREDENTIALS, listOf(aClearMessageEvent))
result shouldBeEqualTo listOf(aClearMessageEvent)
}
@ -44,7 +45,7 @@ class RoomEventsDecrypterTest {
fun `given encrypted message event, when decrypting, then applies decrypted body and removes encrypted content`() = runTest {
givenEncryptedMessage(AN_ENCRYPTED_ROOM_MESSAGE, decryptsTo = A_DECRYPTED_CONTENT)
val result = roomEventsDecrypter.decryptRoomEvents(listOf(AN_ENCRYPTED_ROOM_MESSAGE))
val result = roomEventsDecrypter.decryptRoomEvents(A_USER_CREDENTIALS, listOf(AN_ENCRYPTED_ROOM_MESSAGE))
result shouldBeEqualTo listOf(AN_ENCRYPTED_ROOM_MESSAGE.copy(content = A_DECRYPTED_MESSAGE_CONTENT, encryptedContent = null))
}
@ -53,12 +54,12 @@ class RoomEventsDecrypterTest {
fun `given encrypted reply event, when decrypting, then decrypts message and replyTo`() = runTest {
givenEncryptedReply(AN_ENCRYPTED_ROOM_REPLY, decryptsTo = A_DECRYPTED_CONTENT)
val result = roomEventsDecrypter.decryptRoomEvents(listOf(AN_ENCRYPTED_ROOM_REPLY))
val result = roomEventsDecrypter.decryptRoomEvents(A_USER_CREDENTIALS, listOf(AN_ENCRYPTED_ROOM_REPLY))
result shouldBeEqualTo listOf(
AN_ENCRYPTED_ROOM_REPLY.copy(
message = AN_ENCRYPTED_ROOM_REPLY.message.copy(content = A_DECRYPTED_MESSAGE_CONTENT, encryptedContent = null),
replyingTo = AN_ENCRYPTED_ROOM_REPLY.replyingTo.copy(content = A_DECRYPTED_MESSAGE_CONTENT, encryptedContent = null),
message = (AN_ENCRYPTED_ROOM_REPLY.message as RoomEvent.Message).copy(content = A_DECRYPTED_MESSAGE_CONTENT, encryptedContent = null),
replyingTo = (AN_ENCRYPTED_ROOM_REPLY.replyingTo as RoomEvent.Message).copy(content = A_DECRYPTED_MESSAGE_CONTENT, encryptedContent = null),
)
)
}
@ -66,12 +67,12 @@ class RoomEventsDecrypterTest {
private fun givenEncryptedMessage(roomMessage: RoomEvent.Message, decryptsTo: DecryptedContent) {
val model = roomMessage.encryptedContent!!.toModel()
fakeMessageDecrypter.givenDecrypt(model)
.returns(aDecryptionSuccessResult(payload = JsonString(Json.encodeToString(DecryptedContent.serializer(), decryptsTo))))
.returns(aDecryptionSuccessResult(payload = JsonString(Json { encodeDefaults = true }.encodeToString(DecryptedContent.serializer(), decryptsTo))))
}
private fun givenEncryptedReply(roomReply: RoomEvent.Reply, decryptsTo: DecryptedContent) {
givenEncryptedMessage(roomReply.message, decryptsTo)
givenEncryptedMessage(roomReply.replyingTo, decryptsTo)
givenEncryptedMessage(roomReply.message as RoomEvent.Message, decryptsTo)
givenEncryptedMessage(roomReply.replyingTo as RoomEvent.Message, decryptsTo)
}
}

View File

@ -26,12 +26,13 @@ private val A_TEXT_EVENT_WITHOUT_CONTENT = anApiTimelineTextEvent(
senderId = A_SENDER.id,
content = aTimelineTextEventContent(body = null)
)
private val A_USER_CREDENTIALS = aUserCredentials()
internal class RoomEventCreatorTest {
private val fakeRoomMembersService = FakeRoomMembersService()
private val roomEventCreator = RoomEventCreator(fakeRoomMembersService, FakeMatrixLogger(), FakeErrorTracker())
private val roomEventCreator = RoomEventCreator(fakeRoomMembersService, FakeErrorTracker(), RoomEventFactory(fakeRoomMembersService))
@Test
fun `given Megolm encrypted event then maps to encrypted room message`() = runTest {
@ -71,7 +72,7 @@ internal class RoomEventCreatorTest {
fun `given text event then maps to room message`() = runTest {
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
val result = with(roomEventCreator) { A_TEXT_EVENT.toRoomEvent(A_ROOM_ID, EMPTY_LOOKUP) }
val result = with(roomEventCreator) { A_TEXT_EVENT.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, EMPTY_LOOKUP) }
result shouldBeEqualTo aRoomMessageEvent(
eventId = A_TEXT_EVENT.id,
@ -85,7 +86,7 @@ internal class RoomEventCreatorTest {
fun `given text event without body then maps to redacted room message`() = runTest {
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
val result = with(roomEventCreator) { A_TEXT_EVENT_WITHOUT_CONTENT.toRoomEvent(A_ROOM_ID, EMPTY_LOOKUP) }
val result = with(roomEventCreator) { A_TEXT_EVENT_WITHOUT_CONTENT.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, EMPTY_LOOKUP) }
result shouldBeEqualTo aRoomMessageEvent(
eventId = A_TEXT_EVENT_WITHOUT_CONTENT.id,
@ -100,12 +101,12 @@ internal class RoomEventCreatorTest {
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
val editEvent = anApiTimelineTextEvent().toEditEvent(newTimestamp = 0, messageContent = A_TEXT_EVENT_MESSAGE)
val result = with(roomEventCreator) { editEvent.toRoomEvent(A_ROOM_ID, EMPTY_LOOKUP) }
val result = with(roomEventCreator) { editEvent.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, EMPTY_LOOKUP) }
result shouldBeEqualTo aRoomMessageEvent(
eventId = editEvent.id,
utcTimestamp = editEvent.utcTimestamp,
content = editEvent.content.body!!,
content = editEvent.asTextContent().body!!,
author = A_SENDER,
edited = true
)
@ -118,7 +119,7 @@ internal class RoomEventCreatorTest {
val editedMessage = originalMessage.toEditEvent(newTimestamp = 1000, messageContent = A_TEXT_EVENT_MESSAGE)
val lookup = givenLookup(originalMessage)
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_ROOM_ID, lookup) }
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
result shouldBeEqualTo aRoomMessageEvent(
eventId = originalMessage.id,
@ -136,7 +137,7 @@ internal class RoomEventCreatorTest {
val editedMessage = originalMessage.toEditEvent(newTimestamp = 1000, messageContent = A_TEXT_EVENT_MESSAGE)
val lookup = givenLookup(originalMessage)
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_ROOM_ID, lookup) }
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
result shouldBeEqualTo aRoomMessageEvent(
eventId = originalMessage.eventId,
@ -151,10 +152,10 @@ internal class RoomEventCreatorTest {
fun `given edited event which relates to a room reply event then only updates message`() = runTest {
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
val originalMessage = aRoomReplyMessageEvent(message = aRoomMessageEvent())
val editedMessage = originalMessage.message.toEditEvent(newTimestamp = 1000, messageContent = A_TEXT_EVENT_MESSAGE)
val editedMessage = (originalMessage.message as RoomEvent.Message).toEditEvent(newTimestamp = 1000, messageContent = A_TEXT_EVENT_MESSAGE)
val lookup = givenLookup(originalMessage)
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_ROOM_ID, lookup) }
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
result shouldBeEqualTo aRoomReplyMessageEvent(
replyingTo = originalMessage.replyingTo,
@ -174,7 +175,7 @@ internal class RoomEventCreatorTest {
val editedMessage = originalMessage.toEditEvent(newTimestamp = 0, messageContent = A_TEXT_EVENT_MESSAGE)
val lookup = givenLookup(originalMessage)
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_ROOM_ID, lookup) }
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
result shouldBeEqualTo null
}
@ -185,22 +186,23 @@ internal class RoomEventCreatorTest {
val editedMessage = originalMessage.toEditEvent(newTimestamp = 0, messageContent = A_TEXT_EVENT_MESSAGE)
val lookup = givenLookup(originalMessage)
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_ROOM_ID, lookup) }
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
result shouldBeEqualTo null
}
@Test
fun `given reply event with no relation then maps to new room message`() = runTest {
fun `given reply event with no relation then maps to new room message using the full body`() = runTest {
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
val replyEvent = anApiTimelineTextEvent().toReplyEvent(messageContent = A_TEXT_EVENT_MESSAGE)
val result = with(roomEventCreator) { replyEvent.toRoomEvent(A_ROOM_ID, EMPTY_LOOKUP) }
println(replyEvent.content)
val result = with(roomEventCreator) { replyEvent.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, EMPTY_LOOKUP) }
result shouldBeEqualTo aRoomMessageEvent(
eventId = replyEvent.id,
utcTimestamp = replyEvent.utcTimestamp,
content = "${replyEvent.content.body}",
content = replyEvent.asTextContent().body!!,
author = A_SENDER,
)
}
@ -212,13 +214,13 @@ internal class RoomEventCreatorTest {
val replyMessage = originalMessage.toReplyEvent(messageContent = A_REPLY_EVENT_MESSAGE)
val lookup = givenLookup(originalMessage)
val result = with(roomEventCreator) { replyMessage.toRoomEvent(A_ROOM_ID, lookup) }
val result = with(roomEventCreator) { replyMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
result shouldBeEqualTo aRoomReplyMessageEvent(
replyingTo = aRoomMessageEvent(
eventId = originalMessage.id,
utcTimestamp = originalMessage.utcTimestamp,
content = originalMessage.content.body!!,
content = originalMessage.asTextContent().body!!,
author = A_SENDER,
),
message = aRoomMessageEvent(
@ -237,7 +239,7 @@ internal class RoomEventCreatorTest {
val replyMessage = originalMessage.toReplyEvent(messageContent = A_REPLY_EVENT_MESSAGE)
val lookup = givenLookup(originalMessage)
val result = with(roomEventCreator) { replyMessage.toRoomEvent(A_ROOM_ID, lookup) }
val result = with(roomEventCreator) { replyMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
result shouldBeEqualTo aRoomReplyMessageEvent(
replyingTo = originalMessage,
@ -254,10 +256,10 @@ internal class RoomEventCreatorTest {
fun `given reply event which relates to another room reply event then maps to reply with the reply's message`() = runTest {
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
val originalMessage = aRoomReplyMessageEvent()
val replyMessage = originalMessage.message.toReplyEvent(messageContent = A_REPLY_EVENT_MESSAGE)
val replyMessage = (originalMessage.message as RoomEvent.Message).toReplyEvent(messageContent = A_REPLY_EVENT_MESSAGE)
val lookup = givenLookup(originalMessage)
val result = with(roomEventCreator) { replyMessage.toRoomEvent(A_ROOM_ID, lookup) }
val result = with(roomEventCreator) { replyMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
result shouldBeEqualTo aRoomReplyMessageEvent(
replyingTo = originalMessage.message,
@ -327,3 +329,5 @@ private fun ApiEncryptedContent.toMegolm(): RoomEvent.Message.MegOlmV1 {
private class FakeLookup(private val result: LookupResult) : suspend (EventId) -> LookupResult {
override suspend fun invoke(p1: EventId) = result
}
private fun ApiTimelineEvent.TimelineMessage.asTextContent() = this.content as ApiTimelineEvent.TimelineMessage.Content.Text

View File

@ -22,6 +22,7 @@ private object ARoom {
val DECRYPTED_EVENTS = listOf(MESSAGE_EVENT, DECRYPTED_EVENT)
val NEW_STATE = RoomState(aRoomOverview(lastMessage = DECRYPTED_EVENT.asLastMessage()), DECRYPTED_EVENTS)
}
private val A_USER_CREDENTIALS = aUserCredentials()
internal class RoomRefresherTest {
@ -38,7 +39,7 @@ internal class RoomRefresherTest {
fun `given no existing room when refreshing then does nothing`() = runTest {
fakeRoomDataSource.givenNoCachedRoom(A_ROOM_ID)
val result = roomRefresher.refreshRoomContent(aRoomId())
val result = roomRefresher.refreshRoomContent(aRoomId(), A_USER_CREDENTIALS)
result shouldBeEqualTo null
fakeRoomDataSource.verifyNoChanges()
@ -48,9 +49,9 @@ internal class RoomRefresherTest {
fun `given existing room when refreshing then processes existing state`() = runTest {
fakeRoomDataSource.expect { it.instance.persist(RoomId(any()), any(), any()) }
fakeRoomDataSource.givenRoom(A_ROOM_ID, ARoom.PREVIOUS_STATE)
fakeRoomEventsDecrypter.givenDecrypts(ARoom.PREVIOUS_STATE.events, ARoom.DECRYPTED_EVENTS)
fakeRoomEventsDecrypter.givenDecrypts(A_USER_CREDENTIALS, ARoom.PREVIOUS_STATE.events, ARoom.DECRYPTED_EVENTS)
val result = roomRefresher.refreshRoomContent(aRoomId())
val result = roomRefresher.refreshRoomContent(aRoomId(), A_USER_CREDENTIALS)
fakeRoomDataSource.verifyRoomUpdated(ARoom.PREVIOUS_STATE, ARoom.NEW_STATE)
result shouldBeEqualTo ARoom.NEW_STATE

View File

@ -22,6 +22,7 @@ private val A_TEXT_TIMELINE_EVENT = anApiTimelineTextEvent()
private val A_MESSAGE_ROOM_EVENT = aRoomMessageEvent(anEventId("a-message"))
private val AN_ENCRYPTED_ROOM_EVENT = anEncryptedRoomMessageEvent(anEventId("encrypted-message"))
private val A_LOOKUP_EVENT_ID = anEventId("lookup-id")
private val A_USER_CREDENTIALS = aUserCredentials()
class TimelineEventsProcessorTest {
@ -41,7 +42,7 @@ class TimelineEventsProcessorTest {
fun `given a room with no events then returns empty`() = runTest {
val previousEvents = emptyList<RoomEvent>()
val roomToProcess = aRoomToProcess()
fakeRoomEventsDecrypter.givenDecrypts(previousEvents)
fakeRoomEventsDecrypter.givenDecrypts(A_USER_CREDENTIALS, previousEvents)
fakeSyncEventDecrypter.givenDecrypts(roomToProcess.apiSyncRoom.timeline.apiTimelineEvents)
val result = timelineEventsProcessor.process(roomToProcess, previousEvents)
@ -54,11 +55,18 @@ class TimelineEventsProcessorTest {
val previousEvents = listOf(aRoomMessageEvent(eventId = anEventId("previous-event")))
val newTimelineEvents = listOf(AN_ENCRYPTED_TIMELINE_EVENT, A_TEXT_TIMELINE_EVENT)
val roomToProcess = aRoomToProcess(apiSyncRoom = anApiSyncRoom(anApiSyncRoomTimeline(newTimelineEvents)))
fakeRoomEventsDecrypter.givenDecrypts(previousEvents)
fakeRoomEventsDecrypter.givenDecrypts(A_USER_CREDENTIALS, previousEvents)
fakeSyncEventDecrypter.givenDecrypts(newTimelineEvents)
fakeEventLookup.givenLookup(A_LOOKUP_EVENT_ID, DecryptedTimeline(newTimelineEvents), DecryptedRoomEvents(previousEvents), ANY_LOOKUP_RESULT)
fakeRoomEventCreator.givenCreates(A_ROOM_ID, AN_ENCRYPTED_TIMELINE_EVENT, AN_ENCRYPTED_ROOM_EVENT)
fakeRoomEventCreator.givenCreatesUsingLookup(A_ROOM_ID, A_LOOKUP_EVENT_ID, A_TEXT_TIMELINE_EVENT, A_MESSAGE_ROOM_EVENT, ANY_LOOKUP_RESULT)
fakeRoomEventCreator.givenCreatesUsingLookup(
A_USER_CREDENTIALS,
A_ROOM_ID,
A_LOOKUP_EVENT_ID,
A_TEXT_TIMELINE_EVENT,
A_MESSAGE_ROOM_EVENT,
ANY_LOOKUP_RESULT
)
val result = timelineEventsProcessor.process(roomToProcess, previousEvents)
@ -79,7 +87,7 @@ class TimelineEventsProcessorTest {
anIgnoredApiTimelineEvent()
)
val roomToProcess = aRoomToProcess(apiSyncRoom = anApiSyncRoom(anApiSyncRoomTimeline(newTimelineEvents)))
fakeRoomEventsDecrypter.givenDecrypts(previousEvents)
fakeRoomEventsDecrypter.givenDecrypts(A_USER_CREDENTIALS, previousEvents)
fakeSyncEventDecrypter.givenDecrypts(newTimelineEvents)
val result = timelineEventsProcessor.process(roomToProcess, previousEvents)

View File

@ -2,6 +2,7 @@ package internalfake
import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.common.UserCredentials
import app.dapk.st.matrix.sync.RoomEvent
import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent
import app.dapk.st.matrix.sync.internal.sync.LookupResult
@ -18,9 +19,16 @@ internal class FakeRoomEventCreator {
coEvery { with(instance) { event.toRoomEvent(roomId) } } returns result
}
fun givenCreatesUsingLookup(roomId: RoomId, eventIdToLookup: EventId, event: ApiTimelineEvent.TimelineMessage, result: RoomEvent, lookupResult: LookupResult) {
fun givenCreatesUsingLookup(
userCredentials: UserCredentials,
roomId: RoomId,
eventIdToLookup: EventId,
event: ApiTimelineEvent.TimelineMessage,
result: RoomEvent,
lookupResult: LookupResult
) {
val slot = slot<suspend (EventId) -> LookupResult>()
coEvery { with(instance) { event.toRoomEvent(roomId, capture(slot)) } } answers {
coEvery { with(instance) { event.toRoomEvent(userCredentials, roomId, capture(slot)) } } answers {
runBlocking {
if (slot.captured.invoke(eventIdToLookup) == lookupResult) {
result

View File

@ -1,5 +1,6 @@
package internalfake
import app.dapk.st.matrix.common.UserCredentials
import app.dapk.st.matrix.sync.RoomEvent
import app.dapk.st.matrix.sync.internal.room.RoomEventsDecrypter
import io.mockk.coEvery
@ -8,7 +9,7 @@ import io.mockk.mockk
internal class FakeRoomEventsDecrypter {
val instance = mockk<RoomEventsDecrypter>()
fun givenDecrypts(previousEvents: List<RoomEvent>, result: List<RoomEvent> = previousEvents) {
coEvery { instance.decryptRoomEvents(previousEvents) } returns result
fun givenDecrypts(userCredentials: UserCredentials, previousEvents: List<RoomEvent>, result: List<RoomEvent> = previousEvents) {
coEvery { instance.decryptRoomEvents(userCredentials, previousEvents) } returns result
}
}

View File

@ -39,9 +39,8 @@ internal fun anApiTimelineTextEvent(
internal fun aTimelineTextEventContent(
body: String? = null,
formattedBody: String? = null,
type: String? = null,
relation: ApiTimelineEvent.TimelineMessage.Relation? = null,
) = ApiTimelineEvent.TimelineMessage.Content(body, formattedBody, type, relation)
) = ApiTimelineEvent.TimelineMessage.Content.Text(body, formattedBody, relation)
internal fun anEditRelation(originalId: EventId) = ApiTimelineEvent.TimelineMessage.Relation(
relationType = "m.replace",

View File

@ -15,8 +15,8 @@ fun aRoomMessageEvent(
) = RoomEvent.Message(eventId, utcTimestamp, content, author, meta, encryptedContent, edited)
fun aRoomReplyMessageEvent(
message: RoomEvent.Message = aRoomMessageEvent(),
replyingTo: RoomEvent.Message = aRoomMessageEvent(eventId = anEventId("in-reply-to-id")),
message: RoomEvent = aRoomMessageEvent(),
replyingTo: RoomEvent = aRoomMessageEvent(eventId = anEventId("in-reply-to-id")),
) = RoomEvent.Reply(message, replyingTo)
fun anEncryptedRoomMessageEvent(