Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
4d2c9d7
chore: Add File and Transfer shared interfaces
sirambd Jan 21, 2026
497e708
chore: Add v2 file and transfer models
sirambd Jan 26, 2026
8cc4a1c
chore: Add ApiV2ErrorException to handle api v2 errors
sirambd Jan 26, 2026
b94c55a
chore: Add local thumbnail path in File common model
sirambd Jan 26, 2026
ddde307
chore: Rename linkUUID into id
sirambd Jan 26, 2026
255a586
chore: Update ApiEnvironment to support apiV2
sirambd Jan 26, 2026
42f2fd1
chore: Add isAuth method in AccountManager
sirambd Jan 26, 2026
1ad75f6
chore: Add Transfer routes for ApiV2
sirambd Jan 26, 2026
b535054
chore: Update the Base Request to support apiV2 calls
sirambd Jan 26, 2026
afa66e2
chore: Add missing apiV2 errors exceptions
sirambd Jan 26, 2026
b187c78
chore: Add apiV2 TransferRepository
sirambd Jan 26, 2026
03890bd
chore: Update api v1 TransferRequest
sirambd Jan 26, 2026
e6ba985
chore: Add ApiResponse for error
sirambd Jan 26, 2026
11ac6f1
chore: Update ApiClient to support apiV2
sirambd Jan 26, 2026
a0864ce
chore: Remove useless code
sirambd Jan 26, 2026
ab90d8d
chore: Handle DNS errors
sirambd Jan 26, 2026
107d30b
chore: Rename isNetworkException into isRetryableNetworkException
sirambd Jan 26, 2026
1ab2ab6
chore: Add missing api-v2 exceptions
sirambd Jan 27, 2026
8be9a16
chore: Re-throw api exceptions
sirambd Jan 27, 2026
4641eb2
chore: Add new unit tests
sirambd Jan 27, 2026
e80c764
chore: Remove useless isAuth function
sirambd Jan 30, 2026
8157432
chore: Add api v2 exceptions classes
sirambd Feb 3, 2026
706aae2
chore: Add apiv2 urls
sirambd Feb 3, 2026
8176b28
chore: Add SharedApiV2Routes
sirambd Feb 3, 2026
d07c311
chore: Add TooManyRequestException and UnauthorizedException
sirambd Feb 3, 2026
8f3052b
chore: Add api v2 request and response models
sirambd Feb 3, 2026
5dcca76
chore: Add api v2 UploadRequest
sirambd Feb 3, 2026
90cbe7f
chore: New UploadV2Repository for apiv2
sirambd Feb 3, 2026
d9d54b5
chore: Support token in TransferRequest
sirambd Feb 3, 2026
9037ab7
chore: Rewrite in pascal case
sirambd Feb 4, 2026
96f613d
chore: Add isFolder in File
sirambd Feb 4, 2026
348f113
chore: Handle bearer token from BaseRequest
sirambd Feb 4, 2026
47393a5
chore: Add default token for api v1
sirambd Feb 6, 2026
831972f
chore: Replace potentially confusing return keyword with expression body
LouisCAD Feb 10, 2026
cb0fd27
chore: Extract function to avoid duplicated code
LouisCAD Feb 10, 2026
f32f94f
fix: Use the correct type in Throws declaration
LouisCAD Feb 10, 2026
2269d63
fix: Remove extra slash in url
LouisCAD Feb 10, 2026
f4db38a
fix: Fix type of presignedDownloadUrl
LouisCAD Feb 10, 2026
4968620
fix: Declare DownloadLimitReached in throws on the delete function
LouisCAD Feb 10, 2026
fe0e617
chore: Remove unneeded ByteArray taking overload of uploadChunkToEtag
LouisCAD Feb 11, 2026
a9081e5
chore: Remove unneeded import
LouisCAD Feb 11, 2026
ebf8622
fix: Remove extra leading slash
LouisCAD Feb 11, 2026
c386361
chore: Remove unneeded ByteArray taking overload of uploadChunk
LouisCAD Feb 11, 2026
b096791
fix: Add missing serializable annotation
LouisCAD Feb 11, 2026
dcea8d4
chore: Extract ApiResponseForError from ApiResponse file
LouisCAD Feb 11, 2026
eb2a598
fix: Fix case of endregion comment to have the IDE recognize it
LouisCAD Feb 11, 2026
8797403
chore: Introduce ApiResponseV2Success
LouisCAD Feb 11, 2026
a90952a
refactor: Stop exposing ApiResponseV2Success in repositories
LouisCAD Feb 11, 2026
ef71403
chore: Forward requestContextId
LouisCAD Feb 12, 2026
e79dc46
fix: Fix throws declarations and make their order consistent
LouisCAD Feb 12, 2026
89864a5
chore: Lower visibility of ApiResponseV2Success to internal
LouisCAD Feb 12, 2026
f4cc0b8
chore: Remove unneeded opt-in annotation
LouisCAD Feb 12, 2026
fedf2c9
fix: Replace body with decode (was likely a mistake)
LouisCAD Feb 12, 2026
caa490f
chore: Forward cause into UnexpectedApiErrorFormatException
LouisCAD Feb 12, 2026
eb2bd56
chore: Lower visibility of ApiResponseForError to internal
LouisCAD Feb 12, 2026
19151e9
test: Add TransferV2RepositoryTest
LouisCAD Feb 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/
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("/")
}
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<File> 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<String> get() = emptySet()
//endregion
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -88,7 +91,7 @@ class ApiClientProvider internal constructor(
}
install(HttpRequestRetry) {
retryOnExceptionIf(maxRetries = MAX_RETRY) { _, cause ->
cause.isNetworkException()
cause.isRetryableNetworkException()
}
delayMillis { retry ->
retry * 500L
Expand All @@ -103,17 +106,24 @@ class ApiClientProvider internal constructor(

if (statusCode >= 300) {
val bodyResponse = response.bodyAsText()
val apiError = runCatching {
json.decodeFromString<ApiError>(bodyResponse)
}.getOrElse {
throw UnexpectedApiErrorFormatException(statusCode, bodyResponse, null, requestContextId)
runCatching {
if (bodyResponse.isFromApiV2()) {
val error = json.decodeFromString<ApiResponseForError>(bodyResponse).error
throw ApiV2ErrorException(error.code, error.description, requestContextId)
} else {
val error = json.decodeFromString<ApiError>(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()
Expand Down Expand Up @@ -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\"")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/
package com.infomaniak.multiplatform_swisstransfer.network.exceptions

class TooManyRequestException(requestContextId: String) : ApiException(
errorMessage = "",
cause = null,
requestContextId = requestContextId
)
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/
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
)
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/
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
}
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/
package com.infomaniak.multiplatform_swisstransfer.network.models

import kotlinx.serialization.Serializable

@Serializable
data class ApiErrorV2(
val code: String,
val description: String,
)
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/
package com.infomaniak.multiplatform_swisstransfer.network.models

import kotlinx.serialization.Serializable

@Serializable
internal data class ApiResponseForError(
val responseStatus: ApiResponseStatus = ApiResponseStatus.ERROR,
val error: ApiErrorV2
)
Loading