From 4ef257269a1ac7fb787ce9a3ce3eafe134bb9257 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara Date: Tue, 20 Jan 2026 16:00:38 +0100 Subject: [PATCH 01/10] feat: implement RemoteBackupApi --- .../remoteBackup/ConversationMessagesDTO.kt | 32 + .../remoteBackup/DeleteMessagesResponseDTO.kt | 30 + .../MessageSyncFetchResponseDTO.kt | 34 ++ .../remoteBackup/MessageSyncRequestDTO.kt | 36 ++ .../remoteBackup/MessageSyncResultDTO.kt | 34 ++ .../remoteBackup/MessageSyncUpsertDTO.kt | 34 ++ .../api/base/authenticated/RemoteBackupApi.kt | 100 ++++ .../api/v0/authenticated/RemoteBackupApiV0.kt | 61 ++ .../AuthenticatedNetworkContainerV0.kt | 4 + .../AuthenticatedNetworkContainerV10.kt | 4 + .../AuthenticatedNetworkContainerV11.kt | 4 + .../v12/authenticated/RemoteBackupApiV12.kt | 158 +++++ .../AuthenticatedNetworkContainerV12.kt | 5 + .../v13/authenticated/RemoteBackupApiV13.kt | 27 + .../AuthenticatedNetworkContainerV13.kt | 5 + .../v14/authenticated/RemoteBackupApiV14.kt | 27 + .../AuthenticatedNetworkContainerV14.kt | 5 + .../AuthenticatedNetworkContainerV2.kt | 4 + .../AuthenticatedNetworkContainerV3.kt | 4 + .../AuthenticatedNetworkContainerV4.kt | 4 + .../AuthenticatedNetworkContainerV5.kt | 4 + .../AuthenticatedNetworkContainerV6.kt | 4 + .../AuthenticatedNetworkContainerV7.kt | 4 + .../AuthenticatedNetworkContainerV8.kt | 4 + .../AuthenticatedNetworkContainerV9.kt | 4 + .../AuthenticatedNetworkContainer.kt | 3 + .../network/utils/StreamStateBackupContent.kt | 56 ++ .../kalium/api/v12/RemoteBackupApiV12Test.kt | 566 ++++++++++++++++++ 28 files changed, 1257 insertions(+) create mode 100644 data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/ConversationMessagesDTO.kt create mode 100644 data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/DeleteMessagesResponseDTO.kt create mode 100644 data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncFetchResponseDTO.kt create mode 100644 data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncRequestDTO.kt create mode 100644 data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncResultDTO.kt create mode 100644 data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncUpsertDTO.kt create mode 100644 data/network/src/commonMain/kotlin/com/wire/kalium/network/api/base/authenticated/RemoteBackupApi.kt create mode 100644 data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v0/authenticated/RemoteBackupApiV0.kt create mode 100644 data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v12/authenticated/RemoteBackupApiV12.kt create mode 100644 data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v13/authenticated/RemoteBackupApiV13.kt create mode 100644 data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v14/authenticated/RemoteBackupApiV14.kt create mode 100644 data/network/src/commonMain/kotlin/com/wire/kalium/network/utils/StreamStateBackupContent.kt create mode 100644 data/network/src/commonTest/kotlin/com/wire/kalium/api/v12/RemoteBackupApiV12Test.kt 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/ConversationMessagesDTO.kt new file mode 100644 index 00000000000..d6a1fe8e1a8 --- /dev/null +++ b/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/ConversationMessagesDTO.kt @@ -0,0 +1,32 @@ +/* + * 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 + +/** + * Messages and metadata for a single conversation + */ +@Serializable +data class ConversationMessagesDTO( + @SerialName("last_read") + val lastRead: Long? = null, // Last read timestamp (epoch millis) + @SerialName("messages") + val messages: List +) diff --git a/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/DeleteMessagesResponseDTO.kt b/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/DeleteMessagesResponseDTO.kt new file mode 100644 index 00000000000..a691eb09a13 --- /dev/null +++ b/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/DeleteMessagesResponseDTO.kt @@ -0,0 +1,30 @@ +/* + * 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 + +/** + * Response payload for deleting messages from the backup service + */ +@Serializable +data class DeleteMessagesResponseDTO( + @SerialName("deleted_count") + val deletedCount: Int +) 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 new file mode 100644 index 00000000000..4ec02b5ae46 --- /dev/null +++ b/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncFetchResponseDTO.kt @@ -0,0 +1,34 @@ +/* + * 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 + +/** + * Response payload for fetching messages from the backup service + */ +@Serializable +data class MessageSyncFetchResponseDTO( + @SerialName("has_more") + val hasMore: Boolean, + @SerialName("conversations") + val conversations: Map, + @SerialName("pagination_token") + 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 new file mode 100644 index 00000000000..4bec43ba505 --- /dev/null +++ b/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncRequestDTO.kt @@ -0,0 +1,36 @@ +/* + * 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 + +/** + * Request payload for synchronizing messages to the backup service + */ +@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) +) 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/MessageSyncResultDTO.kt new file mode 100644 index 00000000000..440600b8d73 --- /dev/null +++ b/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncResultDTO.kt @@ -0,0 +1,34 @@ +/* + * 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 result from fetch operation + */ +@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 +) 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 new file mode 100644 index 00000000000..adce390352b --- /dev/null +++ b/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncUpsertDTO.kt @@ -0,0 +1,34 @@ +/* + * 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/src/commonMain/kotlin/com/wire/kalium/network/api/base/authenticated/RemoteBackupApi.kt b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/base/authenticated/RemoteBackupApi.kt new file mode 100644 index 00000000000..5a58aab1e74 --- /dev/null +++ b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/base/authenticated/RemoteBackupApi.kt @@ -0,0 +1,100 @@ +/* + * 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 + +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.exceptions.APINotSupported +import com.wire.kalium.network.utils.NetworkResponse +import okio.Sink +import okio.Source + +/** + * API client for synchronizing messages to an external backup service + */ +interface RemoteBackupApi { + /** + * Synchronizes message updates to the backup service + * @param request The sync request containing user ID and message updates + * @return Network response indicating success or failure + */ + suspend fun syncMessages(request: MessageSyncRequestDTO): NetworkResponse + + /** + * Fetches messages from the backup service with filtering and pagination + * @param user User ID to fetch messages for + * @param since Filter messages after timestamp in epoch milliseconds (optional) + * @param conversation Filter by conversation ID (optional) + * @param paginationToken Message ID for cursor-based pagination (optional) + * @param size Page size: 1-1000 (default: 100) + * @return Network response containing paginated messages grouped by conversation + */ + suspend fun fetchMessages( + user: String, + since: Long? = null, + conversation: String? = null, + paginationToken: String? = null, + size: Int = 100 + ): NetworkResponse + + /** + * Deletes messages from the backup service based on filter criteria + * At least one filter parameter must be provided + * @param userId Delete messages for this user (optional) + * @param conversationId Delete messages in this conversation (optional) + * @param before Delete messages before this timestamp in epoch milliseconds (optional) + * @return Network response containing the count of deleted messages + */ + suspend fun deleteMessages( + userId: String? = null, + conversationId: String? = null, + before: Long? = null + ): NetworkResponse + + /** + * Uploads the cryptographic state backup for the specified user + * @param userId User ID to backup state for + * @param backupDataSource Lazy source providing the zip file data + * @param backupSize Size of the backup data in bytes + * @return Network response indicating upload success (empty response body) + */ + suspend fun uploadStateBackup( + userId: String, + backupDataSource: () -> Source, + backupSize: Long + ): NetworkResponse + + /** + * Downloads the cryptographic state backup for the specified user + * @param userId User ID to download state backup for + * @param tempFileSink Sink to write the downloaded backup data (ZIP file) + * @return Network response indicating download success + * Returns 404 if no backup exists for the user + */ + suspend fun downloadStateBackup( + userId: String, + tempFileSink: Sink + ): NetworkResponse + + companion object { + fun getApiNotSupportError(apiName: String, apiVersion: String = "13") = NetworkResponse.Error( + APINotSupported("RemoteBackupApi: $apiName api is only available on API V$apiVersion") + ) + } +} diff --git a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v0/authenticated/RemoteBackupApiV0.kt b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v0/authenticated/RemoteBackupApiV0.kt new file mode 100644 index 00000000000..ac3d6d8cc26 --- /dev/null +++ b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v0/authenticated/RemoteBackupApiV0.kt @@ -0,0 +1,61 @@ +/* + * Wire + * Copyright (C) 2024 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.v0.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.RemoteBackupApi +import com.wire.kalium.network.utils.NetworkResponse +import okio.Sink +import okio.Source + +internal open class RemoteBackupApiV0 internal constructor() : RemoteBackupApi { + override suspend fun syncMessages(request: MessageSyncRequestDTO): NetworkResponse = + RemoteBackupApi.getApiNotSupportError("syncMessages") + + override suspend fun fetchMessages( + user: String, + since: Long?, + conversation: String?, + paginationToken: String?, + size: Int + ): NetworkResponse = + RemoteBackupApi.getApiNotSupportError("fetchMessages") + + override suspend fun deleteMessages( + userId: String?, + conversationId: String?, + before: Long? + ): NetworkResponse = + RemoteBackupApi.getApiNotSupportError("deleteMessages") + + override suspend fun uploadStateBackup( + userId: String, + backupDataSource: () -> Source, + backupSize: Long + ): NetworkResponse = + RemoteBackupApi.getApiNotSupportError("uploadStateBackup") + + override suspend fun downloadStateBackup( + userId: String, + tempFileSink: Sink + ): NetworkResponse = + RemoteBackupApi.getApiNotSupportError("downloadStateBackup") +} diff --git a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v0/authenticated/networkContainer/AuthenticatedNetworkContainerV0.kt b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v0/authenticated/networkContainer/AuthenticatedNetworkContainerV0.kt index ee647dbc4fe..f1d2486eb25 100644 --- a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v0/authenticated/networkContainer/AuthenticatedNetworkContainerV0.kt +++ b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v0/authenticated/networkContainer/AuthenticatedNetworkContainerV0.kt @@ -21,6 +21,7 @@ package com.wire.kalium.network.api.v0.authenticated.networkContainer import com.wire.kalium.logger.KaliumLogger import com.wire.kalium.network.api.base.authenticated.AccessTokenApi import com.wire.kalium.network.api.base.authenticated.CallApi +import com.wire.kalium.network.api.base.authenticated.RemoteBackupApi import com.wire.kalium.network.api.base.authenticated.ServerTimeApi import com.wire.kalium.network.api.base.authenticated.TeamsApi import com.wire.kalium.network.api.base.authenticated.UpgradePersonalToTeamApi @@ -60,6 +61,7 @@ import com.wire.kalium.network.api.v0.authenticated.MessageApiV0 import com.wire.kalium.network.api.v0.authenticated.NotificationApiV0 import com.wire.kalium.network.api.v0.authenticated.PreKeyApiV0 import com.wire.kalium.network.api.v0.authenticated.PropertiesApiV0 +import com.wire.kalium.network.api.v0.authenticated.RemoteBackupApiV0 import com.wire.kalium.network.api.v0.authenticated.SelfApiV0 import com.wire.kalium.network.api.v0.authenticated.ServerTimeApiV0 import com.wire.kalium.network.api.v0.authenticated.ConversationHistoryApiV0 @@ -152,4 +154,6 @@ internal class AuthenticatedNetworkContainerV0 internal constructor( override val serverTimeApi: ServerTimeApi get() = ServerTimeApiV0(networkClient) + + override val remoteBackupApi: RemoteBackupApi get() = RemoteBackupApiV0() } diff --git a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v10/authenticated/networkContainer/AuthenticatedNetworkContainerV10.kt b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v10/authenticated/networkContainer/AuthenticatedNetworkContainerV10.kt index c32a27a32c8..7f730b7b2b2 100644 --- a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v10/authenticated/networkContainer/AuthenticatedNetworkContainerV10.kt +++ b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v10/authenticated/networkContainer/AuthenticatedNetworkContainerV10.kt @@ -21,6 +21,7 @@ package com.wire.kalium.network.api.v10.authenticated.networkContainer import com.wire.kalium.logger.KaliumLogger import com.wire.kalium.network.api.base.authenticated.AccessTokenApi import com.wire.kalium.network.api.base.authenticated.CallApi +import com.wire.kalium.network.api.base.authenticated.RemoteBackupApi import com.wire.kalium.network.api.base.authenticated.ServerTimeApi import com.wire.kalium.network.api.base.authenticated.TeamsApi import com.wire.kalium.network.api.base.authenticated.UpgradePersonalToTeamApi @@ -45,6 +46,7 @@ import com.wire.kalium.network.api.base.authenticated.self.SelfApi import com.wire.kalium.network.api.base.authenticated.serverpublickey.MLSPublicKeyApi import com.wire.kalium.network.api.base.authenticated.userDetails.UserDetailsApi import com.wire.kalium.network.api.model.UserId +import com.wire.kalium.network.api.v0.authenticated.RemoteBackupApiV0 import com.wire.kalium.network.api.v10.authenticated.AccessTokenApiV10 import com.wire.kalium.network.api.v10.authenticated.AssetApiV10 import com.wire.kalium.network.api.v10.authenticated.CallApiV10 @@ -169,4 +171,6 @@ internal class AuthenticatedNetworkContainerV10 internal constructor( get() = ServerTimeApiV10(networkClient) override val cellsHttpClient: HttpClient = networkClient.httpClient + + override val remoteBackupApi: RemoteBackupApi get() = RemoteBackupApiV0() } diff --git a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v11/authenticated/networkContainer/AuthenticatedNetworkContainerV11.kt b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v11/authenticated/networkContainer/AuthenticatedNetworkContainerV11.kt index 0e63aee92c6..b1344a7242a 100644 --- a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v11/authenticated/networkContainer/AuthenticatedNetworkContainerV11.kt +++ b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v11/authenticated/networkContainer/AuthenticatedNetworkContainerV11.kt @@ -21,6 +21,7 @@ package com.wire.kalium.network.api.v11.authenticated.networkContainer import com.wire.kalium.logger.KaliumLogger import com.wire.kalium.network.api.base.authenticated.AccessTokenApi import com.wire.kalium.network.api.base.authenticated.CallApi +import com.wire.kalium.network.api.base.authenticated.RemoteBackupApi import com.wire.kalium.network.api.base.authenticated.ServerTimeApi import com.wire.kalium.network.api.base.authenticated.TeamsApi import com.wire.kalium.network.api.base.authenticated.UpgradePersonalToTeamApi @@ -45,6 +46,7 @@ import com.wire.kalium.network.api.base.authenticated.self.SelfApi import com.wire.kalium.network.api.base.authenticated.serverpublickey.MLSPublicKeyApi import com.wire.kalium.network.api.base.authenticated.userDetails.UserDetailsApi import com.wire.kalium.network.api.model.UserId +import com.wire.kalium.network.api.v0.authenticated.RemoteBackupApiV0 import com.wire.kalium.network.api.v11.authenticated.AccessTokenApiV11 import com.wire.kalium.network.api.v11.authenticated.AssetApiV11 import com.wire.kalium.network.api.v11.authenticated.CallApiV11 @@ -169,4 +171,6 @@ internal class AuthenticatedNetworkContainerV11 internal constructor( get() = ServerTimeApiV11(networkClient) override val cellsHttpClient: HttpClient = networkClient.httpClient + + override val remoteBackupApi: RemoteBackupApi get() = RemoteBackupApiV0() } 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 new file mode 100644 index 00000000000..69914dc132a --- /dev/null +++ b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v12/authenticated/RemoteBackupApiV12.kt @@ -0,0 +1,158 @@ +/* + * Wire + * Copyright (C) 2024 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.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.v0.authenticated.RemoteBackupApiV0 +import com.wire.kalium.network.utils.NetworkResponse +import com.wire.kalium.network.utils.StreamStateBackupContent +import com.wire.kalium.network.utils.wrapKaliumResponse +import com.wire.kalium.network.utils.wrapRequest +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.request.post +import io.ktor.client.request.prepareGet +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.isSuccess +import io.ktor.utils.io.ByteReadChannel +import io.ktor.utils.io.readAvailable +import kotlinx.coroutines.CancellationException +import okio.Sink +import okio.Source +import okio.buffer +import okio.use + +internal open class RemoteBackupApiV12( + private val httpClient: HttpClient, + private val backupServiceUrl: String? +) : RemoteBackupApiV0() { + + override suspend fun syncMessages(request: MessageSyncRequestDTO): NetworkResponse = + wrapRequest { + httpClient.post("$backupServiceUrl/messages") { + setBody(request) + } + } + + override suspend fun fetchMessages( + user: String, + since: Long?, + conversation: String?, + paginationToken: String?, + size: Int + ): NetworkResponse = + wrapRequest { + httpClient.get("$backupServiceUrl/messages") { + parameter("user", user) + since?.let { parameter("since", it) } + conversation?.let { parameter("conversation", it) } + paginationToken?.let { parameter("pagination_token", it) } + parameter("size", size) + } + } + + override suspend fun deleteMessages( + userId: String?, + conversationId: String?, + before: Long? + ): NetworkResponse = + wrapRequest { + httpClient.delete("$backupServiceUrl/messages") { + userId?.let { parameter("user_id", it) } + conversationId?.let { parameter("conversation_id", it) } + before?.let { parameter("before", it) } + } + } + + override suspend fun uploadStateBackup( + userId: String, + backupDataSource: () -> Source, + backupSize: Long + ): NetworkResponse = + wrapRequest { + httpClient.post("$backupServiceUrl/state") { + parameter("user_id", userId) + contentType(ContentType.Application.OctetStream) + setBody( + StreamStateBackupContent( + backupDataSource = backupDataSource, + backupSize = backupSize + ) + ) + } + } + + override suspend fun downloadStateBackup( + userId: String, + tempFileSink: Sink + ): NetworkResponse = runCatching { + httpClient.prepareGet("$backupServiceUrl/state") { + parameter("user_id", userId) + }.execute { httpResponse -> + if (httpResponse.status.isSuccess()) { + handleStateBackupDownload(httpResponse, tempFileSink) + } else { + wrapKaliumResponse { httpResponse } + } + } + }.getOrElse { unhandledException -> + if (unhandledException is CancellationException) { + throw unhandledException + } + NetworkResponse.Error(com.wire.kalium.network.exceptions.KaliumException.GenericError(unhandledException)) + } + + @Suppress("TooGenericExceptionCaught") + private suspend fun handleStateBackupDownload( + httpResponse: HttpResponse, + tempFileSink: Sink + ): NetworkResponse = try { + val channel = httpResponse.body() + writeChannelToSink(channel, tempFileSink) + NetworkResponse.Success(Unit, httpResponse) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + NetworkResponse.Error(com.wire.kalium.network.exceptions.KaliumException.GenericError(e)) + } + + private suspend fun writeChannelToSink(channel: ByteReadChannel, sink: Sink) { + sink.buffer().use { bufferedSink -> + val buffer = ByteArray(BUFFER_SIZE) + while (!channel.isClosedForRead) { + val bytesRead = channel.readAvailable(buffer, 0, buffer.size) + if (bytesRead <= 0) break + bufferedSink.write(buffer, 0, bytesRead) + } + } + } + + private companion object { + const val BUFFER_SIZE = 16 * 1024 + } + +} diff --git a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v12/authenticated/networkContainer/AuthenticatedNetworkContainerV12.kt b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v12/authenticated/networkContainer/AuthenticatedNetworkContainerV12.kt index e41ea811686..0993129a918 100644 --- a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v12/authenticated/networkContainer/AuthenticatedNetworkContainerV12.kt +++ b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v12/authenticated/networkContainer/AuthenticatedNetworkContainerV12.kt @@ -21,6 +21,7 @@ package com.wire.kalium.network.api.v12.authenticated.networkContainer import com.wire.kalium.logger.KaliumLogger import com.wire.kalium.network.api.base.authenticated.AccessTokenApi import com.wire.kalium.network.api.base.authenticated.CallApi +import com.wire.kalium.network.api.base.authenticated.RemoteBackupApi import com.wire.kalium.network.api.base.authenticated.ServerTimeApi import com.wire.kalium.network.api.base.authenticated.TeamsApi import com.wire.kalium.network.api.base.authenticated.UpgradePersonalToTeamApi @@ -62,6 +63,7 @@ import com.wire.kalium.network.api.v12.authenticated.MessageApiV12 import com.wire.kalium.network.api.v12.authenticated.NotificationApiV12 import com.wire.kalium.network.api.v12.authenticated.PreKeyApiV12 import com.wire.kalium.network.api.v12.authenticated.PropertiesApiV12 +import com.wire.kalium.network.api.v12.authenticated.RemoteBackupApiV12 import com.wire.kalium.network.api.v12.authenticated.SelfApiV12 import com.wire.kalium.network.api.v12.authenticated.ServerTimeApiV12 import com.wire.kalium.network.api.v12.authenticated.TeamsApiV12 @@ -169,4 +171,7 @@ internal class AuthenticatedNetworkContainerV12 internal constructor( get() = ServerTimeApiV12(networkClient) override val cellsHttpClient: HttpClient = networkClient.httpClient + + override val remoteBackupApi: RemoteBackupApi + get() = RemoteBackupApiV12(networkClient.httpClient, null) } diff --git a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v13/authenticated/RemoteBackupApiV13.kt b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v13/authenticated/RemoteBackupApiV13.kt new file mode 100644 index 00000000000..d5e703a97d6 --- /dev/null +++ b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v13/authenticated/RemoteBackupApiV13.kt @@ -0,0 +1,27 @@ +/* + * Wire + * Copyright (C) 2025 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.v13.authenticated + +import com.wire.kalium.network.api.v12.authenticated.RemoteBackupApiV12 +import io.ktor.client.HttpClient + +internal open class RemoteBackupApiV13 internal constructor( + httpClient: HttpClient, + backupServiceUrl: String? +) : RemoteBackupApiV12(httpClient, backupServiceUrl) diff --git a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v13/authenticated/networkContainer/AuthenticatedNetworkContainerV13.kt b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v13/authenticated/networkContainer/AuthenticatedNetworkContainerV13.kt index e32ce11d25f..de1d7a15e87 100644 --- a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v13/authenticated/networkContainer/AuthenticatedNetworkContainerV13.kt +++ b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v13/authenticated/networkContainer/AuthenticatedNetworkContainerV13.kt @@ -21,6 +21,7 @@ package com.wire.kalium.network.api.v13.authenticated.networkContainer import com.wire.kalium.logger.KaliumLogger import com.wire.kalium.network.api.base.authenticated.AccessTokenApi import com.wire.kalium.network.api.base.authenticated.CallApi +import com.wire.kalium.network.api.base.authenticated.RemoteBackupApi import com.wire.kalium.network.api.base.authenticated.ServerTimeApi import com.wire.kalium.network.api.base.authenticated.TeamsApi import com.wire.kalium.network.api.base.authenticated.UpgradePersonalToTeamApi @@ -62,6 +63,7 @@ import com.wire.kalium.network.api.v13.authenticated.MessageApiV13 import com.wire.kalium.network.api.v13.authenticated.NotificationApiV13 import com.wire.kalium.network.api.v13.authenticated.PreKeyApiV13 import com.wire.kalium.network.api.v13.authenticated.PropertiesApiV13 +import com.wire.kalium.network.api.v13.authenticated.RemoteBackupApiV13 import com.wire.kalium.network.api.v13.authenticated.SelfApiV13 import com.wire.kalium.network.api.v13.authenticated.ServerTimeApiV13 import com.wire.kalium.network.api.v13.authenticated.TeamsApiV13 @@ -169,4 +171,7 @@ internal class AuthenticatedNetworkContainerV13 internal constructor( get() = ServerTimeApiV13(networkClient) override val cellsHttpClient: HttpClient = networkClient.httpClient + + override val remoteBackupApi: RemoteBackupApi + get() = RemoteBackupApiV13(networkClient.httpClient, null) } diff --git a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v14/authenticated/RemoteBackupApiV14.kt b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v14/authenticated/RemoteBackupApiV14.kt new file mode 100644 index 00000000000..7bdbaf0c99b --- /dev/null +++ b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v14/authenticated/RemoteBackupApiV14.kt @@ -0,0 +1,27 @@ +/* + * Wire + * Copyright (C) 2025 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.v14.authenticated + +import com.wire.kalium.network.api.v13.authenticated.RemoteBackupApiV13 +import io.ktor.client.HttpClient + +internal open class RemoteBackupApiV14 internal constructor( + httpClient: HttpClient, + backupServiceUrl: String? +) : RemoteBackupApiV13(httpClient, backupServiceUrl) diff --git a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v14/authenticated/networkContainer/AuthenticatedNetworkContainerV14.kt b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v14/authenticated/networkContainer/AuthenticatedNetworkContainerV14.kt index a3a8b17be95..295ba6484fd 100644 --- a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v14/authenticated/networkContainer/AuthenticatedNetworkContainerV14.kt +++ b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v14/authenticated/networkContainer/AuthenticatedNetworkContainerV14.kt @@ -21,6 +21,7 @@ package com.wire.kalium.network.api.v14.authenticated.networkContainer import com.wire.kalium.logger.KaliumLogger import com.wire.kalium.network.api.base.authenticated.AccessTokenApi import com.wire.kalium.network.api.base.authenticated.CallApi +import com.wire.kalium.network.api.base.authenticated.RemoteBackupApi import com.wire.kalium.network.api.base.authenticated.ServerTimeApi import com.wire.kalium.network.api.base.authenticated.TeamsApi import com.wire.kalium.network.api.base.authenticated.UpgradePersonalToTeamApi @@ -62,6 +63,7 @@ import com.wire.kalium.network.api.v14.authenticated.MessageApiV14 import com.wire.kalium.network.api.v14.authenticated.NotificationApiV14 import com.wire.kalium.network.api.v14.authenticated.PreKeyApiV14 import com.wire.kalium.network.api.v14.authenticated.PropertiesApiV14 +import com.wire.kalium.network.api.v14.authenticated.RemoteBackupApiV14 import com.wire.kalium.network.api.v14.authenticated.SelfApiV14 import com.wire.kalium.network.api.v14.authenticated.ServerTimeApiV14 import com.wire.kalium.network.api.v14.authenticated.TeamsApiV14 @@ -169,4 +171,7 @@ internal class AuthenticatedNetworkContainerV14 internal constructor( get() = ServerTimeApiV14(networkClient) override val cellsHttpClient: HttpClient = networkClient.httpClient + + override val remoteBackupApi: RemoteBackupApi + get() = RemoteBackupApiV14(networkClient.httpClient, null) } diff --git a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v2/authenticated/networkContainer/AuthenticatedNetworkContainerV2.kt b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v2/authenticated/networkContainer/AuthenticatedNetworkContainerV2.kt index 7f5d0fd92b0..33f0031ee3c 100644 --- a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v2/authenticated/networkContainer/AuthenticatedNetworkContainerV2.kt +++ b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v2/authenticated/networkContainer/AuthenticatedNetworkContainerV2.kt @@ -21,6 +21,7 @@ package com.wire.kalium.network.api.v2.authenticated.networkContainer import com.wire.kalium.logger.KaliumLogger import com.wire.kalium.network.api.base.authenticated.AccessTokenApi import com.wire.kalium.network.api.base.authenticated.CallApi +import com.wire.kalium.network.api.base.authenticated.RemoteBackupApi import com.wire.kalium.network.api.base.authenticated.ServerTimeApi import com.wire.kalium.network.api.base.authenticated.TeamsApi import com.wire.kalium.network.api.base.authenticated.UpgradePersonalToTeamApi @@ -45,6 +46,7 @@ import com.wire.kalium.network.api.base.authenticated.self.SelfApi import com.wire.kalium.network.api.base.authenticated.serverpublickey.MLSPublicKeyApi import com.wire.kalium.network.api.base.authenticated.userDetails.UserDetailsApi import com.wire.kalium.network.api.model.UserId +import com.wire.kalium.network.api.v0.authenticated.RemoteBackupApiV0 import com.wire.kalium.network.api.v2.authenticated.AccessTokenApiV2 import com.wire.kalium.network.api.v2.authenticated.AssetApiV2 import com.wire.kalium.network.api.v2.authenticated.CallApiV2 @@ -154,4 +156,6 @@ internal class AuthenticatedNetworkContainerV2 internal constructor( ) override val serverTimeApi: ServerTimeApi get() = ServerTimeApiV2(networkClient) + + override val remoteBackupApi: RemoteBackupApi get() = RemoteBackupApiV0() } diff --git a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v3/authenticated/networkContainer/AuthenticatedNetworkContainerV3.kt b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v3/authenticated/networkContainer/AuthenticatedNetworkContainerV3.kt index 5ffbef63850..084825cf473 100644 --- a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v3/authenticated/networkContainer/AuthenticatedNetworkContainerV3.kt +++ b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v3/authenticated/networkContainer/AuthenticatedNetworkContainerV3.kt @@ -21,6 +21,7 @@ package com.wire.kalium.network.api.v3.authenticated.networkContainer import com.wire.kalium.logger.KaliumLogger import com.wire.kalium.network.api.base.authenticated.AccessTokenApi import com.wire.kalium.network.api.base.authenticated.CallApi +import com.wire.kalium.network.api.base.authenticated.RemoteBackupApi import com.wire.kalium.network.api.base.authenticated.ServerTimeApi import com.wire.kalium.network.api.base.authenticated.TeamsApi import com.wire.kalium.network.api.base.authenticated.UpgradePersonalToTeamApi @@ -46,6 +47,7 @@ import com.wire.kalium.network.api.base.authenticated.serverpublickey.MLSPublicK import com.wire.kalium.network.api.base.authenticated.userDetails.UserDetailsApi import com.wire.kalium.network.api.model.ApiModelMapperImpl import com.wire.kalium.network.api.model.UserId +import com.wire.kalium.network.api.v0.authenticated.RemoteBackupApiV0 import com.wire.kalium.network.api.v3.authenticated.AccessTokenApiV3 import com.wire.kalium.network.api.v3.authenticated.AssetApiV3 import com.wire.kalium.network.api.v3.authenticated.CallApiV3 @@ -155,4 +157,6 @@ internal class AuthenticatedNetworkContainerV3 internal constructor( ) override val serverTimeApi: ServerTimeApi get() = ServerTimeApiV3(networkClient) + + override val remoteBackupApi: RemoteBackupApi get() = RemoteBackupApiV0() } diff --git a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v4/authenticated/networkContainer/AuthenticatedNetworkContainerV4.kt b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v4/authenticated/networkContainer/AuthenticatedNetworkContainerV4.kt index 0058aa77185..64a89ad5bd2 100644 --- a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v4/authenticated/networkContainer/AuthenticatedNetworkContainerV4.kt +++ b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v4/authenticated/networkContainer/AuthenticatedNetworkContainerV4.kt @@ -21,6 +21,7 @@ package com.wire.kalium.network.api.v4.authenticated.networkContainer import com.wire.kalium.logger.KaliumLogger import com.wire.kalium.network.api.base.authenticated.AccessTokenApi import com.wire.kalium.network.api.base.authenticated.CallApi +import com.wire.kalium.network.api.base.authenticated.RemoteBackupApi import com.wire.kalium.network.api.base.authenticated.ServerTimeApi import com.wire.kalium.network.api.base.authenticated.TeamsApi import com.wire.kalium.network.api.base.authenticated.UpgradePersonalToTeamApi @@ -45,6 +46,7 @@ import com.wire.kalium.network.api.base.authenticated.self.SelfApi import com.wire.kalium.network.api.base.authenticated.serverpublickey.MLSPublicKeyApi import com.wire.kalium.network.api.base.authenticated.userDetails.UserDetailsApi import com.wire.kalium.network.api.model.UserId +import com.wire.kalium.network.api.v0.authenticated.RemoteBackupApiV0 import com.wire.kalium.network.api.v4.authenticated.AccessTokenApiV4 import com.wire.kalium.network.api.v4.authenticated.AssetApiV4 import com.wire.kalium.network.api.v4.authenticated.CallApiV4 @@ -154,4 +156,6 @@ internal class AuthenticatedNetworkContainerV4 internal constructor( ) override val serverTimeApi: ServerTimeApi get() = ServerTimeApiV4(networkClient) + + override val remoteBackupApi: RemoteBackupApi get() = RemoteBackupApiV0() } diff --git a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v5/authenticated/networkContainer/AuthenticatedNetworkContainerV5.kt b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v5/authenticated/networkContainer/AuthenticatedNetworkContainerV5.kt index 503c8f1b587..b8f7c3b0abd 100644 --- a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v5/authenticated/networkContainer/AuthenticatedNetworkContainerV5.kt +++ b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v5/authenticated/networkContainer/AuthenticatedNetworkContainerV5.kt @@ -21,6 +21,7 @@ package com.wire.kalium.network.api.v5.authenticated.networkContainer import com.wire.kalium.logger.KaliumLogger import com.wire.kalium.network.api.base.authenticated.AccessTokenApi import com.wire.kalium.network.api.base.authenticated.CallApi +import com.wire.kalium.network.api.base.authenticated.RemoteBackupApi import com.wire.kalium.network.api.base.authenticated.ServerTimeApi import com.wire.kalium.network.api.base.authenticated.TeamsApi import com.wire.kalium.network.api.base.authenticated.UpgradePersonalToTeamApi @@ -45,6 +46,7 @@ import com.wire.kalium.network.api.base.authenticated.self.SelfApi import com.wire.kalium.network.api.base.authenticated.serverpublickey.MLSPublicKeyApi import com.wire.kalium.network.api.base.authenticated.userDetails.UserDetailsApi import com.wire.kalium.network.api.model.UserId +import com.wire.kalium.network.api.v0.authenticated.RemoteBackupApiV0 import com.wire.kalium.network.api.v5.authenticated.AccessTokenApiV5 import com.wire.kalium.network.api.v5.authenticated.AssetApiV5 import com.wire.kalium.network.api.v5.authenticated.CallApiV5 @@ -155,4 +157,6 @@ internal class AuthenticatedNetworkContainerV5 internal constructor( override val serverTimeApi: ServerTimeApi get() = ServerTimeApiV5(networkClient) + + override val remoteBackupApi: RemoteBackupApi get() = RemoteBackupApiV0() } diff --git a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v6/authenticated/networkContainer/AuthenticatedNetworkContainerV6.kt b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v6/authenticated/networkContainer/AuthenticatedNetworkContainerV6.kt index 9251a2c0ac2..a27cdd412cb 100644 --- a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v6/authenticated/networkContainer/AuthenticatedNetworkContainerV6.kt +++ b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v6/authenticated/networkContainer/AuthenticatedNetworkContainerV6.kt @@ -21,6 +21,7 @@ package com.wire.kalium.network.api.v6.authenticated.networkContainer import com.wire.kalium.logger.KaliumLogger import com.wire.kalium.network.api.base.authenticated.AccessTokenApi import com.wire.kalium.network.api.base.authenticated.CallApi +import com.wire.kalium.network.api.base.authenticated.RemoteBackupApi import com.wire.kalium.network.api.base.authenticated.ServerTimeApi import com.wire.kalium.network.api.base.authenticated.TeamsApi import com.wire.kalium.network.api.base.authenticated.UpgradePersonalToTeamApi @@ -45,6 +46,7 @@ import com.wire.kalium.network.api.base.authenticated.self.SelfApi import com.wire.kalium.network.api.base.authenticated.serverpublickey.MLSPublicKeyApi import com.wire.kalium.network.api.base.authenticated.userDetails.UserDetailsApi import com.wire.kalium.network.api.model.UserId +import com.wire.kalium.network.api.v0.authenticated.RemoteBackupApiV0 import com.wire.kalium.network.api.v6.authenticated.AccessTokenApiV6 import com.wire.kalium.network.api.v6.authenticated.AssetApiV6 import com.wire.kalium.network.api.v6.authenticated.CallApiV6 @@ -156,4 +158,6 @@ internal class AuthenticatedNetworkContainerV6 internal constructor( override val serverTimeApi: ServerTimeApi get() = ServerTimeApiV6(networkClient) + override val remoteBackupApi: RemoteBackupApi get() = RemoteBackupApiV0() + } diff --git a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v7/authenticated/networkContainer/AuthenticatedNetworkContainerV7.kt b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v7/authenticated/networkContainer/AuthenticatedNetworkContainerV7.kt index 09d5594bb50..352ba42bd2a 100644 --- a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v7/authenticated/networkContainer/AuthenticatedNetworkContainerV7.kt +++ b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v7/authenticated/networkContainer/AuthenticatedNetworkContainerV7.kt @@ -21,6 +21,7 @@ package com.wire.kalium.network.api.v7.authenticated.networkContainer import com.wire.kalium.logger.KaliumLogger import com.wire.kalium.network.api.base.authenticated.AccessTokenApi import com.wire.kalium.network.api.base.authenticated.CallApi +import com.wire.kalium.network.api.base.authenticated.RemoteBackupApi import com.wire.kalium.network.api.base.authenticated.ServerTimeApi import com.wire.kalium.network.api.base.authenticated.TeamsApi import com.wire.kalium.network.api.base.authenticated.UpgradePersonalToTeamApi @@ -45,6 +46,7 @@ import com.wire.kalium.network.api.base.authenticated.self.SelfApi import com.wire.kalium.network.api.base.authenticated.serverpublickey.MLSPublicKeyApi import com.wire.kalium.network.api.base.authenticated.userDetails.UserDetailsApi import com.wire.kalium.network.api.model.UserId +import com.wire.kalium.network.api.v0.authenticated.RemoteBackupApiV0 import com.wire.kalium.network.api.v7.authenticated.AccessTokenApiV7 import com.wire.kalium.network.api.v7.authenticated.AssetApiV7 import com.wire.kalium.network.api.v7.authenticated.CallApiV7 @@ -164,4 +166,6 @@ internal class AuthenticatedNetworkContainerV7 internal constructor( override val serverTimeApi: ServerTimeApi get() = ServerTimeApiV7(networkClient) + + override val remoteBackupApi: RemoteBackupApi get() = RemoteBackupApiV0() } diff --git a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v8/authenticated/networkContainer/AuthenticatedNetworkContainerV8.kt b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v8/authenticated/networkContainer/AuthenticatedNetworkContainerV8.kt index c582832968f..a13eb85efa7 100644 --- a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v8/authenticated/networkContainer/AuthenticatedNetworkContainerV8.kt +++ b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v8/authenticated/networkContainer/AuthenticatedNetworkContainerV8.kt @@ -21,6 +21,7 @@ package com.wire.kalium.network.api.v8.authenticated.networkContainer import com.wire.kalium.logger.KaliumLogger import com.wire.kalium.network.api.base.authenticated.AccessTokenApi import com.wire.kalium.network.api.base.authenticated.CallApi +import com.wire.kalium.network.api.base.authenticated.RemoteBackupApi import com.wire.kalium.network.api.base.authenticated.ServerTimeApi import com.wire.kalium.network.api.base.authenticated.TeamsApi import com.wire.kalium.network.api.base.authenticated.UpgradePersonalToTeamApi @@ -45,6 +46,7 @@ import com.wire.kalium.network.api.base.authenticated.self.SelfApi import com.wire.kalium.network.api.base.authenticated.serverpublickey.MLSPublicKeyApi import com.wire.kalium.network.api.base.authenticated.userDetails.UserDetailsApi import com.wire.kalium.network.api.model.UserId +import com.wire.kalium.network.api.v0.authenticated.RemoteBackupApiV0 import com.wire.kalium.network.api.v8.authenticated.AccessTokenApiV8 import com.wire.kalium.network.api.v8.authenticated.AssetApiV8 import com.wire.kalium.network.api.v8.authenticated.CallApiV8 @@ -165,4 +167,6 @@ internal class AuthenticatedNetworkContainerV8 internal constructor( override val serverTimeApi: ServerTimeApi get() = ServerTimeApiV8(networkClient) + + override val remoteBackupApi: RemoteBackupApi get() = RemoteBackupApiV0() } diff --git a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v9/authenticated/networkContainer/AuthenticatedNetworkContainerV9.kt b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v9/authenticated/networkContainer/AuthenticatedNetworkContainerV9.kt index d66901142c7..9b66ad50339 100644 --- a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v9/authenticated/networkContainer/AuthenticatedNetworkContainerV9.kt +++ b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v9/authenticated/networkContainer/AuthenticatedNetworkContainerV9.kt @@ -21,6 +21,7 @@ package com.wire.kalium.network.api.v9.authenticated.networkContainer import com.wire.kalium.logger.KaliumLogger import com.wire.kalium.network.api.base.authenticated.AccessTokenApi import com.wire.kalium.network.api.base.authenticated.CallApi +import com.wire.kalium.network.api.base.authenticated.RemoteBackupApi import com.wire.kalium.network.api.base.authenticated.ServerTimeApi import com.wire.kalium.network.api.base.authenticated.TeamsApi import com.wire.kalium.network.api.base.authenticated.UpgradePersonalToTeamApi @@ -45,6 +46,7 @@ import com.wire.kalium.network.api.base.authenticated.self.SelfApi import com.wire.kalium.network.api.base.authenticated.serverpublickey.MLSPublicKeyApi import com.wire.kalium.network.api.base.authenticated.userDetails.UserDetailsApi import com.wire.kalium.network.api.model.UserId +import com.wire.kalium.network.api.v0.authenticated.RemoteBackupApiV0 import com.wire.kalium.network.api.v9.authenticated.AccessTokenApiV9 import com.wire.kalium.network.api.v9.authenticated.AssetApiV9 import com.wire.kalium.network.api.v9.authenticated.CallApiV9 @@ -166,4 +168,6 @@ internal class AuthenticatedNetworkContainerV9 internal constructor( override val serverTimeApi: ServerTimeApi get() = ServerTimeApiV9(networkClient) + + override val remoteBackupApi: RemoteBackupApi get() = RemoteBackupApiV0() } diff --git a/data/network/src/commonMain/kotlin/com/wire/kalium/network/networkContainer/AuthenticatedNetworkContainer.kt b/data/network/src/commonMain/kotlin/com/wire/kalium/network/networkContainer/AuthenticatedNetworkContainer.kt index d23c41a9373..2b2447a32d8 100644 --- a/data/network/src/commonMain/kotlin/com/wire/kalium/network/networkContainer/AuthenticatedNetworkContainer.kt +++ b/data/network/src/commonMain/kotlin/com/wire/kalium/network/networkContainer/AuthenticatedNetworkContainer.kt @@ -44,6 +44,7 @@ import com.wire.kalium.network.api.base.authenticated.properties.PropertiesApi import com.wire.kalium.network.api.base.authenticated.search.UserSearchApi import com.wire.kalium.network.api.base.authenticated.self.SelfApi import com.wire.kalium.network.api.base.authenticated.serverpublickey.MLSPublicKeyApi +import com.wire.kalium.network.api.base.authenticated.RemoteBackupApi import com.wire.kalium.network.api.base.authenticated.userDetails.UserDetailsApi import com.wire.kalium.network.api.model.UserId import com.wire.kalium.network.api.unbound.configuration.ServerConfigDTO @@ -126,6 +127,8 @@ interface AuthenticatedNetworkContainer { val serverTimeApi: ServerTimeApi + val remoteBackupApi: RemoteBackupApi + val cellsHttpClient: HttpClient get() = HttpClient() diff --git a/data/network/src/commonMain/kotlin/com/wire/kalium/network/utils/StreamStateBackupContent.kt b/data/network/src/commonMain/kotlin/com/wire/kalium/network/utils/StreamStateBackupContent.kt new file mode 100644 index 00000000000..e2d5dabe860 --- /dev/null +++ b/data/network/src/commonMain/kotlin/com/wire/kalium/network/utils/StreamStateBackupContent.kt @@ -0,0 +1,56 @@ +/* + * Wire + * Copyright (C) 2025 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.utils + +import io.ktor.http.ContentType +import io.ktor.http.content.OutgoingContent +import io.ktor.utils.io.ByteWriteChannel +import io.ktor.utils.io.writeFully +import okio.Buffer +import okio.Source +import okio.use + +/** + * Streaming content handler for uploading cryptographic state backups. + * Streams binary data from an Okio Source without loading the entire file into memory. + */ +internal class StreamStateBackupContent( + private val backupDataSource: () -> Source, + backupSize: Long +) : OutgoingContent.WriteChannelContent() { + + override val contentLength: Long = backupSize + override val contentType: ContentType = ContentType.Application.OctetStream + + override suspend fun writeTo(channel: ByteWriteChannel) { + backupDataSource().use { source -> + val buffer = Buffer() + while (source.read(buffer, BUFFER_SIZE) != -1L) { + val byteArray = buffer.readByteArray() + channel.writeFully(byteArray) + } + } + channel.flush() + channel.flushAndClose() + } + + private companion object { + const val BUFFER_SIZE = 8192L + } +} 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 new file mode 100644 index 00000000000..c72f86d1d44 --- /dev/null +++ b/data/network/src/commonTest/kotlin/com/wire/kalium/api/v12/RemoteBackupApiV12Test.kt @@ -0,0 +1,566 @@ +/* + * 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.api.v12 + +import com.wire.kalium.network.api.authenticated.remoteBackup.MessageSyncRequestDTO +import com.wire.kalium.network.api.authenticated.remoteBackup.MessageSyncUpsertDTO +import com.wire.kalium.network.api.v12.authenticated.RemoteBackupApiV12 +import com.wire.kalium.network.networkContainer.KaliumUserAgentProvider +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 +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +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.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +internal class RemoteBackupApiV12Test { + + @BeforeTest + fun setup() { + KaliumUserAgentProvider.setUserAgent("test/useragent") + } + + // region syncMessages tests + + @Test + fun givenSyncMessagesRequest_whenInvoking_thenShouldUseCorrectEndpointAndMethod() = runTest { + val request = createSyncRequest() + var capturedMethod: HttpMethod? = null + var capturedPath: String? = null + + val httpClient = createMockHttpClient( + responseBody = "", + statusCode = HttpStatusCode.OK + ) { requestData -> + capturedMethod = requestData.method + capturedPath = requestData.url.encodedPath + } + + val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + api.syncMessages(request) + + assertEquals(HttpMethod.Post, capturedMethod) + assertEquals("/messages", capturedPath) + } + + @Test + fun givenSyncMessagesRequest_whenInvoking_thenShouldSerializeBodyCorrectly() = runTest { + val request = createSyncRequest() + var capturedBody: String? = null + + val httpClient = createMockHttpClient( + responseBody = "", + statusCode = HttpStatusCode.OK + ) { requestData -> + val body = requestData.body + if (body is OutgoingContent.ByteArrayContent) { + capturedBody = body.bytes().decodeToString() + } + } + + val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + api.syncMessages(request) + + val expectedJson = KtxSerializer.json.encodeToString(request) + assertEquals(expectedJson, capturedBody) + } + + @Test + fun givenSyncMessagesRequest_whenSuccessful_thenShouldReturnSuccess() = runTest { + val request = createSyncRequest() + + val httpClient = createMockHttpClient( + responseBody = "", + statusCode = HttpStatusCode.OK + ) + + val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + val result = api.syncMessages(request) + + assertTrue(result.isSuccessful()) + } + + // endregion + + // region fetchMessages tests + + @Test + fun givenFetchMessagesRequest_whenInvoking_thenShouldUseCorrectEndpointAndMethod() = runTest { + var capturedMethod: HttpMethod? = null + var capturedPath: String? = null + + val httpClient = createMockHttpClient( + responseBody = FETCH_MESSAGES_RESPONSE, + statusCode = HttpStatusCode.OK + ) { requestData -> + capturedMethod = requestData.method + capturedPath = requestData.url.encodedPath + } + + val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + api.fetchMessages(user = TEST_USER_ID, size = 100) + + assertEquals(HttpMethod.Get, capturedMethod) + assertEquals("/messages", capturedPath) + } + + @Test + fun givenFetchMessagesRequest_whenInvoking_thenShouldIncludeRequiredQueryParams() = runTest { + var capturedUserParam: String? = null + var capturedSizeParam: String? = null + + val httpClient = createMockHttpClient( + responseBody = FETCH_MESSAGES_RESPONSE, + statusCode = HttpStatusCode.OK + ) { requestData -> + capturedUserParam = requestData.url.parameters["user"] + capturedSizeParam = requestData.url.parameters["size"] + } + + val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + api.fetchMessages(user = TEST_USER_ID, size = 50) + + assertEquals(TEST_USER_ID, capturedUserParam) + assertEquals("50", capturedSizeParam) + } + + @Test + fun givenFetchMessagesRequestWithOptionalParams_whenInvoking_thenShouldIncludeOptionalQueryParams() = runTest { + var capturedSinceParam: String? = null + var capturedConversationParam: String? = null + var capturedPaginationTokenParam: String? = null + + val httpClient = createMockHttpClient( + responseBody = FETCH_MESSAGES_RESPONSE, + statusCode = HttpStatusCode.OK + ) { requestData -> + capturedSinceParam = requestData.url.parameters["since"] + capturedConversationParam = requestData.url.parameters["conversation"] + capturedPaginationTokenParam = requestData.url.parameters["pagination_token"] + } + + val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + api.fetchMessages( + user = TEST_USER_ID, + since = 1234567890L, + conversation = TEST_CONVERSATION_ID, + paginationToken = "next-page-token", + size = 100 + ) + + assertEquals("1234567890", capturedSinceParam) + assertEquals(TEST_CONVERSATION_ID, capturedConversationParam) + assertEquals("next-page-token", capturedPaginationTokenParam) + } + + @Test + fun givenFetchMessagesRequestWithoutOptionalParams_whenInvoking_thenShouldNotIncludeOptionalQueryParams() = runTest { + var capturedSinceParam: String? = null + var capturedConversationParam: String? = null + var capturedPaginationTokenParam: String? = null + + val httpClient = createMockHttpClient( + responseBody = FETCH_MESSAGES_RESPONSE, + statusCode = HttpStatusCode.OK + ) { requestData -> + capturedSinceParam = requestData.url.parameters["since"] + capturedConversationParam = requestData.url.parameters["conversation"] + capturedPaginationTokenParam = requestData.url.parameters["pagination_token"] + } + + val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + api.fetchMessages(user = TEST_USER_ID, size = 100) + + assertNull(capturedSinceParam) + assertNull(capturedConversationParam) + assertNull(capturedPaginationTokenParam) + } + + @Test + fun givenFetchMessagesRequest_whenSuccessful_thenShouldDeserializeResponseCorrectly() = runTest { + val httpClient = createMockHttpClient( + responseBody = FETCH_MESSAGES_RESPONSE, + statusCode = HttpStatusCode.OK + ) + + val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + 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) + } + + // endregion + + // region deleteMessages tests + + @Test + fun givenDeleteMessagesRequest_whenInvoking_thenShouldUseCorrectEndpointAndMethod() = runTest { + var capturedMethod: HttpMethod? = null + var capturedPath: String? = null + + val httpClient = createMockHttpClient( + responseBody = DELETE_MESSAGES_RESPONSE, + statusCode = HttpStatusCode.OK + ) { requestData -> + capturedMethod = requestData.method + capturedPath = requestData.url.encodedPath + } + + val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + api.deleteMessages(userId = TEST_USER_ID) + + assertEquals(HttpMethod.Delete, capturedMethod) + assertEquals("/messages", capturedPath) + } + + @Test + fun givenDeleteMessagesRequestWithAllParams_whenInvoking_thenShouldIncludeAllQueryParams() = runTest { + var capturedUserIdParam: String? = null + var capturedConversationIdParam: String? = null + var capturedBeforeParam: String? = null + + val httpClient = createMockHttpClient( + responseBody = DELETE_MESSAGES_RESPONSE, + statusCode = HttpStatusCode.OK + ) { requestData -> + capturedUserIdParam = requestData.url.parameters["user_id"] + capturedConversationIdParam = requestData.url.parameters["conversation_id"] + capturedBeforeParam = requestData.url.parameters["before"] + } + + val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + api.deleteMessages( + userId = TEST_USER_ID, + conversationId = TEST_CONVERSATION_ID, + before = 9876543210L + ) + + assertEquals(TEST_USER_ID, capturedUserIdParam) + assertEquals(TEST_CONVERSATION_ID, capturedConversationIdParam) + assertEquals("9876543210", capturedBeforeParam) + } + + @Test + fun givenDeleteMessagesRequestWithNoParams_whenInvoking_thenShouldNotIncludeQueryParams() = runTest { + var capturedUserIdParam: String? = null + var capturedConversationIdParam: String? = null + var capturedBeforeParam: String? = null + + val httpClient = createMockHttpClient( + responseBody = DELETE_MESSAGES_RESPONSE, + statusCode = HttpStatusCode.OK + ) { requestData -> + capturedUserIdParam = requestData.url.parameters["user_id"] + capturedConversationIdParam = requestData.url.parameters["conversation_id"] + capturedBeforeParam = requestData.url.parameters["before"] + } + + val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + api.deleteMessages() + + assertNull(capturedUserIdParam) + assertNull(capturedConversationIdParam) + assertNull(capturedBeforeParam) + } + + @Test + fun givenDeleteMessagesRequest_whenSuccessful_thenShouldDeserializeResponseCorrectly() = runTest { + val httpClient = createMockHttpClient( + responseBody = DELETE_MESSAGES_RESPONSE, + statusCode = HttpStatusCode.OK + ) + + val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + val result = api.deleteMessages(userId = TEST_USER_ID) + + assertTrue(result.isSuccessful()) + assertEquals(42, result.value.deletedCount) + } + + // endregion + + // region uploadStateBackup tests + + @Test + fun givenUploadStateBackupRequest_whenInvoking_thenShouldUseCorrectEndpointAndMethod() = runTest { + var capturedMethod: HttpMethod? = null + var capturedPath: String? = null + + val httpClient = createMockHttpClient( + responseBody = "", + statusCode = HttpStatusCode.OK + ) { requestData -> + capturedMethod = requestData.method + capturedPath = requestData.url.encodedPath + } + + val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + val backupData = "test backup data".encodeToByteArray() + api.uploadStateBackup( + userId = TEST_USER_ID, + backupDataSource = { Buffer().write(backupData) }, + backupSize = backupData.size.toLong() + ) + + assertEquals(HttpMethod.Post, capturedMethod) + assertEquals("/state", capturedPath) + } + + @Test + fun givenUploadStateBackupRequest_whenInvoking_thenShouldIncludeUserIdQueryParam() = runTest { + var capturedUserIdParam: String? = null + + val httpClient = createMockHttpClient( + responseBody = "", + statusCode = HttpStatusCode.OK + ) { requestData -> + capturedUserIdParam = requestData.url.parameters["user_id"] + } + + val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + val backupData = "test backup data".encodeToByteArray() + api.uploadStateBackup( + userId = TEST_USER_ID, + backupDataSource = { Buffer().write(backupData) }, + backupSize = backupData.size.toLong() + ) + + assertEquals(TEST_USER_ID, capturedUserIdParam) + } + + @Test + fun givenUploadStateBackupRequest_whenInvoking_thenShouldSetContentTypeToOctetStream() = runTest { + var capturedContentType: ContentType? = null + + val httpClient = createMockHttpClient( + responseBody = "", + statusCode = HttpStatusCode.OK + ) { requestData -> + capturedContentType = requestData.body.contentType + } + + val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + val backupData = "test backup data".encodeToByteArray() + api.uploadStateBackup( + userId = TEST_USER_ID, + backupDataSource = { Buffer().write(backupData) }, + backupSize = backupData.size.toLong() + ) + + assertEquals(ContentType.Application.OctetStream, capturedContentType) + } + + @Test + fun givenUploadStateBackupRequest_whenSuccessful_thenShouldReturnSuccess() = runTest { + val httpClient = createMockHttpClient( + responseBody = "", + statusCode = HttpStatusCode.OK + ) + + val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + val backupData = "test backup data".encodeToByteArray() + val result = api.uploadStateBackup( + userId = TEST_USER_ID, + backupDataSource = { Buffer().write(backupData) }, + backupSize = backupData.size.toLong() + ) + + assertTrue(result.isSuccessful()) + } + + // endregion + + // region downloadStateBackup tests + + @Test + fun givenDownloadStateBackupRequest_whenInvoking_thenShouldUseCorrectEndpointAndMethod() = runTest { + var capturedMethod: HttpMethod? = null + var capturedPath: String? = null + + val httpClient = createMockHttpClient( + responseBody = "backup content", + statusCode = HttpStatusCode.OK + ) { requestData -> + capturedMethod = requestData.method + capturedPath = requestData.url.encodedPath + } + + val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + val sink = Buffer() + api.downloadStateBackup(userId = TEST_USER_ID, tempFileSink = sink) + + assertEquals(HttpMethod.Get, capturedMethod) + assertEquals("/state", capturedPath) + } + + @Test + fun givenDownloadStateBackupRequest_whenInvoking_thenShouldIncludeUserIdQueryParam() = runTest { + var capturedUserIdParam: String? = null + + val httpClient = createMockHttpClient( + responseBody = "backup content", + statusCode = HttpStatusCode.OK + ) { requestData -> + capturedUserIdParam = requestData.url.parameters["user_id"] + } + + val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + val sink = Buffer() + api.downloadStateBackup(userId = TEST_USER_ID, tempFileSink = sink) + + assertEquals(TEST_USER_ID, capturedUserIdParam) + } + + @Test + fun givenDownloadStateBackupRequest_whenSuccessful_thenShouldWriteContentToSink() = runTest { + val backupContent = "test backup content data" + val httpClient = createMockHttpClient( + responseBody = backupContent, + statusCode = HttpStatusCode.OK + ) + + val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + val sink = Buffer() + val result = api.downloadStateBackup(userId = TEST_USER_ID, tempFileSink = sink) + + assertTrue(result.isSuccessful()) + assertEquals(backupContent, sink.readUtf8()) + } + + @Test + fun givenDownloadStateBackupRequest_whenServerReturns404_thenShouldReturnError() = runTest { + val httpClient = createMockHttpClient( + responseBody = """{"code":404,"message":"No backup found","label":"not-found"}""", + statusCode = HttpStatusCode.NotFound + ) + + val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + val sink = Buffer() + val result = api.downloadStateBackup(userId = TEST_USER_ID, tempFileSink = sink) + + assertFalse(result.isSuccessful()) + } + + // endregion + + // region helper functions + + private fun createMockHttpClient( + responseBody: String, + statusCode: HttpStatusCode, + assertion: (io.ktor.client.request.HttpRequestData) -> Unit = {} + ): HttpClient { + val mockEngine = MockEngine { request -> + assertion(request) + respond( + content = ByteReadChannel(responseBody), + status = statusCode, + headers = HeadersImpl( + mapOf(HttpHeaders.ContentType to listOf("application/json")) + ) + ) + } + return HttpClient(mockEngine) { + install(ContentNegotiation) { + json(KtxSerializer.json) + } + defaultRequest { + header(HttpHeaders.ContentType, ContentType.Application.Json) + } + 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 BACKUP_SERVICE_URL = "https://backup.wire.com" + 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}""" + } +} From 4c1db3c604e55bdde129dd3a57d0655ebbad5186 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara Date: Tue, 20 Jan 2026 16:20:41 +0100 Subject: [PATCH 02/10] feat: implement wrapStreamingRequest for error handling in streaming downloads --- .../api/base/authenticated/RemoteBackupApi.kt | 2 +- .../v12/authenticated/RemoteBackupApiV12.kt | 38 ++++------------ .../network/utils/HttpResponseHandler.kt | 44 +++++++++++++++++++ .../network/utils/StreamStateBackupContent.kt | 12 ++--- 4 files changed, 60 insertions(+), 36 deletions(-) diff --git a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/base/authenticated/RemoteBackupApi.kt b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/base/authenticated/RemoteBackupApi.kt index 5a58aab1e74..0c2d0ea6a42 100644 --- a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/base/authenticated/RemoteBackupApi.kt +++ b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/base/authenticated/RemoteBackupApi.kt @@ -93,7 +93,7 @@ interface RemoteBackupApi { ): NetworkResponse companion object { - fun getApiNotSupportError(apiName: String, apiVersion: String = "13") = NetworkResponse.Error( + fun getApiNotSupportError(apiName: String, apiVersion: String = "12") = NetworkResponse.Error( APINotSupported("RemoteBackupApi: $apiName api is only available on API V$apiVersion") ) } 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 69914dc132a..56f021bf3ff 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 @@ -22,10 +22,11 @@ import com.wire.kalium.network.api.authenticated.remoteBackup.DeleteMessagesResp import com.wire.kalium.network.api.authenticated.remoteBackup.MessageSyncFetchResponseDTO import com.wire.kalium.network.api.authenticated.remoteBackup.MessageSyncRequestDTO import com.wire.kalium.network.api.v0.authenticated.RemoteBackupApiV0 +import com.wire.kalium.network.utils.BACKUP_STREAM_BUFFER_SIZE import com.wire.kalium.network.utils.NetworkResponse import com.wire.kalium.network.utils.StreamStateBackupContent -import com.wire.kalium.network.utils.wrapKaliumResponse import com.wire.kalium.network.utils.wrapRequest +import com.wire.kalium.network.utils.wrapStreamingRequest import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.delete @@ -40,7 +41,6 @@ import io.ktor.http.contentType import io.ktor.http.isSuccess import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.readAvailable -import kotlinx.coroutines.CancellationException import okio.Sink import okio.Source import okio.buffer @@ -109,40 +109,23 @@ internal open class RemoteBackupApiV12( override suspend fun downloadStateBackup( userId: String, tempFileSink: Sink - ): NetworkResponse = runCatching { + ): NetworkResponse = wrapStreamingRequest { handleError -> httpClient.prepareGet("$backupServiceUrl/state") { parameter("user_id", userId) }.execute { httpResponse -> if (httpResponse.status.isSuccess()) { - handleStateBackupDownload(httpResponse, tempFileSink) + val channel = httpResponse.body() + writeChannelToSink(channel, tempFileSink) + NetworkResponse.Success(Unit, httpResponse) } else { - wrapKaliumResponse { httpResponse } + handleError(httpResponse) } } - }.getOrElse { unhandledException -> - if (unhandledException is CancellationException) { - throw unhandledException - } - NetworkResponse.Error(com.wire.kalium.network.exceptions.KaliumException.GenericError(unhandledException)) - } - - @Suppress("TooGenericExceptionCaught") - private suspend fun handleStateBackupDownload( - httpResponse: HttpResponse, - tempFileSink: Sink - ): NetworkResponse = try { - val channel = httpResponse.body() - writeChannelToSink(channel, tempFileSink) - NetworkResponse.Success(Unit, httpResponse) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - NetworkResponse.Error(com.wire.kalium.network.exceptions.KaliumException.GenericError(e)) } private suspend fun writeChannelToSink(channel: ByteReadChannel, sink: Sink) { sink.buffer().use { bufferedSink -> - val buffer = ByteArray(BUFFER_SIZE) + val buffer = ByteArray(BACKUP_STREAM_BUFFER_SIZE) while (!channel.isClosedForRead) { val bytesRead = channel.readAvailable(buffer, 0, buffer.size) if (bytesRead <= 0) break @@ -150,9 +133,4 @@ internal open class RemoteBackupApiV12( } } } - - private companion object { - const val BUFFER_SIZE = 16 * 1024 - } - } diff --git a/data/network/src/commonMain/kotlin/com/wire/kalium/network/utils/HttpResponseHandler.kt b/data/network/src/commonMain/kotlin/com/wire/kalium/network/utils/HttpResponseHandler.kt index 8fbc39f759b..e9317115513 100644 --- a/data/network/src/commonMain/kotlin/com/wire/kalium/network/utils/HttpResponseHandler.kt +++ b/data/network/src/commonMain/kotlin/com/wire/kalium/network/utils/HttpResponseHandler.kt @@ -252,3 +252,47 @@ internal object FederationErrorResponseInterceptorConflictWithMissingUsers : Bas @Suppress("MagicNumber") val HttpStatusCode.Companion.UnreachableRemoteBackends: HttpStatusCode get() = HttpStatusCode(533, "Unreachable remote backends") + +/** + * Wraps a streaming request (e.g., file download using prepareGet...execute) with proper error handling. + * This handles the common pattern of streaming downloads where: + * - On success: execute custom success handling (e.g., write to sink) + * - On failure: use standard error interceptors + * - On exception: wrap in GenericError (re-throwing CancellationException) + * + * @param json JSON serializer for parsing error responses + * @param performRequest the streaming request block that returns a NetworkResponse + * @return NetworkResponse with either success or properly handled error + */ +@Suppress("TooGenericExceptionCaught") +internal inline fun wrapStreamingRequest( + json: Json = KtxSerializer.json, + performRequest: (handleError: suspend (HttpResponse) -> NetworkResponse) -> NetworkResponse +): NetworkResponse = try { + performRequest { httpResponse -> + handleStreamingError(httpResponse, json) + } +} catch (e: CancellationException) { + throw e +} catch (e: Exception) { + NetworkResponse.Error(KaliumException.GenericError(e)) +} + +/** + * Handles error responses for streaming requests using the standard interceptor chain. + */ +internal suspend inline fun handleStreamingError( + httpResponse: HttpResponse, + json: Json = KtxSerializer.json +): NetworkResponse { + val responseData = HttpResponseData( + headers = httpResponse.headers, + statusCode = httpResponse.status, + body = httpResponse.bodyAsText(), + json = json, + ) + return UnauthorizedResponseInterceptor.intercept(responseData) + ?: FederationErrorResponseInterceptorConflict.intercept(responseData) + ?: MLSErrorResponseHandler.intercept(responseData) + ?: BaseErrorResponseInterceptor.intercept(responseData) +} diff --git a/data/network/src/commonMain/kotlin/com/wire/kalium/network/utils/StreamStateBackupContent.kt b/data/network/src/commonMain/kotlin/com/wire/kalium/network/utils/StreamStateBackupContent.kt index e2d5dabe860..143f5837717 100644 --- a/data/network/src/commonMain/kotlin/com/wire/kalium/network/utils/StreamStateBackupContent.kt +++ b/data/network/src/commonMain/kotlin/com/wire/kalium/network/utils/StreamStateBackupContent.kt @@ -26,6 +26,12 @@ import okio.Buffer import okio.Source import okio.use +/** + * Buffer size for streaming backup content (8KB). + * Used consistently for both upload and download operations. + */ +internal const val BACKUP_STREAM_BUFFER_SIZE = 8 * 1024 + /** * Streaming content handler for uploading cryptographic state backups. * Streams binary data from an Okio Source without loading the entire file into memory. @@ -41,7 +47,7 @@ internal class StreamStateBackupContent( override suspend fun writeTo(channel: ByteWriteChannel) { backupDataSource().use { source -> val buffer = Buffer() - while (source.read(buffer, BUFFER_SIZE) != -1L) { + while (source.read(buffer, BACKUP_STREAM_BUFFER_SIZE.toLong()) != -1L) { val byteArray = buffer.readByteArray() channel.writeFully(byteArray) } @@ -49,8 +55,4 @@ internal class StreamStateBackupContent( channel.flush() channel.flushAndClose() } - - private companion object { - const val BUFFER_SIZE = 8192L - } } From dd0ad134b8e11d948458469e05fa8e2fa697576e Mon Sep 17 00:00:00 2001 From: Mohamad Jaara Date: Tue, 20 Jan 2026 17:20:40 +0100 Subject: [PATCH 03/10] detekt --- .../kalium/network/api/v12/authenticated/RemoteBackupApiV12.kt | 1 - 1 file changed, 1 deletion(-) 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 56f021bf3ff..d4f8335f274 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 @@ -35,7 +35,6 @@ import io.ktor.client.request.parameter import io.ktor.client.request.post import io.ktor.client.request.prepareGet import io.ktor.client.request.setBody -import io.ktor.client.statement.HttpResponse import io.ktor.http.ContentType import io.ktor.http.contentType import io.ktor.http.isSuccess From 4366f1604f0c6751c91f8663bf90292604338368 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara Date: Fri, 23 Jan 2026 16:54:08 +0100 Subject: [PATCH 04/10] remove unused backupServiceUrl parameter from RemoteBackupApi --- .../api/v12/authenticated/RemoteBackupApiV12.kt | 11 +++++------ .../AuthenticatedNetworkContainerV12.kt | 2 +- .../api/v13/authenticated/RemoteBackupApiV13.kt | 3 +-- .../AuthenticatedNetworkContainerV13.kt | 2 +- .../api/v14/authenticated/RemoteBackupApiV14.kt | 3 +-- .../AuthenticatedNetworkContainerV14.kt | 2 +- 6 files changed, 10 insertions(+), 13 deletions(-) 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 d4f8335f274..5b96e9c8136 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 @@ -47,12 +47,11 @@ import okio.use internal open class RemoteBackupApiV12( private val httpClient: HttpClient, - private val backupServiceUrl: String? ) : RemoteBackupApiV0() { override suspend fun syncMessages(request: MessageSyncRequestDTO): NetworkResponse = wrapRequest { - httpClient.post("$backupServiceUrl/messages") { + httpClient.post("backup/messages") { setBody(request) } } @@ -65,7 +64,7 @@ internal open class RemoteBackupApiV12( size: Int ): NetworkResponse = wrapRequest { - httpClient.get("$backupServiceUrl/messages") { + httpClient.get("backup/messages") { parameter("user", user) since?.let { parameter("since", it) } conversation?.let { parameter("conversation", it) } @@ -80,7 +79,7 @@ internal open class RemoteBackupApiV12( before: Long? ): NetworkResponse = wrapRequest { - httpClient.delete("$backupServiceUrl/messages") { + httpClient.delete("backup/messages") { userId?.let { parameter("user_id", it) } conversationId?.let { parameter("conversation_id", it) } before?.let { parameter("before", it) } @@ -93,7 +92,7 @@ internal open class RemoteBackupApiV12( backupSize: Long ): NetworkResponse = wrapRequest { - httpClient.post("$backupServiceUrl/state") { + httpClient.post("backup/state") { parameter("user_id", userId) contentType(ContentType.Application.OctetStream) setBody( @@ -109,7 +108,7 @@ internal open class RemoteBackupApiV12( userId: String, tempFileSink: Sink ): NetworkResponse = wrapStreamingRequest { handleError -> - httpClient.prepareGet("$backupServiceUrl/state") { + httpClient.prepareGet("backup/state") { parameter("user_id", userId) }.execute { httpResponse -> if (httpResponse.status.isSuccess()) { diff --git a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v12/authenticated/networkContainer/AuthenticatedNetworkContainerV12.kt b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v12/authenticated/networkContainer/AuthenticatedNetworkContainerV12.kt index 0993129a918..0730fe517ab 100644 --- a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v12/authenticated/networkContainer/AuthenticatedNetworkContainerV12.kt +++ b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v12/authenticated/networkContainer/AuthenticatedNetworkContainerV12.kt @@ -173,5 +173,5 @@ internal class AuthenticatedNetworkContainerV12 internal constructor( override val cellsHttpClient: HttpClient = networkClient.httpClient override val remoteBackupApi: RemoteBackupApi - get() = RemoteBackupApiV12(networkClient.httpClient, null) + get() = RemoteBackupApiV12(networkClient.httpClient) } diff --git a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v13/authenticated/RemoteBackupApiV13.kt b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v13/authenticated/RemoteBackupApiV13.kt index d5e703a97d6..6ff8eaefe6f 100644 --- a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v13/authenticated/RemoteBackupApiV13.kt +++ b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v13/authenticated/RemoteBackupApiV13.kt @@ -23,5 +23,4 @@ import io.ktor.client.HttpClient internal open class RemoteBackupApiV13 internal constructor( httpClient: HttpClient, - backupServiceUrl: String? -) : RemoteBackupApiV12(httpClient, backupServiceUrl) +) : RemoteBackupApiV12(httpClient) diff --git a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v13/authenticated/networkContainer/AuthenticatedNetworkContainerV13.kt b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v13/authenticated/networkContainer/AuthenticatedNetworkContainerV13.kt index de1d7a15e87..38f9d35e6c9 100644 --- a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v13/authenticated/networkContainer/AuthenticatedNetworkContainerV13.kt +++ b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v13/authenticated/networkContainer/AuthenticatedNetworkContainerV13.kt @@ -173,5 +173,5 @@ internal class AuthenticatedNetworkContainerV13 internal constructor( override val cellsHttpClient: HttpClient = networkClient.httpClient override val remoteBackupApi: RemoteBackupApi - get() = RemoteBackupApiV13(networkClient.httpClient, null) + get() = RemoteBackupApiV13(networkClient.httpClient) } diff --git a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v14/authenticated/RemoteBackupApiV14.kt b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v14/authenticated/RemoteBackupApiV14.kt index 7bdbaf0c99b..cfbb72a536f 100644 --- a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v14/authenticated/RemoteBackupApiV14.kt +++ b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v14/authenticated/RemoteBackupApiV14.kt @@ -23,5 +23,4 @@ import io.ktor.client.HttpClient internal open class RemoteBackupApiV14 internal constructor( httpClient: HttpClient, - backupServiceUrl: String? -) : RemoteBackupApiV13(httpClient, backupServiceUrl) +) : RemoteBackupApiV13(httpClient) diff --git a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v14/authenticated/networkContainer/AuthenticatedNetworkContainerV14.kt b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v14/authenticated/networkContainer/AuthenticatedNetworkContainerV14.kt index 295ba6484fd..b090216f35b 100644 --- a/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v14/authenticated/networkContainer/AuthenticatedNetworkContainerV14.kt +++ b/data/network/src/commonMain/kotlin/com/wire/kalium/network/api/v14/authenticated/networkContainer/AuthenticatedNetworkContainerV14.kt @@ -173,5 +173,5 @@ internal class AuthenticatedNetworkContainerV14 internal constructor( override val cellsHttpClient: HttpClient = networkClient.httpClient override val remoteBackupApi: RemoteBackupApi - get() = RemoteBackupApiV14(networkClient.httpClient, null) + get() = RemoteBackupApiV14(networkClient.httpClient) } From cf62f87a1a5476bb4372cf75e47b429e9bb58561 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara Date: Sat, 24 Jan 2026 23:38:31 +0100 Subject: [PATCH 05/10] adjust tests --- .../kalium/api/v12/RemoteBackupApiV12Test.kt | 51 +++++++++---------- 1 file changed, 25 insertions(+), 26 deletions(-) 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 c72f86d1d44..45551c6520a 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 @@ -72,11 +72,11 @@ internal class RemoteBackupApiV12Test { capturedPath = requestData.url.encodedPath } - val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + val api = RemoteBackupApiV12(httpClient) api.syncMessages(request) assertEquals(HttpMethod.Post, capturedMethod) - assertEquals("/messages", capturedPath) + assertEquals("/backup/messages", capturedPath) } @Test @@ -94,7 +94,7 @@ internal class RemoteBackupApiV12Test { } } - val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + val api = RemoteBackupApiV12(httpClient) api.syncMessages(request) val expectedJson = KtxSerializer.json.encodeToString(request) @@ -110,7 +110,7 @@ internal class RemoteBackupApiV12Test { statusCode = HttpStatusCode.OK ) - val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + val api = RemoteBackupApiV12(httpClient) val result = api.syncMessages(request) assertTrue(result.isSuccessful()) @@ -133,11 +133,11 @@ internal class RemoteBackupApiV12Test { capturedPath = requestData.url.encodedPath } - val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + val api = RemoteBackupApiV12(httpClient) api.fetchMessages(user = TEST_USER_ID, size = 100) assertEquals(HttpMethod.Get, capturedMethod) - assertEquals("/messages", capturedPath) + assertEquals("/backup/messages", capturedPath) } @Test @@ -153,7 +153,7 @@ internal class RemoteBackupApiV12Test { capturedSizeParam = requestData.url.parameters["size"] } - val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + val api = RemoteBackupApiV12(httpClient) api.fetchMessages(user = TEST_USER_ID, size = 50) assertEquals(TEST_USER_ID, capturedUserParam) @@ -175,7 +175,7 @@ internal class RemoteBackupApiV12Test { capturedPaginationTokenParam = requestData.url.parameters["pagination_token"] } - val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + val api = RemoteBackupApiV12(httpClient) api.fetchMessages( user = TEST_USER_ID, since = 1234567890L, @@ -204,7 +204,7 @@ internal class RemoteBackupApiV12Test { capturedPaginationTokenParam = requestData.url.parameters["pagination_token"] } - val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + val api = RemoteBackupApiV12(httpClient) api.fetchMessages(user = TEST_USER_ID, size = 100) assertNull(capturedSinceParam) @@ -219,7 +219,7 @@ internal class RemoteBackupApiV12Test { statusCode = HttpStatusCode.OK ) - val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + val api = RemoteBackupApiV12(httpClient) val result = api.fetchMessages(user = TEST_USER_ID, size = 100) assertTrue(result.isSuccessful()) @@ -250,11 +250,11 @@ internal class RemoteBackupApiV12Test { capturedPath = requestData.url.encodedPath } - val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + val api = RemoteBackupApiV12(httpClient) api.deleteMessages(userId = TEST_USER_ID) assertEquals(HttpMethod.Delete, capturedMethod) - assertEquals("/messages", capturedPath) + assertEquals("/backup/messages", capturedPath) } @Test @@ -272,7 +272,7 @@ internal class RemoteBackupApiV12Test { capturedBeforeParam = requestData.url.parameters["before"] } - val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + val api = RemoteBackupApiV12(httpClient) api.deleteMessages( userId = TEST_USER_ID, conversationId = TEST_CONVERSATION_ID, @@ -299,7 +299,7 @@ internal class RemoteBackupApiV12Test { capturedBeforeParam = requestData.url.parameters["before"] } - val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + val api = RemoteBackupApiV12(httpClient) api.deleteMessages() assertNull(capturedUserIdParam) @@ -314,7 +314,7 @@ internal class RemoteBackupApiV12Test { statusCode = HttpStatusCode.OK ) - val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + val api = RemoteBackupApiV12(httpClient) val result = api.deleteMessages(userId = TEST_USER_ID) assertTrue(result.isSuccessful()) @@ -338,7 +338,7 @@ internal class RemoteBackupApiV12Test { capturedPath = requestData.url.encodedPath } - val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + val api = RemoteBackupApiV12(httpClient) val backupData = "test backup data".encodeToByteArray() api.uploadStateBackup( userId = TEST_USER_ID, @@ -347,7 +347,7 @@ internal class RemoteBackupApiV12Test { ) assertEquals(HttpMethod.Post, capturedMethod) - assertEquals("/state", capturedPath) + assertEquals("/backup/state", capturedPath) } @Test @@ -361,7 +361,7 @@ internal class RemoteBackupApiV12Test { capturedUserIdParam = requestData.url.parameters["user_id"] } - val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + val api = RemoteBackupApiV12(httpClient) val backupData = "test backup data".encodeToByteArray() api.uploadStateBackup( userId = TEST_USER_ID, @@ -383,7 +383,7 @@ internal class RemoteBackupApiV12Test { capturedContentType = requestData.body.contentType } - val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + val api = RemoteBackupApiV12(httpClient) val backupData = "test backup data".encodeToByteArray() api.uploadStateBackup( userId = TEST_USER_ID, @@ -401,7 +401,7 @@ internal class RemoteBackupApiV12Test { statusCode = HttpStatusCode.OK ) - val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + val api = RemoteBackupApiV12(httpClient) val backupData = "test backup data".encodeToByteArray() val result = api.uploadStateBackup( userId = TEST_USER_ID, @@ -429,12 +429,12 @@ internal class RemoteBackupApiV12Test { capturedPath = requestData.url.encodedPath } - val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + val api = RemoteBackupApiV12(httpClient) val sink = Buffer() api.downloadStateBackup(userId = TEST_USER_ID, tempFileSink = sink) assertEquals(HttpMethod.Get, capturedMethod) - assertEquals("/state", capturedPath) + assertEquals("/backup/state", capturedPath) } @Test @@ -448,7 +448,7 @@ internal class RemoteBackupApiV12Test { capturedUserIdParam = requestData.url.parameters["user_id"] } - val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + val api = RemoteBackupApiV12(httpClient) val sink = Buffer() api.downloadStateBackup(userId = TEST_USER_ID, tempFileSink = sink) @@ -463,7 +463,7 @@ internal class RemoteBackupApiV12Test { statusCode = HttpStatusCode.OK ) - val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + val api = RemoteBackupApiV12(httpClient) val sink = Buffer() val result = api.downloadStateBackup(userId = TEST_USER_ID, tempFileSink = sink) @@ -478,7 +478,7 @@ internal class RemoteBackupApiV12Test { statusCode = HttpStatusCode.NotFound ) - val api = RemoteBackupApiV12(httpClient, BACKUP_SERVICE_URL) + val api = RemoteBackupApiV12(httpClient) val sink = Buffer() val result = api.downloadStateBackup(userId = TEST_USER_ID, tempFileSink = sink) @@ -538,7 +538,6 @@ internal class RemoteBackupApiV12Test { // endregion private companion object { - const val BACKUP_SERVICE_URL = "https://backup.wire.com" const val TEST_USER_ID = "user-123-abc" const val TEST_CONVERSATION_ID = "conv-456-def" From fc2c31ba3b5bc9356549ecadd6b6ddd0d326fe4c Mon Sep 17 00:00:00 2001 From: Mohamad Jaara Date: Tue, 27 Jan 2026 12:38:59 +0100 Subject: [PATCH 06/10] feat: add typed DTOs for message sync payloads Introduce MessageSyncContentDTO and MessageSyncPayloadDTO to replace the string-based payload in MessageSyncResultDTO and MessageSyncUpsertDTO. This provides type-safe serialization for text, asset, and location message content when syncing to the remote backup service. --- .../remoteBackup/MessageSyncContentDTO.kt | 136 ++++++++++++++++++ .../remoteBackup/MessageSyncPayloadDTO.kt | 54 +++++++ .../remoteBackup/MessageSyncResultDTO.kt | 2 +- .../remoteBackup/MessageSyncUpsertDTO.kt | 2 +- 4 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncContentDTO.kt create mode 100644 data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncPayloadDTO.kt diff --git a/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncContentDTO.kt b/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncContentDTO.kt new file mode 100644 index 00000000000..a764f60e947 --- /dev/null +++ b/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncContentDTO.kt @@ -0,0 +1,136 @@ +/* + * 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 + +/** + * Sealed class representing different types of message content for sync. + * This mirrors the structure of BackupMessageContent. + */ +@Serializable +sealed class MessageSyncContentDTO { + + @Serializable + @SerialName("text") + data class Text( + @SerialName("text") + val text: String, + @SerialName("mentions") + val mentions: List = emptyList(), + @SerialName("quotedMessageId") + val quotedMessageId: String? = null + ) : MessageSyncContentDTO() + + @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? + ) : MessageSyncContentDTO() + + @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? + ) : MessageSyncContentDTO() +} + +/** + * DTO for user mentions in text messages. + */ +@Serializable +data class MessageSyncMentionDTO( + @SerialName("userId") + val userId: MessageSyncQualifiedIdDTO, + @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/MessageSyncPayloadDTO.kt b/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncPayloadDTO.kt new file mode 100644 index 00000000000..26b665f93eb --- /dev/null +++ b/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncPayloadDTO.kt @@ -0,0 +1,54 @@ +/* + * 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 + +/** + * DTO representing the payload of a message sync operation. + * This mirrors the structure of BackupMessage for type-safe serialization. + */ +@Serializable +data class MessageSyncPayloadDTO( + @SerialName("id") + val id: String, + @SerialName("conversationId") + val conversationId: MessageSyncQualifiedIdDTO, + @SerialName("senderUserId") + val senderUserId: MessageSyncQualifiedIdDTO, + @SerialName("senderClientId") + val senderClientId: String, + @SerialName("creationDate") + val creationDate: Long, + @SerialName("content") + val content: MessageSyncContentDTO, + @SerialName("lastEditTime") + val lastEditTime: Long? = null +) + +/** + * DTO for qualified IDs in message sync payloads. + */ +@Serializable +data class MessageSyncQualifiedIdDTO( + @SerialName("id") + val id: String, + @SerialName("domain") + val domain: String +) 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/MessageSyncResultDTO.kt index 440600b8d73..8a75736d4d4 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/MessageSyncResultDTO.kt @@ -30,5 +30,5 @@ data class MessageSyncResultDTO( @SerialName("message_id") val messageId: String, @SerialName("payload") - val payload: String // JSON-encoded string of BackupMessage + val payload: MessageSyncPayloadDTO ) 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 index adce390352b..1edb7f31319 100644 --- 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 @@ -30,5 +30,5 @@ data class MessageSyncUpsertDTO( @SerialName("timestamp") val timestamp: Long, // Unix timestamp in milliseconds @SerialName("payload") - val payload: String // JSON string of BackupMessage + val payload: MessageSyncPayloadDTO ) From 60626b3ef500ed19ab5b4eb7aee64325f9cceb9c Mon Sep 17 00:00:00 2001 From: Mohamad Jaara Date: Tue, 27 Jan 2026 16:52:11 +0100 Subject: [PATCH 07/10] adjust structs naming --- .../remoteBackup/MessageSyncResultDTO.kt | 2 +- .../remoteBackup/MessageSyncUpsertDTO.kt | 2 +- ...TO.kt => RemoteBAckupMessageContentDTO.kt} | 11 +++++----- ...ayloadDTO.kt => RemoteBackupPayloadDTO.kt} | 20 +++++-------------- 4 files changed, 13 insertions(+), 22 deletions(-) rename data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/{MessageSyncContentDTO.kt => RemoteBAckupMessageContentDTO.kt} (93%) rename data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/{MessageSyncPayloadDTO.kt => RemoteBackupPayloadDTO.kt} (77%) 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/MessageSyncResultDTO.kt index 8a75736d4d4..74c534e82f8 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/MessageSyncResultDTO.kt @@ -30,5 +30,5 @@ data class MessageSyncResultDTO( @SerialName("message_id") val messageId: String, @SerialName("payload") - val payload: MessageSyncPayloadDTO + val payload: RemoteBackupPayloadDTO ) 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 index 1edb7f31319..734db4075e9 100644 --- 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 @@ -30,5 +30,5 @@ data class MessageSyncUpsertDTO( @SerialName("timestamp") val timestamp: Long, // Unix timestamp in milliseconds @SerialName("payload") - val payload: MessageSyncPayloadDTO + val payload: RemoteBackupPayloadDTO ) diff --git a/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncContentDTO.kt b/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/RemoteBAckupMessageContentDTO.kt similarity index 93% rename from data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncContentDTO.kt rename to data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/RemoteBAckupMessageContentDTO.kt index a764f60e947..da0fe3f8dba 100644 --- a/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncContentDTO.kt +++ b/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/RemoteBAckupMessageContentDTO.kt @@ -17,6 +17,7 @@ */ package com.wire.kalium.network.api.authenticated.remoteBackup +import com.wire.kalium.network.api.model.QualifiedID import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -25,7 +26,7 @@ import kotlinx.serialization.Serializable * This mirrors the structure of BackupMessageContent. */ @Serializable -sealed class MessageSyncContentDTO { +sealed class RemoteBAckupMessageContentDTO { @Serializable @SerialName("text") @@ -36,7 +37,7 @@ sealed class MessageSyncContentDTO { val mentions: List = emptyList(), @SerialName("quotedMessageId") val quotedMessageId: String? = null - ) : MessageSyncContentDTO() + ) : RemoteBAckupMessageContentDTO() @Serializable @SerialName("asset") @@ -61,7 +62,7 @@ sealed class MessageSyncContentDTO { val encryption: String?, @SerialName("metaData") val metaData: MessageSyncAssetMetadataDTO? - ) : MessageSyncContentDTO() + ) : RemoteBAckupMessageContentDTO() @Serializable @SerialName("location") @@ -74,7 +75,7 @@ sealed class MessageSyncContentDTO { val name: String?, @SerialName("zoom") val zoom: Int? - ) : MessageSyncContentDTO() + ) : RemoteBAckupMessageContentDTO() } /** @@ -83,7 +84,7 @@ sealed class MessageSyncContentDTO { @Serializable data class MessageSyncMentionDTO( @SerialName("userId") - val userId: MessageSyncQualifiedIdDTO, + val userId: QualifiedID, @SerialName("start") val start: Int, @SerialName("length") diff --git a/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncPayloadDTO.kt b/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/RemoteBackupPayloadDTO.kt similarity index 77% rename from data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncPayloadDTO.kt rename to data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/RemoteBackupPayloadDTO.kt index 26b665f93eb..fc16ccd7b49 100644 --- a/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncPayloadDTO.kt +++ b/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/RemoteBackupPayloadDTO.kt @@ -17,6 +17,7 @@ */ package com.wire.kalium.network.api.authenticated.remoteBackup +import com.wire.kalium.network.api.model.QualifiedID import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -25,30 +26,19 @@ import kotlinx.serialization.Serializable * This mirrors the structure of BackupMessage for type-safe serialization. */ @Serializable -data class MessageSyncPayloadDTO( +data class RemoteBackupPayloadDTO( @SerialName("id") val id: String, @SerialName("conversationId") - val conversationId: MessageSyncQualifiedIdDTO, + val conversationId: QualifiedID, @SerialName("senderUserId") - val senderUserId: MessageSyncQualifiedIdDTO, + val senderUserId: QualifiedID, @SerialName("senderClientId") val senderClientId: String, @SerialName("creationDate") val creationDate: Long, @SerialName("content") - val content: MessageSyncContentDTO, + val content: RemoteBAckupMessageContentDTO, @SerialName("lastEditTime") val lastEditTime: Long? = null ) - -/** - * DTO for qualified IDs in message sync payloads. - */ -@Serializable -data class MessageSyncQualifiedIdDTO( - @SerialName("id") - val id: String, - @SerialName("domain") - val domain: String -) From 2531fd488ffcc9759e2159f5057b0a2c81b77442 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara Date: Tue, 27 Jan 2026 17:26:16 +0100 Subject: [PATCH 08/10] refactor: replace hardcoded response data with structured JSON responses in RemoteBackupApiV12Test --- .../kalium/api/v12/RemoteBackupApiV12Test.kt | 86 ++----- .../responses/RemoteBackupResponseJson.kt | 211 ++++++++++++++++++ 2 files changed, 233 insertions(+), 64 deletions(-) create mode 100644 test/mocks/src/commonMain/kotlin/com/wire/kalium/mocks/responses/RemoteBackupResponseJson.kt 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..45a870d9d53 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,8 +17,7 @@ */ 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.v12.authenticated.RemoteBackupApiV12 import com.wire.kalium.network.networkContainer.KaliumUserAgentProvider import com.wire.kalium.network.tools.KtxSerializer @@ -38,7 +37,6 @@ 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 @@ -46,8 +44,6 @@ 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 +56,7 @@ internal class RemoteBackupApiV12Test { @Test fun givenSyncMessagesRequest_whenInvoking_thenShouldUseCorrectEndpointAndMethod() = runTest { - val request = createSyncRequest() + val request = RemoteBackupResponseJson.validSyncRequest.serializableData var capturedMethod: HttpMethod? = null var capturedPath: String? = null @@ -81,7 +77,7 @@ internal class RemoteBackupApiV12Test { @Test fun givenSyncMessagesRequest_whenInvoking_thenShouldSerializeBodyCorrectly() = runTest { - val request = createSyncRequest() + val request = RemoteBackupResponseJson.validSyncRequest.serializableData var capturedBody: String? = null val httpClient = createMockHttpClient( @@ -103,7 +99,7 @@ internal class RemoteBackupApiV12Test { @Test fun givenSyncMessagesRequest_whenSuccessful_thenShouldReturnSuccess() = runTest { - val request = createSyncRequest() + val request = RemoteBackupResponseJson.validSyncRequest.serializableData val httpClient = createMockHttpClient( responseBody = "", @@ -126,7 +122,7 @@ internal class RemoteBackupApiV12Test { var capturedPath: String? = null val httpClient = createMockHttpClient( - responseBody = FETCH_MESSAGES_RESPONSE, + responseBody = RemoteBackupResponseJson.validFetchResponse.rawJson, statusCode = HttpStatusCode.OK ) { requestData -> capturedMethod = requestData.method @@ -146,7 +142,7 @@ internal class RemoteBackupApiV12Test { var capturedSizeParam: String? = null val httpClient = createMockHttpClient( - responseBody = FETCH_MESSAGES_RESPONSE, + responseBody = RemoteBackupResponseJson.validFetchResponse.rawJson, statusCode = HttpStatusCode.OK ) { requestData -> capturedUserParam = requestData.url.parameters["user"] @@ -167,7 +163,7 @@ internal class RemoteBackupApiV12Test { var capturedPaginationTokenParam: String? = null val httpClient = createMockHttpClient( - responseBody = FETCH_MESSAGES_RESPONSE, + responseBody = RemoteBackupResponseJson.validFetchResponse.rawJson, statusCode = HttpStatusCode.OK ) { requestData -> capturedSinceParam = requestData.url.parameters["since"] @@ -196,7 +192,7 @@ internal class RemoteBackupApiV12Test { var capturedPaginationTokenParam: String? = null val httpClient = createMockHttpClient( - responseBody = FETCH_MESSAGES_RESPONSE, + responseBody = RemoteBackupResponseJson.validFetchResponse.rawJson, statusCode = HttpStatusCode.OK ) { requestData -> capturedSinceParam = requestData.url.parameters["since"] @@ -214,8 +210,9 @@ internal class RemoteBackupApiV12Test { @Test fun givenFetchMessagesRequest_whenSuccessful_thenShouldDeserializeResponseCorrectly() = runTest { + val expectedResponse = RemoteBackupResponseJson.validFetchResponse.serializableData val httpClient = createMockHttpClient( - responseBody = FETCH_MESSAGES_RESPONSE, + responseBody = RemoteBackupResponseJson.validFetchResponse.rawJson, statusCode = HttpStatusCode.OK ) @@ -224,13 +221,14 @@ internal class RemoteBackupApiV12Test { assertTrue(result.isSuccessful()) val response = result.value - assertTrue(response.hasMore) - assertEquals("next-token", response.paginationToken) + assertEquals(expectedResponse.hasMore, response.hasMore) + assertEquals(expectedResponse.paginationToken, 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) + val expectedConversationMessages = expectedResponse.conversations[TEST_CONVERSATION_ID]!! + assertEquals(expectedConversationMessages.lastRead, conversationMessages.lastRead) + assertEquals(expectedConversationMessages.messages.size, conversationMessages.messages.size) + assertEquals(expectedConversationMessages.messages[0].messageId, conversationMessages.messages[0].messageId) } // endregion @@ -243,7 +241,7 @@ internal class RemoteBackupApiV12Test { var capturedPath: String? = null val httpClient = createMockHttpClient( - responseBody = DELETE_MESSAGES_RESPONSE, + responseBody = RemoteBackupResponseJson.validDeleteResponse.rawJson, statusCode = HttpStatusCode.OK ) { requestData -> capturedMethod = requestData.method @@ -264,7 +262,7 @@ internal class RemoteBackupApiV12Test { var capturedBeforeParam: String? = null val httpClient = createMockHttpClient( - responseBody = DELETE_MESSAGES_RESPONSE, + responseBody = RemoteBackupResponseJson.validDeleteResponse.rawJson, statusCode = HttpStatusCode.OK ) { requestData -> capturedUserIdParam = requestData.url.parameters["user_id"] @@ -291,7 +289,7 @@ internal class RemoteBackupApiV12Test { var capturedBeforeParam: String? = null val httpClient = createMockHttpClient( - responseBody = DELETE_MESSAGES_RESPONSE, + responseBody = RemoteBackupResponseJson.validDeleteResponse.rawJson, statusCode = HttpStatusCode.OK ) { requestData -> capturedUserIdParam = requestData.url.parameters["user_id"] @@ -309,8 +307,9 @@ internal class RemoteBackupApiV12Test { @Test fun givenDeleteMessagesRequest_whenSuccessful_thenShouldDeserializeResponseCorrectly() = runTest { + val expectedResponse = RemoteBackupResponseJson.validDeleteResponse.serializableData val httpClient = createMockHttpClient( - responseBody = DELETE_MESSAGES_RESPONSE, + responseBody = RemoteBackupResponseJson.validDeleteResponse.rawJson, statusCode = HttpStatusCode.OK ) @@ -318,7 +317,7 @@ internal class RemoteBackupApiV12Test { val result = api.deleteMessages(userId = TEST_USER_ID) assertTrue(result.isSuccessful()) - assertEquals(42, result.value.deletedCount) + assertEquals(expectedResponse.deletedCount, result.value.deletedCount) } // endregion @@ -515,51 +514,10 @@ internal class RemoteBackupApiV12Test { } } - @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..59640c7ef85 --- /dev/null +++ b/test/mocks/src/commonMain/kotlin/com/wire/kalium/mocks/responses/RemoteBackupResponseJson.kt @@ -0,0 +1,211 @@ +/* + * 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.ConversationMessagesDTO +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.MessageSyncResultDTO +import com.wire.kalium.network.api.authenticated.remoteBackup.MessageSyncUpsertDTO +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.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +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 fun qualifiedIdToJson(qualifiedId: QualifiedID): JsonObject = buildJsonObject { + put("id", qualifiedId.value) + put("domain", qualifiedId.domain) + } + + private fun messageContentToJson(content: RemoteBAckupMessageContentDTO): JsonObject = when (content) { + is RemoteBAckupMessageContentDTO.Text -> buildJsonObject { + put("type", "text") + put("text", content.text) + if (content.mentions.isNotEmpty()) { + put("mentions", buildJsonArray { + content.mentions.forEach { mention -> + add(buildJsonObject { + put("userId", qualifiedIdToJson(mention.userId)) + put("start", mention.start) + put("length", mention.length) + }) + } + }) + } + content.quotedMessageId?.let { put("quotedMessageId", it) } + } + is RemoteBAckupMessageContentDTO.Asset -> buildJsonObject { + put("type", "asset") + put("mimeType", content.mimeType) + put("size", content.size) + content.name?.let { put("name", it) } + put("otrKey", content.otrKey) + put("sha256", content.sha256) + put("assetId", content.assetId) + content.assetToken?.let { put("assetToken", it) } + content.assetDomain?.let { put("assetDomain", it) } + content.encryption?.let { put("encryption", it) } + } + is RemoteBAckupMessageContentDTO.Location -> buildJsonObject { + put("type", "location") + put("longitude", content.longitude) + put("latitude", content.latitude) + content.name?.let { put("name", it) } + content.zoom?.let { put("zoom", it) } + } + } + + private fun payloadToJson(payload: RemoteBackupPayloadDTO): JsonObject = buildJsonObject { + put("id", payload.id) + put("conversationId", qualifiedIdToJson(payload.conversationId)) + put("senderUserId", qualifiedIdToJson(payload.senderUserId)) + put("senderClientId", payload.senderClientId) + put("creationDate", payload.creationDate) + put("content", messageContentToJson(payload.content)) + payload.lastEditTime?.let { put("lastEditTime", it) } + } + + private fun upsertToJson(upsert: MessageSyncUpsertDTO): JsonObject = buildJsonObject { + put("message_id", upsert.messageId) + put("timestamp", upsert.timestamp) + put("payload", payloadToJson(upsert.payload)) + } + + private val syncRequestJsonProvider = { request: MessageSyncRequestDTO -> + buildJsonObject { + put("user_id", request.userId) + put("upserts", buildJsonObject { + request.upserts.forEach { (conversationId, upserts) -> + put(conversationId, buildJsonArray { + upserts.forEach { add(upsertToJson(it)) } + }) + } + }) + put("deletions", buildJsonObject { + request.deletions.forEach { (conversationId, deletions) -> + put(conversationId, JsonArray(deletions.map { JsonPrimitive(it) })) + } + }) + put("conversations_last_read", buildJsonObject { + request.conversationsLastRead.forEach { (conversationId, lastRead) -> + put(conversationId, lastRead) + } + }) + }.toString() + } + + private fun messageResultToJson(result: MessageSyncResultDTO): JsonObject = buildJsonObject { + put("message_id", result.messageId) + put("timestamp", result.timestamp) + put("payload", payloadToJson(result.payload)) + } + + private fun conversationMessagesToJson(messages: ConversationMessagesDTO): JsonObject = buildJsonObject { + put("last_read", messages.lastRead) + put("messages", buildJsonArray { + messages.messages.forEach { add(messageResultToJson(it)) } + }) + } + + private val fetchResponseJsonProvider = { response: MessageSyncFetchResponseDTO -> + buildJsonObject { + put("has_more", response.hasMore) + put("conversations", buildJsonObject { + response.conversations.forEach { (conversationId, messages) -> + put(conversationId, conversationMessagesToJson(messages)) + } + }) + response.paginationToken?.let { put("pagination_token", it) } + }.toString() + } + + 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 = MessageSyncUpsertDTO( + messageId = "msg-1", + timestamp = -62135596800000L, // Instant.DISTANT_PAST + payload = testPayload + ) + + private val testSyncRequest = MessageSyncRequestDTO( + userId = TEST_USER_ID, + upserts = mapOf( + TEST_CONVERSATION_ID to listOf(testUpsert) + ), + deletions = mapOf( + TEST_CONVERSATION_ID to listOf("deleted-msg-1", "deleted-msg-2") + ), + conversationsLastRead = mapOf( + TEST_CONVERSATION_ID to 1234567890L + ) + ) + + private val testMessageResult = MessageSyncResultDTO( + messageId = "msg-1", + timestamp = 999L, + payload = testPayload + ) + + private val testFetchResponse = MessageSyncFetchResponseDTO( + hasMore = true, + conversations = mapOf( + TEST_CONVERSATION_ID to ConversationMessagesDTO( + lastRead = 1000L, + messages = listOf(testMessageResult) + ) + ), + paginationToken = "next-token" + ) + + private val testDeleteResponse = DeleteMessagesResponseDTO( + deletedCount = 42 + ) + + val validSyncRequest = ValidJsonProvider(testSyncRequest, syncRequestJsonProvider) + + val validFetchResponse = ValidJsonProvider(testFetchResponse, fetchResponseJsonProvider) + + val validDeleteResponse = ValidJsonProvider(testDeleteResponse, deleteResponseJsonProvider) +} From e0633fdf5c42c1af9f8561b485a6af8a0aabbe2e Mon Sep 17 00:00:00 2001 From: Mohamad Jaara Date: Wed, 4 Feb 2026 16:16:01 +0100 Subject: [PATCH 09/10] feat: add remote backup protobuf definitions --- .../src/main/proto/remote_backup.proto | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 tools/protobuf-codegen/src/main/proto/remote_backup.proto 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; +} From 87a6bde3e06aa9c94ff168335a816193b68abdad Mon Sep 17 00:00:00 2001 From: Mohamad Jaara Date: Wed, 4 Feb 2026 16:18:48 +0100 Subject: [PATCH 10/10] feat: update message sync request and response DTOs to handle events --- .../MessageSyncFetchResponseDTO.kt | 11 +- .../remoteBackup/MessageSyncRequestDTO.kt | 15 +- .../remoteBackup/MessageSyncResultDTO.kt | 34 --- .../remoteBackup/MessageSyncUpsertDTO.kt | 34 --- ...MessagesDTO.kt => RemoteBackupEventDTO.kt} | 30 +- .../remoteBackup/RemoteBackupProtoMapper.kt | 284 ++++++++++++++++++ .../v12/authenticated/RemoteBackupApiV12.kt | 13 +- .../kalium/api/v12/RemoteBackupApiV12Test.kt | 93 +++--- .../responses/RemoteBackupResponseJson.kt | 158 ++-------- 9 files changed, 388 insertions(+), 284 deletions(-) delete mode 100644 data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncResultDTO.kt delete mode 100644 data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncUpsertDTO.kt rename data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/{ConversationMessagesDTO.kt => RemoteBackupEventDTO.kt} (61%) create mode 100644 data/network/src/commonMain/kotlin/com/wire/kalium/network/api/base/authenticated/remoteBackup/RemoteBackupProtoMapper.kt 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/MessageSyncResultDTO.kt b/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncResultDTO.kt deleted file mode 100644 index 74c534e82f8..00000000000 --- a/data/network-model/src/commonMain/kotlin/com/wire/kalium/network/api/authenticated/remoteBackup/MessageSyncResultDTO.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 result from fetch operation - */ -@Serializable -data class MessageSyncResultDTO( - @SerialName("timestamp") - val timestamp: Long, - @SerialName("message_id") - val messageId: String, - @SerialName("payload") - val payload: RemoteBackupPayloadDTO -) 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 734db4075e9..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: RemoteBackupPayloadDTO -) 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/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 45a870d9d53..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 @@ -18,16 +18,17 @@ package com.wire.kalium.api.v12 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 @@ -40,6 +41,7 @@ import kotlinx.coroutines.test.runTest 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 @@ -56,7 +58,7 @@ internal class RemoteBackupApiV12Test { @Test fun givenSyncMessagesRequest_whenInvoking_thenShouldUseCorrectEndpointAndMethod() = runTest { - val request = RemoteBackupResponseJson.validSyncRequest.serializableData + val request = RemoteBackupResponseJson.validSyncRequest var capturedMethod: HttpMethod? = null var capturedPath: String? = null @@ -77,29 +79,29 @@ internal class RemoteBackupApiV12Test { @Test fun givenSyncMessagesRequest_whenInvoking_thenShouldSerializeBodyCorrectly() = runTest { - val request = RemoteBackupResponseJson.validSyncRequest.serializableData - 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 = RemoteBackupResponseJson.validSyncRequest.serializableData + val request = RemoteBackupResponseJson.validSyncRequest val httpClient = createMockHttpClient( responseBody = "", @@ -122,7 +124,7 @@ internal class RemoteBackupApiV12Test { var capturedPath: String? = null val httpClient = createMockHttpClient( - responseBody = RemoteBackupResponseJson.validFetchResponse.rawJson, + responseBody = RemoteBackupProtoMapper().encodeFetchResponse(RemoteBackupResponseJson.validFetchResponse), statusCode = HttpStatusCode.OK ) { requestData -> capturedMethod = requestData.method @@ -142,7 +144,7 @@ internal class RemoteBackupApiV12Test { var capturedSizeParam: String? = null val httpClient = createMockHttpClient( - responseBody = RemoteBackupResponseJson.validFetchResponse.rawJson, + responseBody = RemoteBackupProtoMapper().encodeFetchResponse(RemoteBackupResponseJson.validFetchResponse), statusCode = HttpStatusCode.OK ) { requestData -> capturedUserParam = requestData.url.parameters["user"] @@ -163,7 +165,7 @@ internal class RemoteBackupApiV12Test { var capturedPaginationTokenParam: String? = null val httpClient = createMockHttpClient( - responseBody = RemoteBackupResponseJson.validFetchResponse.rawJson, + responseBody = RemoteBackupProtoMapper().encodeFetchResponse(RemoteBackupResponseJson.validFetchResponse), statusCode = HttpStatusCode.OK ) { requestData -> capturedSinceParam = requestData.url.parameters["since"] @@ -192,7 +194,7 @@ internal class RemoteBackupApiV12Test { var capturedPaginationTokenParam: String? = null val httpClient = createMockHttpClient( - responseBody = RemoteBackupResponseJson.validFetchResponse.rawJson, + responseBody = RemoteBackupProtoMapper().encodeFetchResponse(RemoteBackupResponseJson.validFetchResponse), statusCode = HttpStatusCode.OK ) { requestData -> capturedSinceParam = requestData.url.parameters["since"] @@ -210,9 +212,9 @@ internal class RemoteBackupApiV12Test { @Test fun givenFetchMessagesRequest_whenSuccessful_thenShouldDeserializeResponseCorrectly() = runTest { - val expectedResponse = RemoteBackupResponseJson.validFetchResponse.serializableData + val expectedResponse = RemoteBackupResponseJson.validFetchResponse val httpClient = createMockHttpClient( - responseBody = RemoteBackupResponseJson.validFetchResponse.rawJson, + responseBody = RemoteBackupProtoMapper().encodeFetchResponse(expectedResponse), statusCode = HttpStatusCode.OK ) @@ -220,15 +222,7 @@ internal class RemoteBackupApiV12Test { val result = api.fetchMessages(user = TEST_USER_ID, size = 100) assertTrue(result.isSuccessful()) - val response = result.value - assertEquals(expectedResponse.hasMore, response.hasMore) - assertEquals(expectedResponse.paginationToken, response.paginationToken) - assertTrue(response.conversations.containsKey(TEST_CONVERSATION_ID)) - val conversationMessages = response.conversations[TEST_CONVERSATION_ID]!! - val expectedConversationMessages = expectedResponse.conversations[TEST_CONVERSATION_ID]!! - assertEquals(expectedConversationMessages.lastRead, conversationMessages.lastRead) - assertEquals(expectedConversationMessages.messages.size, conversationMessages.messages.size) - assertEquals(expectedConversationMessages.messages[0].messageId, conversationMessages.messages[0].messageId) + assertEquals(expectedResponse, result.value) } // endregion @@ -241,8 +235,9 @@ internal class RemoteBackupApiV12Test { var capturedPath: String? = null val httpClient = createMockHttpClient( - responseBody = RemoteBackupResponseJson.validDeleteResponse.rawJson, - statusCode = HttpStatusCode.OK + responseBody = RemoteBackupResponseJson.validDeleteResponse.rawJson.encodeToByteArray(), + statusCode = HttpStatusCode.OK, + contentType = ContentType.Application.Json ) { requestData -> capturedMethod = requestData.method capturedPath = requestData.url.encodedPath @@ -262,8 +257,9 @@ internal class RemoteBackupApiV12Test { var capturedBeforeParam: String? = null val httpClient = createMockHttpClient( - responseBody = RemoteBackupResponseJson.validDeleteResponse.rawJson, - 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"] @@ -289,8 +285,9 @@ internal class RemoteBackupApiV12Test { var capturedBeforeParam: String? = null val httpClient = createMockHttpClient( - responseBody = RemoteBackupResponseJson.validDeleteResponse.rawJson, - 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,8 +306,9 @@ internal class RemoteBackupApiV12Test { fun givenDeleteMessagesRequest_whenSuccessful_thenShouldDeserializeResponseCorrectly() = runTest { val expectedResponse = RemoteBackupResponseJson.validDeleteResponse.serializableData val httpClient = createMockHttpClient( - responseBody = RemoteBackupResponseJson.validDeleteResponse.rawJson, - statusCode = HttpStatusCode.OK + responseBody = RemoteBackupResponseJson.validDeleteResponse.rawJson.encodeToByteArray(), + statusCode = HttpStatusCode.OK, + contentType = ContentType.Application.Json ) val api = RemoteBackupApiV12(httpClient) @@ -421,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 @@ -441,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"] } @@ -458,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) @@ -473,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) @@ -489,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 -> @@ -499,16 +502,14 @@ 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 } 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 index 59640c7ef85..4ed6584a25f 100644 --- 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 @@ -18,19 +18,13 @@ package com.wire.kalium.mocks.responses -import com.wire.kalium.network.api.authenticated.remoteBackup.ConversationMessagesDTO 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.MessageSyncResultDTO -import com.wire.kalium.network.api.authenticated.remoteBackup.MessageSyncUpsertDTO +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.JsonArray -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put @@ -40,113 +34,6 @@ object RemoteBackupResponseJson { private const val TEST_CONVERSATION_ID = "conv-456-def" private const val TEST_DOMAIN = "wire.com" - private fun qualifiedIdToJson(qualifiedId: QualifiedID): JsonObject = buildJsonObject { - put("id", qualifiedId.value) - put("domain", qualifiedId.domain) - } - - private fun messageContentToJson(content: RemoteBAckupMessageContentDTO): JsonObject = when (content) { - is RemoteBAckupMessageContentDTO.Text -> buildJsonObject { - put("type", "text") - put("text", content.text) - if (content.mentions.isNotEmpty()) { - put("mentions", buildJsonArray { - content.mentions.forEach { mention -> - add(buildJsonObject { - put("userId", qualifiedIdToJson(mention.userId)) - put("start", mention.start) - put("length", mention.length) - }) - } - }) - } - content.quotedMessageId?.let { put("quotedMessageId", it) } - } - is RemoteBAckupMessageContentDTO.Asset -> buildJsonObject { - put("type", "asset") - put("mimeType", content.mimeType) - put("size", content.size) - content.name?.let { put("name", it) } - put("otrKey", content.otrKey) - put("sha256", content.sha256) - put("assetId", content.assetId) - content.assetToken?.let { put("assetToken", it) } - content.assetDomain?.let { put("assetDomain", it) } - content.encryption?.let { put("encryption", it) } - } - is RemoteBAckupMessageContentDTO.Location -> buildJsonObject { - put("type", "location") - put("longitude", content.longitude) - put("latitude", content.latitude) - content.name?.let { put("name", it) } - content.zoom?.let { put("zoom", it) } - } - } - - private fun payloadToJson(payload: RemoteBackupPayloadDTO): JsonObject = buildJsonObject { - put("id", payload.id) - put("conversationId", qualifiedIdToJson(payload.conversationId)) - put("senderUserId", qualifiedIdToJson(payload.senderUserId)) - put("senderClientId", payload.senderClientId) - put("creationDate", payload.creationDate) - put("content", messageContentToJson(payload.content)) - payload.lastEditTime?.let { put("lastEditTime", it) } - } - - private fun upsertToJson(upsert: MessageSyncUpsertDTO): JsonObject = buildJsonObject { - put("message_id", upsert.messageId) - put("timestamp", upsert.timestamp) - put("payload", payloadToJson(upsert.payload)) - } - - private val syncRequestJsonProvider = { request: MessageSyncRequestDTO -> - buildJsonObject { - put("user_id", request.userId) - put("upserts", buildJsonObject { - request.upserts.forEach { (conversationId, upserts) -> - put(conversationId, buildJsonArray { - upserts.forEach { add(upsertToJson(it)) } - }) - } - }) - put("deletions", buildJsonObject { - request.deletions.forEach { (conversationId, deletions) -> - put(conversationId, JsonArray(deletions.map { JsonPrimitive(it) })) - } - }) - put("conversations_last_read", buildJsonObject { - request.conversationsLastRead.forEach { (conversationId, lastRead) -> - put(conversationId, lastRead) - } - }) - }.toString() - } - - private fun messageResultToJson(result: MessageSyncResultDTO): JsonObject = buildJsonObject { - put("message_id", result.messageId) - put("timestamp", result.timestamp) - put("payload", payloadToJson(result.payload)) - } - - private fun conversationMessagesToJson(messages: ConversationMessagesDTO): JsonObject = buildJsonObject { - put("last_read", messages.lastRead) - put("messages", buildJsonArray { - messages.messages.forEach { add(messageResultToJson(it)) } - }) - } - - private val fetchResponseJsonProvider = { response: MessageSyncFetchResponseDTO -> - buildJsonObject { - put("has_more", response.hasMore) - put("conversations", buildJsonObject { - response.conversations.forEach { (conversationId, messages) -> - put(conversationId, conversationMessagesToJson(messages)) - } - }) - response.paginationToken?.let { put("pagination_token", it) } - }.toString() - } - private val deleteResponseJsonProvider = { response: DeleteMessagesResponseDTO -> buildJsonObject { put("deleted_count", response.deletedCount) @@ -163,38 +50,37 @@ object RemoteBackupResponseJson { content = RemoteBAckupMessageContentDTO.Text(text = "Hello") ) - private val testUpsert = MessageSyncUpsertDTO( + 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, - upserts = mapOf( - TEST_CONVERSATION_ID to listOf(testUpsert) - ), - deletions = mapOf( - TEST_CONVERSATION_ID to listOf("deleted-msg-1", "deleted-msg-2") - ), - conversationsLastRead = mapOf( - TEST_CONVERSATION_ID to 1234567890L + events = listOf( + testUpsert, + testDelete, + testLastRead ) ) - private val testMessageResult = MessageSyncResultDTO( - messageId = "msg-1", - timestamp = 999L, - payload = testPayload - ) - private val testFetchResponse = MessageSyncFetchResponseDTO( hasMore = true, - conversations = mapOf( - TEST_CONVERSATION_ID to ConversationMessagesDTO( - lastRead = 1000L, - messages = listOf(testMessageResult) - ) + events = listOf( + testUpsert, + testDelete, + testLastRead ), paginationToken = "next-token" ) @@ -203,9 +89,9 @@ object RemoteBackupResponseJson { deletedCount = 42 ) - val validSyncRequest = ValidJsonProvider(testSyncRequest, syncRequestJsonProvider) + val validSyncRequest = testSyncRequest - val validFetchResponse = ValidJsonProvider(testFetchResponse, fetchResponseJsonProvider) + val validFetchResponse = testFetchResponse val validDeleteResponse = ValidJsonProvider(testDeleteResponse, deleteResponseJsonProvider) }