diff --git a/Core2/AppIntegrity/build.gradle.kts b/Core2/AppIntegrity/build.gradle.kts new file mode 100644 index 000000000..d69c63c09 --- /dev/null +++ b/Core2/AppIntegrity/build.gradle.kts @@ -0,0 +1,62 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + kotlin("plugin.serialization") version libs.versions.kotlin +} + +val sharedCompileSdk: Int by rootProject.extra +val sharedMinSdk: Int by rootProject.extra +val sharedJavaVersion: JavaVersion by rootProject.extra + +android { + namespace = "com.infomaniak.core2.appintegrity" + compileSdk = sharedCompileSdk + + defaultConfig { + minSdk = sharedMinSdk + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + buildConfigField("String", "APP_INTEGRITY_BASE_URL", "\"https://api.infomaniak.com\"") + } + + debug { + buildConfigField("String", "APP_INTEGRITY_BASE_URL", "\"https://api.preprod.dev.infomaniak.ch\"") + } + } + + buildFeatures { + buildConfig = true + } + + compileOptions { + sourceCompatibility = sharedJavaVersion + targetCompatibility = sharedJavaVersion + } + + kotlinOptions { + jvmTarget = sharedJavaVersion.toString() + } +} + +dependencies { + + implementation(project(":Core2:Sentry")) + + implementation(core2.integrity) + implementation(core2.ktor.client.core) + implementation(core2.ktor.client.content.negociation) + implementation(core2.ktor.client.json) + implementation(core2.ktor.client.encoding) + implementation(core2.ktor.client.okhttp) + implementation(core2.kotlinx.serialization.json) + testImplementation(core2.junit) + testImplementation(core2.ktor.client.mock) + androidTestImplementation(core2.androidx.junit) +} diff --git a/Core2/AppIntegrity/consumer-rules.pro b/Core2/AppIntegrity/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/Core2/AppIntegrity/proguard-rules.pro b/Core2/AppIntegrity/proguard-rules.pro new file mode 100644 index 000000000..f1b424510 --- /dev/null +++ b/Core2/AppIntegrity/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/ApiClientProvider.kt b/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/ApiClientProvider.kt new file mode 100644 index 000000000..85489dbe5 --- /dev/null +++ b/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/ApiClientProvider.kt @@ -0,0 +1,104 @@ +/* + * 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.core2.appintegrity + +import com.infomaniak.core2.appintegrity.exceptions.ApiException +import com.infomaniak.core2.appintegrity.exceptions.NetworkException +import com.infomaniak.core2.appintegrity.exceptions.UnexpectedApiErrorFormatException +import com.infomaniak.core2.appintegrity.exceptions.UnknownException +import com.infomaniak.core2.appintegrity.models.ApiError +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig +import io.ktor.client.engine.HttpClientEngine +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.HttpRequestRetry +import io.ktor.client.plugins.HttpResponseValidator +import io.ktor.client.plugins.HttpTimeout +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.serialization.kotlinx.json.json +import kotlinx.io.IOException +import kotlinx.serialization.json.Json + +internal class ApiClientProvider(engine: HttpClientEngine = OkHttp.create()) { + + val json = Json { + ignoreUnknownKeys = true + coerceInputValues = true + isLenient = true + useAlternativeNames = false + } + + val httpClient = createHttpClient(engine) + + private fun createHttpClient(engine: HttpClientEngine): HttpClient { + val block: HttpClientConfig<*>.() -> Unit = { + expectSuccess = true + install(ContentNegotiation) { + json(this@ApiClientProvider.json) + } + install(ContentEncoding) { + gzip() + } + install(HttpTimeout) { + requestTimeoutMillis = REQUEST_TIMEOUT + connectTimeoutMillis = REQUEST_TIMEOUT + socketTimeoutMillis = REQUEST_TIMEOUT + } + install(HttpRequestRetry) { + retryOnExceptionIf(maxRetries = MAX_RETRY) { _, cause -> + cause.isNetworkException() + } + delayMillis { retry -> retry * RETRY_DELAY } + } + HttpResponseValidator { + validateResponse { response: HttpResponse -> + val statusCode = response.status.value + if (statusCode >= 300) { + val bodyResponse = response.bodyAsText() + runCatching { + val apiError = json.decodeFromString(bodyResponse) + throw ApiException(apiError.errorCode, apiError.message) + }.onFailure { + throw UnexpectedApiErrorFormatException(statusCode, bodyResponse) + } + } + } + handleResponseExceptionWithRequest { cause, _ -> + when (cause) { + is IOException -> throw NetworkException("Network error: ${cause.message}") + is ApiException, is UnexpectedApiErrorFormatException -> throw cause + else -> throw UnknownException(cause) + } + } + } + } + + return HttpClient(engine, block) + } + + private fun Throwable.isNetworkException() = this is IOException + + companion object { + private const val REQUEST_TIMEOUT = 10_000L + private const val MAX_RETRY = 3 + private const val RETRY_DELAY = 500L + } +} diff --git a/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/AppIntegrityManager.kt b/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/AppIntegrityManager.kt new file mode 100644 index 000000000..19e5b8853 --- /dev/null +++ b/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/AppIntegrityManager.kt @@ -0,0 +1,171 @@ +/* + * Infomaniak Core2 - Android + * 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.core2.appintegrity + +import android.content.Context +import android.util.Base64 +import android.util.Log +import com.google.android.play.core.integrity.IntegrityManagerFactory +import com.google.android.play.core.integrity.IntegrityTokenRequest +import com.google.android.play.core.integrity.StandardIntegrityManager.* +import com.infomaniak.core2.appintegrity.exceptions.NetworkException +import com.infomaniak.sentry.SentryLog +import io.sentry.Sentry +import io.sentry.SentryLevel +import java.util.UUID + +/** + * Manager used to verify that the device used is real and doesn't have integrity problems + * + * There is 2 types of Request: + * - the standard request ([requestIntegrityVerdictToken]) that need a warm-up first ([warmUpTokenProvider]) + * - the classic request ([requestClassicIntegrityVerdictToken]) that need additional Api checks + */ +class AppIntegrityManager(private val appContext: Context) { + + private var appIntegrityTokenProvider: StandardIntegrityTokenProvider? = null + private val classicIntegrityTokenProvider by lazy { IntegrityManagerFactory.create(appContext) } + private val appIntegrityRepository by lazy { AppIntegrityRepository() } + + private var challenge = "" + private var challengeId = "" + + /** + * This function is needed in case of standard verdict request by [requestIntegrityVerdictToken]. + * It must be called once at the initialisation because it can take a long time (up to several minutes) + */ + fun warmUpTokenProvider(appCloudNumber: Long, onFailure: () -> Unit) { + val integrityManager = IntegrityManagerFactory.createStandard(appContext) + integrityManager.prepareIntegrityToken( + PrepareIntegrityTokenRequest.builder().setCloudProjectNumber(appCloudNumber).build() + ).addOnSuccessListener { tokenProvider -> + appIntegrityTokenProvider = tokenProvider + SentryLog.i(APP_INTEGRITY_MANAGER_TAG, "warmUpTokenProvider: Success") + }.addOnFailureListener { manageException(it, "Error during warmup", onFailure) } + } + + /** + * Standard verdict request for Integrity token + * It should protect automatically from replay attack, but for now this protection seemed to not be working + */ + fun requestIntegrityVerdictToken( + requestHash: String, + onSuccess: (String) -> Unit, + onFailure: () -> Unit, + onNullTokenProvider: (String) -> Unit, + ) { + if (appIntegrityTokenProvider == null) { + onNullTokenProvider("Integrity token provider is null during a verdict request. This should not be possible") + } else { + appIntegrityTokenProvider?.request(StandardIntegrityTokenRequest.builder().setRequestHash(requestHash).build()) + ?.addOnSuccessListener { response -> onSuccess(response.token()) } + ?.addOnFailureListener { manageException(it, "Error when requiring a standard integrity token", onFailure) } + } + } + + /** + * Classic verdict request for Integrity token + * + * This doesn't automatically protect from replay attack, thus the use of challenge/challengeId pair with our API to add this + * layer of protection. + */ + fun requestClassicIntegrityVerdictToken(onSuccess: (String) -> Unit, onFailure: () -> Unit) { + + // You can comment this if you want to test the App Integrity (also see getJwtToken in AppIntegrityRepository) + if (BuildConfig.DEBUG) { + onSuccess("Basic app integrity token") + return + } + + val nonce = Base64.encodeToString(challenge.toByteArray(), Base64.DEFAULT) + + classicIntegrityTokenProvider.requestIntegrityToken(IntegrityTokenRequest.builder().setNonce(nonce).build()) + ?.addOnSuccessListener { response -> onSuccess(response.token()) } + ?.addOnFailureListener { manageException(it, "Error when requiring a classic integrity token", onFailure) } + } + + suspend fun getChallenge(onSuccess: () -> Unit, onFailure: () -> Unit) = runCatching { + generateChallengeId() + val apiResponse = appIntegrityRepository.getChallenge(challengeId) + SentryLog.d( + tag = APP_INTEGRITY_MANAGER_TAG, + msg = "challengeId hash : ${challengeId.hashCode()} / challenge hash: ${apiResponse.data.hashCode()}", + ) + apiResponse.data?.let { challenge = it } + onSuccess() + }.getOrElse { + manageException(it, "Error fetching challenge", onFailure) + } + + suspend fun getApiIntegrityVerdict( + integrityToken: String, + packageName: String, + targetUrl: String, + onSuccess: (String) -> Unit, + onFailure: () -> Unit, + ) { + runCatching { + val apiResponse = appIntegrityRepository.getJwtToken( + integrityToken = integrityToken, + packageName = packageName, + targetUrl = targetUrl, + challengeId = challengeId, + ) + apiResponse.data?.let(onSuccess) + }.getOrElse { + manageException(it, "Error during Integrity check by API", onFailure) + } + } + + /** + * Only used to test App Integrity in Apps before their real backend implementation + */ + suspend fun callDemoRoute(mobileToken: String) { + runCatching { + val apiResponse = appIntegrityRepository.demo(mobileToken) + val logMessage = if (apiResponse.isSuccess()) { + "Success demo route response: ${apiResponse.data}" + } else { + "Error demo route : ${apiResponse.error?.errorCode}" + } + Log.d(APP_INTEGRITY_MANAGER_TAG, logMessage) + }.getOrElse { + it.printStackTrace() + } + } + + private fun generateChallengeId() { + challengeId = UUID.randomUUID().toString() + } + + private fun manageException(exception: Throwable, errorMessage: String, onFailure: () -> Unit) { + if (exception !is NetworkException) { + Sentry.captureMessage(errorMessage, SentryLevel.ERROR) { scope -> + scope.setTag("exception", exception.message.toString()) + scope.setExtra("stacktrace", exception.printStackTrace().toString()) + } + } + exception.printStackTrace() + onFailure() + } + + companion object { + const val APP_INTEGRITY_MANAGER_TAG = "App integrity manager" + const val ATTESTATION_TOKEN_HEADER = "Ik-mobile-token" + } +} diff --git a/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/AppIntegrityRepository.kt b/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/AppIntegrityRepository.kt new file mode 100644 index 000000000..31f007acf --- /dev/null +++ b/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/AppIntegrityRepository.kt @@ -0,0 +1,92 @@ +/* + * Infomaniak SwissTransfer - Android + * 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.core2.appintegrity + +import com.infomaniak.core2.appintegrity.AppIntegrityManager.Companion.ATTESTATION_TOKEN_HEADER +import com.infomaniak.core2.appintegrity.exceptions.ApiException +import com.infomaniak.core2.appintegrity.exceptions.NetworkException +import com.infomaniak.core2.appintegrity.exceptions.UnexpectedApiErrorFormatException +import com.infomaniak.core2.appintegrity.exceptions.UnknownException +import com.infomaniak.core2.appintegrity.models.ApiResponse +import io.ktor.client.call.body +import io.ktor.client.request.headers +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.http.ContentType +import io.ktor.http.HeadersBuilder +import io.ktor.http.Url +import io.ktor.http.contentType + +internal class AppIntegrityRepository { + + private val apiClientProvider by lazy { ApiClientProvider() } + + suspend fun getChallenge(challengeId: String): ApiResponse { + val body = mapOf("challenge_id" to challengeId) + return post>(url = Url(AppIntegrityRoutes.requestChallenge), data = body) + } + + suspend fun getJwtToken( + integrityToken: String, + packageName: String, + targetUrl: String, + challengeId: String, + ): ApiResponse { + val body = mutableMapOf( + "token" to integrityToken, + "package_name" to packageName, + "target_url" to targetUrl, + "challenge_id" to challengeId, + ) + + // Add this line to test validation by api + // body["force_integrity_test"] = "true" + + return post>(url = Url(AppIntegrityRoutes.requestApiIntegrityCheck), data = body) + } + + suspend fun demo(mobileToken: String): ApiResponse { + return post>( + url = Url(AppIntegrityRoutes.demo), + data = mapOf(), + appendHeaders = { append(ATTESTATION_TOKEN_HEADER, mobileToken) }, + ) + } + + private suspend inline fun post( + url: Url, + data: Any?, + crossinline appendHeaders: HeadersBuilder.() -> Unit = {}, + ): R { + return apiClientProvider.httpClient.post(url) { + contentType(ContentType.Application.Json) + headers { appendHeaders() } + setBody(data) + }.decode() + } + + private suspend inline fun HttpResponse.decode(): R { + return runCatching { body() }.getOrElse { exception -> + when (exception) { + is ApiException, is NetworkException, is UnexpectedApiErrorFormatException -> throw exception + else -> throw UnknownException(exception) + } + } + } +} diff --git a/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/AppIntegrityRoutes.kt b/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/AppIntegrityRoutes.kt new file mode 100644 index 000000000..4de5af3da --- /dev/null +++ b/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/AppIntegrityRoutes.kt @@ -0,0 +1,27 @@ +/* + * Infomaniak SwissTransfer - Android + * 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.core2.appintegrity + +object AppIntegrityRoutes { + + private val BASE_URL_V1 = "${BuildConfig.APP_INTEGRITY_BASE_URL}/1/attest" + + internal val requestChallenge = "$BASE_URL_V1/challenge" + internal val requestApiIntegrityCheck = "$BASE_URL_V1/integrity" + const val demo = "https://api-core.devd471.dev.infomaniak.ch/1/attest/demo" +} diff --git a/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/exceptions/ApiException.kt b/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/exceptions/ApiException.kt new file mode 100644 index 000000000..deb0964bd --- /dev/null +++ b/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/exceptions/ApiException.kt @@ -0,0 +1,29 @@ +/* + * 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.core2.appintegrity.exceptions + +/** + * 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. + * + * @param errorCode The specific error code returned by the API. + * @param errorMessage The detailed error message explaining the cause of the failure. + */ +internal open class ApiException(val errorCode: String, errorMessage: String) : Exception(errorMessage) diff --git a/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/exceptions/AppIntegrityException.kt b/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/exceptions/AppIntegrityException.kt new file mode 100644 index 000000000..b4008f3e0 --- /dev/null +++ b/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/exceptions/AppIntegrityException.kt @@ -0,0 +1,25 @@ +/* + * Infomaniak SwissTransfer - Android + * 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.core2.appintegrity.exceptions + +/** + * Thrown when any step of the App Integrity check fails + * + * @param message A detailed message describing the error. + */ +class AppIntegrityException(message: String) : Exception(message) diff --git a/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/exceptions/NetworkException.kt b/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/exceptions/NetworkException.kt new file mode 100644 index 000000000..0062e996b --- /dev/null +++ b/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/exceptions/NetworkException.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.core2.appintegrity.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) diff --git a/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/exceptions/UnexpectedApiErrorFormatException.kt b/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/exceptions/UnexpectedApiErrorFormatException.kt new file mode 100644 index 000000000..eb3a4aceb --- /dev/null +++ b/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/exceptions/UnexpectedApiErrorFormatException.kt @@ -0,0 +1,29 @@ +/* + * 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.core2.appintegrity.exceptions + +/** + * 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. + * + * @param statusCode The HTTP status code returned by the API. + * @param bodyResponse The raw response body from the API that could not be parsed. + */ +internal class UnexpectedApiErrorFormatException(val statusCode: Int, val bodyResponse: String) : Exception(bodyResponse) diff --git a/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/exceptions/UnknownException.kt b/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/exceptions/UnknownException.kt new file mode 100644 index 000000000..1b67a5460 --- /dev/null +++ b/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/exceptions/UnknownException.kt @@ -0,0 +1,35 @@ +/* + * 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.core2.appintegrity.exceptions + +/** + * Represents an unknown exception that can occur during the execution of the application. + * + * This exception is used to encapsulate unexpected or unknown errors that are not covered + * by other specific exception types. + * + * @property message The detailed message describing the error. + * @property cause The underlying cause of this exception, if any. + * + * @constructor Creates an instance of `UnknownException` with a detailed error message and an optional cause. + * + * @param cause The underlying exception that caused this exception. + */ +internal class UnknownException(cause: Throwable) : Exception(cause) { + override val message: String = cause.message ?: cause.toString() +} diff --git a/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/models/ApiError.kt b/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/models/ApiError.kt new file mode 100644 index 000000000..06a1d54e1 --- /dev/null +++ b/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/models/ApiError.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.core2.appintegrity.models + +import kotlinx.serialization.Serializable + +@Serializable +internal data class ApiError( + val errorCode: String = "", + val message: String = "", +) diff --git a/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/models/ApiResponse.kt b/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/models/ApiResponse.kt new file mode 100644 index 000000000..490c2be51 --- /dev/null +++ b/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/models/ApiResponse.kt @@ -0,0 +1,29 @@ +/* + * 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.core2.appintegrity.models + +import kotlinx.serialization.Serializable + +@Serializable +internal data class ApiResponse( + val result: ApiResponseStatus = ApiResponseStatus.UNKNOWN, + val data: T? = null, + val error: ApiError? = null, +) { + fun isSuccess() = result == ApiResponseStatus.SUCCESS +} diff --git a/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/models/ApiResponseStatus.kt b/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/models/ApiResponseStatus.kt new file mode 100644 index 000000000..202a45007 --- /dev/null +++ b/Core2/AppIntegrity/src/main/java/com/infomaniak/core2/appintegrity/models/ApiResponseStatus.kt @@ -0,0 +1,37 @@ +/* + * 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.core2.appintegrity.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal enum class ApiResponseStatus { + + @SerialName("error") + ERROR, + + @SerialName("success") + SUCCESS, + + @SerialName("asynchronous") + ASYNCHRONOUS, + + @SerialName("unknown") + UNKNOWN; +} diff --git a/Core2/AppIntegrity/src/test/java/com/infomaniak/core2/appintegrity/ApiClientProviderTest.kt b/Core2/AppIntegrity/src/test/java/com/infomaniak/core2/appintegrity/ApiClientProviderTest.kt new file mode 100644 index 000000000..000ccfa2c --- /dev/null +++ b/Core2/AppIntegrity/src/test/java/com/infomaniak/core2/appintegrity/ApiClientProviderTest.kt @@ -0,0 +1,47 @@ +package com.infomaniak.core2.appintegrity + +import com.infomaniak.core2.appintegrity.exceptions.UnknownException +import com.infomaniak.core2.appintegrity.models.ApiResponse +import io.ktor.client.call.body +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.http.* +import io.ktor.utils.io.ByteReadChannel +import kotlinx.coroutines.runBlocking +import org.junit.Test + +class ApiClientProviderTest { + + private val apiClientProvider by lazy { + ApiClientProvider( + MockEngine { _ -> + respond( + content = ByteReadChannel("""{"ip":"127.0.0.1"}"""), + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + ) + } + + @Test + fun apiClientProviderTest() { + runBlocking { + post>(Url("toto"), data = mapOf("toto" to 1)) + } + } + + private suspend inline fun post(url: Url, data: Any?): R { + return apiClientProvider.httpClient.post(url) { + contentType(ContentType.Application.Json) + setBody(data) + }.decode() + } + + private suspend inline fun HttpResponse.decode(): R { + return runCatching { body() }.getOrElse { throw UnknownException(it) } + } +} diff --git a/Core2/gradle/core2.versions.toml b/Core2/gradle/core2.versions.toml index f3ce715f9..565124d78 100644 --- a/Core2/gradle/core2.versions.toml +++ b/Core2/gradle/core2.versions.toml @@ -1,6 +1,11 @@ [versions] coreKtx = "1.15.0" composeBom = "2024.11.00" +integrity = "1.4.0" +junitAndroidxVersion = "1.2.1" +junitVersion = "4.13.2" +kotlinxSerializationJson = "1.7.1" +ktor = "3.0.1" matomo = "4.1.4" sentry-android = "7.15.0" @@ -12,5 +17,16 @@ compose-material3 = { group = "androidx.compose.material3", name = "material3-an compose-runtime = { group = "androidx.compose.runtime", name = "runtime-android" } compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview-android" } +integrity = { module = "com.google.android.play:integrity", version.ref = "integrity" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } +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-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" } matomo = { module = "com.github.matomo-org:matomo-sdk-android", version.ref = "matomo" } sentry-android = { module = "io.sentry:sentry-android", version.ref = "sentry-android" } +# Tests +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitAndroidxVersion" } +junit = { group = "junit", name = "junit", version.ref = "junitVersion" } diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6aeaef25a..b3913ad88 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -87,6 +87,7 @@ dependencies { implementation(project(":Core2:Sentry")) implementation(project(":Core2:Matomo")) implementation(project(":Core2:Network")) + implementation(project(":Core2:AppIntegrity")) implementation(project(":Core2:Onboarding")) implementation(project(":Core2:Compose:Core")) implementation(project(":FileTypes")) diff --git a/app/src/main/java/com/infomaniak/swisstransfer/di/SwissTransferInjectionModule.kt b/app/src/main/java/com/infomaniak/swisstransfer/di/SwissTransferInjectionModule.kt index cd9ca4fa2..d735eb5ec 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/di/SwissTransferInjectionModule.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/di/SwissTransferInjectionModule.kt @@ -17,6 +17,8 @@ */ package com.infomaniak.swisstransfer.di +import android.app.Application +import com.infomaniak.core2.appintegrity.AppIntegrityManager import com.infomaniak.multiplatform_swisstransfer.SwissTransferInjection import dagger.Module import dagger.Provides @@ -51,4 +53,8 @@ object SwissTransferInjectionModule { @Provides @Singleton fun providesSharedApiUrlCreator(swissTransferInjection: SwissTransferInjection) = swissTransferInjection.sharedApiUrlCreator + + @Provides + @Singleton + fun providesAppIntegrityManger(application: Application) = AppIntegrityManager(application) } diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/ImportFilesViewModel.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/ImportFilesViewModel.kt index 1875d0c79..ba0886b86 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/ImportFilesViewModel.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/ImportFilesViewModel.kt @@ -18,6 +18,7 @@ package com.infomaniak.swisstransfer.ui.screen.newtransfer import android.net.Uri +import android.util.Log import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -25,13 +26,17 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.infomaniak.core2.appintegrity.AppIntegrityManager +import com.infomaniak.core2.appintegrity.AppIntegrityManager.Companion.APP_INTEGRITY_MANAGER_TAG import com.infomaniak.multiplatform_swisstransfer.common.interfaces.upload.RemoteUploadFile import com.infomaniak.multiplatform_swisstransfer.common.interfaces.upload.UploadFileSession import com.infomaniak.multiplatform_swisstransfer.common.utils.mapToList import com.infomaniak.multiplatform_swisstransfer.data.NewUploadSession import com.infomaniak.multiplatform_swisstransfer.managers.AppSettingsManager import com.infomaniak.multiplatform_swisstransfer.managers.UploadManager +import com.infomaniak.multiplatform_swisstransfer.network.utils.SharedApiRoutes import com.infomaniak.sentry.SentryLog +import com.infomaniak.swisstransfer.BuildConfig import com.infomaniak.swisstransfer.di.IoDispatcher import com.infomaniak.swisstransfer.ui.screen.main.settings.DownloadLimitOption import com.infomaniak.swisstransfer.ui.screen.main.settings.DownloadLimitOption.Companion.toTransferOption @@ -57,6 +62,7 @@ import javax.inject.Inject @HiltViewModel class ImportFilesViewModel @Inject constructor( private val appSettingsManager: AppSettingsManager, + private val appIntegrityManager: AppIntegrityManager, private val savedStateHandle: SavedStateHandle, private val importationFilesManager: ImportationFilesManager, private val uploadManager: UploadManager, @@ -67,6 +73,9 @@ class ImportFilesViewModel @Inject constructor( private val _sendActionResult = MutableStateFlow(SendActionResult.NotStarted) val sendActionResult = _sendActionResult.asStateFlow() + private val _integrityCheckResult = MutableStateFlow(AppIntegrityResult.Idle) + val integrityCheckResult = _integrityCheckResult.asStateFlow() + @OptIn(FlowPreview::class) val importedFilesDebounced = importationFilesManager.importedFiles .debounce(50) @@ -128,12 +137,15 @@ class ImportFilesViewModel @Inject constructor( } } - fun sendTransfer() { + private fun sendTransfer(attestationToken: String) { _sendActionResult.update { SendActionResult.Pending } viewModelScope.launch(ioDispatcher) { runCatching { val uuid = uploadManager.createAndGetUpload(generateNewUploadSession()).uuid - uploadManager.initUploadSession(recaptcha = "Recaptcha")!! // TODO: Handle ContainerErrorsException here + uploadManager.initUploadSession( + attestationHeaderName = AppIntegrityManager.ATTESTATION_TOKEN_HEADER, + attestationToken = attestationToken, + )!! // TODO: Handle ContainerErrorsException here uploadWorkerScheduler.scheduleWork(uuid) _sendActionResult.update { val totalSize = importationFilesManager.importedFiles.value.sumOf { it.fileSize } @@ -150,6 +162,58 @@ class ImportFilesViewModel @Inject constructor( _sendActionResult.value = SendActionResult.NotStarted } + //region App Integrity + fun checkAppIntegrity() { + _integrityCheckResult.value = AppIntegrityResult.Ongoing + viewModelScope.launch(ioDispatcher) { + runCatching { + appIntegrityManager.getChallenge( + onSuccess = { requestAppIntegrityToken(appIntegrityManager) }, + onFailure = ::setFailedIntegrityResult, + ) + }.onFailure { exception -> + SentryLog.e(TAG, "Failed to start the upload", exception) + _sendActionResult.update { SendActionResult.Failure } + } + } + } + + private fun requestAppIntegrityToken(appIntegrityManager: AppIntegrityManager) { + appIntegrityManager.requestClassicIntegrityVerdictToken( + onSuccess = { token -> + SentryLog.i(APP_INTEGRITY_MANAGER_TAG, "request for app integrity token successful $token") + getApiIntegrityVerdict(appIntegrityManager, token) + }, + onFailure = ::setFailedIntegrityResult, + ) + } + + private fun getApiIntegrityVerdict(appIntegrityManager: AppIntegrityManager, appIntegrityToken: String) { + viewModelScope.launch(ioDispatcher) { + appIntegrityManager.getApiIntegrityVerdict( + integrityToken = appIntegrityToken, + packageName = BuildConfig.APPLICATION_ID, + targetUrl = SharedApiRoutes.createUploadContainer, + onSuccess = { attestationToken -> + SentryLog.i(APP_INTEGRITY_MANAGER_TAG, "Api verdict check") + Log.i(APP_INTEGRITY_MANAGER_TAG, "getApiIntegrityVerdict: $attestationToken") + _integrityCheckResult.value = AppIntegrityResult.Success + sendTransfer(attestationToken) + }, + onFailure = ::setFailedIntegrityResult, + ) + } + } + + private fun setFailedIntegrityResult() { + _integrityCheckResult.value = AppIntegrityResult.Fail + } + + fun resetIntegrityCheckResult() { + _integrityCheckResult.value = AppIntegrityResult.Idle + } + //endregion + private suspend fun removeOldData() { importationFilesManager.removeLocalCopyFolder() uploadManager.removeAllUploadSession() @@ -159,6 +223,7 @@ class ImportFilesViewModel @Inject constructor( return NewUploadSession( duration = selectedValidityPeriodOption.value.apiValue, authorEmail = if (selectedTransferType.value == TransferTypeUi.MAIL) _transferAuthorEmail else "", + authorEmailToken = null, password = if (selectedPasswordOption.value == PasswordTransferOption.ACTIVATED) transferPassword else NO_PASSWORD, message = _transferMessage, numberOfDownload = selectedDownloadLimitOption.value.apiValue, @@ -173,7 +238,7 @@ class ImportFilesViewModel @Inject constructor( override val remoteUploadFile: RemoteUploadFile? = null override val size: Long = fileUi.fileSize } - } + }, ) } @@ -268,6 +333,10 @@ class ImportFilesViewModel @Inject constructor( data object Failure : SendActionResult() } + enum class AppIntegrityResult { + Idle, Ongoing, Success, Fail + } + companion object { private val TAG = ImportFilesViewModel::class.java.simpleName diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/importfiles/ImportFilesScreen.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/importfiles/ImportFilesScreen.kt index 0001d1354..f8b338d51 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/importfiles/ImportFilesScreen.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/importfiles/ImportFilesScreen.kt @@ -31,7 +31,6 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.PreviewParameter @@ -47,6 +46,7 @@ import com.infomaniak.swisstransfer.ui.screen.main.settings.EmailLanguageOption import com.infomaniak.swisstransfer.ui.screen.main.settings.ValidityPeriodOption import com.infomaniak.swisstransfer.ui.screen.main.settings.components.SettingOption import com.infomaniak.swisstransfer.ui.screen.newtransfer.ImportFilesViewModel +import com.infomaniak.swisstransfer.ui.screen.newtransfer.ImportFilesViewModel.AppIntegrityResult import com.infomaniak.swisstransfer.ui.screen.newtransfer.ImportFilesViewModel.SendActionResult import com.infomaniak.swisstransfer.ui.screen.newtransfer.importfiles.components.* import com.infomaniak.swisstransfer.ui.theme.Margin @@ -62,6 +62,7 @@ fun ImportFilesScreen( closeActivity: () -> Unit, navigateToUploadProgress: (transferType: TransferTypeUi, totalSize: Long) -> Unit, ) { + val files by importFilesViewModel.importedFilesDebounced.collectAsStateWithLifecycle() val filesToImportCount by importFilesViewModel.filesToImportCount.collectAsStateWithLifecycle() val currentSessionFilesCount by importFilesViewModel.currentSessionFilesCount.collectAsStateWithLifecycle() @@ -74,9 +75,16 @@ fun ImportFilesScreen( val emailLanguageState by importFilesViewModel.selectedLanguageOption.collectAsStateWithLifecycle() val sendActionResult by importFilesViewModel.sendActionResult.collectAsStateWithLifecycle() + val integrityCheckResult by importFilesViewModel.integrityCheckResult.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } + HandleIntegrityCheckResult( + snackbarHostState = snackbarHostState, + integrityCheckResult = { integrityCheckResult }, + resetResult = { importFilesViewModel.resetIntegrityCheckResult() }, + ) + HandleSendActionResult( snackbarHostState = snackbarHostState, getSendActionResult = { sendActionResult }, @@ -85,6 +93,7 @@ fun ImportFilesScreen( resetSendActionResult = importFilesViewModel::resetSendActionResult, ) + LaunchedEffect(Unit) { importFilesViewModel.initTransferOptionsValues() } val transferOptionsCallbacks = importFilesViewModel.getTransferOptionsCallbacks( @@ -134,8 +143,9 @@ fun ImportFilesScreen( transferOptionsCallbacks = transferOptionsCallbacks, addFiles = importFilesViewModel::importFiles, closeActivity = closeActivity, + integrityCheckResult = { integrityCheckResult }, + checkAppIntegrity = importFilesViewModel::checkAppIntegrity, shouldStartByPromptingUserForFiles = true, - sendTransfer = importFilesViewModel::sendTransfer, isTransferStarted = { sendActionResult != SendActionResult.NotStarted }, snackbarHostState = snackbarHostState, ) @@ -149,13 +159,12 @@ private fun HandleSendActionResult( navigateToUploadProgress: (transferType: TransferTypeUi, totalSize: Long) -> Unit, resetSendActionResult: () -> Unit, ) { - val context = LocalContext.current - + val errorMessage = stringResource(R.string.errorUnknown) LaunchedEffect(getSendActionResult()) { when (val actionResult = getSendActionResult()) { is SendActionResult.Success -> navigateToUploadProgress(transferType(), actionResult.totalSize) is SendActionResult.Failure -> { - snackbarHostState.showSnackbar(context.getString(R.string.errorUnknown)) + snackbarHostState.showSnackbar(errorMessage) resetSendActionResult() } else -> Unit @@ -163,6 +172,23 @@ private fun HandleSendActionResult( } } +@Composable +private fun HandleIntegrityCheckResult( + snackbarHostState: SnackbarHostState, + integrityCheckResult: () -> AppIntegrityResult, + resetResult: () -> Unit, +) { + val result = integrityCheckResult() + val errorMessage = stringResource(R.string.errorAppIntegrity) + + LaunchedEffect(result == AppIntegrityResult.Success || result == AppIntegrityResult.Fail) { + if (integrityCheckResult() == AppIntegrityResult.Fail) { // TODO: Better error management + snackbarHostState.showSnackbar(errorMessage) + } + resetResult() + } +} + @Composable private fun ImportFilesScreen( files: () -> List, @@ -175,7 +201,8 @@ private fun ImportFilesScreen( addFiles: (List) -> Unit, closeActivity: () -> Unit, shouldStartByPromptingUserForFiles: Boolean, - sendTransfer: () -> Unit, + integrityCheckResult: () -> AppIntegrityResult, + checkAppIntegrity: () -> Unit, isTransferStarted: () -> Boolean, snackbarHostState: SnackbarHostState? = null, ) { @@ -198,8 +225,9 @@ private fun ImportFilesScreen( importedFiles = files, shouldShowEmailAddressesFields = { shouldShowEmailAddressesFields }, transferAuthorEmail = transferAuthorEmail, + integrityCheckResult = integrityCheckResult, + checkAppIntegrityThenSendTransfer = checkAppIntegrity, isTransferStarted = isTransferStarted, - navigateToUploadProgress = sendTransfer, ) }, content = { @@ -377,8 +405,9 @@ private fun SendButton( importedFiles: () -> List, shouldShowEmailAddressesFields: () -> Boolean, transferAuthorEmail: GetSetCallbacks, + integrityCheckResult: () -> AppIntegrityResult, + checkAppIntegrityThenSendTransfer: () -> Unit, isTransferStarted: () -> Boolean, - navigateToUploadProgress: () -> Unit, ) { val remainingFilesCount = filesToImportCount() val isImporting by remember(remainingFilesCount) { derivedStateOf { remainingFilesCount > 0 } } @@ -399,10 +428,10 @@ private fun SendButton( modifier = modifier, title = stringResource(R.string.transferSendButton), style = ButtonType.PRIMARY, + showIndeterminateProgress = { integrityCheckResult() == AppIntegrityResult.Ongoing || isTransferStarted() }, enabled = { importedFiles().isNotEmpty() && !isImporting && isSenderEmailCorrect && !isTransferStarted() }, - showIndeterminateProgress = { isTransferStarted() }, progress = progress, - onClick = navigateToUploadProgress, + onClick = { checkAppIntegrityThenSendTransfer() }, ) } @@ -468,8 +497,9 @@ private fun Preview(@PreviewParameter(FileUiListPreviewParameter::class) files: addFiles = {}, closeActivity = {}, shouldStartByPromptingUserForFiles = false, + integrityCheckResult = { AppIntegrityResult.Idle }, + checkAppIntegrity = {}, isTransferStarted = { false }, - sendTransfer = {}, ) } } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index d5faf95bb..dfe6a5a2d 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -32,6 +32,7 @@ Passwort anzeigen Neuer Transfer Heruntergeladene Übertragung: %d/%d + Ihr Gerät wird nicht als sicher erkannt Das Passwort muss zwischen 6 und 25 Zeichen lang sein Ein Fehler ist aufgetreten Verfällt in %d Tagen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 7e1d88827..6eab30e98 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -32,6 +32,7 @@ Mostrar contraseña Nueva transferencia Transferencia descargada: %d/%d + Su dispositivo no es reconocido como seguro La contraseña debe tener entre 6 y 25 caracteres Se ha producido un error Caduca en %d días diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 5c7661b50..3f9a84ef7 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -32,6 +32,7 @@ Afficher le mot de passe Nouveau transfert Transfert téléchargé : %d/%d\u0020 + Votre appareil n’est pas reconnu comme sûr Le mot de passe doit comporter entre 6 et 25 caractères Une erreur est survenue Expire dans %d jours diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 71c00a75c..182e54071 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -32,6 +32,7 @@ Mostra password Nuovo trasferimento Trasferimento scaricato: %d/%d + Il dispositivo non è riconosciuto come sicuro La password deve essere compresa tra 6 e 24 caratteri Si è verificato un errore Scade tra %d giorni diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 17b259870..443a0e193 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -36,6 +36,7 @@ Show password New transfer Downloaded transfer: %d/%d\u0020 + Your device is not recognized as safe The password must be between 6 and 25 characters An error has occurred Expires in %d days diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bd20cd2f0..5a0e6fa3e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,7 @@ qrose = "1.0.1" recaptcha = "18.6.1" sentry = "4.12.0" serialization = "1.7.3" -swisstransfer = "0.9.5" +swisstransfer = "0.9.6" workmanager = "2.10.0" [libraries] diff --git a/settings.gradle.kts b/settings.gradle.kts index 8db1d9ae5..1faa412f7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -35,4 +35,5 @@ include(":Core2:Matomo") include(":Core2:Network") include(":Core2:Onboarding") include(":Core2:Compose:Core") +include(":Core2:AppIntegrity") include(":FileTypes")