diff --git a/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncFetchResponseDTO.kt b/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncFetchResponseDTO.kt index 4ec02b5ae46..2a68f534bb6 100644 --- a/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncFetchResponseDTO.kt +++ b/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncFetchResponseDTO.kt @@ -17,18 +17,11 @@ */ package com.wire.kalium.network.api.authenticated.remoteBackup -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - /** - * Response payload for fetching messages from the backup service + * Response payload for fetching events from the backup service. */ -@Serializable data class MessageSyncFetchResponseDTO( - @SerialName("has_more") val hasMore: Boolean, - @SerialName("conversations") - val conversations: Map, - @SerialName("pagination_token") + val events: List, val paginationToken: String? = null ) diff --git a/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncRequestDTO.kt b/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncRequestDTO.kt index 4bec43ba505..0658ac5c5e6 100644 --- a/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncRequestDTO.kt +++ b/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncRequestDTO.kt @@ -17,20 +17,11 @@ */ package com.wire.kalium.network.api.authenticated.remoteBackup -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - /** - * Request payload for synchronizing messages to the backup service + * Request payload for synchronizing messages to the backup service. + * Events can be upserts, deletes, or last-read updates. */ -@Serializable data class MessageSyncRequestDTO( - @SerialName("user_id") val userId: String, - @SerialName("upserts") - val upserts: Map>, // Map from conversation ID to list of upserts - @SerialName("deletions") - val deletions: Map>, // Map from conversation ID to list of message IDs to delete - @SerialName("conversations_last_read") - val conversationsLastRead: Map = emptyMap() // Map from conversation ID to last read timestamp (epoch millis) + val events: List ) diff --git a/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncUpsertDTO.kt b/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncUpsertDTO.kt deleted file mode 100644 index adce390352b..00000000000 --- a/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncUpsertDTO.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Wire - * Copyright (C) 2026 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.kalium.network.api.authenticated.remoteBackup - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * Individual message upsert operation - */ -@Serializable -data class MessageSyncUpsertDTO( - @SerialName("message_id") - val messageId: String, - @SerialName("timestamp") - val timestamp: Long, // Unix timestamp in milliseconds - @SerialName("payload") - val payload: String // JSON string of BackupMessage -) diff --git a/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/RemoteBAckupMessageContentDTO.kt b/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/RemoteBAckupMessageContentDTO.kt new file mode 100644 index 00000000000..da0fe3f8dba --- /dev/null +++ b/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/RemoteBAckupMessageContentDTO.kt @@ -0,0 +1,137 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.kalium.network.api.authenticated.remoteBackup + +import com.wire.kalium.network.api.model.QualifiedID +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Sealed class representing different types of message content for sync. + * This mirrors the structure of BackupMessageContent. + */ +@Serializable +sealed class RemoteBAckupMessageContentDTO { + + @Serializable + @SerialName("text") + data class Text( + @SerialName("text") + val text: String, + @SerialName("mentions") + val mentions: List = emptyList(), + @SerialName("quotedMessageId") + val quotedMessageId: String? = null + ) : RemoteBAckupMessageContentDTO() + + @Serializable + @SerialName("asset") + data class Asset( + @SerialName("mimeType") + val mimeType: String, + @SerialName("size") + val size: Int, + @SerialName("name") + val name: String?, + @SerialName("otrKey") + val otrKey: String, + @SerialName("sha256") + val sha256: String, + @SerialName("assetId") + val assetId: String, + @SerialName("assetToken") + val assetToken: String?, + @SerialName("assetDomain") + val assetDomain: String?, + @SerialName("encryption") + val encryption: String?, + @SerialName("metaData") + val metaData: MessageSyncAssetMetadataDTO? + ) : RemoteBAckupMessageContentDTO() + + @Serializable + @SerialName("location") + data class Location( + @SerialName("longitude") + val longitude: Float, + @SerialName("latitude") + val latitude: Float, + @SerialName("name") + val name: String?, + @SerialName("zoom") + val zoom: Int? + ) : RemoteBAckupMessageContentDTO() +} + +/** + * DTO for user mentions in text messages. + */ +@Serializable +data class MessageSyncMentionDTO( + @SerialName("userId") + val userId: QualifiedID, + @SerialName("start") + val start: Int, + @SerialName("length") + val length: Int +) + +/** + * Sealed class representing different types of asset metadata. + */ +@Serializable +sealed class MessageSyncAssetMetadataDTO { + + @Serializable + @SerialName("image") + data class Image( + @SerialName("width") + val width: Int, + @SerialName("height") + val height: Int, + @SerialName("tag") + val tag: String? + ) : MessageSyncAssetMetadataDTO() + + @Serializable + @SerialName("video") + data class Video( + @SerialName("width") + val width: Int?, + @SerialName("height") + val height: Int?, + @SerialName("duration") + val duration: Long? + ) : MessageSyncAssetMetadataDTO() + + @Serializable + @SerialName("audio") + data class Audio( + @SerialName("normalization") + val normalization: String?, + @SerialName("duration") + val duration: Long? + ) : MessageSyncAssetMetadataDTO() + + @Serializable + @SerialName("generic") + data class Generic( + @SerialName("name") + val name: String? + ) : MessageSyncAssetMetadataDTO() +} diff --git a/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/ConversationMessagesDTO.kt b/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/RemoteBackupEventDTO.kt similarity index 61% rename from data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/ConversationMessagesDTO.kt rename to data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/RemoteBackupEventDTO.kt index d6a1fe8e1a8..35191b20b73 100644 --- a/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/ConversationMessagesDTO.kt +++ b/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/RemoteBackupEventDTO.kt @@ -17,16 +17,24 @@ */ package com.wire.kalium.network.api.authenticated.remoteBackup -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - /** - * Messages and metadata for a single conversation + * Events exchanged with the remote backup service. */ -@Serializable -data class ConversationMessagesDTO( - @SerialName("last_read") - val lastRead: Long? = null, // Last read timestamp (epoch millis) - @SerialName("messages") - val messages: List -) +sealed class RemoteBackupEventDTO { + + data class Upsert( + val messageId: String, + val timestamp: Long, + val payload: RemoteBackupPayloadDTO + ) : RemoteBackupEventDTO() + + data class Delete( + val conversationId: String, + val messageId: String + ) : RemoteBackupEventDTO() + + data class LastRead( + val conversationId: String, + val lastRead: Long + ) : RemoteBackupEventDTO() +} diff --git a/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncResultDTO.kt b/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/RemoteBackupPayloadDTO.kt similarity index 56% rename from data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncResultDTO.kt rename to data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/RemoteBackupPayloadDTO.kt index 440600b8d73..fc16ccd7b49 100644 --- a/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncResultDTO.kt +++ b/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/RemoteBackupPayloadDTO.kt @@ -17,18 +17,28 @@ */ package com.wire.kalium.network.api.authenticated.remoteBackup +import com.wire.kalium.network.api.model.QualifiedID import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** - * Individual message result from fetch operation + * DTO representing the payload of a message sync operation. + * This mirrors the structure of BackupMessage for type-safe serialization. */ @Serializable -data class MessageSyncResultDTO( - @SerialName("timestamp") - val timestamp: Long, - @SerialName("message_id") - val messageId: String, - @SerialName("payload") - val payload: String // JSON-encoded string of BackupMessage +data class RemoteBackupPayloadDTO( + @SerialName("id") + val id: String, + @SerialName("conversationId") + val conversationId: QualifiedID, + @SerialName("senderUserId") + val senderUserId: QualifiedID, + @SerialName("senderClientId") + val senderClientId: String, + @SerialName("creationDate") + val creationDate: Long, + @SerialName("content") + val content: RemoteBAckupMessageContentDTO, + @SerialName("lastEditTime") + val lastEditTime: Long? = null ) diff --git a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/base/authenticated/remoteBackup/RemoteBackupProtoMapper.kt b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/base/authenticated/remoteBackup/RemoteBackupProtoMapper.kt new file mode 100644 index 00000000000..cc1fe08e7af --- /dev/null +++ b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/base/authenticated/remoteBackup/RemoteBackupProtoMapper.kt @@ -0,0 +1,284 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.kalium.network.api.base.authenticated.remoteBackup + +import com.wire.kalium.network.api.authenticated.remoteBackup.MessageSyncAssetMetadataDTO +import com.wire.kalium.network.api.authenticated.remoteBackup.MessageSyncFetchResponseDTO +import com.wire.kalium.network.api.authenticated.remoteBackup.MessageSyncMentionDTO +import com.wire.kalium.network.api.authenticated.remoteBackup.MessageSyncRequestDTO +import com.wire.kalium.network.api.authenticated.remoteBackup.RemoteBackupEventDTO +import com.wire.kalium.network.api.authenticated.remoteBackup.RemoteBAckupMessageContentDTO +import com.wire.kalium.network.api.authenticated.remoteBackup.RemoteBackupPayloadDTO +import com.wire.kalium.network.api.model.QualifiedID +import com.wire.kalium.protobuf.decodeFromByteArray +import com.wire.kalium.protobuf.encodeToByteArray +import com.wire.kalium.protobuf.remote_backup.RemoteBackupAsset +import com.wire.kalium.protobuf.remote_backup.RemoteBackupAudioMetaData +import com.wire.kalium.protobuf.remote_backup.RemoteBackupDelete +import com.wire.kalium.protobuf.remote_backup.RemoteBackupEvent +import com.wire.kalium.protobuf.remote_backup.RemoteBackupGenericMetaData +import com.wire.kalium.protobuf.remote_backup.RemoteBackupImageMetaData +import com.wire.kalium.protobuf.remote_backup.RemoteBackupLastRead +import com.wire.kalium.protobuf.remote_backup.RemoteBackupLocation +import com.wire.kalium.protobuf.remote_backup.RemoteBackupMention +import com.wire.kalium.protobuf.remote_backup.RemoteBackupMessage +import com.wire.kalium.protobuf.remote_backup.RemoteBackupMessageContent +import com.wire.kalium.protobuf.remote_backup.RemoteBackupMessageSyncRequest +import com.wire.kalium.protobuf.remote_backup.RemoteBackupMessageSyncResponse +import com.wire.kalium.protobuf.remote_backup.RemoteBackupQualifiedId +import com.wire.kalium.protobuf.remote_backup.RemoteBackupText +import com.wire.kalium.protobuf.remote_backup.RemoteBackupUpsert +import com.wire.kalium.protobuf.remote_backup.RemoteBackupVideoMetaData + +internal class RemoteBackupProtoMapper { + + fun encodeSyncRequest(request: MessageSyncRequestDTO): ByteArray = + RemoteBackupMessageSyncRequest( + userId = request.userId, + events = request.events.map(::mapEventToProto) + ).encodeToByteArray() + + fun decodeFetchResponse(bytes: ByteArray): MessageSyncFetchResponseDTO { + val response = RemoteBackupMessageSyncResponse.decodeFromByteArray(bytes) + return MessageSyncFetchResponseDTO( + hasMore = response.hasMore, + events = response.events.map(::mapEventFromProto), + paginationToken = response.paginationToken + ) + } + + fun encodeFetchResponse(response: MessageSyncFetchResponseDTO): ByteArray = + RemoteBackupMessageSyncResponse( + hasMore = response.hasMore, + events = response.events.map(::mapEventToProto), + paginationToken = response.paginationToken + ).encodeToByteArray() + + private fun mapEventToProto(event: RemoteBackupEventDTO): RemoteBackupEvent = + when (event) { + is RemoteBackupEventDTO.Upsert -> RemoteBackupEvent( + event = RemoteBackupEvent.Event.Upsert( + RemoteBackupUpsert( + messageId = event.messageId, + timestamp = event.timestamp, + payload = mapPayloadToProto(event.payload) + ) + ) + ) + is RemoteBackupEventDTO.Delete -> RemoteBackupEvent( + event = RemoteBackupEvent.Event.Delete( + RemoteBackupDelete( + conversationId = event.conversationId, + messageId = event.messageId + ) + ) + ) + is RemoteBackupEventDTO.LastRead -> RemoteBackupEvent( + event = RemoteBackupEvent.Event.LastRead( + RemoteBackupLastRead( + conversationId = event.conversationId, + lastRead = event.lastRead + ) + ) + ) + } + + private fun mapEventFromProto(event: RemoteBackupEvent): RemoteBackupEventDTO = + when (val value = event.event) { + is RemoteBackupEvent.Event.Upsert -> RemoteBackupEventDTO.Upsert( + messageId = value.value.messageId, + timestamp = value.value.timestamp, + payload = mapPayloadFromProto(value.value.payload) + ) + is RemoteBackupEvent.Event.Delete -> RemoteBackupEventDTO.Delete( + conversationId = value.value.conversationId, + messageId = value.value.messageId + ) + is RemoteBackupEvent.Event.LastRead -> RemoteBackupEventDTO.LastRead( + conversationId = value.value.conversationId, + lastRead = value.value.lastRead + ) + null -> error("RemoteBackupEvent is missing its event payload") + } + + private fun mapPayloadToProto(payload: RemoteBackupPayloadDTO): RemoteBackupMessage = + RemoteBackupMessage( + id = payload.id, + conversationId = payload.conversationId.toProto(), + senderUserId = payload.senderUserId.toProto(), + senderClientId = payload.senderClientId, + creationDate = payload.creationDate, + content = mapContentToProto(payload.content), + lastEditTime = payload.lastEditTime + ) + + private fun mapPayloadFromProto(payload: RemoteBackupMessage): RemoteBackupPayloadDTO = + RemoteBackupPayloadDTO( + id = payload.id, + conversationId = payload.conversationId.toDto(), + senderUserId = payload.senderUserId.toDto(), + senderClientId = payload.senderClientId, + creationDate = payload.creationDate, + content = mapContentFromProto(payload.content), + lastEditTime = payload.lastEditTime + ) + + private fun mapContentToProto(content: RemoteBAckupMessageContentDTO): RemoteBackupMessageContent = + when (content) { + is RemoteBAckupMessageContentDTO.Text -> + RemoteBackupMessageContent( + content = RemoteBackupMessageContent.Content.Text( + RemoteBackupText( + text = content.text, + mentions = content.mentions.map(::mapMentionToProto), + quotedMessageId = content.quotedMessageId + ) + ) + ) + is RemoteBAckupMessageContentDTO.Asset -> + RemoteBackupMessageContent( + content = RemoteBackupMessageContent.Content.Asset( + RemoteBackupAsset( + mimeType = content.mimeType, + size = content.size.toLong(), + name = content.name, + otrKey = content.otrKey, + sha256 = content.sha256, + assetId = content.assetId, + assetToken = content.assetToken, + assetDomain = content.assetDomain, + encryption = content.encryption, + metaData = content.metaData?.let(::mapAssetMetaToProto) + ) + ) + ) + is RemoteBAckupMessageContentDTO.Location -> + RemoteBackupMessageContent( + content = RemoteBackupMessageContent.Content.Location( + RemoteBackupLocation( + longitude = content.longitude, + latitude = content.latitude, + name = content.name, + zoom = content.zoom + ) + ) + ) + } + + private fun mapContentFromProto(content: RemoteBackupMessageContent): RemoteBAckupMessageContentDTO = + when (val payload = content.content) { + is RemoteBackupMessageContent.Content.Text -> RemoteBAckupMessageContentDTO.Text( + text = payload.value.text, + mentions = payload.value.mentions.map(::mapMentionFromProto), + quotedMessageId = payload.value.quotedMessageId + ) + is RemoteBackupMessageContent.Content.Asset -> RemoteBAckupMessageContentDTO.Asset( + mimeType = payload.value.mimeType, + size = payload.value.size.toInt(), + name = payload.value.name, + otrKey = payload.value.otrKey, + sha256 = payload.value.sha256, + assetId = payload.value.assetId, + assetToken = payload.value.assetToken, + assetDomain = payload.value.assetDomain, + encryption = payload.value.encryption, + metaData = payload.value.metaData?.let(::mapAssetMetaFromProto) + ) + is RemoteBackupMessageContent.Content.Location -> RemoteBAckupMessageContentDTO.Location( + longitude = payload.value.longitude, + latitude = payload.value.latitude, + name = payload.value.name, + zoom = payload.value.zoom + ) + null -> error("RemoteBackupMessage is missing its content payload") + } + + private fun mapMentionToProto(mention: MessageSyncMentionDTO): RemoteBackupMention = + RemoteBackupMention( + userId = mention.userId.toProto(), + start = mention.start, + length = mention.length + ) + + private fun mapMentionFromProto(mention: RemoteBackupMention): MessageSyncMentionDTO = + MessageSyncMentionDTO( + userId = mention.userId.toDto(), + start = mention.start, + length = mention.length + ) + + private fun mapAssetMetaToProto(metaData: MessageSyncAssetMetadataDTO): RemoteBackupAsset.MetaData<*> = + when (metaData) { + is MessageSyncAssetMetadataDTO.Image -> RemoteBackupAsset.MetaData.Image( + RemoteBackupImageMetaData( + width = metaData.width, + height = metaData.height, + tag = metaData.tag + ) + ) + is MessageSyncAssetMetadataDTO.Video -> RemoteBackupAsset.MetaData.Video( + RemoteBackupVideoMetaData( + width = metaData.width, + height = metaData.height, + durationInMillis = metaData.duration + ) + ) + is MessageSyncAssetMetadataDTO.Audio -> RemoteBackupAsset.MetaData.Audio( + RemoteBackupAudioMetaData( + normalization = metaData.normalization, + durationInMillis = metaData.duration + ) + ) + is MessageSyncAssetMetadataDTO.Generic -> RemoteBackupAsset.MetaData.Generic( + RemoteBackupGenericMetaData(name = metaData.name) + ) + } + + private fun mapAssetMetaFromProto(metaData: RemoteBackupAsset.MetaData<*>): MessageSyncAssetMetadataDTO = + when (metaData) { + is RemoteBackupAsset.MetaData.Image -> MessageSyncAssetMetadataDTO.Image( + width = metaData.value.width, + height = metaData.value.height, + tag = metaData.value.tag + ) + is RemoteBackupAsset.MetaData.Video -> MessageSyncAssetMetadataDTO.Video( + width = metaData.value.width, + height = metaData.value.height, + duration = metaData.value.durationInMillis + ) + is RemoteBackupAsset.MetaData.Audio -> MessageSyncAssetMetadataDTO.Audio( + normalization = metaData.value.normalization, + duration = metaData.value.durationInMillis + ) + is RemoteBackupAsset.MetaData.Generic -> MessageSyncAssetMetadataDTO.Generic( + name = metaData.value.name + ) + } + + private fun QualifiedID.toProto(): RemoteBackupQualifiedId = + RemoteBackupQualifiedId( + id = value, + domain = domain + ) + + private fun RemoteBackupQualifiedId.toDto(): QualifiedID = + QualifiedID( + value = id, + domain = domain + ) +} diff --git a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v12/authenticated/RemoteBackupApiV12.kt b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v12/authenticated/RemoteBackupApiV12.kt index 5b96e9c8136..2a97a7f77ec 100644 --- a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v12/authenticated/RemoteBackupApiV12.kt +++ b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v12/authenticated/RemoteBackupApiV12.kt @@ -21,7 +21,9 @@ package com.wire.kalium.network.api.v12.authenticated import com.wire.kalium.network.api.authenticated.remoteBackup.DeleteMessagesResponseDTO import com.wire.kalium.network.api.authenticated.remoteBackup.MessageSyncFetchResponseDTO import com.wire.kalium.network.api.authenticated.remoteBackup.MessageSyncRequestDTO +import com.wire.kalium.network.api.base.authenticated.remoteBackup.RemoteBackupProtoMapper import com.wire.kalium.network.api.v0.authenticated.RemoteBackupApiV0 +import com.wire.kalium.network.serialization.XProtoBuf import com.wire.kalium.network.utils.BACKUP_STREAM_BUFFER_SIZE import com.wire.kalium.network.utils.NetworkResponse import com.wire.kalium.network.utils.StreamStateBackupContent @@ -47,12 +49,14 @@ import okio.use internal open class RemoteBackupApiV12( private val httpClient: HttpClient, + private val protoMapper: RemoteBackupProtoMapper = RemoteBackupProtoMapper(), ) : RemoteBackupApiV0() { override suspend fun syncMessages(request: MessageSyncRequestDTO): NetworkResponse = wrapRequest { httpClient.post("backup/messages") { - setBody(request) + contentType(ContentType.Application.XProtoBuf) + setBody(protoMapper.encodeSyncRequest(request)) } } @@ -63,7 +67,12 @@ internal open class RemoteBackupApiV12( paginationToken: String?, size: Int ): NetworkResponse = - wrapRequest { + wrapRequest( + successHandler = { response -> + val bytes = response.body() + NetworkResponse.Success(protoMapper.decodeFetchResponse(bytes), response) + } + ) { httpClient.get("backup/messages") { parameter("user", user) since?.let { parameter("since", it) } diff --git a/data/network/src/commonTest/kotlin/com/wire/kalium/api/v12/RemoteBackupApiV12Test.kt b/data/network/src/commonTest/kotlin/com/wire/kalium/api/v12/RemoteBackupApiV12Test.kt index 45551c6520a..8c7c18c081e 100644 --- a/data/network/src/commonTest/kotlin/com/wire/kalium/api/v12/RemoteBackupApiV12Test.kt +++ b/data/network/src/commonTest/kotlin/com/wire/kalium/api/v12/RemoteBackupApiV12Test.kt @@ -17,18 +17,18 @@ */ package com.wire.kalium.api.v12 -import com.wire.kalium.network.api.authenticated.remoteBackup.MessageSyncRequestDTO -import com.wire.kalium.network.api.authenticated.remoteBackup.MessageSyncUpsertDTO +import com.wire.kalium.mocks.responses.RemoteBackupResponseJson +import com.wire.kalium.network.api.base.authenticated.remoteBackup.RemoteBackupProtoMapper import com.wire.kalium.network.api.v12.authenticated.RemoteBackupApiV12 import com.wire.kalium.network.networkContainer.KaliumUserAgentProvider +import com.wire.kalium.network.serialization.XProtoBuf +import com.wire.kalium.network.serialization.xprotobuf import com.wire.kalium.network.tools.KtxSerializer import com.wire.kalium.network.utils.isSuccessful import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.client.plugins.defaultRequest -import io.ktor.client.request.header import io.ktor.http.ContentType import io.ktor.http.HeadersImpl import io.ktor.http.HttpHeaders @@ -38,16 +38,14 @@ import io.ktor.http.content.OutgoingContent import io.ktor.serialization.kotlinx.json.json import io.ktor.utils.io.ByteReadChannel import kotlinx.coroutines.test.runTest -import kotlinx.datetime.Clock import okio.Buffer import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue -import kotlin.time.ExperimentalTime -import kotlin.time.Instant internal class RemoteBackupApiV12Test { @@ -60,7 +58,7 @@ internal class RemoteBackupApiV12Test { @Test fun givenSyncMessagesRequest_whenInvoking_thenShouldUseCorrectEndpointAndMethod() = runTest { - val request = createSyncRequest() + val request = RemoteBackupResponseJson.validSyncRequest var capturedMethod: HttpMethod? = null var capturedPath: String? = null @@ -81,29 +79,29 @@ internal class RemoteBackupApiV12Test { @Test fun givenSyncMessagesRequest_whenInvoking_thenShouldSerializeBodyCorrectly() = runTest { - val request = createSyncRequest() - var capturedBody: String? = null + val request = RemoteBackupResponseJson.validSyncRequest + var capturedBody: ByteArray? = null val httpClient = createMockHttpClient( - responseBody = "", + responseBody = ByteArray(0), statusCode = HttpStatusCode.OK ) { requestData -> val body = requestData.body if (body is OutgoingContent.ByteArrayContent) { - capturedBody = body.bytes().decodeToString() + capturedBody = body.bytes() } } val api = RemoteBackupApiV12(httpClient) api.syncMessages(request) - val expectedJson = KtxSerializer.json.encodeToString(request) - assertEquals(expectedJson, capturedBody) + val expectedBytes = RemoteBackupProtoMapper().encodeSyncRequest(request) + assertContentEquals(expectedBytes, checkNotNull(capturedBody)) } @Test fun givenSyncMessagesRequest_whenSuccessful_thenShouldReturnSuccess() = runTest { - val request = createSyncRequest() + val request = RemoteBackupResponseJson.validSyncRequest val httpClient = createMockHttpClient( responseBody = "", @@ -126,7 +124,7 @@ internal class RemoteBackupApiV12Test { var capturedPath: String? = null val httpClient = createMockHttpClient( - responseBody = FETCH_MESSAGES_RESPONSE, + responseBody = RemoteBackupProtoMapper().encodeFetchResponse(RemoteBackupResponseJson.validFetchResponse), statusCode = HttpStatusCode.OK ) { requestData -> capturedMethod = requestData.method @@ -146,7 +144,7 @@ internal class RemoteBackupApiV12Test { var capturedSizeParam: String? = null val httpClient = createMockHttpClient( - responseBody = FETCH_MESSAGES_RESPONSE, + responseBody = RemoteBackupProtoMapper().encodeFetchResponse(RemoteBackupResponseJson.validFetchResponse), statusCode = HttpStatusCode.OK ) { requestData -> capturedUserParam = requestData.url.parameters["user"] @@ -167,7 +165,7 @@ internal class RemoteBackupApiV12Test { var capturedPaginationTokenParam: String? = null val httpClient = createMockHttpClient( - responseBody = FETCH_MESSAGES_RESPONSE, + responseBody = RemoteBackupProtoMapper().encodeFetchResponse(RemoteBackupResponseJson.validFetchResponse), statusCode = HttpStatusCode.OK ) { requestData -> capturedSinceParam = requestData.url.parameters["since"] @@ -196,7 +194,7 @@ internal class RemoteBackupApiV12Test { var capturedPaginationTokenParam: String? = null val httpClient = createMockHttpClient( - responseBody = FETCH_MESSAGES_RESPONSE, + responseBody = RemoteBackupProtoMapper().encodeFetchResponse(RemoteBackupResponseJson.validFetchResponse), statusCode = HttpStatusCode.OK ) { requestData -> capturedSinceParam = requestData.url.parameters["since"] @@ -214,8 +212,9 @@ internal class RemoteBackupApiV12Test { @Test fun givenFetchMessagesRequest_whenSuccessful_thenShouldDeserializeResponseCorrectly() = runTest { + val expectedResponse = RemoteBackupResponseJson.validFetchResponse val httpClient = createMockHttpClient( - responseBody = FETCH_MESSAGES_RESPONSE, + responseBody = RemoteBackupProtoMapper().encodeFetchResponse(expectedResponse), statusCode = HttpStatusCode.OK ) @@ -223,14 +222,7 @@ internal class RemoteBackupApiV12Test { val result = api.fetchMessages(user = TEST_USER_ID, size = 100) assertTrue(result.isSuccessful()) - val response = result.value - assertTrue(response.hasMore) - assertEquals("next-token", response.paginationToken) - assertTrue(response.conversations.containsKey(TEST_CONVERSATION_ID)) - val conversationMessages = response.conversations[TEST_CONVERSATION_ID]!! - assertEquals(1000L, conversationMessages.lastRead) - assertEquals(1, conversationMessages.messages.size) - assertEquals("msg-1", conversationMessages.messages[0].messageId) + assertEquals(expectedResponse, result.value) } // endregion @@ -243,8 +235,9 @@ internal class RemoteBackupApiV12Test { var capturedPath: String? = null val httpClient = createMockHttpClient( - responseBody = DELETE_MESSAGES_RESPONSE, - statusCode = HttpStatusCode.OK + responseBody = RemoteBackupResponseJson.validDeleteResponse.rawJson.encodeToByteArray(), + statusCode = HttpStatusCode.OK, + contentType = ContentType.Application.Json ) { requestData -> capturedMethod = requestData.method capturedPath = requestData.url.encodedPath @@ -264,8 +257,9 @@ internal class RemoteBackupApiV12Test { var capturedBeforeParam: String? = null val httpClient = createMockHttpClient( - responseBody = DELETE_MESSAGES_RESPONSE, - statusCode = HttpStatusCode.OK + responseBody = RemoteBackupResponseJson.validDeleteResponse.rawJson.encodeToByteArray(), + statusCode = HttpStatusCode.OK, + contentType = ContentType.Application.Json ) { requestData -> capturedUserIdParam = requestData.url.parameters["user_id"] capturedConversationIdParam = requestData.url.parameters["conversation_id"] @@ -291,8 +285,9 @@ internal class RemoteBackupApiV12Test { var capturedBeforeParam: String? = null val httpClient = createMockHttpClient( - responseBody = DELETE_MESSAGES_RESPONSE, - statusCode = HttpStatusCode.OK + responseBody = RemoteBackupResponseJson.validDeleteResponse.rawJson.encodeToByteArray(), + statusCode = HttpStatusCode.OK, + contentType = ContentType.Application.Json ) { requestData -> capturedUserIdParam = requestData.url.parameters["user_id"] capturedConversationIdParam = requestData.url.parameters["conversation_id"] @@ -309,16 +304,18 @@ internal class RemoteBackupApiV12Test { @Test fun givenDeleteMessagesRequest_whenSuccessful_thenShouldDeserializeResponseCorrectly() = runTest { + val expectedResponse = RemoteBackupResponseJson.validDeleteResponse.serializableData val httpClient = createMockHttpClient( - responseBody = DELETE_MESSAGES_RESPONSE, - statusCode = HttpStatusCode.OK + responseBody = RemoteBackupResponseJson.validDeleteResponse.rawJson.encodeToByteArray(), + statusCode = HttpStatusCode.OK, + contentType = ContentType.Application.Json ) val api = RemoteBackupApiV12(httpClient) val result = api.deleteMessages(userId = TEST_USER_ID) assertTrue(result.isSuccessful()) - assertEquals(42, result.value.deletedCount) + assertEquals(expectedResponse.deletedCount, result.value.deletedCount) } // endregion @@ -422,8 +419,9 @@ internal class RemoteBackupApiV12Test { var capturedPath: String? = null val httpClient = createMockHttpClient( - responseBody = "backup content", - statusCode = HttpStatusCode.OK + responseBody = "backup content".encodeToByteArray(), + statusCode = HttpStatusCode.OK, + contentType = ContentType.Text.Plain ) { requestData -> capturedMethod = requestData.method capturedPath = requestData.url.encodedPath @@ -442,8 +440,9 @@ internal class RemoteBackupApiV12Test { var capturedUserIdParam: String? = null val httpClient = createMockHttpClient( - responseBody = "backup content", - statusCode = HttpStatusCode.OK + responseBody = "backup content".encodeToByteArray(), + statusCode = HttpStatusCode.OK, + contentType = ContentType.Text.Plain ) { requestData -> capturedUserIdParam = requestData.url.parameters["user_id"] } @@ -459,8 +458,9 @@ internal class RemoteBackupApiV12Test { fun givenDownloadStateBackupRequest_whenSuccessful_thenShouldWriteContentToSink() = runTest { val backupContent = "test backup content data" val httpClient = createMockHttpClient( - responseBody = backupContent, - statusCode = HttpStatusCode.OK + responseBody = backupContent.encodeToByteArray(), + statusCode = HttpStatusCode.OK, + contentType = ContentType.Text.Plain ) val api = RemoteBackupApiV12(httpClient) @@ -474,8 +474,9 @@ internal class RemoteBackupApiV12Test { @Test fun givenDownloadStateBackupRequest_whenServerReturns404_thenShouldReturnError() = runTest { val httpClient = createMockHttpClient( - responseBody = """{"code":404,"message":"No backup found","label":"not-found"}""", - statusCode = HttpStatusCode.NotFound + responseBody = """{"code":404,"message":"No backup found","label":"not-found"}""".encodeToByteArray(), + statusCode = HttpStatusCode.NotFound, + contentType = ContentType.Application.Json ) val api = RemoteBackupApiV12(httpClient) @@ -490,8 +491,9 @@ internal class RemoteBackupApiV12Test { // region helper functions private fun createMockHttpClient( - responseBody: String, + responseBody: ByteArray, statusCode: HttpStatusCode, + contentType: ContentType = ContentType.Application.XProtoBuf, assertion: (io.ktor.client.request.HttpRequestData) -> Unit = {} ): HttpClient { val mockEngine = MockEngine { request -> @@ -500,66 +502,23 @@ internal class RemoteBackupApiV12Test { content = ByteReadChannel(responseBody), status = statusCode, headers = HeadersImpl( - mapOf(HttpHeaders.ContentType to listOf("application/json")) + mapOf(HttpHeaders.ContentType to listOf(contentType.toString())) ) ) } return HttpClient(mockEngine) { install(ContentNegotiation) { json(KtxSerializer.json) - } - defaultRequest { - header(HttpHeaders.ContentType, ContentType.Application.Json) + xprotobuf() } expectSuccess = false } } - @OptIn(ExperimentalTime::class) - private fun createSyncRequest(): MessageSyncRequestDTO = MessageSyncRequestDTO( - userId = TEST_USER_ID, - upserts = mapOf( - TEST_CONVERSATION_ID to listOf( - MessageSyncUpsertDTO( - messageId = "msg-1", - timestamp = Instant.DISTANT_PAST.toEpochMilliseconds(), - payload = """{"type":"text","content":"Hello"}""" - ) - ) - ), - deletions = mapOf( - TEST_CONVERSATION_ID to listOf("deleted-msg-1", "deleted-msg-2") - ), - conversationsLastRead = mapOf( - TEST_CONVERSATION_ID to 1234567890L - ) - ) - // endregion private companion object { const val TEST_USER_ID = "user-123-abc" const val TEST_CONVERSATION_ID = "conv-456-def" - - val FETCH_MESSAGES_RESPONSE = """ - { - "has_more": true, - "conversations": { - "$TEST_CONVERSATION_ID": { - "last_read": 1000, - "messages": [ - { - "message_id": "msg-1", - "timestamp": 999, - "payload": "{\"type\":\"text\"}" - } - ] - } - }, - "pagination_token": "next-token" - } - """.trimIndent() - - const val DELETE_MESSAGES_RESPONSE = """{"deleted_count": 42}""" } } diff --git a/test/mocks/src/commonMain/kotlin/com/wire/kalium/mocks/responses/RemoteBackupResponseJson.kt b/test/mocks/src/commonMain/kotlin/com/wire/kalium/mocks/responses/RemoteBackupResponseJson.kt new file mode 100644 index 00000000000..4ed6584a25f --- /dev/null +++ b/test/mocks/src/commonMain/kotlin/com/wire/kalium/mocks/responses/RemoteBackupResponseJson.kt @@ -0,0 +1,97 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.kalium.mocks.responses + +import com.wire.kalium.network.api.authenticated.remoteBackup.DeleteMessagesResponseDTO +import com.wire.kalium.network.api.authenticated.remoteBackup.MessageSyncFetchResponseDTO +import com.wire.kalium.network.api.authenticated.remoteBackup.MessageSyncRequestDTO +import com.wire.kalium.network.api.authenticated.remoteBackup.RemoteBackupEventDTO +import com.wire.kalium.network.api.authenticated.remoteBackup.RemoteBAckupMessageContentDTO +import com.wire.kalium.network.api.authenticated.remoteBackup.RemoteBackupPayloadDTO +import com.wire.kalium.network.api.model.QualifiedID +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +object RemoteBackupResponseJson { + + private const val TEST_USER_ID = "user-123-abc" + private const val TEST_CONVERSATION_ID = "conv-456-def" + private const val TEST_DOMAIN = "wire.com" + + private val deleteResponseJsonProvider = { response: DeleteMessagesResponseDTO -> + buildJsonObject { + put("deleted_count", response.deletedCount) + }.toString() + } + + // Test data + private val testPayload = RemoteBackupPayloadDTO( + id = "msg-1", + conversationId = QualifiedID(value = TEST_CONVERSATION_ID, domain = TEST_DOMAIN), + senderUserId = QualifiedID(value = TEST_USER_ID, domain = TEST_DOMAIN), + senderClientId = "client-123", + creationDate = 999L, + content = RemoteBAckupMessageContentDTO.Text(text = "Hello") + ) + + private val testUpsert = RemoteBackupEventDTO.Upsert( + messageId = "msg-1", + timestamp = -62135596800000L, // Instant.DISTANT_PAST + payload = testPayload + ) + + private val testDelete = RemoteBackupEventDTO.Delete( + conversationId = TEST_CONVERSATION_ID, + messageId = "deleted-msg-1" + ) + + private val testLastRead = RemoteBackupEventDTO.LastRead( + conversationId = TEST_CONVERSATION_ID, + lastRead = 1234567890L + ) + + private val testSyncRequest = MessageSyncRequestDTO( + userId = TEST_USER_ID, + events = listOf( + testUpsert, + testDelete, + testLastRead + ) + ) + + private val testFetchResponse = MessageSyncFetchResponseDTO( + hasMore = true, + events = listOf( + testUpsert, + testDelete, + testLastRead + ), + paginationToken = "next-token" + ) + + private val testDeleteResponse = DeleteMessagesResponseDTO( + deletedCount = 42 + ) + + val validSyncRequest = testSyncRequest + + val validFetchResponse = testFetchResponse + + val validDeleteResponse = ValidJsonProvider(testDeleteResponse, deleteResponseJsonProvider) +} diff --git a/tools/protobuf-codegen/src/main/proto/remote_backup.proto b/tools/protobuf-codegen/src/main/proto/remote_backup.proto new file mode 100644 index 00000000000..ce20941932e --- /dev/null +++ b/tools/protobuf-codegen/src/main/proto/remote_backup.proto @@ -0,0 +1,137 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +syntax = "proto2"; + +option java_package = "com.wire.kalium.protobuf.remote_backup"; + +message RemoteBackupMessageSyncRequest { + repeated RemoteBackupEvent events = 2; +} + +message RemoteBackupMessageSyncResponse { + required bool has_more = 1; + repeated RemoteBackupEvent events = 2; + optional string pagination_token = 3; +} + +message RemoteBackupEvent { + oneof event { + RemoteBackupUpsert upsert = 1; + RemoteBackupDelete delete = 2; + RemoteBackupLastRead last_read = 3; + } +} + +message RemoteBackupUpsert { + required string message_id = 1; + required int64 timestamp = 2; + required RemoteBackupMessage payload = 3; +} + +message RemoteBackupDelete { + required string conversation_id = 1; + required string message_id = 2; +} + +message RemoteBackupLastRead { + required string conversation_id = 1; + required int64 last_read = 2; +} + +message RemoteBackupMessage { + required string id = 1; + required RemoteBackupQualifiedId conversation_id = 2; + required RemoteBackupQualifiedId sender_user_id = 3; + required string sender_client_id = 4; + required int64 creation_date = 5; + required RemoteBackupMessageContent content = 6; + optional int64 last_edit_time = 7; +} + +message RemoteBackupQualifiedId { + required string id = 1; + required string domain = 2; +} + +message RemoteBackupMessageContent { + oneof content { + RemoteBackupText text = 1; + RemoteBackupAsset asset = 2; + RemoteBackupLocation location = 3; + } +} + +message RemoteBackupText { + required string text = 1; + repeated RemoteBackupMention mentions = 2; + optional string quoted_message_id = 3; +} + +message RemoteBackupMention { + required RemoteBackupQualifiedId user_id = 1; + required int32 start = 2; + required int32 length = 3; +} + +message RemoteBackupAsset { + required string mime_type = 1; + required int64 size = 2; + optional string name = 3; + required string otr_key = 4; + required string sha256 = 5; + required string asset_id = 6; + optional string asset_token = 7; + optional string asset_domain = 8; + optional string encryption = 9; + oneof meta_data { + RemoteBackupImageMetaData image = 10; + RemoteBackupVideoMetaData video = 11; + RemoteBackupAudioMetaData audio = 12; + RemoteBackupGenericMetaData generic = 13; + } +} + +message RemoteBackupImageMetaData { + required int32 width = 1; + required int32 height = 2; + optional string tag = 3; +} + +message RemoteBackupVideoMetaData { + optional int32 width = 1; + optional int32 height = 2; + optional int64 duration_in_millis = 3; +} + +message RemoteBackupAudioMetaData { + optional string normalization = 1; + optional int64 duration_in_millis = 2; +} + +message RemoteBackupGenericMetaData { + optional string name = 1; +} + +message RemoteBackupLocation { + required float longitude = 1; + required float latitude = 2; + optional string name = 3; + optional int32 zoom = 4; +}