diff --git a/STCommon/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/common/interfaces/transfers/v2/File.kt b/STCommon/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/common/interfaces/transfers/v2/File.kt new file mode 100644 index 00000000..ea90b483 --- /dev/null +++ b/STCommon/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/common/interfaces/transfers/v2/File.kt @@ -0,0 +1,31 @@ +/* + * Infomaniak SwissTransfer - Multiplatform + * Copyright (C) 2026 Infomaniak Network SA + * + * 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 . + */ +package com.infomaniak.multiplatform_swisstransfer.common.interfaces.transfers.v2 + +interface File { + val id: String + val path: String + val size: Long + val mimeType: String? get() = null + + // Local + val thumbnailPath: String? get() = null + val isFolder: Boolean get() = false + // utils + val name get() = path.substringAfterLast("/") +} diff --git a/STCommon/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/common/interfaces/transfers/v2/Transfer.kt b/STCommon/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/common/interfaces/transfers/v2/Transfer.kt new file mode 100644 index 00000000..67195b13 --- /dev/null +++ b/STCommon/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/common/interfaces/transfers/v2/Transfer.kt @@ -0,0 +1,39 @@ +/* + * Infomaniak SwissTransfer - Multiplatform + * Copyright (C) 2026 Infomaniak Network SA + * + * 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 . + */ +package com.infomaniak.multiplatform_swisstransfer.common.interfaces.transfers.v2 + +import com.infomaniak.multiplatform_swisstransfer.common.models.TransferDirection +import com.infomaniak.multiplatform_swisstransfer.common.models.TransferStatus + +interface Transfer { + val id: String + val senderEmail: String + val title: String? + val message: String? + val createdAt: Long + val expiresAt: Long + val files: List get() = emptyList() + val totalSize: Long + + //region Only local + val password: String? get() = null + val transferDirection: TransferDirection? get() = null + val transferStatus: TransferStatus? get() = null + val recipientsEmails: Set get() = emptySet() + //endregion +} diff --git a/STCommon/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/common/utils/ApiEnvironment.kt b/STCommon/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/common/utils/ApiEnvironment.kt index 56f69e2a..906b5bfb 100644 --- a/STCommon/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/common/utils/ApiEnvironment.kt +++ b/STCommon/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/common/utils/ApiEnvironment.kt @@ -17,9 +17,13 @@ */ package com.infomaniak.multiplatform_swisstransfer.common.utils -sealed class ApiEnvironment(val baseUrl: String) { +sealed class ApiEnvironment(val baseUrl: String, val baseUrlV2: String) { // Those urls are duplicated with the ones we have in Android so don't forget to change them also in Android - data object Preprod : ApiEnvironment("https://swisstransfer-legacy.preprod.dev.infomaniak.ch") - data object Prod : ApiEnvironment("https://www.swisstransfer.com") - data class Custom(private val url: String) : ApiEnvironment(url) + data object Preprod : ApiEnvironment( + baseUrl = "https://swisstransfer-legacy.preprod.dev.infomaniak.ch", + baseUrlV2 = "https://swisstransfer.preprod.dev.infomaniak.ch" + ) + + data object Prod : ApiEnvironment("https://www.swisstransfer.com", "https://swisstransfer.infomaniak.com") + data class Custom(private val url: String, private val urlV2: String) : ApiEnvironment(url, urlV2) } diff --git a/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/ApiClientProvider.kt b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/ApiClientProvider.kt index e600b9d2..956051ba 100644 --- a/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/ApiClientProvider.kt +++ b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/ApiClientProvider.kt @@ -22,9 +22,11 @@ import com.infomaniak.multiplatform_swisstransfer.common.interfaces.CrashReportI import com.infomaniak.multiplatform_swisstransfer.common.interfaces.CrashReportLevel import com.infomaniak.multiplatform_swisstransfer.network.exceptions.ApiException import com.infomaniak.multiplatform_swisstransfer.network.exceptions.ApiException.ApiErrorException +import com.infomaniak.multiplatform_swisstransfer.network.exceptions.ApiException.ApiV2ErrorException import com.infomaniak.multiplatform_swisstransfer.network.exceptions.ApiException.UnexpectedApiErrorFormatException import com.infomaniak.multiplatform_swisstransfer.network.exceptions.NetworkException import com.infomaniak.multiplatform_swisstransfer.network.models.ApiError +import com.infomaniak.multiplatform_swisstransfer.network.models.ApiResponseForError import com.infomaniak.multiplatform_swisstransfer.network.utils.getRequestContextId import io.ktor.client.HttpClient import io.ktor.client.HttpClientConfig @@ -40,6 +42,7 @@ import io.ktor.client.statement.bodyAsText import io.ktor.client.statement.request import io.ktor.http.contentLength import io.ktor.serialization.kotlinx.json.json +import io.ktor.util.network.UnresolvedAddressException import io.ktor.utils.io.CancellationException import kotlinx.io.IOException import kotlinx.serialization.json.Json @@ -88,7 +91,7 @@ class ApiClientProvider internal constructor( } install(HttpRequestRetry) { retryOnExceptionIf(maxRetries = MAX_RETRY) { _, cause -> - cause.isNetworkException() + cause.isRetryableNetworkException() } delayMillis { retry -> retry * 500L @@ -103,17 +106,24 @@ class ApiClientProvider internal constructor( if (statusCode >= 300) { val bodyResponse = response.bodyAsText() - val apiError = runCatching { - json.decodeFromString(bodyResponse) - }.getOrElse { - throw UnexpectedApiErrorFormatException(statusCode, bodyResponse, null, requestContextId) + runCatching { + if (bodyResponse.isFromApiV2()) { + val error = json.decodeFromString(bodyResponse).error + throw ApiV2ErrorException(error.code, error.description, requestContextId) + } else { + val error = json.decodeFromString(bodyResponse) + throw ApiErrorException(error.errorCode, error.message, requestContextId) + } + }.getOrElse { exception -> + if (exception is ApiException) throw exception + throw UnexpectedApiErrorFormatException(statusCode, bodyResponse, exception, requestContextId) } - throw ApiErrorException(apiError.errorCode, apiError.message, requestContextId) } } handleResponseExceptionWithRequest { cause, request -> when (cause) { - is IOException -> throw NetworkException("Network error: ${cause.message}") + is UnresolvedAddressException, + is IOException -> throw NetworkException("Network error: ${cause.message}", cause) is ApiException, is CancellationException -> throw cause else -> { val response = runCatching { request.call.response }.getOrNull() @@ -151,9 +161,12 @@ class ApiClientProvider internal constructor( ) } - private fun Throwable.isNetworkException() = this is IOException + private fun Throwable.isRetryableNetworkException() = this is IOException companion object { private const val MAX_RETRY = 3 + + //TODO[API-V2]: Delete once the v1 api has been removed + private fun String.isFromApiV2() = contains("\"result\": \"error\"") } } diff --git a/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/exceptions/ApiException.kt b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/exceptions/ApiException.kt index 5203bfb4..87f4b83c 100644 --- a/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/exceptions/ApiException.kt +++ b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/exceptions/ApiException.kt @@ -49,6 +49,22 @@ sealed class ApiException( requestContextId: String, ) : ApiException(errorMessage, null, requestContextId) + /** + * Thrown when an API(v2) call fails due to an error identified by a specific error code. + * + * This exception is used to represent errors returned by an API, with an associated error code + * and message describing the problem. + * + * @property code The specific error code returned by the API. + * @property errorMessage The detailed error message explaining the cause of the failure. + * @param requestContextId The request context id send by the backend to track the call + */ + open class ApiV2ErrorException( + val code: String, + val description: String, + requestContextId: String, + ) : ApiException(description, null, requestContextId) + /** * Thrown when an API call returns an error in an unexpected format that cannot be parsed. * diff --git a/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/exceptions/FetchTransferException.kt b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/exceptions/FetchTransferException.kt index 82c4a395..5f37824a 100644 --- a/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/exceptions/FetchTransferException.kt +++ b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/exceptions/FetchTransferException.kt @@ -89,6 +89,11 @@ sealed class FetchTransferException( class WrongPasswordFetchTransferException(requestContextId: String) : FetchTransferException(401, "Wrong password for this Transfer", requestContextId) + class TransferCancelledException(requestContextId: String) : + FetchTransferException(409, "Transfer cancelled", requestContextId) + + class DownloadLimitReached(requestContextId: String) : FetchTransferException(429, "Download limit reached", requestContextId) + companion object { private const val ERROR_VIRUS_CHECK = "wait_virus_check" @@ -129,6 +134,26 @@ sealed class FetchTransferException( errorCode == 404 -> NotFoundFetchTransferException(requestContextId) else -> this } + + /** + * Extension function to convert an instance of [ApiException.ApiV2ErrorException] to a specific [FetchTransferException] + * based on its error code. + * + * Useful to translate some correctly formatted api error exceptions as our custom type of errors we handle everywhere. + * + * @receiver An instance of [ApiException.ApiV2ErrorException]. + * @return An instance of [FetchTransferException] or the original [ApiException.ApiV2ErrorException] if we cannot map it + * to a [FetchTransferException]. + */ + internal fun ApiV2ErrorException.toFetchTransferException() = when (code) { + "access_denied" -> PasswordNeededFetchTransferException(requestContextId) + "invalid_password" -> WrongPasswordFetchTransferException(requestContextId) + "object_not_found" -> NotFoundFetchTransferException(requestContextId) + "transfer_cancelled" -> TransferCancelledException(requestContextId) + "transfer_expired" -> ExpiredDateFetchTransferException(requestContextId) + "download_limit_reached" -> DownloadLimitReached(requestContextId) + else -> this + } } } diff --git a/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/exceptions/NetworkException.kt b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/exceptions/NetworkException.kt index a624c4c7..01d5ba1c 100644 --- a/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/exceptions/NetworkException.kt +++ b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/exceptions/NetworkException.kt @@ -22,4 +22,4 @@ package com.infomaniak.multiplatform_swisstransfer.network.exceptions * * @param message A detailed message describing the network error. */ -class NetworkException(message: String) : Exception(message) +class NetworkException(message: String, cause: Throwable?) : Exception(message, cause) diff --git a/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/exceptions/TooManyRequestException.kt b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/exceptions/TooManyRequestException.kt new file mode 100644 index 00000000..0b6564f6 --- /dev/null +++ b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/exceptions/TooManyRequestException.kt @@ -0,0 +1,24 @@ +/* + * Infomaniak SwissTransfer - Multiplatform + * Copyright (C) 2026 Infomaniak Network SA + * + * 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 . + */ +package com.infomaniak.multiplatform_swisstransfer.network.exceptions + +class TooManyRequestException(requestContextId: String) : ApiException( + errorMessage = "", + cause = null, + requestContextId = requestContextId +) diff --git a/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/exceptions/UnauthorizedException.kt b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/exceptions/UnauthorizedException.kt new file mode 100644 index 00000000..a209b36e --- /dev/null +++ b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/exceptions/UnauthorizedException.kt @@ -0,0 +1,30 @@ +/* + * Infomaniak SwissTransfer - Multiplatform + * Copyright (C) 2026 Infomaniak Network SA + * + * 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 . + */ +package com.infomaniak.multiplatform_swisstransfer.network.exceptions + +/** + * Exception thrown when an API request fails due to missing or invalid Bearer token authorization. + * + * This exception indicates that the request requires a valid Bearer token for authentication, + * but either no token was provided or the token was invalid/expired. + */ +class UnauthorizedException(requestContextId: String) : ApiException( + errorMessage = "", + cause = null, + requestContextId = requestContextId +) diff --git a/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/exceptions/UploadErrorsException.kt b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/exceptions/UploadErrorsException.kt new file mode 100644 index 00000000..e992b3dc --- /dev/null +++ b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/exceptions/UploadErrorsException.kt @@ -0,0 +1,38 @@ +/* + * Infomaniak SwissTransfer - Multiplatform + * Copyright (C) 2026 Infomaniak Network SA + * + * 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 . + */ +package com.infomaniak.multiplatform_swisstransfer.network.exceptions + +sealed class UploadErrorsException(requestContextId: String) : ApiException.ApiV2ErrorException( + code = "", + description = "", + requestContextId = requestContextId +) { + + class NotFoundException(requestContextId: String) : UploadErrorsException(requestContextId = requestContextId) + class TransferCancelled(requestContextId: String) : UploadErrorsException(requestContextId = requestContextId) + class TransferExpired(requestContextId: String) : UploadErrorsException(requestContextId = requestContextId) + class TransferFailed(requestContextId: String) : UploadErrorsException(requestContextId = requestContextId) +} + +internal fun ApiException.ApiV2ErrorException.toUploadErrorsException() = when (code) { + "object_not_found" -> UploadErrorsException.NotFoundException(requestContextId) + "transfer_cancelled" -> UploadErrorsException.TransferCancelled(requestContextId) + "transfer_expired" -> UploadErrorsException.TransferExpired(requestContextId) + "transfer_failed" -> UploadErrorsException.TransferFailed(requestContextId) + else -> this +} diff --git a/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/models/ApiErrorV2.kt b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/models/ApiErrorV2.kt new file mode 100644 index 00000000..f9bf106d --- /dev/null +++ b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/models/ApiErrorV2.kt @@ -0,0 +1,26 @@ +/* + * Infomaniak SwissTransfer - Multiplatform + * Copyright (C) 2024 Infomaniak Network SA + * + * 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 . + */ +package com.infomaniak.multiplatform_swisstransfer.network.models + +import kotlinx.serialization.Serializable + +@Serializable +data class ApiErrorV2( + val code: String, + val description: String, +) diff --git a/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/models/ApiResponseForError.kt b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/models/ApiResponseForError.kt new file mode 100644 index 00000000..56aa51a3 --- /dev/null +++ b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/models/ApiResponseForError.kt @@ -0,0 +1,26 @@ +/* + * Infomaniak SwissTransfer - Multiplatform + * Copyright (C) 2026 Infomaniak Network SA + * + * 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 . + */ +package com.infomaniak.multiplatform_swisstransfer.network.models + +import kotlinx.serialization.Serializable + +@Serializable +internal data class ApiResponseForError( + val responseStatus: ApiResponseStatus = ApiResponseStatus.ERROR, + val error: ApiErrorV2 +) diff --git a/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/models/ApiResponseV2Success.kt b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/models/ApiResponseV2Success.kt new file mode 100644 index 00000000..7463acc3 --- /dev/null +++ b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/models/ApiResponseV2Success.kt @@ -0,0 +1,36 @@ +/* + * Infomaniak SwissTransfer - Multiplatform + * Copyright (C) 2024 Infomaniak Network SA + * + * 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 . + */ +package com.infomaniak.multiplatform_swisstransfer.network.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class ApiResponseV2Success( + val result: Status, + val data: T, +) { + @Serializable + enum class Status { + @SerialName("success") + SUCCESS, + @SerialName("asynchronous") + ASYNCHRONOUS, + ; + } +} diff --git a/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/models/transfer/v2/FileApi.kt b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/models/transfer/v2/FileApi.kt new file mode 100644 index 00000000..a655db76 --- /dev/null +++ b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/models/transfer/v2/FileApi.kt @@ -0,0 +1,31 @@ +/* + * Infomaniak SwissTransfer - Multiplatform + * Copyright (C) 2026 Infomaniak Network SA + * + * 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 . + */ +package com.infomaniak.multiplatform_swisstransfer.network.models.transfer.v2 + +import com.infomaniak.multiplatform_swisstransfer.common.interfaces.transfers.v2.File +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class FileApi( + override val id: String, + override val path: String, + override val size: Long, + @SerialName("mime_type") + override val mimeType: String? = null, +) : File diff --git a/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/models/transfer/v2/TransferApi.kt b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/models/transfer/v2/TransferApi.kt new file mode 100644 index 00000000..5c6224be --- /dev/null +++ b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/models/transfer/v2/TransferApi.kt @@ -0,0 +1,38 @@ +/* + * Infomaniak SwissTransfer - Multiplatform + * Copyright (C) 2026 Infomaniak Network SA + * + * 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 . + */ +package com.infomaniak.multiplatform_swisstransfer.network.models.transfer.v2 + +import com.infomaniak.multiplatform_swisstransfer.common.interfaces.transfers.v2.Transfer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TransferApi( + override val id: String, + @SerialName("sender_email") + override val senderEmail: String, + override val title: String? = null, + override val message: String? = null, + @SerialName("created_at") + override val createdAt: Long, + @SerialName("expires_at") + override val expiresAt: Long, + override val files: List, + @SerialName("total_size") + override val totalSize: Long, +) : Transfer diff --git a/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/models/upload/request/v2/ChunkEtag.kt b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/models/upload/request/v2/ChunkEtag.kt new file mode 100644 index 00000000..246aa2ac --- /dev/null +++ b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/models/upload/request/v2/ChunkEtag.kt @@ -0,0 +1,28 @@ +/* + * Infomaniak SwissTransfer - Multiplatform + * Copyright (C) 2026 Infomaniak Network SA + * + * 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 . + */ +package com.infomaniak.multiplatform_swisstransfer.network.models.upload.request.v2 + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ChunkEtag( + val etag: String, + @SerialName("chunk_index") + val chunkIndex: Int, // Start with index 1 +) diff --git a/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/models/upload/request/v2/CreateTransfer.kt b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/models/upload/request/v2/CreateTransfer.kt new file mode 100644 index 00000000..1c746842 --- /dev/null +++ b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/models/upload/request/v2/CreateTransfer.kt @@ -0,0 +1,36 @@ +/* + * Infomaniak SwissTransfer - Multiplatform + * Copyright (C) 2026 Infomaniak Network SA + * + * 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 . + */ +package com.infomaniak.multiplatform_swisstransfer.network.models.upload.request.v2 + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CreateTransfer( + val title: String? = null, + val message: String? = null, + val password: String? = null, + val language: String, + @SerialName("expires_in_days") + val expiresInDays: Int, + @SerialName("max_download") + val maxDownload: Int, + val files: List, + val recipients: List +) + diff --git a/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/models/upload/request/v2/TransferFile.kt b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/models/upload/request/v2/TransferFile.kt new file mode 100644 index 00000000..e2636c53 --- /dev/null +++ b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/models/upload/request/v2/TransferFile.kt @@ -0,0 +1,29 @@ +/* + * Infomaniak SwissTransfer - Multiplatform + * Copyright (C) 2026 Infomaniak Network SA + * + * 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 . + */ +package com.infomaniak.multiplatform_swisstransfer.network.models.upload.request.v2 + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TransferFile( + val path: String, + val size: Long, + @SerialName("mime_type") + val mimeType: String = "" // Empty otherwise +) diff --git a/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/models/upload/request/v2/UploadTransferStatus.kt b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/models/upload/request/v2/UploadTransferStatus.kt new file mode 100644 index 00000000..1e26b9d1 --- /dev/null +++ b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/models/upload/request/v2/UploadTransferStatus.kt @@ -0,0 +1,24 @@ +/* + * Infomaniak SwissTransfer - Multiplatform + * Copyright (C) 2026 Infomaniak Network SA + * + * 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 . + */ +package com.infomaniak.multiplatform_swisstransfer.network.models.upload.request.v2 + +internal enum class UploadTransferStatus(val apiValue: String) { + Completed("completed"), + Cancelled("cancelled"), + Failed("failed"), +} diff --git a/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/models/upload/response/v2/PresignedUrlResponse.kt b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/models/upload/response/v2/PresignedUrlResponse.kt new file mode 100644 index 00000000..7bf4cf74 --- /dev/null +++ b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/models/upload/response/v2/PresignedUrlResponse.kt @@ -0,0 +1,25 @@ +/* + * Infomaniak SwissTransfer - Multiplatform + * Copyright (C) 2026 Infomaniak Network SA + * + * 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 . + */ +package com.infomaniak.multiplatform_swisstransfer.network.models.upload.response.v2 + +import kotlinx.serialization.Serializable + +@Serializable +data class PresignedUrlResponse( + val url: String +) diff --git a/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/repositories/TransferV2Repository.kt b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/repositories/TransferV2Repository.kt new file mode 100644 index 00000000..7ef3d6e0 --- /dev/null +++ b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/repositories/TransferV2Repository.kt @@ -0,0 +1,156 @@ +/* + * Infomaniak SwissTransfer - Multiplatform + * Copyright (C) 2024 Infomaniak Network SA + * + * 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 . + */ +package com.infomaniak.multiplatform_swisstransfer.network.repositories + +import com.infomaniak.multiplatform_swisstransfer.common.exceptions.UnknownException +import com.infomaniak.multiplatform_swisstransfer.common.utils.ApiEnvironment +import com.infomaniak.multiplatform_swisstransfer.network.ApiClientProvider +import com.infomaniak.multiplatform_swisstransfer.network.exceptions.ApiException.ApiV2ErrorException +import com.infomaniak.multiplatform_swisstransfer.network.exceptions.ApiException.UnexpectedApiErrorFormatException +import com.infomaniak.multiplatform_swisstransfer.network.exceptions.FetchTransferException.Companion.toFetchTransferException +import com.infomaniak.multiplatform_swisstransfer.network.exceptions.FetchTransferException.DownloadLimitReached +import com.infomaniak.multiplatform_swisstransfer.network.exceptions.FetchTransferException.ExpiredDateFetchTransferException +import com.infomaniak.multiplatform_swisstransfer.network.exceptions.FetchTransferException.NotFoundFetchTransferException +import com.infomaniak.multiplatform_swisstransfer.network.exceptions.FetchTransferException.PasswordNeededFetchTransferException +import com.infomaniak.multiplatform_swisstransfer.network.exceptions.FetchTransferException.TransferCancelledException +import com.infomaniak.multiplatform_swisstransfer.network.exceptions.FetchTransferException.WrongPasswordFetchTransferException +import com.infomaniak.multiplatform_swisstransfer.network.exceptions.NetworkException +import com.infomaniak.multiplatform_swisstransfer.network.exceptions.UnauthorizedException +import com.infomaniak.multiplatform_swisstransfer.network.models.ApiResponseV2Success +import com.infomaniak.multiplatform_swisstransfer.network.models.transfer.v2.TransferApi +import com.infomaniak.multiplatform_swisstransfer.network.models.upload.response.v2.PresignedUrlResponse +import com.infomaniak.multiplatform_swisstransfer.network.requests.v2.TransferRequest +import io.ktor.client.HttpClient +import kotlinx.serialization.json.Json +import kotlin.coroutines.cancellation.CancellationException + +class TransferV2Repository internal constructor(private val transferRequest: TransferRequest) { + + // for Obj-C https://youtrack.jetbrains.com/issue/KT-68288/KMP-Support-Kotlin-default-parameters-into-Swift-default-parameters-and-Objective-C-somehow-possibly-a-JvmOverloads-like + constructor(environment: ApiEnvironment, token: () -> String) : this(ApiClientProvider(), environment, token) + constructor( + apiClientProvider: ApiClientProvider = ApiClientProvider(), + environment: ApiEnvironment, + token: () -> String, + ) : this( + environment = environment, + json = apiClientProvider.json, + httpClient = apiClientProvider.httpClient, + token = token, + ) + + internal constructor( + environment: ApiEnvironment, + json: Json, + httpClient: HttpClient, + token: () -> String, + ) : this(TransferRequest(environment, json, httpClient, token)) + + @Throws( + CancellationException::class, + ApiV2ErrorException::class, + UnexpectedApiErrorFormatException::class, + NetworkException::class, + UnknownException::class, + UnauthorizedException::class, + PasswordNeededFetchTransferException::class, + WrongPasswordFetchTransferException::class, + NotFoundFetchTransferException::class, + TransferCancelledException::class, + ExpiredDateFetchTransferException::class, + DownloadLimitReached::class, + ) + suspend fun getTransferByLinkUUID( + linkUUID: String, + password: String? + ): TransferApi = withTransferErrorHandling { + transferRequest.getTransfer(linkUUID, password).data + } + + @Throws( + CancellationException::class, + ApiV2ErrorException::class, + UnexpectedApiErrorFormatException::class, + NetworkException::class, + UnknownException::class, + UnauthorizedException::class, + PasswordNeededFetchTransferException::class, + WrongPasswordFetchTransferException::class, + NotFoundFetchTransferException::class, + TransferCancelledException::class, + ExpiredDateFetchTransferException::class, + DownloadLimitReached::class, + ) + suspend fun getTransferByUrl(url: String, password: String?): TransferApi { + return getTransferByLinkUUID(extractUUID(url), password) + } + + @Throws( + CancellationException::class, + ApiV2ErrorException::class, + UnexpectedApiErrorFormatException::class, + NetworkException::class, + UnknownException::class, + UnauthorizedException::class, + PasswordNeededFetchTransferException::class, + WrongPasswordFetchTransferException::class, + NotFoundFetchTransferException::class, + TransferCancelledException::class, + ExpiredDateFetchTransferException::class, + DownloadLimitReached::class, + ) + suspend fun presignedDownloadUrl( + linkId: String, + fileId: String, + password: String? + ): String = withTransferErrorHandling { + transferRequest.presignedDownloadUrl(linkId, fileId, password).data.url + } + + @Throws( + CancellationException::class, + ApiV2ErrorException::class, + UnexpectedApiErrorFormatException::class, + NetworkException::class, + UnknownException::class, + UnauthorizedException::class, + PasswordNeededFetchTransferException::class, + WrongPasswordFetchTransferException::class, + NotFoundFetchTransferException::class, + TransferCancelledException::class, + ExpiredDateFetchTransferException::class, + DownloadLimitReached::class, + ) + suspend fun delete( + transferId: String, + ): Boolean = withTransferErrorHandling { + transferRequest.deleteTransfer(transferId) + } + + + internal fun extractUUID(url: String) = url.substringAfterLast("/") + + private suspend fun withTransferErrorHandling(block: suspend () -> T) = runCatching { + block() + }.getOrElse { exception -> + throw when (exception) { + is ApiV2ErrorException -> exception.toFetchTransferException() + else -> exception + } + } +} diff --git a/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/repositories/UploadV2Repository.kt b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/repositories/UploadV2Repository.kt new file mode 100644 index 00000000..45162d60 --- /dev/null +++ b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/repositories/UploadV2Repository.kt @@ -0,0 +1,222 @@ +/* + * Infomaniak SwissTransfer - Multiplatform + * Copyright (C) 2024 Infomaniak Network SA + * + * 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 . + */ +package com.infomaniak.multiplatform_swisstransfer.network.repositories + +import com.infomaniak.multiplatform_swisstransfer.common.exceptions.UnknownException +import com.infomaniak.multiplatform_swisstransfer.common.interfaces.transfers.v2.Transfer +import com.infomaniak.multiplatform_swisstransfer.common.utils.ApiEnvironment +import com.infomaniak.multiplatform_swisstransfer.network.ApiClientProvider +import com.infomaniak.multiplatform_swisstransfer.network.exceptions.ApiException.ApiV2ErrorException +import com.infomaniak.multiplatform_swisstransfer.network.exceptions.ApiException.UnexpectedApiErrorFormatException +import com.infomaniak.multiplatform_swisstransfer.network.exceptions.NetworkException +import com.infomaniak.multiplatform_swisstransfer.network.exceptions.TooManyRequestException +import com.infomaniak.multiplatform_swisstransfer.network.exceptions.UnauthorizedException +import com.infomaniak.multiplatform_swisstransfer.network.exceptions.UploadErrorsException.NotFoundException +import com.infomaniak.multiplatform_swisstransfer.network.exceptions.UploadErrorsException.TransferCancelled +import com.infomaniak.multiplatform_swisstransfer.network.exceptions.UploadErrorsException.TransferExpired +import com.infomaniak.multiplatform_swisstransfer.network.exceptions.UploadErrorsException.TransferFailed +import com.infomaniak.multiplatform_swisstransfer.network.exceptions.toUploadErrorsException +import com.infomaniak.multiplatform_swisstransfer.network.models.upload.request.v2.ChunkEtag +import com.infomaniak.multiplatform_swisstransfer.network.models.upload.request.v2.CreateTransfer +import com.infomaniak.multiplatform_swisstransfer.network.models.upload.request.v2.UploadTransferStatus +import com.infomaniak.multiplatform_swisstransfer.network.models.upload.response.v2.PresignedUrlResponse +import com.infomaniak.multiplatform_swisstransfer.network.requests.v2.UploadRequest +import io.ktor.client.HttpClient +import io.ktor.http.content.OutgoingContent +import kotlinx.serialization.json.Json +import kotlin.coroutines.cancellation.CancellationException + +class UploadV2Repository internal constructor(private val uploadRequest: UploadRequest) { + + // for Obj-C https://youtrack.jetbrains.com/issue/KT-68288/KMP-Support-Kotlin-default-parameters-into-Swift-default-parameters-and-Objective-C-somehow-possibly-a-JvmOverloads-like + constructor(environment: ApiEnvironment, token: () -> String) : this(ApiClientProvider(), environment, token) + constructor( + apiClientProvider: ApiClientProvider = ApiClientProvider(), + environment: ApiEnvironment, + token: () -> String, + ) : this( + environment = environment, + json = apiClientProvider.json, + httpClient = apiClientProvider.httpClient, + token = token, + ) + + internal constructor( + environment: ApiEnvironment, + json: Json, + httpClient: HttpClient, + token: () -> String, + ) : this(UploadRequest(environment, json, httpClient, token)) + + @Throws( + CancellationException::class, + NetworkException::class, + ApiV2ErrorException::class, + UnexpectedApiErrorFormatException::class, + UnknownException::class, + UnauthorizedException::class, + TooManyRequestException::class, + ) + suspend fun createTransfer(createTransfer: CreateTransfer): Transfer { + return withUploadErrorHandling { + uploadRequest.createTransfer(createTransfer).data + } + } + + @Throws( + CancellationException::class, + NetworkException::class, + ApiV2ErrorException::class, + UnexpectedApiErrorFormatException::class, + UnknownException::class, + NotFoundException::class, + ) + suspend fun getPresignedUploadUrl(transferId: String, fileId: String): PresignedUrlResponse { + return withUploadErrorHandling { + uploadRequest.getPresignedUploadUrl(transferId, fileId).data + } + } + + @Throws( + CancellationException::class, + NetworkException::class, + ApiV2ErrorException::class, + UnexpectedApiErrorFormatException::class, + UnknownException::class, + NotFoundException::class, + ) + suspend fun getPresignedUploadChunkUrl( + transferId: String, + fileId: String, + chunkIndex: Int, + ): PresignedUrlResponse { + return withUploadErrorHandling { + uploadRequest.getPresignedUploadChunkUrl(transferId, fileId, chunkIndex).data + } + } + + @Throws( + CancellationException::class, + NetworkException::class, + ApiV2ErrorException::class, + UnexpectedApiErrorFormatException::class, + UnknownException::class, + NotFoundException::class, + TransferCancelled::class, + ) + suspend fun finalizeTransferFile(transferId: String, fileId: String, etags: List): Boolean { + return withUploadErrorHandling { + uploadRequest.finalizeTransferFile(transferId, fileId, etags) + } + } + + @Throws( + CancellationException::class, + NetworkException::class, + ApiV2ErrorException::class, + UnexpectedApiErrorFormatException::class, + UnknownException::class, + UnauthorizedException::class, + TooManyRequestException::class, + NotFoundException::class, + TransferExpired::class, + TransferCancelled::class, + ) + suspend fun finalizeTransfer(transferId: String): Boolean { + return withUploadErrorHandling { + uploadRequest.updateTransferStatus(transferId, UploadTransferStatus.Completed) + } + } + + @Throws( + CancellationException::class, + NetworkException::class, + ApiV2ErrorException::class, + UnexpectedApiErrorFormatException::class, + UnknownException::class, + UnauthorizedException::class, + TooManyRequestException::class, + NotFoundException::class, + TransferExpired::class, + TransferCancelled::class, + TransferFailed::class, + ) + suspend fun cancelTransfer(transferId: String): Boolean { + return withUploadErrorHandling { + uploadRequest.updateTransferStatus(transferId, UploadTransferStatus.Cancelled) + } + } + + @Throws( + CancellationException::class, + NetworkException::class, + ApiV2ErrorException::class, + UnexpectedApiErrorFormatException::class, + UnknownException::class, + UnauthorizedException::class, + TooManyRequestException::class, + NotFoundException::class, + TransferExpired::class, + TransferCancelled::class, + TransferFailed::class, + ) + suspend fun markTransferAsFailed(transferId: String): Boolean { + return withUploadErrorHandling { + uploadRequest.updateTransferStatus(transferId, UploadTransferStatus.Failed) + } + } + + @Throws( + CancellationException::class, + NetworkException::class, + UnexpectedApiErrorFormatException::class, + UnknownException::class, + ) + suspend fun uploadFile( + cephUrl: String, + data: OutgoingContent.WriteChannelContent, + onUpload: suspend (bytesSentTotal: Long, chunkSize: Long) -> Unit, + ) = withUploadErrorHandling { + uploadRequest.uploadFile(cephUrl, data, onUpload) + } + + @Throws( + CancellationException::class, + NetworkException::class, + UnexpectedApiErrorFormatException::class, + UnknownException::class, + ) + suspend fun uploadChunkToEtag( + cephUrl: String, + data: OutgoingContent.WriteChannelContent, + onUpload: suspend (bytesSentTotal: Long, chunkSize: Long) -> Unit, + ): String? = withUploadErrorHandling { + uploadRequest.uploadChunk(cephUrl, data, onUpload) + } + + private suspend fun withUploadErrorHandling(block: suspend () -> T) = runCatching { + block() + }.getOrElse { exception -> + throw when (exception) { + is ApiV2ErrorException if exception.code == "not_authorized" -> UnauthorizedException(exception.requestContextId) + is ApiV2ErrorException if exception.code == "too_many_request" -> TooManyRequestException(exception.requestContextId) + is ApiV2ErrorException -> exception.toUploadErrorsException() + else -> exception + } + } +} diff --git a/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/requests/BaseRequest.kt b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/requests/BaseRequest.kt index b6581584..1267e47d 100644 --- a/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/requests/BaseRequest.kt +++ b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/requests/BaseRequest.kt @@ -21,14 +21,17 @@ import com.infomaniak.multiplatform_swisstransfer.common.utils.ApiEnvironment import com.infomaniak.multiplatform_swisstransfer.network.utils.ApiRoutes import com.infomaniak.multiplatform_swisstransfer.network.utils.decode import io.ktor.client.HttpClient +import io.ktor.client.call.body import io.ktor.client.request.delete import io.ktor.client.request.get import io.ktor.client.request.headers +import io.ktor.client.request.patch import io.ktor.client.request.post import io.ktor.client.request.put import io.ktor.client.request.setBody import io.ktor.http.ContentType import io.ktor.http.HeadersBuilder +import io.ktor.http.HttpHeaders import io.ktor.http.URLBuilder import io.ktor.http.Url import io.ktor.http.contentType @@ -38,13 +41,27 @@ internal open class BaseRequest( protected val environment: ApiEnvironment, protected val json: Json, protected val httpClient: HttpClient, + private val token: () -> String, ) { protected fun createUrl(path: String, vararg queries: Pair): Url { - val baseUrl = Url(ApiRoutes.apiBaseUrl(environment) + path) - return URLBuilder(baseUrl).apply { - queries.forEach { parameters.append(it.first, it.second) } - }.build() + return createUrl(ApiRoutes.apiBaseUrl(environment), path, queries) + } + + protected fun createV2Url(path: String, vararg queries: Pair): Url { + return createUrl(ApiRoutes.apiBaseUrlV2(environment), path, queries) + } + + private fun createUrl( + baseUrl: String, + path: String, + queries: Array> + ): Url = URLBuilder(Url(baseUrl + path)).apply { + queries.forEach { parameters.append(it.first, it.second) } + }.build() + + protected fun HeadersBuilder.appendBearer() { + append(HttpHeaders.Authorization, "Bearer ${token()}") } protected suspend inline fun get( @@ -76,6 +93,13 @@ internal open class BaseRequest( }.decode() } + protected suspend inline fun patch(url: Url, data: Any?, httpClient: HttpClient = this.httpClient): R { + return httpClient.patch(url) { + contentType(ContentType.Application.Json) + setBody(data) + }.decode() + } + protected suspend inline fun delete(url: Url, httpClient: HttpClient = this.httpClient): R { return httpClient.delete(url) {}.decode() } diff --git a/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/requests/TransferRequest.kt b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/requests/TransferRequest.kt index 2a3a9b1a..57ea851f 100644 --- a/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/requests/TransferRequest.kt +++ b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/requests/TransferRequest.kt @@ -40,7 +40,7 @@ internal class TransferRequest( environment: ApiEnvironment, json: Json, httpClient: HttpClient, -) : BaseRequest(environment, json, httpClient) { +) : BaseRequest(environment, json, httpClient, token = { "" }) { @OptIn(ExperimentalEncodingApi::class) suspend fun getTransfer(linkUUID: String, password: String? = null): ApiResponse { @@ -48,7 +48,7 @@ internal class TransferRequest( url = createUrl(ApiRoutes.getTransfer(linkUUID)), appendHeaders = { if (password?.isNotEmpty() == true) { - append(HttpHeaders.Authorization, Base64.Default.encode(password.encodeToByteArray())) + append(HttpHeaders.Authorization, Base64.encode(password.encodeToByteArray())) } } ) diff --git a/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/requests/UploadRequest.kt b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/requests/UploadRequest.kt index add5a404..d3168813 100644 --- a/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/requests/UploadRequest.kt +++ b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/requests/UploadRequest.kt @@ -37,7 +37,6 @@ import io.ktor.http.ContentType import io.ktor.http.Url import io.ktor.http.content.OutgoingContent import io.ktor.http.contentType -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject @@ -45,7 +44,7 @@ internal class UploadRequest( environment: ApiEnvironment, json: Json, httpClient: HttpClient, -) : BaseRequest(environment, json, httpClient) { +) : BaseRequest(environment, json, httpClient, token = { "" }) { suspend fun initUpload( initUploadBody: InitUploadBody, diff --git a/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/requests/v2/TransferRequest.kt b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/requests/v2/TransferRequest.kt new file mode 100644 index 00000000..b9fd1d2e --- /dev/null +++ b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/requests/v2/TransferRequest.kt @@ -0,0 +1,71 @@ +/* + * Infomaniak SwissTransfer - Multiplatform + * Copyright (C) 2024 Infomaniak Network SA + * + * 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 . + */ +package com.infomaniak.multiplatform_swisstransfer.network.requests.v2 + +import com.infomaniak.multiplatform_swisstransfer.common.utils.ApiEnvironment +import com.infomaniak.multiplatform_swisstransfer.network.models.ApiResponseV2Success +import com.infomaniak.multiplatform_swisstransfer.network.models.transfer.v2.TransferApi +import com.infomaniak.multiplatform_swisstransfer.network.models.upload.response.v2.PresignedUrlResponse +import com.infomaniak.multiplatform_swisstransfer.network.requests.BaseRequest +import com.infomaniak.multiplatform_swisstransfer.network.utils.ApiRoutes +import io.ktor.client.HttpClient +import io.ktor.client.request.delete +import io.ktor.http.HeadersBuilder +import io.ktor.http.isSuccess +import kotlinx.serialization.json.Json +import kotlin.io.encoding.ExperimentalEncodingApi + +internal class TransferRequest( + environment: ApiEnvironment, + json: Json, + httpClient: HttpClient, + token: () -> String, +) : BaseRequest(environment, json, httpClient, token) { + + suspend fun getTransfer(linkUUID: String, password: String? = null): ApiResponseV2Success { + return get( + url = createV2Url(ApiRoutes.getTransfer(linkUUID)), + appendHeaders = { + appendBearer() + appendPasswordIfNeeded(password) + } + ) + } + + suspend fun presignedDownloadUrl( + linkId: String, + fileId: String, + password: String? + ): ApiResponseV2Success = get( + url = createV2Url(ApiRoutes.presignedDownloadUrl(linkId, fileId)), + appendHeaders = { appendPasswordIfNeeded(password) } + ) + + suspend fun deleteTransfer(transferId: String): Boolean { + val response = httpClient.delete( + url = createV2Url(ApiRoutes.v2DeleteTransfer(transferId)), + ) + return response.status.isSuccess() + } + + private fun HeadersBuilder.appendPasswordIfNeeded(password: String?) { + if (password?.isNotEmpty() == true) { + append("Transfer-Password", password) + } + } +} diff --git a/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/requests/v2/UploadRequest.kt b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/requests/v2/UploadRequest.kt new file mode 100644 index 00000000..78cde2a4 --- /dev/null +++ b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/requests/v2/UploadRequest.kt @@ -0,0 +1,123 @@ +/* + * Infomaniak SwissTransfer - Multiplatform + * Copyright (C) 2024 Infomaniak Network SA + * + * 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 . + */ +package com.infomaniak.multiplatform_swisstransfer.network.requests.v2 + +import com.infomaniak.multiplatform_swisstransfer.common.interfaces.transfers.v2.Transfer +import com.infomaniak.multiplatform_swisstransfer.common.utils.ApiEnvironment +import com.infomaniak.multiplatform_swisstransfer.network.models.ApiResponseV2Success +import com.infomaniak.multiplatform_swisstransfer.network.models.upload.request.v2.ChunkEtag +import com.infomaniak.multiplatform_swisstransfer.network.models.upload.request.v2.CreateTransfer +import com.infomaniak.multiplatform_swisstransfer.network.models.upload.request.v2.UploadTransferStatus +import com.infomaniak.multiplatform_swisstransfer.network.models.upload.response.v2.PresignedUrlResponse +import com.infomaniak.multiplatform_swisstransfer.network.requests.BaseRequest +import com.infomaniak.multiplatform_swisstransfer.network.utils.ApiRoutes +import com.infomaniak.multiplatform_swisstransfer.network.utils.longTimeout +import io.ktor.client.HttpClient +import io.ktor.client.plugins.onUpload +import io.ktor.client.plugins.retry +import io.ktor.client.request.headers +import io.ktor.client.request.patch +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.content.OutgoingContent +import io.ktor.http.contentType +import io.ktor.http.isSuccess +import kotlinx.serialization.json.Json + +internal class UploadRequest( + environment: ApiEnvironment, + json: Json, + httpClient: HttpClient, + token: () -> String, +) : BaseRequest(environment, json, httpClient, token) { + + suspend fun createTransfer(createTransfer: CreateTransfer): ApiResponseV2Success { + val nullableJson = Json(json) { + explicitNulls = false + } + return post( + url = createV2Url(ApiRoutes.createTransfer()), + data = nullableJson.encodeToString(createTransfer), + appendHeaders = { appendBearer() } + ) + } + + suspend fun getPresignedUploadUrl(transferId: String, fileId: String): ApiResponseV2Success { + return post( + url = createV2Url(ApiRoutes.uploadDirectly(transferId, fileId)), + data = null, + ) + } + + suspend fun getPresignedUploadChunkUrl( + transferId: String, + fileId: String, + chunkIndex: Int, + ): ApiResponseV2Success { + return post( + url = createV2Url(ApiRoutes.uploadChunk(transferId, fileId, chunkIndex)), + data = null, + ) + } + + suspend fun finalizeTransferFile(transferId: String, fileId: String, etags: List): Boolean { + val response = httpClient.patch(createV2Url(ApiRoutes.uploadDirectly(transferId, fileId))) { + contentType(ContentType.Application.Json) + setBody(etags) + } + return response.status.isSuccess() + } + + suspend fun updateTransferStatus(transferId: String, status: UploadTransferStatus): Boolean { + val response = httpClient.patch(createV2Url(ApiRoutes.finishTransfer(transferId))) { + headers { appendBearer() } + contentType(ContentType.Application.Json) + setBody(mapOf("status" to status.apiValue)) + } + return response.status.isSuccess() + } + + suspend fun uploadFile( + cephUrl: String, + data: OutgoingContent.WriteChannelContent, + onUpload: suspend (bytesSentTotal: Long, chunkSize: Long) -> Unit, + ) { + httpClient.post(urlString = cephUrl) { + retry { noRetry() } + longTimeout() + setBody(data) + onUpload { bytesSentTotal, contentLength -> onUpload(bytesSentTotal, contentLength ?: 0) } + } + } + + suspend fun uploadChunk( + cephUrl: String, + data: OutgoingContent.WriteChannelContent, + onUpload: suspend (bytesSentTotal: Long, chunkSize: Long) -> Unit, + ): String? { + val response = httpClient.post(urlString = cephUrl) { + retry { noRetry() } + longTimeout() + setBody(data) + onUpload { bytesSentTotal, contentLength -> onUpload(bytesSentTotal, contentLength ?: 0) } + } + + return if (response.status.isSuccess()) response.headers["Etag"] else null + } +} diff --git a/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/utils/ApiRoutes.kt b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/utils/ApiRoutes.kt index d4e926cc..14d0e89c 100644 --- a/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/utils/ApiRoutes.kt +++ b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/utils/ApiRoutes.kt @@ -22,14 +22,17 @@ import com.infomaniak.multiplatform_swisstransfer.common.utils.ApiEnvironment internal object ApiRoutes { fun apiBaseUrl(environment: ApiEnvironment) = "${environment.baseUrl}/api/" + fun apiBaseUrlV2(environment: ApiEnvironment) = "${environment.baseUrlV2}/api/1/" //region Transfer fun getTransfer(linkUUID: String): String = "links/$linkUUID" fun disableLinks(): String = "disableLinks" + fun presignedDownloadUrl(linkId: String, fileId: String) = "links/$linkId/files/$fileId" + fun v2DeleteTransfer(transferId: String) = "transfers/$transferId" fun generateDownloadToken() = "generateDownloadToken" - //endRegion + //endregion //region Upload const val initUpload = "mobile/containers" @@ -37,5 +40,13 @@ internal object ApiRoutes { const val resendEmailCode = "$verifyEmailCode/resend" const val finishUpload = "uploadComplete" const val cancelUpload = "cancelUpload" + + fun createTransfer() = "transfers" + fun uploadDirectly(transferId: String, fileId: String) = "transfers/$transferId/files/$fileId" + fun uploadChunk(transferId: String, fileId: String, chunkIndex: Int): String { + return "${uploadDirectly(transferId, fileId)}/chunks/$chunkIndex" + } + + fun finishTransfer(transferId: String) = "transfers/$transferId" //endregion } diff --git a/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/utils/SharedApiV2Routes.kt b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/utils/SharedApiV2Routes.kt new file mode 100644 index 00000000..da285d9f --- /dev/null +++ b/STNetwork/src/commonMain/kotlin/com/infomaniak/multiplatform_swisstransfer/network/utils/SharedApiV2Routes.kt @@ -0,0 +1,25 @@ +/* + * Infomaniak SwissTransfer - Multiplatform + * Copyright (C) 2024 Infomaniak Network SA + * + * 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 . + */ +package com.infomaniak.multiplatform_swisstransfer.network.utils + +import com.infomaniak.multiplatform_swisstransfer.common.utils.ApiEnvironment + +object SharedApiV2Routes { + + fun shareTransfer(environment: ApiEnvironment, linkUUID: String): String = "${environment.baseUrlV2}/d/$linkUUID" +} diff --git a/STNetwork/src/commonTest/kotlin/com/infomaniak/multiplatform_swisstransfer/network/ApiClientProviderTest.kt b/STNetwork/src/commonTest/kotlin/com/infomaniak/multiplatform_swisstransfer/network/ApiClientProviderTest.kt index 2ed26384..0d886fca 100644 --- a/STNetwork/src/commonTest/kotlin/com/infomaniak/multiplatform_swisstransfer/network/ApiClientProviderTest.kt +++ b/STNetwork/src/commonTest/kotlin/com/infomaniak/multiplatform_swisstransfer/network/ApiClientProviderTest.kt @@ -18,6 +18,7 @@ package com.infomaniak.multiplatform_swisstransfer.network import com.infomaniak.multiplatform_swisstransfer.network.exceptions.ApiException +import com.infomaniak.multiplatform_swisstransfer.network.exceptions.NetworkException import com.infomaniak.multiplatform_swisstransfer.network.models.ApiError import com.infomaniak.multiplatform_swisstransfer.network.utils.CONTENT_REQUEST_ID_HEADER import com.infomaniak.multiplatform_swisstransfer.network.utils.decode @@ -31,10 +32,12 @@ import io.ktor.http.HttpStatusCode import io.ktor.http.Url import io.ktor.http.contentType import io.ktor.http.headersOf +import io.ktor.util.network.UnresolvedAddressException import kotlinx.coroutines.runBlocking -import kotlinx.serialization.encodeToString +import kotlinx.io.IOException import kotlinx.serialization.json.Json import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.expect class ApiClientProviderTest { @@ -73,6 +76,42 @@ class ApiClientProviderTest { } } + @Test + fun unresolvedAddressExceptionConvertedToNetworkException() { + val apiClientProvider = ApiClientProvider( + createFailingEngine(UnresolvedAddressException()), + userAgent = TEST_USER_AGENT + ) + + val result = runCatching { + runBlocking { + post(apiClientProvider = apiClientProvider, url = Url("http://invalid-hostname.com"), data = null) + } + } + + val exception = result.exceptionOrNull() + assertEquals(NetworkException::class, exception!!::class) + assertEquals(UnresolvedAddressException::class, exception.cause!!::class) + } + + @Test + fun ioExceptionConvertedToNetworkException() { + val apiClientProvider = ApiClientProvider( + createFailingEngine(IOException("Connection refused")), + userAgent = TEST_USER_AGENT + ) + + val result = runCatching { + runBlocking { + post(apiClientProvider = apiClientProvider, url = Url("http://localhost:8080"), data = null) + } + } + + val exception = result.exceptionOrNull() + assertEquals(NetworkException::class, exception!!::class) + assertEquals(IOException::class, exception.cause!!::class) + } + private suspend inline fun post(apiClientProvider: ApiClientProvider, url: Url, data: Any?): R { return apiClientProvider.httpClient.post(url) { contentType(ContentType.Application.Json) @@ -91,6 +130,10 @@ class ApiClientProviderTest { ) } + private fun createFailingEngine(throwable: Throwable) = MockEngine { _ -> + throw throwable + } + companion object { private const val TEST_USER_AGENT = "Ktor client test" diff --git a/STNetwork/src/commonTest/kotlin/com/infomaniak/multiplatform_swisstransfer/network/TransferV2RepositoryTest.kt b/STNetwork/src/commonTest/kotlin/com/infomaniak/multiplatform_swisstransfer/network/TransferV2RepositoryTest.kt new file mode 100644 index 00000000..0f837875 --- /dev/null +++ b/STNetwork/src/commonTest/kotlin/com/infomaniak/multiplatform_swisstransfer/network/TransferV2RepositoryTest.kt @@ -0,0 +1,99 @@ +/* + * Infomaniak SwissTransfer - Multiplatform + * Copyright (C) 2024 Infomaniak Network SA + * + * 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 . + */ +package com.infomaniak.multiplatform_swisstransfer.network + +import com.infomaniak.multiplatform_swisstransfer.common.utils.ApiEnvironment +import com.infomaniak.multiplatform_swisstransfer.network.models.ApiResponseV2Success +import com.infomaniak.multiplatform_swisstransfer.network.models.transfer.v2.TransferApi +import com.infomaniak.multiplatform_swisstransfer.network.repositories.TransferV2Repository +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.MissingFieldException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class TransferV2RepositoryTest { + + private val apiClientProvider = ApiClientProvider() + private val transferRepository = TransferV2Repository( + apiClientProvider = apiClientProvider, + environment = ApiEnvironment.Preprod, + token = { "dummy_token" } + ) // TODO: Use mock client + + @Test + fun canExtractLinkUUIDFromUrl() { + val url = "https://www.swisstransfer.com/d/fa7d299d-1001-4668-83a4-2a9b61aa59e8" + val result = transferRepository.extractUUID(url) + assertEquals("fa7d299d-1001-4668-83a4-2a9b61aa59e8", result) + } + + @Test + fun canParseEmptyMimeTypeToNull() { + val data = """ + { + "result": "success", + "data": { + "id": "c96350da-4d8c-4c43-aa6a-40f2d2b31eac", + "sender_email": "example@ik.me", + "title": "Coucou", + "message": "Voici ce que tu m'as demandé", + "created_at": 1771324513, + "expires_at": 1771324513, + "files": [ + { + "id": "2e085789-43cb-48f7-ab1f-b7322fbf5367", + "path": "app-release (1).aab", + "size": 26070874, + "mimeType": "" + } + ], + "total_size": 26070874 + } + } + """.trimIndent() + + val responseData = apiClientProvider.json.decodeFromString>(data).data + assertNotNull(responseData) + + val file = responseData.files.firstOrNull() + assertNotNull(file) + + assertNull(file.mimeType) + } + + @OptIn(ExperimentalSerializationApi::class) + @Test + fun throwWhenApiResponseWithError() { + val data = """ + { + "result": "success", + "data": { + "type": "need_password", + "message": "Transfer need a password" + } + } + """.trimIndent() + + assertFailsWith(MissingFieldException::class) { + apiClientProvider.json.decodeFromString>(data) + } + } +}