diff --git a/cells/.gitignore b/cells/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/cells/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/cells/build.gradle.kts b/cells/build.gradle.kts new file mode 100644 index 0000000000..81c295f76f --- /dev/null +++ b/cells/build.gradle.kts @@ -0,0 +1,86 @@ +/* + * 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/. + */ +plugins { + id(libs.plugins.android.library.get().pluginId) + id(libs.plugins.kotlin.multiplatform.get().pluginId) + alias(libs.plugins.ksp) + id(libs.plugins.kalium.library.get().pluginId) +} + +kaliumLibrary { + multiplatform { enableJs.set(false) } +} + +kotlin { + explicitApi() + sourceSets { + val commonMain by getting { + dependencies { + implementation(project(":common")) + implementation(project(":network")) + implementation(project(":data")) + implementation(project(":util")) + implementation(project(":persistence")) + implementation(libs.coroutines.core) + implementation(libs.ktor.authClient) + implementation(libs.okio.core) + implementation(libs.benAsherUUID) + implementation(libs.wire.cells.sdk) + } + } + val commonTest by getting { + dependencies { + // coroutines + implementation(libs.coroutines.test) + implementation(libs.turbine) + // ktor test + implementation(libs.ktor.mock) + // mocks + implementation(libs.mockative.runtime) + implementation(libs.okio.test) + } + } + + fun org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet.addCommonKotlinJvmSourceDir() { + kotlin.srcDir("src/commonJvmAndroid/kotlin") + } + + val jvmMain by getting { + addCommonKotlinJvmSourceDir() + dependencies { + implementation(libs.ktor.okHttp) + implementation(awssdk.services.s3) + } + } + val androidMain by getting { + addCommonKotlinJvmSourceDir() + dependencies { + implementation(libs.ktor.okHttp) + implementation(awssdk.services.s3) + } + } + } +} + +dependencies { + configurations + .filter { it.name.startsWith("ksp") && it.name.contains("Test") } + .forEach { + add(it.name, libs.mockative.processor) + } +} diff --git a/cells/src/commonJvmAndroid/kotlin/com/wire/kalium/cells/data/AwsProgressListenerInterceptor.kt b/cells/src/commonJvmAndroid/kotlin/com/wire/kalium/cells/data/AwsProgressListenerInterceptor.kt new file mode 100644 index 0000000000..62bb01e064 --- /dev/null +++ b/cells/src/commonJvmAndroid/kotlin/com/wire/kalium/cells/data/AwsProgressListenerInterceptor.kt @@ -0,0 +1,142 @@ +/* + * 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.cells.data + +import aws.smithy.kotlin.runtime.client.ProtocolRequestInterceptorContext +import aws.smithy.kotlin.runtime.client.ProtocolResponseInterceptorContext +import aws.smithy.kotlin.runtime.http.HttpBody +import aws.smithy.kotlin.runtime.http.interceptors.HttpInterceptor +import aws.smithy.kotlin.runtime.http.request.HttpRequest +import aws.smithy.kotlin.runtime.http.request.toBuilder +import aws.smithy.kotlin.runtime.http.response.HttpResponse +import aws.smithy.kotlin.runtime.io.SdkBuffer +import aws.smithy.kotlin.runtime.io.SdkByteReadChannel +import aws.smithy.kotlin.runtime.io.SdkSource +import aws.smithy.kotlin.runtime.io.readAll + +internal open class AwsProgressListenerInterceptor( + private val progressListener: (Long) -> Unit +) : HttpInterceptor { + fun convertBodyWithProgressUpdates(httpBody: HttpBody): HttpBody { + return when (httpBody) { + is HttpBody.ChannelContent -> { + SdkByteReadChannelWithProgressUpdates( + httpBody, + progressListener + ) + } + is HttpBody.SourceContent -> { + SourceContentWithProgressUpdates( + httpBody, + progressListener + ) + } + is HttpBody.Bytes -> { + httpBody + } + is HttpBody.Empty -> { + httpBody + } + } + } + + internal class SourceContentWithProgressUpdates( + private val sourceContent: SourceContent, + private val progressListener: (Long) -> Unit + ) : HttpBody.SourceContent() { + private val delegate = sourceContent.readFrom() + private var uploaded = 0L + override val contentLength: Long? + get() = sourceContent.contentLength + + override fun readFrom(): SdkSource { + return object : SdkSource { + override fun close() { + delegate.close() + } + + override fun read(sink: SdkBuffer, limit: Long): Long { + return delegate.read(sink, limit).also { + if (it > 0) { + uploaded += it + progressListener(uploaded) + } + } + } + } + } + } + + internal class SdkByteReadChannelWithProgressUpdates( + private val httpBody: ChannelContent, + private val progressListener: (Long) -> Unit + ) : HttpBody.ChannelContent() { + val delegate = httpBody.readFrom() + private var uploaded = 0L + override val contentLength: Long? + get() = httpBody.contentLength + override fun readFrom(): SdkByteReadChannel { + return object : SdkByteReadChannel by delegate { + override val availableForRead: Int + get() = delegate.availableForRead + + override val isClosedForRead: Boolean + get() = delegate.isClosedForRead + + override val isClosedForWrite: Boolean + get() = delegate.isClosedForWrite + + override fun cancel(cause: Throwable?): Boolean { + return delegate.cancel(cause) + } + + override suspend fun read(sink: SdkBuffer, limit: Long): Long { + return delegate.readAll(sink).also { + if (it > 0) { + uploaded += it + progressListener(uploaded) + } + } + } + } + } + } + + internal class DownloadProgressListenerInterceptor( + progressListener: (Long) -> Unit + ) : AwsProgressListenerInterceptor(progressListener) { + override suspend fun modifyBeforeDeserialization( + context: ProtocolResponseInterceptorContext + ): HttpResponse { + val body = convertBodyWithProgressUpdates(context.protocolResponse.body) + return HttpResponse(context.protocolResponse.status, context.protocolResponse.headers, body) + } + } + + internal class UploadProgressListenerInterceptor( + progressListener: (Long) -> Unit + ) : AwsProgressListenerInterceptor(progressListener) { + override suspend fun modifyBeforeTransmit( + context: ProtocolRequestInterceptorContext + ): HttpRequest { + val builder = context.protocolRequest.toBuilder() + builder.body = convertBodyWithProgressUpdates(builder.body) + return builder.build() + } + } +} diff --git a/cells/src/commonJvmAndroid/kotlin/com/wire/kalium/cells/data/CellsAwsClientJvm.kt b/cells/src/commonJvmAndroid/kotlin/com/wire/kalium/cells/data/CellsAwsClientJvm.kt new file mode 100644 index 0000000000..554ebee11b --- /dev/null +++ b/cells/src/commonJvmAndroid/kotlin/com/wire/kalium/cells/data/CellsAwsClientJvm.kt @@ -0,0 +1,161 @@ +/* + * 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.cells.data + +import aws.sdk.kotlin.runtime.auth.credentials.StaticCredentialsProvider +import aws.sdk.kotlin.services.s3.S3Client +import aws.sdk.kotlin.services.s3.completeMultipartUpload +import aws.sdk.kotlin.services.s3.createMultipartUpload +import aws.sdk.kotlin.services.s3.model.CompletedMultipartUpload +import aws.sdk.kotlin.services.s3.model.CompletedPart +import aws.sdk.kotlin.services.s3.putObject +import aws.sdk.kotlin.services.s3.uploadPart +import aws.sdk.kotlin.services.s3.withConfig +import aws.smithy.kotlin.runtime.auth.awscredentials.Credentials +import aws.smithy.kotlin.runtime.content.ByteStream +import aws.smithy.kotlin.runtime.content.asByteStream +import aws.smithy.kotlin.runtime.net.url.Url +import com.wire.kalium.cells.data.model.CellNodeDTO +import com.wire.kalium.cells.domain.model.CellsCredentials +import okhttp3.internal.http2.Header +import okio.Path +import java.io.RandomAccessFile +import java.nio.ByteBuffer + +internal actual fun cellsAwsClient(credentials: CellsCredentials): CellsAwsClient = CellsAwsClientJvm(credentials) + +private class CellsAwsClientJvm( + private val credentials: CellsCredentials +) : CellsAwsClient { + + private companion object { + const val DEFAULT_REGION = "us-east-1" + const val DEFAULT_BUCKET_NAME = "io" + const val MAX_REGULAR_UPLOAD_SIZE = 100 * 1024 * 1024L + const val MULTIPART_CHUNK_SIZE = 10 * 1024 * 1024 + } + + private val s3Client: S3Client by lazy { buildS3Client() } + + private fun buildS3Client() = with(credentials) { + S3Client { + region = DEFAULT_REGION + enableAwsChunked = false + credentialsProvider = StaticCredentialsProvider( + Credentials( + accessKeyId = accessToken, + secretAccessKey = gatewaySecret, + ) + ) + endpointUrl = Url.parse(serverUrl) + } + } + + override suspend fun upload(path: Path, node: CellNodeDTO, onProgressUpdate: (Long) -> Unit) { + val length = path.toFile().length() + if (length > MAX_REGULAR_UPLOAD_SIZE) { + uploadMultipart(path, node, onProgressUpdate) + } else { + uploadRegular(path, node, onProgressUpdate) + } + } + + private suspend fun uploadRegular(path: Path, node: CellNodeDTO, onProgressUpdate: (Long) -> Unit) { + withS3Client(uploadProgressListener = onProgressUpdate) { + putObject { + bucket = DEFAULT_BUCKET_NAME + key = node.path + metadata = node.createDraftNodeMetaData() + body = path.toFile().asByteStream() + } + } + } + + private suspend fun uploadMultipart(path: Path, node: CellNodeDTO, onProgressUpdate: (Long) -> Unit) { + val buffer = ByteBuffer.allocate(MULTIPART_CHUNK_SIZE) + var number = 1 + val completed = mutableListOf() + withS3Client { + val requestId = createMultipartUpload { + bucket = DEFAULT_BUCKET_NAME + key = node.path + metadata = node.createDraftNodeMetaData() + }.uploadId + RandomAccessFile(path.toFile(), "r").use { file -> + val fileSize = file.length() + var position = 0L + while (position < fileSize) { + file.seek(position) + val bytesRead = file.channel.read(buffer) + onProgressUpdate(position + bytesRead) + buffer.flip() + val partData = ByteArray(bytesRead) + buffer.get(partData, 0, bytesRead) + val response = uploadPart { + bucket = DEFAULT_BUCKET_NAME + key = node.path + uploadId = requestId + partNumber = number + contentLength = bytesRead.toLong() + body = ByteStream.fromBytes(partData) + } + completed.add( + CompletedPart { + partNumber = number + eTag = response.eTag + } + ) + buffer.clear() + position += bytesRead + number++ + } + } + completeMultipartUpload { + bucket = DEFAULT_BUCKET_NAME + key = node.path + uploadId = requestId + multipartUpload = CompletedMultipartUpload { + parts = completed + } + } + } + } + + private suspend fun withS3Client( + uploadProgressListener: ((Long) -> Unit)? = null, + downloadProgressListener: ((Long) -> Unit)? = null, + block: suspend S3Client.() -> T, + ): T = + s3Client.withConfig { + if (uploadProgressListener != null) { + Header.TARGET_PATH + interceptors.add(AwsProgressListenerInterceptor.UploadProgressListenerInterceptor(uploadProgressListener)) + } + if (downloadProgressListener != null) { + interceptors.add(AwsProgressListenerInterceptor.DownloadProgressListenerInterceptor(downloadProgressListener)) + } + }.use { + block(it) + } +} + +private fun CellNodeDTO.createDraftNodeMetaData() = mapOf( + MetadataHeaders.DRAFT_MODE to "true", + MetadataHeaders.CREATE_RESOURCE_UUID to uuid, + MetadataHeaders.CREATE_VERSION_ID to versionId, +) diff --git a/cells/src/commonJvmAndroid/kotlin/com/wire/kalium/cells/data/MetadataHeaders.kt b/cells/src/commonJvmAndroid/kotlin/com/wire/kalium/cells/data/MetadataHeaders.kt new file mode 100644 index 0000000000..8e7e9d213b --- /dev/null +++ b/cells/src/commonJvmAndroid/kotlin/com/wire/kalium/cells/data/MetadataHeaders.kt @@ -0,0 +1,24 @@ +/* + * 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.cells.data + +internal object MetadataHeaders { + const val DRAFT_MODE = "draft-mode" + const val CREATE_RESOURCE_UUID = "create-resource-uuid" + const val CREATE_VERSION_ID = "create-version-id" +} diff --git a/cells/src/commonMain/kotlin/com/wire/kalium/cells/CellsScope.kt b/cells/src/commonMain/kotlin/com/wire/kalium/cells/CellsScope.kt new file mode 100644 index 0000000000..08848be4a2 --- /dev/null +++ b/cells/src/commonMain/kotlin/com/wire/kalium/cells/CellsScope.kt @@ -0,0 +1,113 @@ +/* + * 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.cells + +import com.wire.kalium.cells.data.CellUploadManagerImpl +import com.wire.kalium.cells.data.CellsApiImpl +import com.wire.kalium.cells.data.CellsAwsClient +import com.wire.kalium.cells.data.CellsDataSource +import com.wire.kalium.cells.data.MessageAttachmentDraftDataSource +import com.wire.kalium.cells.data.cellsAwsClient +import com.wire.kalium.cells.domain.CellUploadManager +import com.wire.kalium.cells.domain.CellsApi +import com.wire.kalium.cells.domain.CellsRepository +import com.wire.kalium.cells.domain.MessageAttachmentDraftRepository +import com.wire.kalium.cells.domain.NodeServiceBuilder +import com.wire.kalium.cells.domain.model.CellsCredentials +import com.wire.kalium.cells.domain.usecase.AddAttachmentDraftUseCase +import com.wire.kalium.cells.domain.usecase.AddAttachmentDraftUseCaseImpl +import com.wire.kalium.cells.domain.usecase.ObserveAttachmentDraftsUseCase +import com.wire.kalium.cells.domain.usecase.ObserveAttachmentDraftsUseCaseImpl +import com.wire.kalium.cells.domain.usecase.ObserveCellFilesUseCase +import com.wire.kalium.cells.domain.usecase.ObserveCellFilesUseCaseImpl +import com.wire.kalium.cells.domain.usecase.PublishAttachmentsUseCase +import com.wire.kalium.cells.domain.usecase.PublishAttachmentsUseCaseImpl +import com.wire.kalium.cells.domain.usecase.RemoveAttachmentDraftUseCase +import com.wire.kalium.cells.domain.usecase.RemoveAttachmentDraftUseCaseImpl +import com.wire.kalium.cells.sdk.kmp.api.NodeServiceApi +import com.wire.kalium.persistence.dao.conversation.ConversationDAO +import com.wire.kalium.persistence.dao.messageattachment.MessageAttachmentDraftDao +import io.ktor.client.HttpClient +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlin.coroutines.CoroutineContext + +public class CellsScope( + private val cellsClient: HttpClient, + private val attachmentDraftDao: MessageAttachmentDraftDao, + private val conversationsDAO: ConversationDAO, +) : CoroutineScope { + + internal companion object { + // Temporary hardcoded root cell + const val ROOT_CELL = "wire-cells-android" + } + + override val coroutineContext: CoroutineContext = SupervisorJob() + + // Temporary hardcoded credentials and server URL + private val cellClientCredentials: CellsCredentials + get() = CellsCredentials( + serverUrl = "https://service.zeta.pydiocells.com", + accessToken = "", + gatewaySecret = "gatewaysecret", + ) + + private val cellAwsClient: CellsAwsClient + get() = cellsAwsClient(cellClientCredentials) + + private val nodeServiceApi: NodeServiceApi + get() = NodeServiceBuilder + .withHttpClient(cellsClient) + .withCredentials(cellClientCredentials) + .build() + + private val cellsApi: CellsApi + get() = CellsApiImpl(nodeServiceApi = nodeServiceApi) + + private val cellsRepository: CellsRepository + get() = CellsDataSource( + cellsApi = cellsApi, + awsClient = cellAwsClient + ) + + private val messageAttachmentsDraftRepository: MessageAttachmentDraftRepository + get() = MessageAttachmentDraftDataSource(attachmentDraftDao) + + public val uploadManager: CellUploadManager by lazy { + CellUploadManagerImpl( + repository = cellsRepository, + uploadScope = this, + ) + } + + public val addAttachment: AddAttachmentDraftUseCase + get() = AddAttachmentDraftUseCaseImpl(uploadManager, messageAttachmentsDraftRepository, this) + + public val removeAttachment: RemoveAttachmentDraftUseCase + get() = RemoveAttachmentDraftUseCaseImpl(uploadManager, messageAttachmentsDraftRepository, cellsRepository) + + public val observeAttachments: ObserveAttachmentDraftsUseCase + get() = ObserveAttachmentDraftsUseCaseImpl(messageAttachmentsDraftRepository, uploadManager) + + public val publishAttachments: PublishAttachmentsUseCase + get() = PublishAttachmentsUseCaseImpl(cellsRepository, messageAttachmentsDraftRepository, cellClientCredentials) + + public val observeFiles: ObserveCellFilesUseCase + get() = ObserveCellFilesUseCaseImpl(conversationsDAO, cellsRepository) +} diff --git a/cells/src/commonMain/kotlin/com/wire/kalium/cells/data/CellUploadManagerImpl.kt b/cells/src/commonMain/kotlin/com/wire/kalium/cells/data/CellUploadManagerImpl.kt new file mode 100644 index 0000000000..c156d70dcf --- /dev/null +++ b/cells/src/commonMain/kotlin/com/wire/kalium/cells/data/CellUploadManagerImpl.kt @@ -0,0 +1,144 @@ +/* + * 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.cells.data + +import com.benasher44.uuid.uuid4 +import com.wire.kalium.cells.domain.CellUploadEvent +import com.wire.kalium.cells.domain.CellUploadInfo +import com.wire.kalium.cells.domain.CellUploadManager +import com.wire.kalium.cells.domain.CellsRepository +import com.wire.kalium.cells.domain.model.CellNode +import com.wire.kalium.cells.domain.model.PreCheckResult +import com.wire.kalium.common.error.NetworkFailure +import com.wire.kalium.common.functional.Either +import com.wire.kalium.common.functional.map +import com.wire.kalium.common.functional.onFailure +import com.wire.kalium.common.functional.onSuccess +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import okio.Path + +internal class CellUploadManagerImpl internal constructor( + private val repository: CellsRepository, + private val uploadScope: CoroutineScope, +) : CellUploadManager { + + private val uploads = mutableMapOf() + + override suspend fun upload( + assetPath: Path, + assetSize: Long, + destNodePath: String, + ): Either = + repository.preCheck(destNodePath).map { result -> + + val path = when (result) { + is PreCheckResult.FileExists -> result.nextPath + is PreCheckResult.Success -> destNodePath + } + + CellNode( + uuid = uuid4().toString(), + versionId = uuid4().toString(), + path = path, + size = assetSize, + isDraft = true, + ).also { + startUpload(assetPath, it) + } + } + + private fun startUpload(assetPath: Path, node: CellNode) { + + val uploadEventsFlow = MutableSharedFlow() + + val uploadJob = uploadScope.launch { + repository.uploadFile( + path = assetPath, + node = node, + onProgressUpdate = { updateUploadProgress(node.uuid, it) } + ) + .onSuccess { + uploads.remove(node.uuid) + uploadEventsFlow.emit(CellUploadEvent.UploadCompleted) + } + .onFailure { + updateJobInfo(node.uuid) { copy(uploadFiled = true) } + uploadEventsFlow.emit(CellUploadEvent.UploadError) + } + } + + uploads[node.uuid] = UploadInfo( + node = node, + job = uploadJob, + events = uploadEventsFlow, + ) + } + + private fun updateUploadProgress(nodeUuid: String, uploaded: Long) { + uploads[nodeUuid]?.let { info -> + val progress = info.node.size?.let { uploaded.toFloat() / it } ?: 0f + updateJobInfo(info.node.uuid) { copy(progress = progress) } + uploadScope.launch { + info.events.emit(CellUploadEvent.UploadProgress(progress)) + } + } + } + + override suspend fun cancelUpload(nodeUuid: String) { + uploads[nodeUuid]?.run { + events.emit(CellUploadEvent.UploadCancelled) + job.cancelAndJoin() + uploads.remove(nodeUuid) + } + } + + override fun observeUpload(nodeUuid: String): Flow? { + return uploads[nodeUuid]?.events?.asSharedFlow() + } + + override fun getUploadInfo(nodeUuid: String): CellUploadInfo? { + return uploads[nodeUuid]?.run { + CellUploadInfo( + progress = progress, + uploadFailed = uploadFiled, + ) + } + } + + override fun isUploading(nodeUuid: String): Boolean { + return uploads.containsKey(nodeUuid) + } + + private fun updateJobInfo(uuid: String, block: UploadInfo.() -> UploadInfo) { + uploads[uuid]?.let { uploads[uuid] = block(it) } + } +} + +private data class UploadInfo( + val node: CellNode, + val job: Job, + val events: MutableSharedFlow, + val progress: Float = 0f, + val uploadFiled: Boolean = false, +) diff --git a/cells/src/commonMain/kotlin/com/wire/kalium/cells/data/CellsApiImpl.kt b/cells/src/commonMain/kotlin/com/wire/kalium/cells/data/CellsApiImpl.kt new file mode 100644 index 0000000000..b50bc89412 --- /dev/null +++ b/cells/src/commonMain/kotlin/com/wire/kalium/cells/data/CellsApiImpl.kt @@ -0,0 +1,146 @@ +/* + * 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.cells.data + +import com.wire.kalium.cells.data.model.CellNodeDTO +import com.wire.kalium.cells.data.model.GetFilesResponseDTO +import com.wire.kalium.cells.data.model.PreCheckResultDTO +import com.wire.kalium.cells.data.model.toDto +import com.wire.kalium.cells.domain.CellsApi +import com.wire.kalium.cells.sdk.kmp.api.NodeServiceApi +import com.wire.kalium.cells.sdk.kmp.infrastructure.HttpResponse +import com.wire.kalium.cells.sdk.kmp.model.RestActionParameters +import com.wire.kalium.cells.sdk.kmp.model.RestCreateCheckRequest +import com.wire.kalium.cells.sdk.kmp.model.RestIncomingNode +import com.wire.kalium.cells.sdk.kmp.model.RestLookupRequest +import com.wire.kalium.cells.sdk.kmp.model.RestNodeLocator +import com.wire.kalium.cells.sdk.kmp.model.RestNodeLocators +import com.wire.kalium.cells.sdk.kmp.model.RestNodeVersionsFilter +import com.wire.kalium.cells.sdk.kmp.model.RestPromoteParameters +import com.wire.kalium.cells.sdk.kmp.model.RestPublicLinkRequest +import com.wire.kalium.cells.sdk.kmp.model.RestShareLink +import com.wire.kalium.cells.sdk.kmp.model.RestShareLinkAccessType +import com.wire.kalium.cells.sdk.kmp.model.RestVersionCollection +import com.wire.kalium.network.api.model.ErrorResponse +import com.wire.kalium.network.exceptions.KaliumException +import com.wire.kalium.network.utils.NetworkResponse +import com.wire.kalium.network.utils.mapSuccess +import io.ktor.http.HttpStatusCode +import io.ktor.http.isSuccess +import kotlinx.coroutines.CancellationException + +internal class CellsApiImpl( + private val nodeServiceApi: NodeServiceApi, +) : CellsApi { + + override suspend fun getFiles(cellName: String): NetworkResponse = + wrapCellsResponse { + nodeServiceApi.lookup( + RestLookupRequest( + locators = RestNodeLocators(listOf(RestNodeLocator(path = "$cellName/*"))), + sortField = "Modified" + ) + ) + }.mapSuccess { response -> response.toDto() } + + override suspend fun delete(node: CellNodeDTO): NetworkResponse = + wrapCellsResponse { + nodeServiceApi.performAction( + name = NodeServiceApi.NamePerformAction.delete, + parameters = RestActionParameters( + nodes = listOf(RestNodeLocator(path = node.path)) + ) + ) + }.mapSuccess {} + + override suspend fun publishDraft(nodeUuid: String): NetworkResponse = + getNodeDraftVersions(nodeUuid).mapSuccess { response -> + wrapCellsResponse { + val version = response.versions?.firstOrNull() ?: error("Draft version not found") + nodeServiceApi.promoteVersion(nodeUuid, version.versionId, RestPromoteParameters(publish = true)) + } + } + + override suspend fun cancelDraft(nodeUuid: String, versionUuid: String): NetworkResponse = + wrapCellsResponse { + nodeServiceApi.deleteVersion(nodeUuid, versionUuid) + }.mapSuccess {} + + private suspend fun getNodeDraftVersions(uuid: String): NetworkResponse = + wrapCellsResponse { + nodeServiceApi.nodeVersions(uuid, RestNodeVersionsFilter()) + } + + override suspend fun preCheck(path: String): NetworkResponse = + wrapCellsResponse { + nodeServiceApi.createCheck( + RestCreateCheckRequest( + inputs = listOf(RestIncomingNode(locator = RestNodeLocator(path))), + findAvailablePath = true + ) + ) + }.mapSuccess { response -> + response.results?.firstOrNull()?.let { + PreCheckResultDTO( + fileExists = it.exists ?: false, + nextPath = it.nextPath, + ) + } ?: PreCheckResultDTO() + } + + override suspend fun createPublicUrl(uuid: String, fileName: String): NetworkResponse { + return wrapCellsResponse { + nodeServiceApi.createPublicLink( + uuid = uuid, + publicLinkRequest = RestPublicLinkRequest( + link = RestShareLink( + label = fileName, + permissions = listOf( + RestShareLinkAccessType.Preview, + RestShareLinkAccessType.Download, + ) + ), + ) + ) + }.mapSuccess { response -> + response.linkUrl ?: error("Link URL not found") + } + } + +} + +@Suppress("TooGenericExceptionCaught") +private suspend inline fun wrapCellsResponse( + performRequest: () -> HttpResponse +): NetworkResponse = + try { + + val response = performRequest() + val status = HttpStatusCode.fromValue(response.status) + + if (status.isSuccess()) { + NetworkResponse.Success(response.body(), emptyMap(), response.status) + } else { + NetworkResponse.Error(KaliumException.ServerError(ErrorResponse(response.status, "", ""))) + } + + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + NetworkResponse.Error(KaliumException.GenericError(e)) + } diff --git a/cells/src/commonMain/kotlin/com/wire/kalium/cells/data/CellsAwsClient.kt b/cells/src/commonMain/kotlin/com/wire/kalium/cells/data/CellsAwsClient.kt new file mode 100644 index 0000000000..3fac4dcb3e --- /dev/null +++ b/cells/src/commonMain/kotlin/com/wire/kalium/cells/data/CellsAwsClient.kt @@ -0,0 +1,28 @@ +/* + * 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.cells.data + +import com.wire.kalium.cells.data.model.CellNodeDTO +import com.wire.kalium.cells.domain.model.CellsCredentials +import okio.Path + +internal interface CellsAwsClient { + suspend fun upload(path: Path, node: CellNodeDTO, onProgressUpdate: (Long) -> Unit) +} + +internal expect fun cellsAwsClient(credentials: CellsCredentials): CellsAwsClient diff --git a/cells/src/commonMain/kotlin/com/wire/kalium/cells/data/CellsDataSource.kt b/cells/src/commonMain/kotlin/com/wire/kalium/cells/data/CellsDataSource.kt new file mode 100644 index 0000000000..faafdcc2e6 --- /dev/null +++ b/cells/src/commonMain/kotlin/com/wire/kalium/cells/data/CellsDataSource.kt @@ -0,0 +1,100 @@ +/* + * 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.cells.data + +import com.wire.kalium.cells.domain.CellsRepository +import com.wire.kalium.cells.data.model.toDto +import com.wire.kalium.cells.data.model.toModel +import com.wire.kalium.cells.domain.CellsApi +import com.wire.kalium.cells.domain.model.CellNode +import com.wire.kalium.cells.domain.model.PreCheckResult +import com.wire.kalium.common.error.NetworkFailure +import com.wire.kalium.common.error.wrapApiRequest +import com.wire.kalium.common.functional.Either +import com.wire.kalium.common.functional.map +import com.wire.kalium.util.KaliumDispatcher +import com.wire.kalium.util.KaliumDispatcherImpl +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.withContext +import okio.Path + +internal class CellsDataSource internal constructor( + private val cellsApi: CellsApi, + private val awsClient: CellsAwsClient, + private val dispatchers: KaliumDispatcher = KaliumDispatcherImpl +) : CellsRepository { + + override suspend fun preCheck(nodePath: String): Either { + return withContext(dispatchers.io) { + wrapApiRequest { + cellsApi.preCheck(nodePath) + }.map { result -> + if (result.fileExists) { + PreCheckResult.FileExists(result.nextPath ?: nodePath) + } else { + PreCheckResult.Success + } + } + } + } + + @Suppress("TooGenericExceptionCaught") + override suspend fun uploadFile( + path: Path, + node: CellNode, + onProgressUpdate: (Long) -> Unit, + ): Either = withContext(dispatchers.io) { + try { + awsClient.upload(path, node.toDto(), onProgressUpdate) + Either.Right(Unit) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Either.Left(NetworkFailure.ServerMiscommunication(e)) + } + } + + override suspend fun getFiles(cellName: String): Either> = + wrapApiRequest { + cellsApi.getFiles(cellName) + }.map { response -> + response.nodes + .filterNot { it.isRecycleBin } + .map { it.toModel() } + } + + override suspend fun deleteFile(node: CellNode): Either = + wrapApiRequest { + cellsApi.delete(node.toDto()) + } + + override suspend fun publishDraft(nodeUuid: String): Either = + wrapApiRequest { + cellsApi.publishDraft(nodeUuid) + } + + override suspend fun cancelDraft(nodeUuid: String, versionUuid: String): Either = + wrapApiRequest { + cellsApi.cancelDraft(nodeUuid, versionUuid) + } + + override suspend fun getPublicUrl(nodeUuid: String, fileName: String): Either = + wrapApiRequest { + cellsApi.createPublicUrl(nodeUuid, fileName) + } +} diff --git a/cells/src/commonMain/kotlin/com/wire/kalium/cells/data/MessageAttachmentDraftDataSource.kt b/cells/src/commonMain/kotlin/com/wire/kalium/cells/data/MessageAttachmentDraftDataSource.kt new file mode 100644 index 0000000000..67baf7ed83 --- /dev/null +++ b/cells/src/commonMain/kotlin/com/wire/kalium/cells/data/MessageAttachmentDraftDataSource.kt @@ -0,0 +1,81 @@ +/* + * 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.cells.data + +import com.wire.kalium.cells.domain.MessageAttachmentDraftRepository +import com.wire.kalium.cells.domain.model.AttachmentDraft +import com.wire.kalium.cells.domain.model.AttachmentUploadStatus +import com.wire.kalium.cells.domain.model.CellNode +import com.wire.kalium.common.error.wrapStorageRequest +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.id.QualifiedID +import com.wire.kalium.persistence.dao.QualifiedIDEntity +import com.wire.kalium.persistence.dao.messageattachment.MessageAttachmentDraftDao +import com.wire.kalium.persistence.dao.messageattachment.MessageAttachmentDraftEntity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +internal class MessageAttachmentDraftDataSource internal constructor( + private val messageAttachmentDao: MessageAttachmentDraftDao +) : MessageAttachmentDraftRepository { + + override suspend fun add(conversationId: QualifiedID, node: CellNode, dataPath: String) = wrapStorageRequest { + messageAttachmentDao.addAttachment( + uuid = node.uuid, + versionId = node.versionId, + conversationId = QualifiedIDEntity(conversationId.value, conversationId.domain), + fileName = node.path.substringAfterLast("/"), + fileSize = node.size ?: 0, + dataPath = dataPath, + nodePath = node.path, + status = AttachmentUploadStatus.UPLOADING.name + ) + } + + override suspend fun observe(conversationId: QualifiedID): Flow> = + messageAttachmentDao.observeAttachments(QualifiedIDEntity(conversationId.value, conversationId.domain)) + .map { list -> list.map { it.toModel() } } + + override suspend fun updateStatus(uuid: String, status: AttachmentUploadStatus) = wrapStorageRequest { + messageAttachmentDao.updateUploadStatus(uuid, status.name) + } + + override suspend fun remove(uuid: String) = wrapStorageRequest { + messageAttachmentDao.deleteAttachment(uuid) + } + + override suspend fun get(uuid: String) = wrapStorageRequest { + messageAttachmentDao.getAttachment(uuid)?.toModel() + } + + override suspend fun getAll(conversationId: ConversationId) = + wrapStorageRequest { + messageAttachmentDao.getAttachments( + QualifiedIDEntity(conversationId.value, conversationId.domain) + ).map { it.toModel() } + } +} + +private fun MessageAttachmentDraftEntity.toModel() = AttachmentDraft( + uuid = uuid, + versionId = versionId, + fileName = fileName, + localFilePath = dataPath, + fileSize = fileSize, + uploadStatus = AttachmentUploadStatus.valueOf(uploadStatus), +) diff --git a/cells/src/commonMain/kotlin/com/wire/kalium/cells/data/model/CellNodeDTO.kt b/cells/src/commonMain/kotlin/com/wire/kalium/cells/data/model/CellNodeDTO.kt new file mode 100644 index 0000000000..aef9ebeb07 --- /dev/null +++ b/cells/src/commonMain/kotlin/com/wire/kalium/cells/data/model/CellNodeDTO.kt @@ -0,0 +1,73 @@ +/* + * 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.cells.data.model + +import com.wire.kalium.cells.domain.model.CellNode +import com.wire.kalium.cells.sdk.kmp.model.RestNode + +internal data class CellNodeDTO( + val uuid: String, + val versionId: String, + val path: String, + val modified: Long?, + val size: Long?, + val eTag: String? = null, + val type: String? = null, + val isRecycleBin: Boolean = false, + val isDraft: Boolean = false, +) + +internal fun CellNodeDTO.toModel() = CellNode( + uuid = uuid, + versionId = versionId, + path = path, + modified = modified, + size = size, + eTag = eTag, + type = type, + isRecycleBin = isRecycleBin, + isDraft = isDraft, +) + +internal fun CellNode.toDto() = CellNodeDTO( + uuid = uuid, + versionId = versionId, + path = path, + modified = modified, + size = size, + eTag = eTag, + type = type, + isRecycleBin = isRecycleBin, + isDraft = isDraft, +) + +internal fun RestNode.toDto() = CellNodeDTO( + uuid = uuid, + versionId = versionMeta?.versionId ?: "", + path = path, + modified = modified?.toLong(), + size = propertySize?.toLong(), + type = type?.name ?: "", + eTag = storageETag, + isRecycleBin = isRecycleBin ?: false, + isDraft = isDraft(), +) + +private fun RestNode.isDraft(): Boolean { + return userMetadata?.firstOrNull { it.namespace == "usermeta-draft" }?.jsonValue == "true" +} diff --git a/cells/src/commonMain/kotlin/com/wire/kalium/cells/data/model/GetFilesResponseDTO.kt b/cells/src/commonMain/kotlin/com/wire/kalium/cells/data/model/GetFilesResponseDTO.kt new file mode 100644 index 0000000000..dd90110ead --- /dev/null +++ b/cells/src/commonMain/kotlin/com/wire/kalium/cells/data/model/GetFilesResponseDTO.kt @@ -0,0 +1,30 @@ +/* + * 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.cells.data.model + +import com.wire.kalium.cells.sdk.kmp.model.RestNodeCollection + +internal data class GetFilesResponseDTO( + val nodes: List +) + +internal fun RestNodeCollection.toDto() = GetFilesResponseDTO( + nodes = nodes?.map { node -> + node.toDto() + } ?: emptyList() +) diff --git a/cells/src/commonMain/kotlin/com/wire/kalium/cells/data/model/PreCheckResultDTO.kt b/cells/src/commonMain/kotlin/com/wire/kalium/cells/data/model/PreCheckResultDTO.kt new file mode 100644 index 0000000000..3233199324 --- /dev/null +++ b/cells/src/commonMain/kotlin/com/wire/kalium/cells/data/model/PreCheckResultDTO.kt @@ -0,0 +1,23 @@ +/* + * 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.cells.data.model + +internal data class PreCheckResultDTO( + val fileExists: Boolean = false, + val nextPath: String? = null, +) diff --git a/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/CellUploadManager.kt b/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/CellUploadManager.kt new file mode 100644 index 0000000000..8b5564aef3 --- /dev/null +++ b/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/CellUploadManager.kt @@ -0,0 +1,80 @@ +/* + * 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.cells.domain + +import com.wire.kalium.cells.domain.model.CellNode +import com.wire.kalium.common.error.NetworkFailure +import com.wire.kalium.common.functional.Either +import kotlinx.coroutines.flow.Flow +import okio.Path + +public interface CellUploadManager { + /** + * Starts file upload to the cell. Returns immediately after pre-checking the file name and returns the new node. + * The upload is done in the background and can be observed with [observeUpload] function. + * @param assetPath path to the file to upload + * @param assetSize size of the file to upload + * @param destNodePath path to the node where the file should be uploaded (cellName/fileName) + * @return [Either] with the new [CellNode] or [NetworkFailure] + */ + public suspend fun upload(assetPath: Path, assetSize: Long, destNodePath: String): Either + + /** + * Observe upload events for the node with [nodeUuid]. + * @param nodeUuid UUID of the node to observe + * @return [Flow] of [CellUploadEvent] or null if the node is not being uploaded + */ + public fun observeUpload(nodeUuid: String): Flow? + + /** + * Cancel upload of the node with [nodeUuid]. + * @param nodeUuid UUID of the node to cancel + */ + public suspend fun cancelUpload(nodeUuid: String) + + /** + * Get upload info for the node with [nodeUuid]. + * @param nodeUuid UUID of the node to get info for + * @return [CellUploadInfo] or null if the node is not being uploaded + */ + public fun getUploadInfo(nodeUuid: String): CellUploadInfo? + + /** + * Check if the node with [nodeUuid] is being uploaded. + * @param nodeUuid UUID of the node to check + * @return true if the node is being uploaded + */ + public fun isUploading(nodeUuid: String): Boolean +} + +/** + * Information about the upload of the file. + * @param progress upload progress + * @param uploadFailed true if the upload failed + */ +public data class CellUploadInfo( + val progress: Float = 0f, + val uploadFailed: Boolean = false, +) + +public sealed interface CellUploadEvent { + public data class UploadProgress(val progress: Float) : CellUploadEvent + public data object UploadCompleted : CellUploadEvent + public data object UploadError : CellUploadEvent + public data object UploadCancelled : CellUploadEvent +} diff --git a/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/CellsApi.kt b/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/CellsApi.kt new file mode 100644 index 0000000000..e8d314c329 --- /dev/null +++ b/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/CellsApi.kt @@ -0,0 +1,32 @@ +/* + * 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.cells.domain + +import com.wire.kalium.cells.data.model.CellNodeDTO +import com.wire.kalium.cells.data.model.GetFilesResponseDTO +import com.wire.kalium.cells.data.model.PreCheckResultDTO +import com.wire.kalium.network.utils.NetworkResponse + +internal interface CellsApi { + suspend fun preCheck(path: String): NetworkResponse + suspend fun cancelDraft(nodeUuid: String, versionUuid: String): NetworkResponse + suspend fun publishDraft(nodeUuid: String): NetworkResponse + suspend fun delete(node: CellNodeDTO): NetworkResponse + suspend fun getFiles(cellName: String): NetworkResponse + suspend fun createPublicUrl(uuid: String, fileName: String): NetworkResponse +} diff --git a/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/CellsRepository.kt b/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/CellsRepository.kt new file mode 100644 index 0000000000..912ca21b08 --- /dev/null +++ b/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/CellsRepository.kt @@ -0,0 +1,34 @@ +/* + * 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.cells.domain + +import com.wire.kalium.cells.domain.model.CellNode +import com.wire.kalium.cells.domain.model.PreCheckResult +import com.wire.kalium.common.error.NetworkFailure +import com.wire.kalium.common.functional.Either +import okio.Path + +internal interface CellsRepository { + suspend fun preCheck(nodePath: String): Either + suspend fun uploadFile(path: Path, node: CellNode, onProgressUpdate: (Long) -> Unit): Either + suspend fun getFiles(cellName: String): Either> + suspend fun deleteFile(node: CellNode): Either + suspend fun cancelDraft(nodeUuid: String, versionUuid: String): Either + suspend fun publishDraft(nodeUuid: String): Either + suspend fun getPublicUrl(nodeUuid: String, fileName: String): Either +} diff --git a/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/MessageAttachmentDraftRepository.kt b/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/MessageAttachmentDraftRepository.kt new file mode 100644 index 0000000000..7f75ade630 --- /dev/null +++ b/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/MessageAttachmentDraftRepository.kt @@ -0,0 +1,36 @@ +/* + * 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.cells.domain + +import com.wire.kalium.cells.domain.model.AttachmentDraft +import com.wire.kalium.cells.domain.model.AttachmentUploadStatus +import com.wire.kalium.cells.domain.model.CellNode +import com.wire.kalium.common.error.CoreFailure +import com.wire.kalium.common.functional.Either +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.id.QualifiedID +import kotlinx.coroutines.flow.Flow + +internal interface MessageAttachmentDraftRepository { + suspend fun add(conversationId: QualifiedID, node: CellNode, dataPath: String): Either + suspend fun get(uuid: String): Either + suspend fun getAll(conversationId: ConversationId): Either> + suspend fun observe(conversationId: QualifiedID): Flow> + suspend fun updateStatus(uuid: String, status: AttachmentUploadStatus): Either + suspend fun remove(uuid: String): Either +} diff --git a/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/NodeServiceBuilder.kt b/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/NodeServiceBuilder.kt new file mode 100644 index 0000000000..01bfd30bd1 --- /dev/null +++ b/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/NodeServiceBuilder.kt @@ -0,0 +1,60 @@ +/* + * 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.cells.domain + +import com.wire.kalium.cells.domain.model.CellsCredentials +import com.wire.kalium.cells.sdk.kmp.api.NodeServiceApi +import com.wire.kalium.network.session.installAuth +import io.ktor.client.HttpClient +import io.ktor.client.plugins.auth.providers.BearerAuthProvider +import io.ktor.client.plugins.auth.providers.BearerTokens + +internal object NodeServiceBuilder { + + private const val API_VERSION = "v2" + + private var httpClient: HttpClient? = null + private var baseUrl: String? = null + private var accessToken: String? = null + + fun withCredentials(credentials: CellsCredentials): NodeServiceBuilder { + baseUrl = "${credentials.serverUrl}/$API_VERSION" + accessToken = credentials.accessToken + return this + } + + fun withHttpClient(httpClient: HttpClient): NodeServiceBuilder { + this.httpClient = httpClient + return this + } + + fun build(): NodeServiceApi { + return NodeServiceApi( + baseUrl = baseUrl ?: error("Base URL is not set"), + httpClient = httpClient?.config { + installAuth( + BearerAuthProvider( + loadTokens = { BearerTokens(accessToken ?: error("Access token not set"), "") }, + refreshTokens = { null }, + realm = null + ) + ) + } ?: error("HttpClient is not set") + ) + } +} diff --git a/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/model/AttachmentDraft.kt b/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/model/AttachmentDraft.kt new file mode 100644 index 0000000000..653a16a54f --- /dev/null +++ b/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/model/AttachmentDraft.kt @@ -0,0 +1,33 @@ +/* + * 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.cells.domain.model + +public data class AttachmentDraft( + val uuid: String, + val versionId: String, + val fileName: String, + val localFilePath: String, + val fileSize: Long, + val uploadStatus: AttachmentUploadStatus, +) + +public enum class AttachmentUploadStatus { + UPLOADING, + UPLOADED, + FAILED, +} diff --git a/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/model/CellNode.kt b/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/model/CellNode.kt new file mode 100644 index 0000000000..1f8d4bad15 --- /dev/null +++ b/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/model/CellNode.kt @@ -0,0 +1,30 @@ +/* + * 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.cells.domain.model + +public data class CellNode( + val uuid: String, + val versionId: String, + val path: String, + val modified: Long? = null, + val size: Long? = null, + val eTag: String? = null, + val type: String? = null, + val isRecycleBin: Boolean = false, + val isDraft: Boolean = false, +) diff --git a/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/model/CellsCredentials.kt b/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/model/CellsCredentials.kt new file mode 100644 index 0000000000..c0caf9fae0 --- /dev/null +++ b/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/model/CellsCredentials.kt @@ -0,0 +1,24 @@ +/* + * 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.cells.domain.model + +internal data class CellsCredentials( + val serverUrl: String, + val accessToken: String, + val gatewaySecret: String, +) diff --git a/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/model/PreCheckResult.kt b/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/model/PreCheckResult.kt new file mode 100644 index 0000000000..102cd5e8db --- /dev/null +++ b/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/model/PreCheckResult.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.cells.domain.model + +/** + * Represents the result of a pre-check operation to determine if file + * with the given path and name already exists on the Cell server. + */ +public sealed interface PreCheckResult { + public data object Success : PreCheckResult + public data class FileExists(val nextPath: String) : PreCheckResult +} diff --git a/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/usecase/AddAttachmentDraftUseCase.kt b/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/usecase/AddAttachmentDraftUseCase.kt new file mode 100644 index 0000000000..f52bc67299 --- /dev/null +++ b/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/usecase/AddAttachmentDraftUseCase.kt @@ -0,0 +1,93 @@ +/* + * 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.cells.domain.usecase + +import com.wire.kalium.cells.CellsScope.Companion.ROOT_CELL +import com.wire.kalium.cells.domain.CellUploadEvent +import com.wire.kalium.cells.domain.CellUploadManager +import com.wire.kalium.cells.domain.MessageAttachmentDraftRepository +import com.wire.kalium.cells.domain.model.AttachmentUploadStatus.FAILED +import com.wire.kalium.cells.domain.model.AttachmentUploadStatus.UPLOADED +import com.wire.kalium.cells.domain.model.CellNode +import com.wire.kalium.common.error.NetworkFailure +import com.wire.kalium.common.functional.Either +import com.wire.kalium.common.functional.map +import com.wire.kalium.common.functional.onSuccess +import com.wire.kalium.logic.data.id.QualifiedID +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import okio.Path + +public interface AddAttachmentDraftUseCase { + /** + * Adds an attachment draft to the conversation and starts attachment upload. + * + * @param conversationId ID of the conversation + * @param fileName name of the attachment + * @param assetPath path to the attachment asset + * @param assetSize size of the attachment asset + * @return [Either] with [Unit] or [NetworkFailure] + */ + public suspend operator fun invoke( + conversationId: QualifiedID, + fileName: String, + assetPath: Path, + assetSize: Long, + ): Either +} + +internal class AddAttachmentDraftUseCaseImpl internal constructor( + private val uploadManager: CellUploadManager, + private val repository: MessageAttachmentDraftRepository, + private val scope: CoroutineScope, +) : AddAttachmentDraftUseCase { + + override suspend fun invoke( + conversationId: QualifiedID, + fileName: String, + assetPath: Path, + assetSize: Long, + ): Either { + + val destNodePath = "$ROOT_CELL/$conversationId/$fileName" + + return uploadManager.upload(assetPath, assetSize, destNodePath).map { node -> + persistDraftNode(conversationId, assetPath, node).onSuccess { + scope.launch observer@{ + uploadManager.observeUpload(node.uuid)?.collectLatest { event -> + when (event) { + CellUploadEvent.UploadCompleted -> repository.updateStatus(node.uuid, UPLOADED) + CellUploadEvent.UploadError -> repository.updateStatus(node.uuid, FAILED) + CellUploadEvent.UploadCancelled -> this@observer.cancel() + is CellUploadEvent.UploadProgress -> {} + } + } + } + } + } + } + + private suspend fun persistDraftNode(conversationId: QualifiedID, assetPath: Path, node: CellNode) = + repository.add( + conversationId = conversationId, + node = node, + dataPath = assetPath.toString(), + ) +} diff --git a/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/usecase/ObserveAttachmentDraftsUseCase.kt b/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/usecase/ObserveAttachmentDraftsUseCase.kt new file mode 100644 index 0000000000..8743527cbb --- /dev/null +++ b/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/usecase/ObserveAttachmentDraftsUseCase.kt @@ -0,0 +1,59 @@ +/* + * 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.cells.domain.usecase + +import com.wire.kalium.cells.domain.CellUploadManager +import com.wire.kalium.cells.domain.MessageAttachmentDraftRepository +import com.wire.kalium.cells.domain.model.AttachmentDraft +import com.wire.kalium.cells.domain.model.AttachmentUploadStatus +import com.wire.kalium.common.functional.getOrNull +import com.wire.kalium.logic.data.id.QualifiedID +import kotlinx.coroutines.flow.Flow + +public interface ObserveAttachmentDraftsUseCase { + /** + * Observe draft attachments for the given conversation. + * + * @param conversationId ID of the conversation + * @return [Flow] of [List] of [AttachmentDraft] + */ + public suspend operator fun invoke(conversationId: QualifiedID): Flow> +} + +internal class ObserveAttachmentDraftsUseCaseImpl internal constructor( + private val repository: MessageAttachmentDraftRepository, + private val uploadManager: CellUploadManager, +) : ObserveAttachmentDraftsUseCase { + + override suspend fun invoke(conversationId: QualifiedID): Flow> { + removeStaleUploads(conversationId) + return repository.observe(conversationId) + } + + /** + * Remove uploads that are in UPLOADING status but not being uploaded. + */ + private suspend fun removeStaleUploads(conversationId: QualifiedID) { + repository.getAll(conversationId).getOrNull() + ?.filter { it.uploadStatus == AttachmentUploadStatus.UPLOADING } + ?.filterNot { uploadManager.isUploading(it.uuid) } + ?.onEach { + repository.remove(it.uuid) + } + } +} diff --git a/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/usecase/ObserveCellFilesUseCase.kt b/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/usecase/ObserveCellFilesUseCase.kt new file mode 100644 index 0000000000..db5faf0b49 --- /dev/null +++ b/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/usecase/ObserveCellFilesUseCase.kt @@ -0,0 +1,62 @@ +/* + * 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.cells.domain.usecase + +import com.wire.kalium.cells.CellsScope.Companion.ROOT_CELL +import com.wire.kalium.cells.domain.CellsRepository +import com.wire.kalium.cells.domain.model.CellNode +import com.wire.kalium.common.functional.getOrElse +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.id.QualifiedID +import com.wire.kalium.persistence.dao.conversation.ConversationDAO +import com.wire.kalium.persistence.dao.conversation.ConversationFilterEntity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +public interface ObserveCellFilesUseCase { + /** + * For TESTING purposes only. + * Observe files from all conversations. + * + * @return [Flow] of [List] of [ConversationFiles] + */ + public suspend operator fun invoke(): Flow> +} + +internal class ObserveCellFilesUseCaseImpl( + private val conversationsDAO: ConversationDAO, + private val cellsRepository: CellsRepository, +) : ObserveCellFilesUseCase { + + override suspend operator fun invoke(): Flow> { + return conversationsDAO.getAllConversationDetails(fromArchive = false, filter = ConversationFilterEntity.ALL).map { conversations -> + conversations.map { conversation -> + ConversationFiles( + conversationId = QualifiedID(conversation.id.value, conversation.id.domain), + conversationTitle = conversation.name ?: "", + files = cellsRepository.getFiles("${ROOT_CELL}/${conversation.id}").getOrElse { emptyList() } + ) + }.filter { it.files.isNotEmpty() } + } + } +} +public data class ConversationFiles( + val conversationId: ConversationId, + val conversationTitle: String, + val files: List +) diff --git a/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/usecase/PublishAttachmentsUseCase.kt b/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/usecase/PublishAttachmentsUseCase.kt new file mode 100644 index 0000000000..acec246c40 --- /dev/null +++ b/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/usecase/PublishAttachmentsUseCase.kt @@ -0,0 +1,69 @@ +/* + * 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.cells.domain.usecase + +import com.wire.kalium.cells.domain.CellsRepository +import com.wire.kalium.cells.domain.MessageAttachmentDraftRepository +import com.wire.kalium.cells.domain.model.CellsCredentials +import com.wire.kalium.common.error.NetworkFailure +import com.wire.kalium.common.functional.Either +import com.wire.kalium.common.functional.getOrNull +import com.wire.kalium.common.functional.onFailure +import com.wire.kalium.common.functional.onSuccess +import com.wire.kalium.logic.data.id.ConversationId + +public interface PublishAttachmentsUseCase { + /** + * For TESTING purposes only. + * Use case for publishing all draft attachments and creating public URLs. + */ + public suspend operator fun invoke(conversationId: ConversationId): Either> +} + +internal class PublishAttachmentsUseCaseImpl internal constructor( + private val cellsRepository: CellsRepository, + private val repository: MessageAttachmentDraftRepository, + private val credentials: CellsCredentials, +) : PublishAttachmentsUseCase { + + @Suppress("ReturnCount") + override suspend fun invoke(conversationId: ConversationId): Either> { + + val publicUrls = mutableListOf() + val attachments = repository.getAll(conversationId).getOrNull() + + attachments?.forEach { attachment -> + + cellsRepository.publishDraft(attachment.uuid).onFailure { + return Either.Left(it) + } + + cellsRepository.getPublicUrl(attachment.uuid, attachment.fileName) + .onSuccess { + publicUrls.add("${credentials.serverUrl}$it") + } + .onFailure { + return Either.Left(it) + } + + repository.remove(attachment.uuid) + } + + return Either.Right(publicUrls.toList()) + } +} diff --git a/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/usecase/RemoveAttachmentDraftUseCase.kt b/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/usecase/RemoveAttachmentDraftUseCase.kt new file mode 100644 index 0000000000..5d0807d402 --- /dev/null +++ b/cells/src/commonMain/kotlin/com/wire/kalium/cells/domain/usecase/RemoveAttachmentDraftUseCase.kt @@ -0,0 +1,69 @@ +/* + * 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.cells.domain.usecase + +import com.wire.kalium.cells.domain.CellUploadManager +import com.wire.kalium.cells.domain.CellsRepository +import com.wire.kalium.cells.domain.MessageAttachmentDraftRepository +import com.wire.kalium.cells.domain.model.AttachmentDraft +import com.wire.kalium.cells.domain.model.AttachmentUploadStatus +import com.wire.kalium.common.error.CoreFailure +import com.wire.kalium.common.error.StorageFailure +import com.wire.kalium.common.functional.Either +import com.wire.kalium.common.functional.flatMap +import com.wire.kalium.common.functional.left +import com.wire.kalium.common.functional.onSuccess + +public interface RemoveAttachmentDraftUseCase { + /** + * Removes the draft attachment from conversation. + * If the attachment is in the process of uploading, the upload will be cancelled. + * If the attachment is already uploaded, the attachment draft will be removed from the server. + * + * @param uuid UUID of the attachment + * @return [Either] with [Unit] or [CoreFailure] + */ + public suspend operator fun invoke(uuid: String): Either +} + +internal class RemoveAttachmentDraftUseCaseImpl internal constructor( + private val uploadManager: CellUploadManager, + private val attachmentsRepository: MessageAttachmentDraftRepository, + private val cellsRepository: CellsRepository, +) : RemoveAttachmentDraftUseCase { + + override suspend fun invoke(uuid: String): Either = + attachmentsRepository.get(uuid).flatMap { attachment -> + when (attachment?.uploadStatus) { + AttachmentUploadStatus.UPLOADING -> cancelAttachmentUpload(uuid) + AttachmentUploadStatus.UPLOADED -> removeAttachmentDraft(attachment) + AttachmentUploadStatus.FAILED -> attachmentsRepository.remove(uuid) + null -> StorageFailure.DataNotFound.left() + } + } + + private suspend fun cancelAttachmentUpload(uuid: String): Either { + uploadManager.cancelUpload(uuid) + return attachmentsRepository.remove(uuid) + } + + private suspend fun removeAttachmentDraft(attachment: AttachmentDraft) = + cellsRepository.cancelDraft(attachment.uuid, attachment.versionId).onSuccess { + attachmentsRepository.remove(attachment.uuid) + } +} diff --git a/cells/src/commonTest/kotlin/com/wire/kalium/cells/domain/CellUploadManagerTest.kt b/cells/src/commonTest/kotlin/com/wire/kalium/cells/domain/CellUploadManagerTest.kt new file mode 100644 index 0000000000..12a8e9e10e --- /dev/null +++ b/cells/src/commonTest/kotlin/com/wire/kalium/cells/domain/CellUploadManagerTest.kt @@ -0,0 +1,241 @@ +/* + * 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.cells.domain + +import app.cash.turbine.test +import com.wire.kalium.cells.CellsScope +import com.wire.kalium.cells.data.CellUploadManagerImpl +import com.wire.kalium.cells.domain.model.CellNode +import com.wire.kalium.cells.domain.model.PreCheckResult +import com.wire.kalium.common.error.NetworkFailure +import com.wire.kalium.common.functional.Either +import com.wire.kalium.common.functional.getOrFail +import com.wire.kalium.common.functional.getOrNull +import com.wire.kalium.common.functional.isLeft +import com.wire.kalium.common.functional.left +import com.wire.kalium.common.functional.right +import io.mockative.Mock +import io.mockative.any +import io.mockative.coEvery +import io.mockative.coVerify +import io.mockative.mock +import io.mockative.once +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import okio.Path +import okio.Path.Companion.toPath +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class CellUploadManagerTest { + + private companion object { + val assetPath = "path".toPath() + const val assetSize = 1000L + const val fileName = "testfile.test" + const val suggestedFileName = "testfile-1.test" + val destNodePath = "${CellsScope.ROOT_CELL}/$fileName" + val suggestedDestNodePath = "${CellsScope.ROOT_CELL}/$suggestedFileName" + } + + @Test + fun `given new file pre-check new node returned` () = runTest { + + val (_, uploadManager) = Arrangement() + .withPreCheckSuccess() + .withUploadSuccess() + .arrange() + + val result = uploadManager.upload(assetPath, assetSize, destNodePath).getOrNull() + + assertEquals(destNodePath, result?.path) + } + + @Test + fun `given existing file pre-check new node with suggested name returned` () = runTest { + + val (_, uploadManager) = Arrangement() + .withPreCheckFileExists(suggestedDestNodePath) + .withUploadSuccess() + .arrange() + + val result = uploadManager.upload(assetPath, assetSize, destNodePath).getOrNull() + + assertEquals(suggestedDestNodePath, result?.path) + } + + @Test + fun `given pre-check failure error is returned` () = runTest { + + val (_, uploadManager) = Arrangement() + .withPreCheckFailed() + .withUploadSuccess() + .arrange() + + val result = uploadManager.upload(assetPath, assetSize, destNodePath) + + assertTrue { result.isLeft() } + } + + @Test + fun `given success pre-check file upload is started` () = runTest { + val (arrangement, uploadManager) = Arrangement() + .withPreCheckSuccess() + .withUploadSuccess() + .arrange() + + uploadManager.upload(assetPath, assetSize, destNodePath) + + advanceTimeBy(1) + + coVerify { + arrangement.repository.uploadFile(any(), any(), any()) + }.wasInvoked(once) + } + + @Test + fun `given success upload upload complete event is emitted` () = runTest { + val (_, uploadManager) = Arrangement() + .withPreCheckSuccess() + .withUploadSuccess() + .arrange() + + val node = uploadManager.upload(assetPath, assetSize, destNodePath).getOrFail { error("") } + + uploadManager.observeUpload(node.uuid)?.test { + val uploadEvent = awaitItem() + assertEquals(CellUploadEvent.UploadCompleted, uploadEvent) + } + } + + @Test + fun `given success upload upload job is removed from upload manager` () = runTest { + val (_, uploadManager) = Arrangement() + .withPreCheckSuccess() + .withUploadSuccess() + .arrange() + + val node = uploadManager.upload(assetPath, assetSize, destNodePath).getOrFail { error("") } + + advanceTimeBy(1) + + assertFalse(uploadManager.isUploading(node.uuid)) + } + + @Test + fun `given failed upload upload error event is emitted` () = runTest { + val (_, uploadManager) = Arrangement() + .withPreCheckSuccess() + .withUploadFailed() + .arrange() + + val node = uploadManager.upload(assetPath, assetSize, destNodePath).getOrFail { error("") } + + uploadManager.observeUpload(node.uuid)?.test { + val uploadEvent = awaitItem() + assertEquals(CellUploadEvent.UploadError, uploadEvent) + } + } + + @Test + fun `given failed upload upload job error flag is set` () = runTest { + val (_, uploadManager) = Arrangement() + .withPreCheckSuccess() + .withUploadFailed() + .arrange() + + val node = uploadManager.upload(assetPath, assetSize, destNodePath).getOrFail { error("") } + + advanceTimeBy(1) + + assertTrue(uploadManager.getUploadInfo(node.uuid)?.uploadFailed == true) + } + + @Test + fun `given upload progress is updated then progress event is emitted` () = runTest { + val (_, uploadManager) = Arrangement() + .withPreCheckSuccess() + .withUploadSuccess() + .arrange(TestRepository()) + + val node = uploadManager.upload(assetPath, assetSize, destNodePath).getOrFail { error("") } + + uploadManager.observeUpload(node.uuid)?.test { + val uploadEvent = awaitItem() + assertEquals(CellUploadEvent.UploadProgress(0.5f), uploadEvent) + } + } + + private class Arrangement(val uploadScope: CoroutineScope) { + + @Mock + val repository = mock(CellsRepository::class) + + suspend fun withPreCheckFileExists(suggestedName: String) = apply { + coEvery { repository.preCheck(any()) }.returns(PreCheckResult.FileExists(suggestedName).right()) + } + + suspend fun withPreCheckSuccess() = apply { + coEvery { repository.preCheck(any()) }.returns(PreCheckResult.Success.right()) + } + + suspend fun withPreCheckFailed() = apply { + coEvery { repository.preCheck(any()) }.returns(NetworkFailure.NoNetworkConnection(IllegalStateException("test")).left()) + } + + suspend fun withUploadSuccess() = apply { + coEvery { repository.uploadFile(any(), any(), any()) }.returns(Unit.right()) + } + + suspend fun withUploadFailed() = apply { + coEvery { repository.uploadFile(any(), any(), any()) }.returns( + NetworkFailure.NoNetworkConnection(IllegalStateException("test")).left() + ) + } + + fun arrange(cellRepo: CellsRepository = repository) = this to CellUploadManagerImpl( + repository = cellRepo, + uploadScope = uploadScope + ) + } + + private fun TestScope.Arrangement() = Arrangement(this.backgroundScope) +} + +private class TestRepository : CellsRepository { + + override suspend fun uploadFile(path: Path, node: CellNode, onProgressUpdate: (Long) -> Unit): Either { + onProgressUpdate(500) + delay(100) + return Unit.right() + } + + override suspend fun preCheck(nodePath: String) = PreCheckResult.Success.right() + override suspend fun getFiles(cellName: String) = emptyList().right() + override suspend fun deleteFile(node: CellNode) = Unit.right() + override suspend fun cancelDraft(nodeUuid: String, versionUuid: String) = Unit.right() + override suspend fun publishDraft(nodeUuid: String) = Unit.right() + override suspend fun getPublicUrl(nodeUuid: String, fileName: String) = "".right() +} diff --git a/cells/src/commonTest/kotlin/com/wire/kalium/cells/domain/CellsApiTest.kt b/cells/src/commonTest/kotlin/com/wire/kalium/cells/domain/CellsApiTest.kt new file mode 100644 index 0000000000..d5389c9b03 --- /dev/null +++ b/cells/src/commonTest/kotlin/com/wire/kalium/cells/domain/CellsApiTest.kt @@ -0,0 +1,111 @@ +/* + * 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.cells.domain + +import com.wire.kalium.cells.data.CellsApiImpl +import com.wire.kalium.cells.sdk.kmp.api.NodeServiceApi +import com.wire.kalium.cells.sdk.kmp.model.RestCheckResult +import com.wire.kalium.cells.sdk.kmp.model.RestCreateCheckResponse +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.request.HttpRequestData +import io.ktor.http.HeadersImpl +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.serialization.kotlinx.json.json +import io.ktor.utils.io.core.toByteArray +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlin.test.Test + +class CellsApiTest { + + @Test + fun `given new file pre-check then success is returned`() = runTest { + + val (_, cellApi) = Arrangement() + .withHttpClient( + Json.encodeToString( + RestCreateCheckResponse( + results = listOf(RestCheckResult(exists = false)) + ) + ).toByteArray() + ) + .arrange() + + val result = cellApi.preCheck("path") + + // TODO: Fix issue with response deserialization +// assertEquals(PreCheckResultDTO(fileExists = false), result) + } + + @Test + fun `given existing file pre-check then existing file is returned`() = runTest { + + } + + @Test + fun `given pre-check failure then failure is returned`() = runTest { + + } + + private class Arrangement() { + + var httpClient: HttpClient = HttpClient(createMockEngine( + ByteArray(0), + HttpStatusCode.OK + )) + + val nodeService = NodeServiceApi("", httpClient) + + fun withHttpClient(body: ByteArray, statusCode: HttpStatusCode = HttpStatusCode.OK) = apply { + httpClient = HttpClient(createMockEngine(body, statusCode)) { + install(ContentNegotiation) { + json() + } + } + } + + fun arrange() = this to CellsApiImpl(nodeService) + } +} + +private fun createMockEngine( + responseBody: ByteArray, + statusCode: HttpStatusCode, + assertion: (HttpRequestData.() -> Unit) = {}, + headers: Map? = null +): MockEngine { + val newHeaders: Map> = (headers?.let { + headers.mapValues { listOf(it.value) } + } ?: run { + mapOf(HttpHeaders.ContentType to "application/json").mapValues { listOf(it.value) } + }) + + return MockEngine { request -> + request.assertion() + respond( + content = responseBody, + status = statusCode, + headers = HeadersImpl(newHeaders) + ) + } +} diff --git a/cells/src/commonTest/kotlin/com/wire/kalium/cells/domain/usecase/AddAttachmentDraftUseCaseTest.kt b/cells/src/commonTest/kotlin/com/wire/kalium/cells/domain/usecase/AddAttachmentDraftUseCaseTest.kt new file mode 100644 index 0000000000..6466a041b1 --- /dev/null +++ b/cells/src/commonTest/kotlin/com/wire/kalium/cells/domain/usecase/AddAttachmentDraftUseCaseTest.kt @@ -0,0 +1,221 @@ +/* + * 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.cells.domain.usecase + +import com.wire.kalium.cells.CellsScope +import com.wire.kalium.cells.domain.CellUploadEvent +import com.wire.kalium.cells.domain.CellUploadManager +import com.wire.kalium.cells.domain.MessageAttachmentDraftRepository +import com.wire.kalium.cells.domain.model.AttachmentUploadStatus +import com.wire.kalium.cells.domain.model.CellNode +import com.wire.kalium.common.functional.right +import com.wire.kalium.logic.data.id.ConversationId +import io.mockative.Mock +import io.mockative.any +import io.mockative.coEvery +import io.mockative.coVerify +import io.mockative.every +import io.mockative.mock +import io.mockative.once +import io.mockative.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toPath +import kotlin.test.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class AddAttachmentDraftUseCaseTest { + + private companion object { + val conversationId = ConversationId("1", "test") + val assetPath = "path".toPath() + const val assetSize = 1000L + const val fileName = "testfile.test" + val destNodePath = "${CellsScope.ROOT_CELL}/$conversationId/$fileName" + } + + @Test + fun `given valid request upload manager is called`() = runTest { + val (arrangement, useCase) = Arrangement() + .withSuccessAdd() + .withSuccessPreCheck() + .arrange() + + useCase(conversationId, fileName, assetPath, assetSize) + + coVerify { + arrangement.uploadManager.upload( + assetPath = assetPath, + assetSize = assetSize, + destNodePath = destNodePath, + ) + }.wasInvoked(once) + } + + @Test + fun `given success pre-check attachment is persisted`() = runTest { + val (arrangement, useCase) = Arrangement() + .withSuccessAdd() + .withSuccessPreCheck() + .arrange() + + useCase(conversationId, fileName, assetPath, assetSize) + + coVerify { + arrangement.repository.add( + conversationId = conversationId, + node = testNode, + dataPath = assetPath.toString(), + ) + }.wasInvoked(once) + } + + @Test + fun `given success attachment persist upload events observer is started`() = runTest { + val (arrangement, useCase) = Arrangement(this.backgroundScope) + .withSuccessAdd() + .withSuccessPreCheck() + .withUploadEvents() + .arrange() + + useCase(conversationId, fileName, assetPath, assetSize) + + advanceTimeBy(1) + + verify { + arrangement.uploadManager.observeUpload(any()) + }.wasInvoked(once) + } + + @Test + fun `given upload complete event upload status is updated`() = runTest { + val (arrangement, useCase) = Arrangement(this.backgroundScope) + .withSuccessAdd() + .withSuccessPreCheck() + .withSuccessUpdate() + .withUploadCompleteEvent() + .arrange() + + useCase(conversationId, fileName, assetPath, assetSize) + + advanceTimeBy(1) + + coVerify { + arrangement.repository.updateStatus(testNode.uuid, AttachmentUploadStatus.UPLOADED) + }.wasInvoked(once) + } + + @Test + fun `given upload error event upload status is updated`() = runTest { + val (arrangement, useCase) = Arrangement(this.backgroundScope) + .withSuccessAdd() + .withSuccessPreCheck() + .withSuccessUpdate() + .withUploadErrorEvent() + .arrange() + + useCase(conversationId, fileName, assetPath, assetSize) + + advanceTimeBy(1) + + coVerify { + arrangement.repository.updateStatus(testNode.uuid, AttachmentUploadStatus.FAILED) + }.wasInvoked(once) + } + + private class Arrangement(val useCaseScope: CoroutineScope = TestScope()) { + + @Mock + val uploadManager = mock(CellUploadManager::class) + + @Mock + val repository = mock(MessageAttachmentDraftRepository::class) + + val uploadEventsFlow = MutableSharedFlow() + + suspend fun withSuccessPreCheck() = apply { + coEvery { + uploadManager.upload( + assetPath = any(), + assetSize = any(), + destNodePath = any() + ) + }.returns(testNode.right()) + } + + suspend fun withSuccessAdd() = apply { + coEvery { + repository.add( + conversationId = any(), + node = any(), + dataPath = any() + ) + }.returns(Unit.right()) + } + + suspend fun withSuccessUpdate() = apply { + coEvery { + repository.updateStatus( + uuid = any(), + status = any() + ) + }.returns(Unit.right()) + } + + fun withUploadEvents() = apply { + every { + uploadManager.observeUpload(any()) + }.returns(uploadEventsFlow) + } + + fun withUploadCompleteEvent() = apply { + every { + uploadManager.observeUpload(any()) + }.returns(flowOf(CellUploadEvent.UploadCompleted)) + } + + fun withUploadErrorEvent() = apply { + every { + uploadManager.observeUpload(any()) + }.returns(flowOf(CellUploadEvent.UploadError)) + } + + fun arrange() = this to AddAttachmentDraftUseCaseImpl( + uploadManager = uploadManager, + repository = repository, + scope = useCaseScope, + ) + } +} + +private val testNode = CellNode( + uuid = "uuid", + versionId = "versionId", + path = "path", + modified = 0, + size = 0, + eTag = "eTag", + type = "type", + isRecycleBin = false, + isDraft = false +) diff --git a/cells/src/commonTest/kotlin/com/wire/kalium/cells/domain/usecase/ObserveAttachmentDraftsUseCaseTest.kt b/cells/src/commonTest/kotlin/com/wire/kalium/cells/domain/usecase/ObserveAttachmentDraftsUseCaseTest.kt new file mode 100644 index 0000000000..08e65d5338 --- /dev/null +++ b/cells/src/commonTest/kotlin/com/wire/kalium/cells/domain/usecase/ObserveAttachmentDraftsUseCaseTest.kt @@ -0,0 +1,84 @@ +/* + * 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.cells.domain.usecase + +import com.wire.kalium.cells.domain.CellUploadManager +import com.wire.kalium.cells.domain.MessageAttachmentDraftRepository +import com.wire.kalium.cells.domain.model.AttachmentDraft +import com.wire.kalium.cells.domain.model.AttachmentUploadStatus +import com.wire.kalium.common.functional.right +import com.wire.kalium.logic.data.id.ConversationId +import io.mockative.Mock +import io.mockative.any +import io.mockative.coEvery +import io.mockative.coVerify +import io.mockative.every +import io.mockative.mock +import io.mockative.once +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.runTest +import kotlin.test.Test + +class ObserveAttachmentDraftsUseCaseTest { + + @Test + fun `test stale uploads are removed`() = runTest { + + val (arrangement, useCase) = Arrangement() + .withUploadManager() + .withRepository() + .arrange() + + useCase.invoke(ConversationId("1", "test")) + + coVerify { + arrangement.repository.remove("1") + }.wasInvoked(once) + } + + private class Arrangement { + @Mock + val repository = mock(MessageAttachmentDraftRepository::class) + + @Mock + val uploadManager = mock(CellUploadManager::class) + + fun withUploadManager() = apply { + every { uploadManager.isUploading(any()) }.returns(false) + } + + suspend fun withRepository() = apply { + coEvery { repository.getAll(any()) }.returns(listOf( + AttachmentDraft( + uuid = "1", + versionId = "1", + uploadStatus = AttachmentUploadStatus.UPLOADING, + fileName = "", + localFilePath = "", + fileSize = 1, + ) + ).right()) + + coEvery { repository.remove(any()) }.returns(Unit.right()) + + coEvery { repository.observe(any()) }.returns(emptyFlow()) + } + + fun arrange() = this to ObserveAttachmentDraftsUseCaseImpl(repository, uploadManager) + } +} diff --git a/cells/src/commonTest/kotlin/com/wire/kalium/cells/domain/usecase/RemoveAttachmentDraftUseCaseTest.kt b/cells/src/commonTest/kotlin/com/wire/kalium/cells/domain/usecase/RemoveAttachmentDraftUseCaseTest.kt new file mode 100644 index 0000000000..78be3eda85 --- /dev/null +++ b/cells/src/commonTest/kotlin/com/wire/kalium/cells/domain/usecase/RemoveAttachmentDraftUseCaseTest.kt @@ -0,0 +1,183 @@ +/* + * 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.cells.domain.usecase + +import com.benasher44.uuid.uuid4 +import com.wire.kalium.cells.domain.CellUploadManager +import com.wire.kalium.cells.domain.CellsRepository +import com.wire.kalium.cells.domain.MessageAttachmentDraftRepository +import com.wire.kalium.cells.domain.model.AttachmentDraft +import com.wire.kalium.cells.domain.model.AttachmentUploadStatus +import com.wire.kalium.common.error.StorageFailure +import com.wire.kalium.common.functional.left +import com.wire.kalium.common.functional.right +import io.mockative.Mock +import io.mockative.any +import io.mockative.coEvery +import io.mockative.coVerify +import io.mockative.mock +import io.mockative.once +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class RemoveAttachmentDraftUseCaseTest { + + @Test + fun `given attachment with UPLOADING status when removing then upload is cancelled`() = runTest { + val uuid = uuid4().toString() + val (arrangement, removeAttachment) = Arrangement() + .withRepository() + .withUploadManager() + .withCellsRepository() + .withAttachment(uuid, AttachmentUploadStatus.UPLOADING) + .arrange() + + removeAttachment(uuid) + + coVerify { + arrangement.uploadManager.cancelUpload(uuid) + }.wasInvoked(once) + } + + @Test + fun `given attachment with UPLOADING status when removing then attachment is removed`() = runTest { + val uuid = uuid4().toString() + val (arrangement, removeAttachment) = Arrangement() + .withRepository() + .withUploadManager() + .withCellsRepository() + .withAttachment(uuid, AttachmentUploadStatus.UPLOADING) + .arrange() + + removeAttachment(uuid) + + coVerify { + arrangement.attachmentsRepository.remove(uuid) + }.wasInvoked(once) + } + + @Test + fun `given attachment with UPLOADED status when removing then attachment draft is cancelled`() = runTest { + val uuid = uuid4().toString() + val (arrangement, removeAttachment) = Arrangement() + .withRepository() + .withUploadManager() + .withCellsRepository() + .withAttachment(uuid, AttachmentUploadStatus.UPLOADED) + .arrange() + + removeAttachment(uuid) + + coVerify { + arrangement.cellsRepository.cancelDraft(uuid, "") + }.wasInvoked(once) + } + + @Test + fun `given attachment with UPLOADED status when removing then attachment is removed`() = runTest { + val uuid = uuid4().toString() + val (arrangement, removeAttachment) = Arrangement() + .withRepository() + .withUploadManager() + .withCellsRepository() + .withAttachment(uuid, AttachmentUploadStatus.UPLOADED) + .arrange() + + removeAttachment(uuid) + + coVerify { + arrangement.attachmentsRepository.remove(uuid) + }.wasInvoked(once) + } + + @Test + fun `given attachment with FAILED status when removing then attachment is removed`() = runTest { + val uuid = uuid4().toString() + val (arrangement, removeAttachment) = Arrangement() + .withRepository() + .withUploadManager() + .withCellsRepository() + .withAttachment(uuid, AttachmentUploadStatus.FAILED) + .arrange() + + removeAttachment(uuid) + + coVerify { + arrangement.attachmentsRepository.remove(uuid) + }.wasInvoked(once) + } + + @Test + fun `given attachment not found when removing then error is returned`() = runTest { + val uuid = uuid4().toString() + val (_, removeAttachment) = Arrangement() + .withRepository() + .withUploadManager() + .withCellsRepository() + .withNoAttachment() + .arrange() + + val result = removeAttachment(uuid) + + assertEquals(StorageFailure.DataNotFound.left(), result) + } + + private class Arrangement { + + @Mock + val attachmentsRepository = mock(MessageAttachmentDraftRepository::class) + + @Mock + val cellsRepository = mock(CellsRepository::class) + + @Mock + val uploadManager = mock(CellUploadManager::class) + + suspend fun withRepository() = apply { + coEvery { attachmentsRepository.remove(any()) }.returns(Unit.right()) + } + + suspend fun withCellsRepository() = apply { + coEvery { cellsRepository.cancelDraft(any(), any()) }.returns(Unit.right()) + } + + suspend fun withUploadManager() = apply { + coEvery { uploadManager.cancelUpload(any()) }.returns(Unit) + } + + suspend fun withAttachment(uuid: String, status: AttachmentUploadStatus) = apply { + coEvery { attachmentsRepository.get(any()) }.returns( + AttachmentDraft( + uuid = uuid, + versionId = "", + fileName = "", + localFilePath = "", + fileSize = 1, + uploadStatus = status + ).right() + ) + } + + suspend fun withNoAttachment() = apply { + coEvery { attachmentsRepository.get(any()) }.returns(null.right()) + } + + fun arrange() = this to RemoveAttachmentDraftUseCaseImpl(uploadManager, attachmentsRepository, cellsRepository) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1b01d5865f..08ddf5b7fb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -69,6 +69,7 @@ jmh = "1.37" jmhReport = "0.9.6" xerialDriver = "3.45.3.0" kotlinx-io = "0.5.3" +cells-sdk = "0.1.1-alpha01" [plugins] # Home-made convention plugins @@ -228,3 +229,6 @@ jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } micrometer = { module = "io.micrometer:micrometer-registry-prometheus", version.ref = "micrometer" } slf4js = { module = "org.slf4j:slf4j-simple", version.ref = "slf4js" } + +# cells +wire-cells-sdk = { module = "com.wire:cells-sdk-kmp", version.ref = "cells-sdk" } diff --git a/logic/build.gradle.kts b/logic/build.gradle.kts index a7bffdef0c..ea08a0ec5d 100644 --- a/logic/build.gradle.kts +++ b/logic/build.gradle.kts @@ -46,6 +46,7 @@ kotlin { api(project(":logger")) api(project(":calling")) implementation(project(":util")) + implementation(project(":cells")) // coroutines implementation(libs.coroutines.core) diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/UserSessionScope.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/UserSessionScope.kt index b855903cbb..fc1577efb8 100644 --- a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/UserSessionScope.kt +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/UserSessionScope.kt @@ -19,6 +19,7 @@ package com.wire.kalium.logic.feature +import com.wire.kalium.cells.CellsScope import com.wire.kalium.logger.KaliumLogger import com.wire.kalium.logger.obfuscateId import com.wire.kalium.common.error.CoreFailure @@ -1879,6 +1880,7 @@ class UserSessionScope internal constructor( staleEpochVerifier, legalHoldHandler, observeFileSharingStatus, + cells.publishAttachments, this, userScopedLogger, ) @@ -2179,6 +2181,14 @@ class UserSessionScope internal constructor( InCallReactionsDataSource() } + val cells: CellsScope by lazy { + CellsScope( + cellsClient = globalScope.unboundNetworkContainer.cellsClient, + attachmentDraftDao = userStorage.database.messageAttachmentDraftDao, + conversationsDAO = userStorage.database.conversationDAO, + ) + } + /** * This will start subscribers of observable work per user session, as long as the user is logged in. * When the user logs out, this work will be canceled. diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/message/MessageScope.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/message/MessageScope.kt index 2a6129225b..e411098ad3 100644 --- a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/message/MessageScope.kt +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/message/MessageScope.kt @@ -18,6 +18,7 @@ package com.wire.kalium.logic.feature.message +import com.wire.kalium.cells.domain.usecase.PublishAttachmentsUseCase import com.wire.kalium.logger.KaliumLogger import com.wire.kalium.logic.cache.SelfConversationIdProvider import com.wire.kalium.logic.data.asset.AssetRepository @@ -126,6 +127,7 @@ class MessageScope internal constructor( private val staleEpochVerifier: StaleEpochVerifier, private val legalHoldHandler: LegalHoldHandler, private val observeFileSharingStatusUseCase: ObserveFileSharingStatusUseCase, + private val publishAttachmentsUseCase: PublishAttachmentsUseCase, private val scope: CoroutineScope, kaliumLogger: KaliumLogger, internal val dispatcher: KaliumDispatcher = KaliumDispatcherImpl, @@ -234,6 +236,7 @@ class MessageScope internal constructor( messageSendFailureHandler = messageSendFailureHandler, userPropertyRepository = userPropertyRepository, selfDeleteTimer = observeSelfDeletingMessages, + publishAttachmentsUseCase = publishAttachmentsUseCase, scope = scope ) diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/message/SendTextMessageUseCase.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/message/SendTextMessageUseCase.kt index e835097898..52e343cae1 100644 --- a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/message/SendTextMessageUseCase.kt +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/message/SendTextMessageUseCase.kt @@ -19,6 +19,7 @@ package com.wire.kalium.logic.feature.message import com.benasher44.uuid.uuid4 +import com.wire.kalium.cells.domain.usecase.PublishAttachmentsUseCase import com.wire.kalium.cryptography.utils.AES256Key import com.wire.kalium.cryptography.utils.generateRandomAES256Key import com.wire.kalium.common.error.CoreFailure @@ -37,6 +38,7 @@ import com.wire.kalium.logic.data.message.linkpreview.MessageLinkPreview import com.wire.kalium.logic.feature.selfDeletingMessages.ObserveSelfDeletionTimerSettingsForConversationUseCase import com.wire.kalium.common.functional.Either import com.wire.kalium.common.functional.flatMap +import com.wire.kalium.common.functional.getOrElse import com.wire.kalium.common.functional.getOrNull import com.wire.kalium.common.functional.onFailure import com.wire.kalium.common.logger.kaliumLogger @@ -63,6 +65,7 @@ class SendTextMessageUseCase internal constructor( private val messageSendFailureHandler: MessageSendFailureHandler, private val userPropertyRepository: UserPropertyRepository, private val selfDeleteTimer: ObserveSelfDeletionTimerSettingsForConversationUseCase, + private val publishAttachmentsUseCase: PublishAttachmentsUseCase, private val dispatchers: KaliumDispatcher = KaliumDispatcherImpl, private val scope: CoroutineScope ) { @@ -86,11 +89,23 @@ class SendTextMessageUseCase internal constructor( val previews = uploadLinkPreviewImages(linkPreviews) + val attachments = publishAttachmentsUseCase.invoke(conversationId).getOrElse { emptyList() } + + val textWithAttachments = if (attachments.isNotEmpty()) { + buildString { + append(text) + appendLine() + attachments.forEach { appendLine(it) } + } + } else { + text + } + provideClientId().flatMap { clientId -> val message = Message.Regular( id = generatedMessageUuid, content = MessageContent.Text( - value = text, + value = textWithAttachments, linkPreviews = previews, mentions = mentions, quotedMessageReference = quotedMessageId?.let { quotedMessageId -> diff --git a/logic/src/commonTest/kotlin/com/wire/kalium/logic/feature/message/SendTextMessageCaseTest.kt b/logic/src/commonTest/kotlin/com/wire/kalium/logic/feature/message/SendTextMessageCaseTest.kt index 14a4dc99d4..7f9c16d603 100644 --- a/logic/src/commonTest/kotlin/com/wire/kalium/logic/feature/message/SendTextMessageCaseTest.kt +++ b/logic/src/commonTest/kotlin/com/wire/kalium/logic/feature/message/SendTextMessageCaseTest.kt @@ -18,6 +18,7 @@ package com.wire.kalium.logic.feature.message +import com.wire.kalium.cells.domain.usecase.PublishAttachmentsUseCase import com.wire.kalium.cryptography.utils.SHA256Key import com.wire.kalium.common.error.NetworkFailure import com.wire.kalium.logic.data.asset.AssetRepository @@ -311,6 +312,9 @@ class SendTextMessageCaseTest { @Mock val observeSelfDeletionTimerSettingsForConversation = mock(ObserveSelfDeletionTimerSettingsForConversationUseCase::class) + @Mock + val publishAttachmentsUseCase = mock(PublishAttachmentsUseCase::class) + suspend fun withSendMessageSuccess() = apply { coEvery { messageSender.sendMessage(any(), any()) @@ -376,6 +380,7 @@ class SendTextMessageCaseTest { messageSendFailureHandler, userPropertyRepository, observeSelfDeletionTimerSettingsForConversation, + publishAttachmentsUseCase, scope = coroutineScope, dispatchers = coroutineScope.testKaliumDispatcher ) diff --git a/network/src/commonMain/kotlin/com/wire/kalium/network/networkContainer/UnboundNetworkContainer.kt b/network/src/commonMain/kotlin/com/wire/kalium/network/networkContainer/UnboundNetworkContainer.kt index b82e6b1dc7..98d1ff6017 100644 --- a/network/src/commonMain/kotlin/com/wire/kalium/network/networkContainer/UnboundNetworkContainer.kt +++ b/network/src/commonMain/kotlin/com/wire/kalium/network/networkContainer/UnboundNetworkContainer.kt @@ -26,11 +26,13 @@ import com.wire.kalium.network.api.base.unbound.configuration.ServerConfigApiImp import com.wire.kalium.network.clearTextTrafficEngine import com.wire.kalium.network.defaultHttpEngine import com.wire.kalium.network.session.CertificatePinning +import io.ktor.client.HttpClient import io.ktor.client.engine.HttpClientEngine interface UnboundNetworkContainer { val serverConfigApi: ServerConfigApi val acmeApi: ACMEApi + val cellsClient: HttpClient } private interface UnboundNetworkClientProvider { @@ -95,4 +97,7 @@ class UnboundNetworkContainerCommon( unboundNetworkClient, unboundClearTextTrafficNetworkClient ) + + override val cellsClient: HttpClient + get() = unboundNetworkClient.httpClient } diff --git a/network/src/commonMain/kotlin/com/wire/kalium/network/utils/NetworkUtils.kt b/network/src/commonMain/kotlin/com/wire/kalium/network/utils/NetworkUtils.kt index bde987f1f2..14e2ae5c73 100644 --- a/network/src/commonMain/kotlin/com/wire/kalium/network/utils/NetworkUtils.kt +++ b/network/src/commonMain/kotlin/com/wire/kalium/network/utils/NetworkUtils.kt @@ -120,7 +120,7 @@ internal fun String.splitSetCookieHeader(): List { * @return A new [NetworkResponse.Success] with the mapped result, * or [NetworkResponse.Error] if it was never a success to begin with */ -internal inline fun NetworkResponse.mapSuccess(mapping: ((T) -> U)): NetworkResponse = +inline fun NetworkResponse.mapSuccess(mapping: ((T) -> U)): NetworkResponse = if (isSuccessful()) { NetworkResponse.Success(mapping(this.value), this.headers, this.httpCode) } else { diff --git a/persistence/src/commonMain/db_user/com/wire/kalium/persistence/MessageAttachmentDraft.sq b/persistence/src/commonMain/db_user/com/wire/kalium/persistence/MessageAttachmentDraft.sq new file mode 100644 index 0000000000..7b6c0ef07b --- /dev/null +++ b/persistence/src/commonMain/db_user/com/wire/kalium/persistence/MessageAttachmentDraft.sq @@ -0,0 +1,54 @@ +import com.wire.kalium.persistence.dao.QualifiedIDEntity; +import kotlin.Boolean; +import kotlin.Float; +import kotlin.Int; +import kotlin.String; + +CREATE TABLE MessageAttachmentDraft ( + attachment_id TEXT NOT NULL, + version_id TEXT NOT NULL, + conversation_id TEXT AS QualifiedIDEntity NOT NULL, + file_name TEXT NOT NULL, + file_size INTEGER NOT NULL, + data_path TEXT NOT NULL, + node_path TEXT NOT NULL, + upload_status TEXT NOT NULL, + + FOREIGN KEY (conversation_id) REFERENCES Conversation(qualified_id) ON DELETE CASCADE ON UPDATE CASCADE, + PRIMARY KEY (attachment_id) +); + +getDraft: +SELECT * FROM MessageAttachmentDraft WHERE attachment_id = ?; + +getDrafts: +SELECT * FROM MessageAttachmentDraft WHERE conversation_id = ?; + +upsertDraft: +INSERT INTO MessageAttachmentDraft( + attachment_id, + version_id, + conversation_id, + file_name, + file_size, + data_path, + node_path, + upload_status +) VALUES(?,?,?,?,?,?,?,?) +ON CONFLICT(attachment_id) DO UPDATE SET + version_id = excluded.version_id, + conversation_id = excluded.conversation_id, + file_name = excluded.file_name, + file_size = excluded.file_size, + data_path = excluded.data_path, + node_path = excluded.node_path, + upload_status = excluded.upload_status; + +updateUploadStatus: +UPDATE MessageAttachmentDraft SET upload_status = ? WHERE attachment_id = ?; + +deleteDraft: +DELETE FROM MessageAttachmentDraft WHERE attachment_id = ?; + +selectChanges: +SELECT changes(); diff --git a/persistence/src/commonMain/db_user/migrations/99.sqm b/persistence/src/commonMain/db_user/migrations/99.sqm new file mode 100644 index 0000000000..1451e36cb6 --- /dev/null +++ b/persistence/src/commonMain/db_user/migrations/99.sqm @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS MessageAttachmentDraft ( + attachment_id TEXT NOT NULL, + verison_id TEXT NOT NULL, + conversation_id TEXT AS QualifiedIDEntity NOT NULL, + file_name TEXT NOT NULL, + file_size INTEGER NOT NULL, + data_path TEXT NOT NULL, + node_path TEXT NOT NULL, + upload_status TEXT NOT NULL, + + FOREIGN KEY (conversation_id) REFERENCES Conversation(qualified_id) ON DELETE CASCADE ON UPDATE CASCADE, + PRIMARY KEY (attachment_id) +); diff --git a/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/messageattachment/MessageAttachmentDraftDao.kt b/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/messageattachment/MessageAttachmentDraftDao.kt new file mode 100644 index 0000000000..c060d5675a --- /dev/null +++ b/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/messageattachment/MessageAttachmentDraftDao.kt @@ -0,0 +1,40 @@ +/* + * 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.persistence.dao.messageattachment + +import com.wire.kalium.persistence.dao.QualifiedIDEntity +import kotlinx.coroutines.flow.Flow + +@Suppress("LongParameterList") +interface MessageAttachmentDraftDao { + suspend fun getAttachment(uuid: String): MessageAttachmentDraftEntity? + suspend fun getAttachments(conversationId: QualifiedIDEntity): List + suspend fun deleteAttachment(uuid: String) + suspend fun observeAttachments(conversationId: QualifiedIDEntity): Flow> + suspend fun addAttachment( + uuid: String, + versionId: String, + conversationId: QualifiedIDEntity, + fileName: String, + fileSize: Long, + dataPath: String, + nodePath: String, + status: String, + ) + suspend fun updateUploadStatus(uuid: String, status: String) +} diff --git a/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/messageattachment/MessageAttachmentDraftDaoImpl.kt b/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/messageattachment/MessageAttachmentDraftDaoImpl.kt new file mode 100644 index 0000000000..5128707ed1 --- /dev/null +++ b/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/messageattachment/MessageAttachmentDraftDaoImpl.kt @@ -0,0 +1,72 @@ +/* + * 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.persistence.dao.messageattachment + +import app.cash.sqldelight.coroutines.asFlow +import com.wire.kalium.persistence.MessageAttachmentDraftQueries +import com.wire.kalium.persistence.dao.QualifiedIDEntity +import com.wire.kalium.persistence.dao.messageattachment.MessageAttachmentDraftMapper.toDao +import com.wire.kalium.persistence.util.mapToList +import kotlinx.coroutines.flow.Flow + +internal class MessageAttachmentDraftDaoImpl internal constructor( + private val queries: MessageAttachmentDraftQueries, +) : MessageAttachmentDraftDao { + + override suspend fun addAttachment( + uuid: String, + versionId: String, + conversationId: QualifiedIDEntity, + fileName: String, + fileSize: Long, + dataPath: String, + nodePath: String, + status: String, + ) { + queries.upsertDraft( + attachment_id = uuid, + version_id = versionId, + conversation_id = conversationId, + file_name = fileName, + file_size = fileSize, + data_path = dataPath, + node_path = nodePath, + upload_status = status, + ) + } + + override suspend fun updateUploadStatus(uuid: String, status: String) { + queries.updateUploadStatus(status, uuid) + } + + override suspend fun getAttachments(conversationId: QualifiedIDEntity): List { + return queries.getDrafts(conversationId, ::toDao).executeAsList() + } + + override suspend fun observeAttachments(conversationId: QualifiedIDEntity): Flow> { + return queries.getDrafts(conversationId, ::toDao).asFlow().mapToList() + } + + override suspend fun getAttachment(uuid: String): MessageAttachmentDraftEntity? { + return queries.getDraft(uuid, ::toDao).executeAsOneOrNull() + } + + override suspend fun deleteAttachment(uuid: String) { + queries.deleteDraft(uuid) + } +} diff --git a/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/messageattachment/MessageAttachmentDraftEntity.kt b/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/messageattachment/MessageAttachmentDraftEntity.kt new file mode 100644 index 0000000000..24572add13 --- /dev/null +++ b/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/messageattachment/MessageAttachmentDraftEntity.kt @@ -0,0 +1,31 @@ +/* + * 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.persistence.dao.messageattachment + +import com.wire.kalium.persistence.dao.ConversationIDEntity + +data class MessageAttachmentDraftEntity( + val uuid: String, + val versionId: String, + val conversationId: ConversationIDEntity, + val fileName: String, + val fileSize: Long, + val dataPath: String, + val nodePath: String, + val uploadStatus: String, +) diff --git a/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/messageattachment/MessageAttachmentDraftMapper.kt b/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/messageattachment/MessageAttachmentDraftMapper.kt new file mode 100644 index 0000000000..93bd667f65 --- /dev/null +++ b/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/messageattachment/MessageAttachmentDraftMapper.kt @@ -0,0 +1,43 @@ +/* + * 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.persistence.dao.messageattachment + +import com.wire.kalium.persistence.dao.QualifiedIDEntity + +@Suppress("LongParameterList") +internal data object MessageAttachmentDraftMapper { + fun toDao( + attachmentId: String, + versionId: String, + conversationId: QualifiedIDEntity, + fileName: String, + fileSize: Long, + dataPath: String, + nodePath: String, + uploadStatus: String, + ) = MessageAttachmentDraftEntity( + uuid = attachmentId, + versionId = versionId, + conversationId = conversationId, + fileName = fileName, + fileSize = fileSize, + dataPath = dataPath, + nodePath = nodePath, + uploadStatus = uploadStatus, + ) +} diff --git a/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/db/TableMapper.kt b/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/db/TableMapper.kt index f58f4f2b27..0301df668d 100644 --- a/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/db/TableMapper.kt +++ b/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/db/TableMapper.kt @@ -33,6 +33,7 @@ import com.wire.kalium.persistence.Member import com.wire.kalium.persistence.Message import com.wire.kalium.persistence.MessageAssetContent import com.wire.kalium.persistence.MessageAssetTransferStatus +import com.wire.kalium.persistence.MessageAttachmentDraft import com.wire.kalium.persistence.MessageConversationChangedContent import com.wire.kalium.persistence.MessageConversationLocationContent import com.wire.kalium.persistence.MessageConversationProtocolChangedContent @@ -281,4 +282,8 @@ internal object TableMapper { val conversationFolderAdapter = ConversationFolder.Adapter( folder_typeAdapter = EnumColumnAdapter() ) + + val messageAttachmentDraftAdapter = MessageAttachmentDraft.Adapter( + conversation_idAdapter = QualifiedIDAdapter, + ) } diff --git a/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/db/UserDatabaseBuilder.kt b/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/db/UserDatabaseBuilder.kt index 0396b04f77..a2bc50f520 100644 --- a/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/db/UserDatabaseBuilder.kt +++ b/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/db/UserDatabaseBuilder.kt @@ -71,6 +71,8 @@ import com.wire.kalium.persistence.dao.message.MessageDAOImpl import com.wire.kalium.persistence.dao.message.MessageMetadataDAO import com.wire.kalium.persistence.dao.message.MessageMetadataDAOImpl import com.wire.kalium.persistence.dao.message.draft.MessageDraftDAOImpl +import com.wire.kalium.persistence.dao.messageattachment.MessageAttachmentDraftDao +import com.wire.kalium.persistence.dao.messageattachment.MessageAttachmentDraftDaoImpl import com.wire.kalium.persistence.dao.newclient.NewClientDAO import com.wire.kalium.persistence.dao.newclient.NewClientDAOImpl import com.wire.kalium.persistence.dao.reaction.ReactionDAO @@ -165,7 +167,8 @@ class UserDatabaseBuilder internal constructor( MessageDraftAdapter = TableMapper.messageDraftsAdapter, LastMessageAdapter = TableMapper.lastMessageAdapter, LabeledConversationAdapter = TableMapper.labeledConversationAdapter, - ConversationFolderAdapter = TableMapper.conversationFolderAdapter + ConversationFolderAdapter = TableMapper.conversationFolderAdapter, + MessageAttachmentDraftAdapter = TableMapper.messageAttachmentDraftAdapter, ) init { @@ -318,6 +321,9 @@ class UserDatabaseBuilder internal constructor( queriesContext ) + val messageAttachmentDraftDao: MessageAttachmentDraftDao + get() = MessageAttachmentDraftDaoImpl(database.messageAttachmentDraftQueries) + val debugExtension: DebugExtension get() = DebugExtension( sqlDriver = sqlDriver, diff --git a/settings.gradle.kts b/settings.gradle.kts index 5349f73070..52536a42b0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -39,3 +39,18 @@ pluginManagement { mavenCentral() } } + +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.9.0" +} + +dependencyResolutionManagement { + repositories { + mavenCentral() + } + versionCatalogs { + create("awssdk") { + from("aws.sdk.kotlin:version-catalog:1.3.112") + } + } +}