diff --git a/Auth/build.gradle.kts b/Auth/build.gradle.kts index 4ed24df4d..1e4e96cfb 100644 --- a/Auth/build.gradle.kts +++ b/Auth/build.gradle.kts @@ -45,6 +45,7 @@ android { } dependencies { + implementation(project(":Core")) implementation(project(":Core:Network")) implementation(project(":Core:Sentry")) diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/api/ApiRepositoryCore.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/api/ApiRepositoryCore.kt index eec678830..65b83da03 100644 --- a/Auth/src/main/kotlin/com/infomaniak/core/auth/api/ApiRepositoryCore.kt +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/api/ApiRepositoryCore.kt @@ -29,14 +29,23 @@ abstract class ApiRepositoryCore { withEmails: Boolean = false, withPhones: Boolean = false, withSecurity: Boolean = false, - ): ApiResponse { - var with = "" - if (withEmails) with += "emails" - if (withPhones) with += "phones" - if (withSecurity) with += "security" - if (with.isNotEmpty()) with = "?with=$with" + ): ApiResponse = ApiRepositoryCore.getUserProfile(okHttpClient, withEmails, withPhones, withSecurity) - val url = "${ApiRoutesCore.getUserProfile()}$with" - return ApiController.callApi(url, ApiController.ApiMethod.GET, okHttpClient = okHttpClient) + companion object { + suspend fun getUserProfile( + okHttpClient: OkHttpClient, + withEmails: Boolean = false, + withPhones: Boolean = false, + withSecurity: Boolean = false, + ): ApiResponse { + var with = "" + if (withEmails) with += "emails" + if (withPhones) with += "phones" + if (withSecurity) with += "security" + if (with.isNotEmpty()) with = "?with=$with" + + val url = "${ApiRoutesCore.getUserProfile()}$with" + return ApiController.callApi(url, ApiController.ApiMethod.GET, okHttpClient = okHttpClient) + } } } diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/models/AuthCodeResult.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/models/AuthCodeResult.kt new file mode 100644 index 000000000..8b8b324c1 --- /dev/null +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/models/AuthCodeResult.kt @@ -0,0 +1,24 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2025 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.core.auth.models + +internal sealed interface AuthCodeResult { + data class Success(val code: String) : AuthCodeResult + data class Error(val message: String) : AuthCodeResult + data object Canceled : AuthCodeResult +} diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/models/UserLoginResult.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/models/UserLoginResult.kt new file mode 100644 index 000000000..59d75cbd7 --- /dev/null +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/models/UserLoginResult.kt @@ -0,0 +1,25 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2025 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.core.auth.models + +import com.infomaniak.core.auth.models.user.User + +sealed interface UserLoginResult { + data class Success(val user: User) : UserLoginResult + data class Failure(val errorMessage: String) : UserLoginResult +} diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/models/UserResult.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/models/UserResult.kt new file mode 100644 index 000000000..a9f29390d --- /dev/null +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/models/UserResult.kt @@ -0,0 +1,36 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2025 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.core.auth.models + +import com.infomaniak.core.auth.models.user.User +import com.infomaniak.core.network.api.ApiController.toApiError +import com.infomaniak.core.network.api.InternalTranslatedErrorCode +import com.infomaniak.core.network.models.ApiResponse +import com.infomaniak.core.network.models.ApiResponseStatus + +internal sealed interface UserResult { + data class Success(val user: User) : UserResult + open class Failure(val apiResponse: ApiResponse<*>) : UserResult { + data object Unknown : Failure( + ApiResponse( + result = ApiResponseStatus.ERROR, + error = InternalTranslatedErrorCode.UnknownError.toApiError() + ) + ) + } +} diff --git a/Auth/src/main/kotlin/com/infomaniak/core/auth/utils/LoginUtils.kt b/Auth/src/main/kotlin/com/infomaniak/core/auth/utils/LoginUtils.kt new file mode 100644 index 000000000..0be8ea8ae --- /dev/null +++ b/Auth/src/main/kotlin/com/infomaniak/core/auth/utils/LoginUtils.kt @@ -0,0 +1,154 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2025 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.core.auth.utils + +import android.content.Context +import androidx.activity.result.ActivityResult +import androidx.appcompat.app.AppCompatActivity +import com.infomaniak.core.auth.CredentialManager +import com.infomaniak.core.auth.TokenAuthenticator.Companion.changeAccessToken +import com.infomaniak.core.auth.api.ApiRepositoryCore +import com.infomaniak.core.auth.models.AuthCodeResult +import com.infomaniak.core.auth.models.UserLoginResult +import com.infomaniak.core.auth.models.UserResult +import com.infomaniak.core.auth.utils.LoginUtils.getLoginResultAfterWebView +import com.infomaniak.core.auth.utils.LoginUtils.getLoginResultsAfterCrossApp +import com.infomaniak.core.cancellable +import com.infomaniak.core.network.api.ApiController.toApiError +import com.infomaniak.core.network.api.InternalTranslatedErrorCode +import com.infomaniak.core.network.models.ApiResponse +import com.infomaniak.core.network.models.ApiResponseStatus +import com.infomaniak.core.network.networking.HttpClient +import com.infomaniak.core.network.utils.ApiErrorCode.Companion.translateError +import com.infomaniak.lib.login.ApiToken +import com.infomaniak.lib.login.InfomaniakLogin +import com.infomaniak.lib.login.InfomaniakLogin.ErrorStatus +import com.infomaniak.lib.login.InfomaniakLogin.TokenResult + +object LoginUtils { + /** + * Logs the user based on the ActivityResult of our login WebView. + * + * @return a [UserLoginResult] or null if the user canceled the login webview without going through to the end + */ + suspend fun getLoginResultAfterWebView( + result: ActivityResult, + context: Context, + infomaniakLogin: InfomaniakLogin, + credentialManager: CredentialManager, + ): UserLoginResult? { + val authCodeResult = result.toAuthCodeResult(context) + when (authCodeResult) { + is AuthCodeResult.Error -> return UserLoginResult.Failure(authCodeResult.message) + is AuthCodeResult.Canceled -> return null + is AuthCodeResult.Success -> Unit + } + + val tokenResult = infomaniakLogin.getToken(okHttpClient = HttpClient.okHttpClient, code = authCodeResult.code) + when (tokenResult) { + is TokenResult.Error -> return UserLoginResult.Failure(context.formatAuthErrorMessage(tokenResult.errorStatus)) + is TokenResult.Success -> Unit + } + + val userResult = getUsersByToken(listOf(tokenResult.apiToken), credentialManager).single() + return when (userResult) { + is UserResult.Failure -> { + UserLoginResult.Failure(context.getString(userResult.apiResponse.translateError())) + } + is UserResult.Success -> UserLoginResult.Success(userResult.user) + } + } + + /** + * Logs the user based on the [ApiToken]s returned by the cross app login logic. + * + * @return a [UserLoginResult] or null if the user canceled the login webview without going through to the end + */ + suspend fun getLoginResultsAfterCrossApp( + apiTokens: List, + context: Context, + credentialManager: CredentialManager, + ): List = getUsersByToken(apiTokens, credentialManager).map { result -> + when (result) { + is UserResult.Success -> UserLoginResult.Success(result.user) + is UserResult.Failure -> UserLoginResult.Failure(context.getString(result.apiResponse.translateError())) + } + } +} + +/** + * This method is the basis on which [getLoginResultAfterWebView] and its alternative [getLoginResultsAfterCrossApp] are built. + */ +private suspend fun getUsersByToken( + apiTokens: List, + credentialManager: CredentialManager, +): List = apiTokens.map { apiToken -> + runCatching { + authenticateUser(apiToken, credentialManager) + }.cancellable().getOrDefault(UserResult.Failure.Unknown) +} + +private fun ActivityResult.toAuthCodeResult(context: Context): AuthCodeResult { + if (resultCode != AppCompatActivity.RESULT_OK) return AuthCodeResult.Canceled + + val authCode = data?.getStringExtra(InfomaniakLogin.CODE_TAG) + val translatedError = data?.getStringExtra(InfomaniakLogin.ERROR_TRANSLATED_TAG) + + return when { + translatedError?.isNotBlank() == true -> AuthCodeResult.Error(translatedError) + authCode?.isNotBlank() == true -> AuthCodeResult.Success(authCode) + else -> AuthCodeResult.Error(context.getString(InternalTranslatedErrorCode.UnknownError.translateRes)) + } +} + +private fun Context.formatAuthErrorMessage(errorStatus: ErrorStatus): String { + return getString( + when (errorStatus) { + ErrorStatus.SERVER -> InternalTranslatedErrorCode.ServerError + ErrorStatus.CONNECTION -> InternalTranslatedErrorCode.ConnectionError + else -> InternalTranslatedErrorCode.UnknownError + }.translateRes + ) +} + +private suspend fun authenticateUser(apiToken: ApiToken, credentialManager: CredentialManager): UserResult { + if (credentialManager.getUserById(apiToken.userId) != null) return UserResult.Failure( + getErrorResponse(InternalTranslatedErrorCode.UserAlreadyPresent) + ) + + val okhttpClient = HttpClient.okHttpClient.newBuilder().addInterceptor { chain -> + val newRequest = changeAccessToken(chain.request(), apiToken) + chain.proceed(newRequest) + }.build() + + val userProfileResponse = ApiRepositoryCore.getUserProfile(okhttpClient) + + if (userProfileResponse.result == ApiResponseStatus.ERROR) return UserResult.Failure(userProfileResponse) + val userData = userProfileResponse.data ?: return UserResult.Failure.Unknown + + val user = userData.apply { + this.apiToken = apiToken + this.organizations = arrayListOf() + } + + return UserResult.Success(user) +} + +private fun getErrorResponse(error: InternalTranslatedErrorCode): ApiResponse { + return ApiResponse(result = ApiResponseStatus.ERROR, error = error.toApiError()) +} diff --git a/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/BaseCrossAppLoginViewModel.kt b/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/BaseCrossAppLoginViewModel.kt index 140d02a17..e890e882b 100644 --- a/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/BaseCrossAppLoginViewModel.kt +++ b/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/BaseCrossAppLoginViewModel.kt @@ -98,8 +98,6 @@ abstract class BaseCrossAppLoginViewModel(applicationId: String, clientId: Strin createElement = { account -> async { getFirstValidTokenOrError(account) } }, ) - private val apiRepository = object : ApiRepositoryCore() {} - private val baseOkHttpClient by lazy { OkHttpClient.Builder().apply { addCache() @@ -191,7 +189,7 @@ abstract class BaseCrossAppLoginViewModel(applicationId: String, clientId: Strin val customTokenHttpClient = baseOkHttpClient.addInterceptor(CustomTokenInterceptor(token)).build() - when (apiRepository.getUserProfile(customTokenHttpClient).data) { + when (ApiRepositoryCore.getUserProfile(customTokenHttpClient).data) { is User -> { // Put the just checked token first. Will speed up later derivation attempts. val tokens = setOf(token) + account.tokens diff --git a/Network/src/main/kotlin/com/infomaniak/core/network/api/InternalTranslatedErrorCode.kt b/Network/src/main/kotlin/com/infomaniak/core/network/api/InternalTranslatedErrorCode.kt index 2687bb92d..8f89020ee 100644 --- a/Network/src/main/kotlin/com/infomaniak/core/network/api/InternalTranslatedErrorCode.kt +++ b/Network/src/main/kotlin/com/infomaniak/core/network/api/InternalTranslatedErrorCode.kt @@ -27,6 +27,7 @@ enum class InternalTranslatedErrorCode( ) : ErrorCodeTranslated { NoConnection("no_connection", R.string.noConnection), ConnectionError("connection_error", R.string.connectionError), + ServerError("server_error", R.string.serverError), UnknownError("an_error_has_occurred", R.string.anErrorHasOccurred), UserLoggedOut("user_logged_out", R.string.anErrorHasOccurred), // We don't display this error UserAlreadyPresent("user_already_present", R.string.errorUserAlreadyPresent), diff --git a/Network/src/main/res/values-de/strings.xml b/Network/src/main/res/values-de/strings.xml index 3f1a8abe5..489d3b0ec 100644 --- a/Network/src/main/res/values-de/strings.xml +++ b/Network/src/main/res/values-de/strings.xml @@ -20,4 +20,5 @@ Problem mit der Internetverbindung Fehler Benutzer bereits vorhanden Keine Verbindung + Server-Fehler diff --git a/Network/src/main/res/values-es/strings.xml b/Network/src/main/res/values-es/strings.xml index e8fce8a98..fbe2e6c2a 100644 --- a/Network/src/main/res/values-es/strings.xml +++ b/Network/src/main/res/values-es/strings.xml @@ -20,4 +20,5 @@ Problema de conexión a Internet Error, usuario ya registrado Sin conexión + Error del servidor diff --git a/Network/src/main/res/values-fr/strings.xml b/Network/src/main/res/values-fr/strings.xml index cd8e99548..d974bffb6 100644 --- a/Network/src/main/res/values-fr/strings.xml +++ b/Network/src/main/res/values-fr/strings.xml @@ -20,4 +20,5 @@ Problème de connexion internet Erreur utilisateur déjà présent Pas de connexion + Erreur serveur diff --git a/Network/src/main/res/values-it/strings.xml b/Network/src/main/res/values-it/strings.xml index b163c2561..0f485d89d 100644 --- a/Network/src/main/res/values-it/strings.xml +++ b/Network/src/main/res/values-it/strings.xml @@ -20,4 +20,5 @@ Problema di connessione a Internet Errore utente già esistente Nessuna connessione + Errore del server diff --git a/Network/src/main/res/values/strings.xml b/Network/src/main/res/values/strings.xml index 46bfc3695..90890d855 100644 --- a/Network/src/main/res/values/strings.xml +++ b/Network/src/main/res/values/strings.xml @@ -18,6 +18,7 @@ An error has occurred Internet connection problem - Error, User already present + Error, user already present No connection + Server Error