Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion Core
8 changes: 8 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
kotlin = "2.3.0"
kotlinxCollectionsImmutable = "0.4.0"
ksp = "2.3.4"
ktor = "3.3.0"
lifecycleViewmodelNav3 = "2.10.0"
nav3Core = "1.0.0"
room = "2.8.4"
Expand All @@ -16,6 +17,13 @@ androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref =
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" }
kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" }
ktor-client-content-negociation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-encoding = { module = "io.ktor:ktor-client-encoding", version.ref = "ktor" }
ktor-client-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }

infomaniak-core-auth = { module = "com.infomaniak.core:Auth" }
infomaniak-core-common = { module = "com.infomaniak.core:Common" }
Expand Down
12 changes: 12 additions & 0 deletions multiplatform-lib/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -52,20 +52,32 @@ kotlin {
commonMain {
dependencies {
implementation(core.kotlinx.coroutines.core)
implementation(core.kotlinx.serialization.cbor)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negociation)
implementation(libs.ktor.client.json)
implementation(libs.ktor.client.encoding)
}
}
commonTest {
dependencies {
implementation(kotlin("test"))
implementation(core.kotlinx.coroutines.test)
implementation(libs.ktor.client.mock)
}
}
androidMain {
dependencies {
implementation(libs.ktor.client.okhttp)
implementation(core.splitties.appctx)
implementation(core.splitties.bitflags)
}
}
iosMain {
dependencies {
implementation(libs.ktor.client.darwin)
}
}
val androidDeviceTest by getting {
dependencies {
implementation(core.androidx.junit)
Expand Down
50 changes: 50 additions & 0 deletions multiplatform-lib/src/commonMain/kotlin/AuthenticatorInjection.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* Infomaniak Authenticator - Android
* 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/>.
*/
import com.infomaniak.auth.lib.network.ApiClientProvider
import com.infomaniak.auth.lib.network.interfaces.CrashReportInterface
import network.repositories.WebAuthnRepository
import network.utils.ApiEnvironment

/*
Infomaniak Authenticator - Android
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/>.
*/

class AuthenticatorInjection(
private val environment: ApiEnvironment,
private val userAgent: String,
private val databaseRootDirectory: String? = null,
private val crashReport: CrashReportInterface,
) {
private val apiClientProvider by lazy { ApiClientProvider(userAgent, crashReport) }

private val webAuthnRepository by lazy { WebAuthnRepository(apiClientProvider, environment) }
}
164 changes: 164 additions & 0 deletions multiplatform-lib/src/commonMain/kotlin/network/ApiClientProvider.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/*
* Infomaniak Authenticator - Android
* 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.auth.lib.network

import com.infomaniak.auth.lib.network.interfaces.BreadcrumbType
import com.infomaniak.auth.lib.network.interfaces.CrashReportInterface
import com.infomaniak.auth.lib.network.interfaces.CrashReportLevel
import com.infomaniak.auth.lib.network.models.ApiError
import com.infomaniak.auth.lib.network.utils.getRequestContextId
import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.plugins.HttpRequestRetry
import io.ktor.client.plugins.HttpResponseValidator
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.UserAgent
import io.ktor.client.plugins.compression.ContentEncoding
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.statement.HttpResponse
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.utils.io.CancellationException
import kotlinx.io.IOException
import kotlinx.serialization.json.Json
import network.exceptions.ApiException
import network.exceptions.ApiException.ApiErrorException
import network.exceptions.ApiException.UnexpectedApiErrorFormatException
import network.exceptions.NetworkException
import kotlin.time.Duration.Companion.seconds

class ApiClientProvider internal constructor(
engine: HttpClientEngine? = null,
// When you don't use AuthenticatorInjection, you don't have an userAgent, so we're currently setting a default value.
// See later how to improve it.
private val userAgent: String = "Ktor client",
private val crashReport: CrashReportInterface? = null,
) {

constructor() : this(null)
constructor(userAgent: String, crashReport: CrashReportInterface) : this(
engine = null,
userAgent = userAgent,
crashReport = crashReport,
)

val json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
isLenient = true
useAlternativeNames = false
}

val httpClient = createHttpClient(engine)

fun createHttpClient(engine: HttpClientEngine?): HttpClient {
val block: HttpClientConfig<*>.() -> Unit = {
install(UserAgent) {
agent = userAgent
}
install(ContentNegotiation) {
json(this@ApiClientProvider.json)
}
install(ContentEncoding) {
gzip()
}
install(HttpTimeout) {
// Each value can be fine-tuned independently, hence the value not being shared.
requestTimeoutMillis = 10.seconds.inWholeMilliseconds
connectTimeoutMillis = 10.seconds.inWholeMilliseconds
socketTimeoutMillis = 10.seconds.inWholeMilliseconds
}
install(HttpRequestRetry) {
retryOnExceptionIf(maxRetries = MAX_RETRY) { _, cause ->
cause.isNetworkException()
}
delayMillis { retry ->
retry * 500L
}
}
HttpResponseValidator {
validateResponse { response: HttpResponse ->
val requestContextId = response.getRequestContextId()
val statusCode = response.status.value

addSentryUrlBreadcrumb(response, statusCode, requestContextId)

if (statusCode >= 300) {
val bodyResponse = response.bodyAsText()
val apiError = runCatching {
json.decodeFromString<ApiError>(bodyResponse)
}.getOrElse {
throw UnexpectedApiErrorFormatException(statusCode, bodyResponse, null, requestContextId)
}
throw ApiErrorException(apiError.errorCode, apiError.message, requestContextId)
}
}
handleResponseExceptionWithRequest { cause, request ->
when (cause) {
is IOException -> throw NetworkException("Network error: ${cause.message}")
is ApiException, is CancellationException -> throw cause
else -> {
val response = runCatching { request.call.response }.getOrNull()
val requestContextId = response?.getRequestContextId() ?: ""
val bodyResponse = response?.bodyAsText() ?: cause.message ?: ""
val statusCode = response?.status?.value ?: -1
throw UnexpectedApiErrorFormatException(
statusCode,
bodyResponse,
cause,
requestContextId
)
}
}
}
}
}

return if (engine != null) HttpClient(engine, block) else HttpClient(block)
}

private fun addSentryUrlBreadcrumb(response: HttpResponse, statusCode: Int, requestContextId: String) {
val requestUrl = response.request.url
val data = buildMap {
put("url", "${requestUrl.protocol.name}://${requestUrl.host}${requestUrl.encodedPath}")
put("method", response.request.method.value)
put("status_code", "$statusCode")
if (requestUrl.encodedQuery.isNotEmpty()) put("http.query", requestUrl.encodedQuery)
put("request_id", requestContextId)
put("http.start_timestamp", "${response.requestTime.timestamp}")
put("http.end_timestamp", "${response.responseTime.timestamp}")
response.contentLength()?.let { put("response_content_length", "$it") }
}
crashReport?.addBreadcrumb(
message = "",
category = "http",
level = CrashReportLevel.INFO,
type = BreadcrumbType.HTTP,
data = data
)
}

private fun Throwable.isNetworkException() = this is IOException

companion object {
private const val MAX_RETRY = 3
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*
* Infomaniak Authenticator - Android
* 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 network.exceptions

/**
* Parent class of API calls exception.
*
* This exception is used to represent errors returned by an API, with an associated [requestContextId]
* and message describing the problem.
*
* @param errorMessage The detailed error message explaining the cause of the failure.
* @param cause The cause of the exception if exists otherwise null
* @property requestContextId The request context id used to track what happened during calls session by the backend
*/
sealed class ApiException(
errorMessage: String,
cause: Throwable?,
val requestContextId: String,
) : Exception(errorMessage, cause) {

/**
* Thrown when an API 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 errorCode 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 ApiErrorException(
val errorCode: Int,
val errorMessage: String,
requestContextId: String,
) : ApiException(errorMessage, null, requestContextId)

/**
* Thrown when an API call returns an error in an unexpected format that cannot be parsed.
*
* This exception indicates that the API response format is different from what was expected,
* preventing proper parsing of the error details.
*
* @property statusCode The HTTP status code returned by the API.
* @property bodyResponse The raw response body from the API that could not be parsed.
* @param cause The cause of the exception if exists otherwise null
* @param requestContextId The request context id send by the backend to track the call
*/
class UnexpectedApiErrorFormatException(
val statusCode: Int,
val bodyResponse: String,
cause: Throwable?,
requestContextId: String,
) : ApiException(bodyResponse, cause, requestContextId)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* Infomaniak Authenticator - Android
* 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 network.exceptions

/**
* Thrown when a network-related error occurs, such as connectivity issues or timeouts.
*
* @param message A detailed message describing the network error.
*/
class NetworkException(message: String) : Exception(message)
Loading