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")