diff --git a/Compose/Basics/src/main/kotlin/com/infomaniak/core/compose/basics/Dimens.kt b/Compose/Basics/src/main/kotlin/com/infomaniak/core/compose/basics/Dimens.kt
index 6b8af3732..1924f35bf 100644
--- a/Compose/Basics/src/main/kotlin/com/infomaniak/core/compose/basics/Dimens.kt
+++ b/Compose/Basics/src/main/kotlin/com/infomaniak/core/compose/basics/Dimens.kt
@@ -31,6 +31,9 @@ object Dimens {
/** 32 dp */
val avatarSize = 32.dp
+ /** 40 dp */
+ val bigAvatarSize = 40.dp
+
/** 8 dp */
val smallCornerRadius = 8.dp
/** 16 dp */
diff --git a/Compose/Basics/src/main/kotlin/com/infomaniak/core/compose/basics/WithLatestNotNull.kt b/Compose/Basics/src/main/kotlin/com/infomaniak/core/compose/basics/WithLatestNotNull.kt
new file mode 100644
index 000000000..4efaa738f
--- /dev/null
+++ b/Compose/Basics/src/main/kotlin/com/infomaniak/core/compose/basics/WithLatestNotNull.kt
@@ -0,0 +1,39 @@
+/*
+ * Infomaniak Core - Android
+ * Copyright (C) 2025 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.core.compose.basics
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+
+/**
+ * Gives access to the latest non null value of the receiver.
+ *
+ * Helpful to keep showing populated UI during exit animations.
+ */
+@Composable
+fun T?.WithLatestNotNull(content: @Composable (T) -> Unit) {
+ // The implementation is similar to rememberUpdatedState.
+ val value by remember { mutableStateOf(this) }.also {
+ if (this != null) it.value = this
+ }
+ value?.let {
+ content(it)
+ }
+}
diff --git a/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/internal/deviceinfo/AbstractDeviceInfoUpdateWorker.kt b/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/internal/deviceinfo/AbstractDeviceInfoUpdateWorker.kt
index 183f144ae..2108c74c3 100644
--- a/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/internal/deviceinfo/AbstractDeviceInfoUpdateWorker.kt
+++ b/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/internal/deviceinfo/AbstractDeviceInfoUpdateWorker.kt
@@ -15,7 +15,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-@file:OptIn(ExperimentalCoroutinesApi::class)
+@file:OptIn(ExperimentalCoroutinesApi::class, ExperimentalSplittiesApi::class, ExperimentalContracts::class)
package com.infomaniak.core.crossapplogin.back.internal.deviceinfo
@@ -39,19 +39,26 @@ import com.infomaniak.core.autoCancelScope
import com.infomaniak.core.cancellable
import com.infomaniak.core.crossapplogin.back.CrossAppLogin
import com.infomaniak.core.crossapplogin.back.internal.deviceinfo.DeviceInfo.Type
-import com.infomaniak.core.crossapplogin.back.internal.extensions.toHexDashStringKotlin2
import com.infomaniak.core.network.networking.HttpUtils
import com.infomaniak.core.network.networking.ManualAuthorizationRequired
import com.infomaniak.core.sentry.SentryLog
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
+import io.ktor.client.plugins.HttpRequestRetry
+import io.ktor.client.plugins.HttpResponseValidator
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.client.plugins.defaultRequest
+import io.ktor.client.request.accept
import io.ktor.client.request.post
import io.ktor.client.request.setBody
+import io.ktor.client.statement.HttpResponse
+import io.ktor.client.statement.request
import io.ktor.http.ContentType
+import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
import io.ktor.http.headers
import io.ktor.http.isSuccess
+import io.ktor.http.userAgent
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -64,10 +71,15 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
+import kotlinx.serialization.SerializationException
import okhttp3.OkHttpClient
+import splitties.experimental.ExperimentalSplittiesApi
import splitties.init.appCtx
import java.io.IOException
import java.util.concurrent.TimeUnit
+import kotlin.contracts.ExperimentalContracts
+import kotlin.contracts.InvocationKind
+import kotlin.contracts.contract
import kotlin.time.Duration.Companion.milliseconds
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@@ -114,7 +126,7 @@ abstract class AbstractDeviceInfoUpdateWorker(
val currentCrossAppDeviceId = sharedDeviceIdManager.crossAppDeviceIdFlow.first()
- val deviceInfo = currentDeviceInfo(currentCrossAppDeviceId)
+ val deviceInfo = currentDeviceInfo(currentCrossAppDeviceId, deviceInfoUpdateManager.currentAppAppVersions())
val deviceInfoUpdatersForUserId = DynamicLazyMap>(
cacheManager = { userId, deferred ->
@@ -163,17 +175,34 @@ abstract class AbstractDeviceInfoUpdateWorker(
install(ContentNegotiation) {
json()
}
+ install(HttpRequestRetry) {
+ maxRetries = 3
+ retryOnExceptionIf { request, cause -> cause !is SerializationException }
+ }
+ defaultRequest {
+ userAgent(HttpUtils.getUserAgent)
+ headers {
+ @OptIn(ManualAuthorizationRequired::class) // Already handled by the http client.
+ HttpUtils.getHeaders().forEach { (header, value) -> append(header, value) }
+ }
+ contentType(ContentType.Application.Json)
+ accept(ContentType.Application.Json)
+ }
+ HttpResponseValidator {
+ validateResponse { response ->
+ response.validateContentType { accepted, received ->
+ val url = response.request.url
+ val method = response.request.method
+ throw IllegalArgumentException("Expected Content-Type $accepted but got $received from $method on $url")
+ }
+ }
+ }
}
SentryLog.i(TAG, "Will attempt updating device info for user id $targetUserId")
val url = ApiRoutesCore.sendDeviceInfo()
val response = httpClient.post(url) {
- headers {
- @OptIn(ManualAuthorizationRequired::class) // Already handled by the http client.
- HttpUtils.getHeaders().forEach { (header, value) -> append(header, value) }
- }
- contentType(ContentType.Application.Json)
setBody(deviceInfo)
}
if (response.status.isSuccess()) {
@@ -184,7 +213,7 @@ abstract class AbstractDeviceInfoUpdateWorker(
val httpStatusCode = response.status.value
val errorMessage = "attemptUpdatingDeviceInfoIfNeeded led to http $httpStatusCode"
when (httpStatusCode) {
- in 500..599 -> {
+ in 500..<600 -> {
SentryLog.i(TAG, errorMessage)
Outcome.ShouldRetry
}
@@ -210,7 +239,10 @@ abstract class AbstractDeviceInfoUpdateWorker(
}
@ExperimentalUuidApi
- private fun currentDeviceInfo(currentCrossAppDeviceId: Uuid): DeviceInfo {
+ private fun currentDeviceInfo(
+ currentCrossAppDeviceId: Uuid,
+ appAppVersions: DeviceInfoUpdateManager.AppVersions,
+ ): DeviceInfo {
val hasTabletSizedScreen = appCtx.resources.configuration.smallestScreenWidthDp >= 600
val packageManager = appCtx.packageManager
val isFoldable = when {
@@ -228,7 +260,32 @@ abstract class AbstractDeviceInfoUpdateWorker(
hasTabletSizedScreen -> Type.Tablet
else -> Type.Phone
},
- uuidV4 = currentCrossAppDeviceId.toHexDashStringKotlin2(),
+ uuidV4 = currentCrossAppDeviceId.toHexDashString(),
+ capabilities = listOf(
+ "2fa:push_challenge:approval",
+ ),
+ version = appAppVersions.versionName
)
}
}
+
+private inline fun HttpResponse.validateContentType(
+ onContentTypeMismatch: (accepted: String, received: String?) -> Unit
+) {
+ contract { callsInPlace(onContentTypeMismatch, InvocationKind.AT_MOST_ONCE) }
+ val acceptedContentType = request.headers[HttpHeaders.Accept]
+ val receivedContentType = headers[HttpHeaders.ContentType]
+
+ when (acceptedContentType) {
+ receivedContentType, null -> return
+ }
+
+ val expectedContentType = ContentType.parse(acceptedContentType)
+
+ if (expectedContentType == ContentType.Any) return
+
+ val actualContentType = receivedContentType?.let { ContentType.parse(it) }
+ if (actualContentType?.match(expectedContentType) ?: false) return
+
+ onContentTypeMismatch(acceptedContentType, receivedContentType)
+}
diff --git a/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/internal/deviceinfo/DeviceInfo.kt b/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/internal/deviceinfo/DeviceInfo.kt
index f0ffe8bfc..b34146106 100644
--- a/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/internal/deviceinfo/DeviceInfo.kt
+++ b/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/internal/deviceinfo/DeviceInfo.kt
@@ -28,6 +28,8 @@ internal data class DeviceInfo(
val type: Type,
@SerialName("uid")
val uuidV4: String,
+ val capabilities: List,
+ val version: String?,
) {
@Serializable
enum class Type {
diff --git a/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/internal/deviceinfo/DeviceInfoUpdateManager.kt b/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/internal/deviceinfo/DeviceInfoUpdateManager.kt
index ce6bfc593..018591b3b 100644
--- a/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/internal/deviceinfo/DeviceInfoUpdateManager.kt
+++ b/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/internal/deviceinfo/DeviceInfoUpdateManager.kt
@@ -15,6 +15,8 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
+@file:OptIn(ExperimentalSplittiesApi::class)
+
package com.infomaniak.core.crossapplogin.back.internal.deviceinfo
import android.os.Build.VERSION.SDK_INT
@@ -29,6 +31,8 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.invoke
+import splitties.coroutines.suspendBlockingLazy
+import splitties.experimental.ExperimentalSplittiesApi
import splitties.init.appCtx
import java.io.DataInputStream
import java.io.DataOutputStream
@@ -46,12 +50,18 @@ class DeviceInfoUpdateManager private constructor() {
private val lastSyncedKeyDir = appCtx.filesDir.resolve("lastSyncedDeviceInfoKeys")
- private val currentAppVersion by lazy {
+ data class AppVersions(
+ val versionName: String?,
+ val versionCode: Long,
+ )
+
+ val currentAppAppVersions = suspendBlockingLazy(Dispatchers.IO) {
appCtx.packageManager.getPackageInfo(appCtx.packageName, 0).let {
- when {
+ val versionCode = when {
SDK_INT >= 28 -> it.longVersionCode
else -> @Suppress("Deprecation") it.versionCode.toLong()
}
+ AppVersions(versionName = it.versionName, versionCode = versionCode)
}
}
@@ -68,6 +78,7 @@ class DeviceInfoUpdateManager private constructor() {
)
lastSyncedAppVersion = stream.readLong()
}
+ val currentAppVersion = currentAppAppVersions().versionCode
currentAppVersion == lastSyncedAppVersion && lastSyncedUuid == crossAppDeviceId
} catch (_: FileNotFoundException) {
false
@@ -77,6 +88,7 @@ class DeviceInfoUpdateManager private constructor() {
@Throws(IOException::class)
suspend fun updateLastSyncedKey(crossAppDeviceId: Uuid, userId: Long) = Dispatchers.IO {
val lastSyncedKeyFile = lastSyncKeyFileForUser(userId)
+ val currentAppVersion = currentAppAppVersions().versionCode
lastSyncedKeyFile.write { outputStream ->
DataOutputStream(outputStream).use {
crossAppDeviceId.toLongs { mostSignificantBits: Long, leastSignificantBits: Long ->
diff --git a/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/internal/extensions/UuidForKotlin2.kt b/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/internal/extensions/UuidForKotlin2.kt
deleted file mode 100644
index a1171a1c0..000000000
--- a/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/internal/extensions/UuidForKotlin2.kt
+++ /dev/null
@@ -1,59 +0,0 @@
-/*
- * Infomaniak Core - Android
- * Copyright (C) 2025 Infomaniak Network SA
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package com.infomaniak.core.crossapplogin.back.internal.extensions
-
-import kotlin.uuid.ExperimentalUuidApi
-import kotlin.uuid.Uuid
-
-private val BYTE_TO_LOWER_CASE_HEX_DIGITS_KOTLIN2 = IntArray(256) {
- (LOWER_CASE_HEX_DIGITS_KOTLIN2[(it shr 4)].code shl 8) or LOWER_CASE_HEX_DIGITS_KOTLIN2[(it and 0xF)].code
-}
-
-private const val LOWER_CASE_HEX_DIGITS_KOTLIN2 = "0123456789abcdef"
-
-/**
- * Since in some projects we are using Realm Kotlin, we are locked to Kotlin 2.0 and can't bump to Kotlin 2.1.
- * So we extracted the Kotlin 2.1 specific code to be able to use it while we are locked to Kotlin 2.0.
- */
-@OptIn(ExperimentalUuidApi::class)
-internal fun Uuid.toHexDashStringKotlin2(): String {
- val bytes = ByteArray(36)
- toLongs { mostSignificantBits, leastSignificantBits ->
- mostSignificantBits.formatBytesIntoKotlin2(bytes, 0, startIndex = 0, endIndex = 4)
- bytes[8] = '-'.code.toByte()
- mostSignificantBits.formatBytesIntoKotlin2(bytes, 9, startIndex = 4, endIndex = 6)
- bytes[13] = '-'.code.toByte()
- mostSignificantBits.formatBytesIntoKotlin2(bytes, 14, startIndex = 6, endIndex = 8)
- bytes[18] = '-'.code.toByte()
- leastSignificantBits.formatBytesIntoKotlin2(bytes, 19, startIndex = 0, endIndex = 2)
- bytes[23] = '-'.code.toByte()
- leastSignificantBits.formatBytesIntoKotlin2(bytes, 24, startIndex = 2, endIndex = 8)
- }
- return bytes.decodeToString()
-}
-
-private fun Long.formatBytesIntoKotlin2(dst: ByteArray, dstOffset: Int, startIndex: Int, endIndex: Int) {
- var dstIndex = dstOffset
- for (reversedIndex in 7 - startIndex downTo 8 - endIndex) {
- val shift = reversedIndex shl 3
- val byte = ((this shr shift) and 0xFF).toInt()
- val byteDigits = BYTE_TO_LOWER_CASE_HEX_DIGITS_KOTLIN2[byte]
- dst[dstIndex++] = (byteDigits shr 8).toByte()
- dst[dstIndex++] = byteDigits.toByte()
- }
-}
diff --git a/TwoFactorAuth/Back/build.gradle.kts b/TwoFactorAuth/Back/build.gradle.kts
new file mode 100644
index 000000000..ab0b47ee5
--- /dev/null
+++ b/TwoFactorAuth/Back/build.gradle.kts
@@ -0,0 +1,69 @@
+/*
+ * Infomaniak Core - Android
+ * Copyright (C) 2025 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+plugins {
+ id("com.android.library")
+ alias(core.plugins.kotlin.android)
+ kotlin("plugin.serialization")
+}
+
+val coreCompileSdk: Int by rootProject.extra
+val coreMinSdk: Int by rootProject.extra
+val javaVersion: JavaVersion by rootProject.extra
+
+android {
+
+ namespace = "com.infomaniak.core.twofactorauth.back"
+ compileSdk = coreCompileSdk
+
+ defaultConfig {
+ minSdk = coreMinSdk
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = javaVersion
+ targetCompatibility = javaVersion
+ }
+
+ kotlinOptions {
+ jvmTarget = javaVersion.toString()
+ }
+}
+
+dependencies {
+ api(core.kotlinx.coroutines.core)
+ api(core.androidx.lifecycle.viewmodel.ktx)
+
+ implementation(project(":Core"))
+ implementation(project(":Core:Auth"))
+ implementation(project(":Core:Network"))
+ implementation(project(":Core:Sentry"))
+
+ implementation(core.androidx.core.ktx)
+ implementation(core.androidx.lifecycle.process)
+ implementation(core.kotlinx.serialization.json)
+ implementation(core.ktor.client.json)
+ implementation(core.ktor.client.content.negociation)
+ implementation(core.ktor.client.core)
+ implementation(core.ktor.client.okhttp)
+}
diff --git a/TwoFactorAuth/Back/src/main/kotlin/AbstractTwoFactorAuthViewModel.kt b/TwoFactorAuth/Back/src/main/kotlin/AbstractTwoFactorAuthViewModel.kt
new file mode 100644
index 000000000..474d8c6d7
--- /dev/null
+++ b/TwoFactorAuth/Back/src/main/kotlin/AbstractTwoFactorAuthViewModel.kt
@@ -0,0 +1,139 @@
+/*
+ * Infomaniak Core - Android
+ * Copyright (C) 2025 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+@file:OptIn(ExperimentalUuidApi::class, ExperimentalCoroutinesApi::class)
+
+package com.infomaniak.core.twofactorauth.back
+
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.ProcessLifecycleOwner
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.eventFlow
+import androidx.lifecycle.viewModelScope
+import com.infomaniak.core.auth.models.user.User
+import com.infomaniak.core.auth.room.UserDatabase
+import com.infomaniak.core.rateLimit
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.flow.shareIn
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.transform
+import kotlinx.coroutines.flow.update
+import okhttp3.OkHttpClient
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.TimeMark
+import kotlin.time.TimeSource
+import kotlin.uuid.ExperimentalUuidApi
+
+abstract class AbstractTwoFactorAuthViewModel : ViewModel() {
+
+ data class Challenge(
+ val data: ConnectionAttemptInfo,
+ val attemptTimeMark: TimeMark,
+ val action: ((Boolean?) -> Unit)?,
+ )
+
+ protected abstract suspend fun getConnectedHttpClient(userId: Int): OkHttpClient
+
+ private val rateLimitedForegroundEvents = ProcessLifecycleOwner.get().lifecycle.eventFlow.filter {
+ it == Lifecycle.Event.ON_START
+ }.rateLimit(30.seconds)
+
+ private val actionedChallengesFlow = MutableStateFlow>(emptyList())
+
+ /**
+ * We don't want to bring back any challenges that have been actioned, and are being sent to the backend,
+ * or have been fully handled since the most recent call to [attemptGettingAllCurrentChallenges].
+ */
+ private val firstUnactionedChallenge: Flow?> = rateLimitedForegroundEvents.map {
+ attemptGettingAllCurrentChallenges()
+ }.flatMapLatest { challengesMap ->
+ actionedChallengesFlow.map { actionedChallenges ->
+ challengesMap.firstNotNullOfOrNull { (auth, challenge) ->
+ challenge.takeIf { it !in actionedChallenges }?.let { auth to challenge }
+ }
+ }
+ }
+
+ val challengeToResolve: StateFlow = firstUnactionedChallenge.transform { unactionedChallenge ->
+ emit(null) // Start with no challenge.
+ val (twoFactorAuth, remoteChallenge) = unactionedChallenge ?: return@transform
+ val user = UserDatabase().userDao().findById(twoFactorAuth.userId)
+ ?: return@transform // User was removed in the meantime (unlikely, but possible).
+ val action = CompletableDeferred()
+ val uiChallenge = Challenge(
+ data = remoteChallenge.toConnectionAttemptInfo(user),
+ attemptTimeMark = utcTimestampToTimeMark(utcOffsetMillis = remoteChallenge.createdAt * 1000L),
+ action = { action.complete(it) }
+ )
+ emit(uiChallenge)
+ val confirmOrReject = action.await()
+ emit(uiChallenge.copy(action = null))
+ actionedChallengesFlow.update { it + remoteChallenge }
+ when (confirmOrReject) {
+ true -> twoFactorAuth.approveChallenge(remoteChallenge.uuid)
+ false -> twoFactorAuth.rejectChallenge(remoteChallenge.uuid)
+ null -> Unit
+ }
+ }.stateIn(viewModelScope, SharingStarted.Lazily, initialValue = null)
+
+ private val userIds = UserDatabase().userDao().allUsers.map { users ->
+ users.mapTo(hashSetOf()) { it.id }
+ }.distinctUntilChanged()
+
+ private val allUsersTwoFactorAuth: Flow> = userIds.mapLatest { userIds ->
+ userIds.map { id -> TwoFactorAuthImpl(getConnectedHttpClient(id), id) }
+ }.shareIn(viewModelScope, SharingStarted.Lazily, replay = 1)
+
+ private suspend fun attemptGettingAllCurrentChallenges(): Map = buildMap {
+ val currentUsersTwoFactorAuth = allUsersTwoFactorAuth.first()
+ currentUsersTwoFactorAuth.forEach { twoFactorAuth ->
+ val challenge = twoFactorAuth.tryGettingLatestChallenge() ?: return@forEach
+ put(twoFactorAuth, challenge)
+ }
+ }
+}
+
+private fun RemoteChallenge.toConnectionAttemptInfo(user: User): ConnectionAttemptInfo = ConnectionAttemptInfo(
+ targetAccount = ConnectionAttemptInfo.TargetAccount(
+ avatarUrl = user.avatar,
+ fullName = user.displayName ?: user.run { "$firstname $lastname" },
+ initials = user.getInitials(),
+ email = user.email,
+ id = user.id.toLong(),
+ ),
+ deviceOrBrowserName = device.name,
+ deviceType = device.type,
+ location = location
+)
+
+private fun utcTimestampToTimeMark(utcOffsetMillis: Long): TimeMark {
+ val nowUtcMillis = System.currentTimeMillis()
+ val elapsedMillis = nowUtcMillis - utcOffsetMillis
+ return TimeSource.Monotonic.markNow() - elapsedMillis.milliseconds
+}
diff --git a/TwoFactorAuth/Back/src/main/kotlin/ConnectionAttemptInfo.kt b/TwoFactorAuth/Back/src/main/kotlin/ConnectionAttemptInfo.kt
new file mode 100644
index 000000000..27609c4c8
--- /dev/null
+++ b/TwoFactorAuth/Back/src/main/kotlin/ConnectionAttemptInfo.kt
@@ -0,0 +1,37 @@
+/*
+ * Infomaniak Core - Android
+ * Copyright (C) 2025 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+@file:OptIn(ExperimentalUuidApi::class)
+
+package com.infomaniak.core.twofactorauth.back
+
+import kotlin.uuid.ExperimentalUuidApi
+
+data class ConnectionAttemptInfo(
+ val targetAccount: TargetAccount,
+ val deviceOrBrowserName: String,
+ val deviceType: RemoteChallenge.Device.Type?,
+ val location: String,
+) {
+ data class TargetAccount(
+ val avatarUrl: String?,
+ val fullName: String,
+ val initials: String,
+ val email: String,
+ val id: Long,
+ )
+}
diff --git a/TwoFactorAuth/Back/src/main/kotlin/RemoteChallenge.kt b/TwoFactorAuth/Back/src/main/kotlin/RemoteChallenge.kt
new file mode 100644
index 000000000..f3f2d5883
--- /dev/null
+++ b/TwoFactorAuth/Back/src/main/kotlin/RemoteChallenge.kt
@@ -0,0 +1,71 @@
+/*
+ * Infomaniak Core - Android
+ * Copyright (C) 2025 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.core.twofactorauth.back
+
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonConfiguration
+import kotlinx.serialization.json.JsonIgnoreUnknownKeys
+import kotlin.uuid.ExperimentalUuidApi
+import kotlin.uuid.Uuid
+
+/**
+ * # WARNING
+ * In the [Json] config, make sure to enable those flags:
+ * - [JsonConfiguration.coerceInputValues]
+ * - [JsonConfiguration.ignoreUnknownKeys]
+ * - [JsonConfiguration.decodeEnumsCaseInsensitive]
+ *
+ * @property type Type of challenge, `null` if unknown (and can therefore not be acted on).
+ * @property createdAt UTC timestamp (seconds offset) of when the login request happened.
+ * @property expiresAt UTC timestamp (seconds offset) of when the challenge expires.
+ */
+@ExperimentalUuidApi
+@OptIn(ExperimentalSerializationApi::class)
+@JsonIgnoreUnknownKeys
+@Serializable
+data class RemoteChallenge(
+ val uuid: Uuid,
+ val device: Device,
+ val type: Type? = null,
+ val location: String,
+ @SerialName("created_at")
+ val createdAt: Long,
+ @SerialName("expires_at")
+ val expiresAt: Long,
+) {
+ @Serializable
+ enum class Type {
+ Approval,
+ }
+
+ @Serializable
+ data class Device(
+ val name: String,
+ val type: Type? = null,
+ ) {
+ @Serializable
+ enum class Type {
+ Phone,
+ Tablet,
+ Computer,
+ }
+ }
+}
diff --git a/TwoFactorAuth/Back/src/main/kotlin/TwoFactorAuth.kt b/TwoFactorAuth/Back/src/main/kotlin/TwoFactorAuth.kt
new file mode 100644
index 000000000..8924e700e
--- /dev/null
+++ b/TwoFactorAuth/Back/src/main/kotlin/TwoFactorAuth.kt
@@ -0,0 +1,51 @@
+/*
+ * Infomaniak Core - Android
+ * Copyright (C) 2025 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.core.twofactorauth.back
+
+import java.io.IOException
+import kotlin.uuid.ExperimentalUuidApi
+import kotlin.uuid.Uuid
+
+@ExperimentalUuidApi
+interface TwoFactorAuth {
+
+ val userId: Int
+
+ /**
+ * Retrieves the latest login challenge, if any.
+ *
+ * Returns null otherwise, or in case of another issue (network or backend related).
+ *
+ * Might retry several times before returning.
+ */
+ suspend fun tryGettingLatestChallenge(): RemoteChallenge?
+
+ suspend fun approveChallenge(challengeUid: Uuid): Outcome
+
+ suspend fun rejectChallenge(challengeUid: Uuid): Outcome
+
+ sealed interface Outcome {
+ data object Success : Outcome
+ sealed interface Issue : Outcome {
+ data object Expired : Issue
+ data class ErrorResponse(val httpStatusCode: Int) : Issue
+ data class Network(val exception: IOException) : Issue
+ data class Unknown(val exception: Throwable) : Issue
+ }
+ }
+}
diff --git a/TwoFactorAuth/Back/src/main/kotlin/TwoFactorAuthImpl.kt b/TwoFactorAuth/Back/src/main/kotlin/TwoFactorAuthImpl.kt
new file mode 100644
index 000000000..6d8fc2435
--- /dev/null
+++ b/TwoFactorAuth/Back/src/main/kotlin/TwoFactorAuthImpl.kt
@@ -0,0 +1,166 @@
+/*
+ * Infomaniak Core - Android
+ * Copyright (C) 2025 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.core.twofactorauth.back
+
+import com.infomaniak.core.cancellable
+import com.infomaniak.core.network.LOGIN_ENDPOINT_URL
+import com.infomaniak.core.network.models.ApiResponse
+import com.infomaniak.core.network.networking.HttpUtils
+import com.infomaniak.core.network.networking.ManualAuthorizationRequired
+import com.infomaniak.core.sentry.SentryLog
+import com.infomaniak.core.twofactorauth.back.TwoFactorAuth.Outcome
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.engine.okhttp.OkHttp
+import io.ktor.client.plugins.HttpRequestRetry
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.client.plugins.defaultRequest
+import io.ktor.client.request.delete
+import io.ktor.client.request.get
+import io.ktor.client.request.headers
+import io.ktor.client.request.patch
+import io.ktor.client.statement.HttpResponse
+import io.ktor.client.statement.bodyAsText
+import io.ktor.http.isSuccess
+import io.ktor.http.userAgent
+import io.ktor.serialization.kotlinx.json.json
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.json.Json
+import okhttp3.OkHttpClient
+import java.io.IOException
+import kotlin.uuid.ExperimentalUuidApi
+import kotlin.uuid.Uuid
+
+@ExperimentalUuidApi
+internal class TwoFactorAuthImpl(
+ connectedHttpClient: OkHttpClient,
+ override val userId: Int,
+) : TwoFactorAuth {
+
+ private val httpClient = HttpClient(OkHttp) {
+ engine { preconfigured = connectedHttpClient }
+ install(ContentNegotiation) {
+ val jsonConfig = Json {
+ // From DefaultJson (in ktor kotlinx.serialization):
+ encodeDefaults = true
+ isLenient = true
+ allowSpecialFloatingPointValues = true
+ allowStructuredMapKeys = true
+ prettyPrint = false
+ useArrayPolymorphism = false
+
+ // Use-case specific config:
+ coerceInputValues = true // Use default values if not recognized (used for enums).
+ ignoreUnknownKeys = true // Don't break if keys are added.
+ @OptIn(ExperimentalSerializationApi::class)
+ decodeEnumsCaseInsensitive = true
+ }
+ json(jsonConfig)
+ }
+ install(HttpRequestRetry) {
+ maxRetries = 3
+ retryOnExceptionIf { request, cause -> cause !is SerializationException }
+ }
+ defaultRequest {
+ url(LOGIN_ENDPOINT_URL + "api/2fa/push/")
+ userAgent(HttpUtils.getUserAgent)
+ headers {
+ @OptIn(ManualAuthorizationRequired::class) // Already handled by the http client.
+ HttpUtils.getHeaders().forEach { (header, value) -> append(header, value) }
+ }
+ }
+ }
+
+ /**
+ * Retrieves the latest login challenge, if any.
+ *
+ * Returns null otherwise, or in case of another issue (network or backend related).
+ *
+ * Might retry several times before returning.
+ */
+ override suspend fun tryGettingLatestChallenge(): RemoteChallenge? = runCatching {
+ val response = httpClient.get("challenges")
+ val challenge: RemoteChallenge? = when {
+ response.status.isSuccess() -> response.body>().data
+ else -> {
+ val httpCode = response.status.value
+ SentryLog.e(TAG, "Failed to get the latest challenge [http $httpCode]")
+ null
+ }
+ }
+ when (challenge?.type) {
+ RemoteChallenge.Type.Approval -> Unit
+ null -> return null
+ }
+ challenge
+ }.cancellable().getOrElse { t ->
+ when (t) {
+ is IOException -> SentryLog.i(TAG, "I/O issue while trying to get the latest challenge", t)
+ else -> SentryLog.e(TAG, "Couldn't get the latest challenge because of an unknow issue", t)
+ }
+ null
+ }
+
+ override suspend fun approveChallenge(challengeUid: Uuid): Outcome = respondToChallenge(
+ actionVerb = "approve",
+ challengeUid = challengeUid,
+ ) { httpClient.patch("challenges/" + challengeUid.toHexDashString()) }
+
+ override suspend fun rejectChallenge(challengeUid: Uuid): Outcome = respondToChallenge(
+ actionVerb = "reject",
+ challengeUid = challengeUid,
+ ) { httpClient.delete("challenges/" + challengeUid.toHexDashString()) }
+
+ private suspend inline fun respondToChallenge(
+ actionVerb: String,
+ challengeUid: Uuid,
+ operation: suspend () -> HttpResponse
+ ): Outcome = runCatching {
+ SentryLog.i(TAG, "Trying to $actionVerb the challenge ($challengeUid)")
+ val response = operation()
+ when {
+ //TODO: Handle expired. Will it be http 410 Gone?
+ response.status.isSuccess() -> {
+ SentryLog.i(TAG, "Attempt to $actionVerb the challenge succeeded! ($challengeUid)")
+ Outcome.Success
+ }
+ else -> {
+ val httpCode = response.status.value
+ val bodyString = runCatching { response.bodyAsText() }.cancellable().getOrNull()
+ SentryLog.e(TAG, "Failed to $actionVerb the challenge [http $httpCode] ($challengeUid) $bodyString")
+ Outcome.Issue.ErrorResponse(httpCode)
+ }
+ }
+ }.cancellable().getOrElse { t ->
+ when (t) {
+ is IOException -> {
+ SentryLog.i(TAG, "I/O issue while trying to $actionVerb the challenge ($challengeUid)", t)
+ Outcome.Issue.Network(t)
+ }
+ else -> {
+ SentryLog.e(TAG, "Couldn't $actionVerb the challenge ($challengeUid) because of an unknow issue", t)
+ Outcome.Issue.Unknown(t)
+ }
+ }
+ }
+
+ private companion object {
+ const val TAG = "TwoFactorAuthImpl"
+ }
+}
diff --git a/TwoFactorAuth/Back/src/main/kotlin/TwoFactorAuthTestImpl.kt b/TwoFactorAuth/Back/src/main/kotlin/TwoFactorAuthTestImpl.kt
new file mode 100644
index 000000000..d29e0621d
--- /dev/null
+++ b/TwoFactorAuth/Back/src/main/kotlin/TwoFactorAuthTestImpl.kt
@@ -0,0 +1,61 @@
+/*
+ * Infomaniak Core - Android
+ * Copyright (C) 2025 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.core.twofactorauth.back
+
+import kotlinx.coroutines.delay
+import kotlin.time.Duration.Companion.seconds
+import kotlin.uuid.ExperimentalUuidApi
+import kotlin.uuid.Uuid
+
+@ExperimentalUuidApi
+class TwoFactorAuthTestImpl(
+ override val userId: Int,
+) : TwoFactorAuth {
+
+ /**
+ * Retrieves the latest login challenge, if any.
+ *
+ * Returns null otherwise, or in case of another issue (network or backend related).
+ *
+ * Might retry several times before returning.
+ */
+ override suspend fun tryGettingLatestChallenge(): RemoteChallenge? {
+ delay(2.seconds)
+ return RemoteChallenge(
+ uuid = Uuid.random(),
+ device = RemoteChallenge.Device(
+ name = "Pixel 9 Pro Fold",
+ type = RemoteChallenge.Device.Type.Phone
+ ),
+ type = RemoteChallenge.Type.Approval,
+ location = "Geneva, Switzerland",
+ createdAt = (System.currentTimeMillis() / 1000L) - 58L,
+ expiresAt = (System.currentTimeMillis() / 1000L) + 300L
+ )
+ }
+
+ override suspend fun approveChallenge(challengeUid: Uuid): TwoFactorAuth.Outcome {
+ delay(1.5.seconds)
+ return TwoFactorAuth.Outcome.Success
+ }
+
+ override suspend fun rejectChallenge(challengeUid: Uuid): TwoFactorAuth.Outcome {
+ delay(1.5.seconds)
+ return TwoFactorAuth.Outcome.Success
+ }
+}
diff --git a/TwoFactorAuth/Front/build.gradle.kts b/TwoFactorAuth/Front/build.gradle.kts
new file mode 100644
index 000000000..ff0b539e6
--- /dev/null
+++ b/TwoFactorAuth/Front/build.gradle.kts
@@ -0,0 +1,79 @@
+/*
+ * Infomaniak Core - Android
+ * Copyright (C) 2025 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+plugins {
+ id("com.android.library")
+ alias(core.plugins.kotlin.android)
+ alias(core.plugins.compose.compiler)
+}
+
+val coreCompileSdk: Int by rootProject.extra
+val coreMinSdk: Int by rootProject.extra
+val javaVersion: JavaVersion by rootProject.extra
+
+android {
+
+ namespace = "com.infomaniak.core.twofactorauth.front"
+ compileSdk = coreCompileSdk
+
+ defaultConfig {
+ minSdk = coreMinSdk
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = javaVersion
+ targetCompatibility = javaVersion
+ }
+
+ kotlinOptions {
+ jvmTarget = javaVersion.toString()
+ }
+
+ buildFeatures {
+ compose = true
+ }
+}
+
+dependencies {
+ api(project(":Core:TwoFactorAuth:Back"))
+
+ implementation(project(":Core"))
+ implementation(project(":Core:Avatar"))
+ implementation(project(":Core:Coil"))
+ implementation(project(":Core:Compose:BasicButton"))
+ implementation(project(":Core:Compose:Basics"))
+ implementation(project(":Core:Compose:Margin"))
+
+ implementation(core.androidx.core.ktx)
+
+ // Compose
+ implementation(core.coil.compose)
+ implementation(core.coil.network.okhttp)
+ implementation(platform(core.compose.bom))
+ api(core.compose.runtime)
+ implementation(core.compose.ui.android)
+ debugImplementation(core.compose.ui.tooling)
+ implementation(core.compose.material3)
+ api(core.compose.ui)
+ implementation(core.compose.ui.tooling.preview)
+}
diff --git a/TwoFactorAuth/Front/src/main/kotlin/ComposeOverlayHosting.kt b/TwoFactorAuth/Front/src/main/kotlin/ComposeOverlayHosting.kt
new file mode 100644
index 000000000..97ff25384
--- /dev/null
+++ b/TwoFactorAuth/Front/src/main/kotlin/ComposeOverlayHosting.kt
@@ -0,0 +1,50 @@
+/*
+ * Infomaniak Core - Android
+ * Copyright (C) 2025 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.core.twofactorauth.front
+
+import android.app.Activity
+import android.view.ViewGroup
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.ViewCompositionStrategy
+import androidx.core.app.ComponentActivity
+import androidx.lifecycle.lifecycleScope
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.launch
+
+fun ComponentActivity.addComposeOverlay(content: @Composable () -> Unit) = lifecycleScope.launch {
+ hostComposeOverlay(content)
+}
+
+suspend fun Activity.hostComposeOverlay(content: @Composable () -> Unit) {
+ val targetView = findViewById(android.R.id.content)
+ targetView.hostComposeOverlay(content)
+}
+
+suspend fun ViewGroup.hostComposeOverlay(content: @Composable () -> Unit): Nothing {
+ val container = ComposeView(context).apply {
+ setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool)
+ setContent(content)
+ }
+ try {
+ addView(container)
+ awaitCancellation()
+ } finally {
+ removeView(container)
+ }
+}
diff --git a/TwoFactorAuth/Front/src/main/kotlin/ConfirmLoginBottomSheet.kt b/TwoFactorAuth/Front/src/main/kotlin/ConfirmLoginBottomSheet.kt
new file mode 100644
index 000000000..c47c6442b
--- /dev/null
+++ b/TwoFactorAuth/Front/src/main/kotlin/ConfirmLoginBottomSheet.kt
@@ -0,0 +1,97 @@
+/*
+ * Infomaniak Core - Android
+ * Copyright (C) 2025 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+@file:OptIn(ExperimentalUuidApi::class)
+
+package com.infomaniak.core.twofactorauth.front
+
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.SheetState
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.unit.dp
+import com.infomaniak.core.compose.basics.WithLatestNotNull
+import com.infomaniak.core.compose.basics.rememberCallableState
+import com.infomaniak.core.twofactorauth.back.AbstractTwoFactorAuthViewModel
+import com.infomaniak.core.twofactorauth.front.components.SecurityTheme
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.ensureActive
+import kotlin.uuid.ExperimentalUuidApi
+
+@Composable
+fun ConfirmLoginAutoManagedBottomSheet(
+ twoFactorAuthViewModel: AbstractTwoFactorAuthViewModel
+) = SecurityTheme {
+ val challenge by twoFactorAuthViewModel.challengeToResolve.collectAsState()
+ ConfirmLoginAutoManagedBottomSheet(challenge)
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ConfirmLoginAutoManagedBottomSheet(challenge: AbstractTwoFactorAuthViewModel.Challenge?) {
+
+ val sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ var showTheSheet by remember { mutableStateOf(false) }
+
+ val confirmRequest = rememberCallableState()
+
+ val challengeAction = challenge?.action
+ LaunchedEffect(challengeAction) {
+ val action = challengeAction ?: return@LaunchedEffect
+ action(confirmRequest.awaitOneCall())
+ }
+ LaunchedEffect(challenge) {
+ when (challenge) {
+ null -> {
+ sheetState.hideCatching()
+ if (sheetState.isVisible.not()) showTheSheet = false
+ }
+ else -> showTheSheet = true
+ }
+ }
+
+ if (showTheSheet) challenge?.WithLatestNotNull { challenge ->
+ ModalBottomSheet(
+ onDismissRequest = { challenge.action?.invoke(null) },
+ tonalElevation = 1.dp,
+ sheetState = sheetState,
+ ) {
+ ConfirmLoginBottomSheetContent(
+ attemptTimeMark = challenge.attemptTimeMark,
+ connectionAttemptInfo = challenge.data,
+ confirmRequest = confirmRequest
+ )
+ }
+ }
+}
+
+@ExperimentalMaterial3Api
+private suspend fun SheetState.hideCatching(): Boolean = try {
+ hide()
+ true
+} catch (_: CancellationException) { // Thrown when the user swipes the sheet during hiding.
+ currentCoroutineContext().ensureActive()
+ false
+}
diff --git a/TwoFactorAuth/Front/src/main/kotlin/ConfirmLoginBottomSheetContent.kt b/TwoFactorAuth/Front/src/main/kotlin/ConfirmLoginBottomSheetContent.kt
new file mode 100644
index 000000000..0b2481487
--- /dev/null
+++ b/TwoFactorAuth/Front/src/main/kotlin/ConfirmLoginBottomSheetContent.kt
@@ -0,0 +1,272 @@
+/*
+ * Infomaniak Core - Android
+ * Copyright (C) 2025 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+@file:OptIn(ExperimentalSplittiesApi::class, ExperimentalUuidApi::class)
+
+package com.infomaniak.core.twofactorauth.front
+
+import android.content.res.Configuration
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ProvideTextStyle
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.infomaniak.core.compose.basics.CallableState
+import com.infomaniak.core.compose.basics.Dimens
+import com.infomaniak.core.compose.basics.Typography
+import com.infomaniak.core.twofactorauth.back.ConnectionAttemptInfo
+import com.infomaniak.core.twofactorauth.back.RemoteChallenge
+import com.infomaniak.core.twofactorauth.back.RemoteChallenge.Device.Type.Computer
+import com.infomaniak.core.twofactorauth.back.RemoteChallenge.Device.Type.Phone
+import com.infomaniak.core.twofactorauth.back.RemoteChallenge.Device.Type.Tablet
+import com.infomaniak.core.twofactorauth.front.components.Button
+import com.infomaniak.core.twofactorauth.front.components.CardElement
+import com.infomaniak.core.twofactorauth.front.components.CardElementPosition
+import com.infomaniak.core.twofactorauth.front.components.CardKind
+import com.infomaniak.core.twofactorauth.front.components.SecurityTheme
+import com.infomaniak.core.twofactorauth.front.components.TwoFactorAuthAvatar
+import com.infomaniak.core.twofactorauth.front.components.rememberVirtualCardState
+import com.infomaniak.core.twofactorauth.front.components.virtualCardHost
+import com.infomaniak.core.twofactorauth.front.elements.ShieldK
+import com.infomaniak.core.twofactorauth.front.elements.lightSourceBehind
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.launch
+import splitties.experimental.ExperimentalSplittiesApi
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.TimeMark
+import kotlin.time.TimeSource
+import kotlin.uuid.ExperimentalUuidApi
+
+@Composable
+fun ConfirmLoginBottomSheetContent(
+ attemptTimeMark: TimeMark,
+ connectionAttemptInfo: ConnectionAttemptInfo,
+ confirmRequest: CallableState,
+) = Surface(Modifier.fillMaxSize()) {
+ Box(Modifier, contentAlignment = Alignment.Center) {
+
+ val virtualCardState = rememberVirtualCardState(kind = if (isSystemInDarkTheme()) CardKind.Outlined else CardKind.Normal)
+ val scrollState = rememberScrollState()
+ val maxWidth = 480.dp
+ val cardOuterPadding = 16.dp
+ Column(
+ modifier = Modifier
+ .widthIn(max = maxWidth)
+ .fillMaxHeight()
+ .verticalScroll(scrollState)
+ .padding(cardOuterPadding)
+ .virtualCardHost(virtualCardState),
+ verticalArrangement = Arrangement.Bottom
+ ) {
+ Spacer(Modifier.weight(1f))
+
+ BrandedPrompt()
+
+ Spacer(Modifier.weight(1f))
+ //TODO[issue-blocked]: Replace line below with heightIn above once this is fixed:
+ // https://issuetracker.google.com/issues/294046936
+ Spacer(Modifier.height(16.dp))
+
+ CardElement(
+ virtualCardState,
+ Modifier.lightSourceBehind(maxWidth, cardOuterPadding),
+ elementPosition = CardElementPosition.First
+ ) {
+ Column(
+ Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ AccountInfoContent(connectionAttemptInfo.targetAccount)
+ HorizontalDivider()
+ InfoElement(stringResource(R.string.twoFactorAuthWhenLabel)) { TimeAgoText(attemptTimeMark) }
+ InfoElement(stringResource(R.string.twoFactorAuthDeviceLabel)) { DeviceRow(connectionAttemptInfo) }
+ InfoElement(stringResource(R.string.twoFactorAuthLocationLabel)) { Text(connectionAttemptInfo.location) }
+ }
+ }
+ CardElement(virtualCardState, Modifier.weight(1f).fillMaxWidth())
+ CardElement(virtualCardState, elementPosition = CardElementPosition.Last) {
+ ConfirmOrRejectRow(confirmRequest, Modifier.padding(16.dp))
+ }
+ }
+ }
+}
+
+@Composable
+private fun DeviceRow(connectionAttemptInfo: ConnectionAttemptInfo) {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
+ Text(connectionAttemptInfo.deviceOrBrowserName)
+ connectionAttemptInfo.deviceType?.let { DeviceIcon(it) }
+ }
+}
+
+@Composable
+private fun DeviceIcon(type: RemoteChallenge.Device.Type) {
+ val resId = when (type) {
+ Phone -> R.drawable.mobile_24dp
+ Tablet -> R.drawable.tablet_24dp
+ Computer -> R.drawable.computer_24dp
+ }
+ Icon(painterResource(resId), contentDescription = null)
+}
+
+
+@Composable
+private fun ConfirmOrRejectRow(
+ confirmRequest: CallableState,
+ modifier: Modifier = Modifier,
+) = Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally
+) {
+ Text(
+ text = stringResource(R.string.twoFactorAuthConfirmationDescription),
+ style = Typography.bodyRegular,
+ textAlign = TextAlign.Center
+ )
+ Spacer(Modifier.height(16.dp))
+ FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.CenterHorizontally),
+ ) {
+ Button(
+ modifier = Modifier.weight(1f),
+ onClick = { confirmRequest(false) },
+ colors = ButtonDefaults.filledTonalButtonColors(),
+ enabled = { confirmRequest.isAwaitingCall },
+ ) { Text(stringResource(R.string.buttonDeny), overflow = TextOverflow.Ellipsis, maxLines = 1) }
+ Button(
+ modifier = Modifier.weight(1f),
+ onClick = { confirmRequest(true) },
+ enabled = { confirmRequest.isAwaitingCall },
+ ) { Text(stringResource(R.string.buttonApprove), overflow = TextOverflow.Ellipsis, maxLines = 1) }
+ }
+}
+
+@Composable
+private fun BrandedPrompt() {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 32.dp * 1 / LocalDensity.current.fontScale),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ ShieldK()
+ Text(
+ text = stringResource(R.string.twoFactorAuthTryingToLogInTitle),
+ style = Typography.h1,
+ textAlign = TextAlign.Center
+ )
+ }
+}
+
+@Composable
+private fun AccountInfoContent(account: ConnectionAttemptInfo.TargetAccount) = Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+) {
+ TwoFactorAuthAvatar(
+ modifier = Modifier
+ .size(Dimens.bigAvatarSize)
+ .clip(CircleShape),
+ account = account
+ )
+ Column(Modifier.weight(1f)) {
+ Text(
+ text = account.fullName,
+ style = Typography.bodyMedium
+ )
+ Text(
+ text = account.email,
+ style = Typography.bodyRegular
+ )
+ }
+}
+
+@Composable
+private fun InfoElement(label: String, content: @Composable () -> Unit) {
+ Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(label, style = Typography.labelRegular)
+ ProvideTextStyle(Typography.bodyMedium) {
+ content()
+ }
+ }
+}
+
+@Preview(fontScale = 1f)
+@Preview(device = "id:Nexus One")
+@Preview(device = "spec:width=1280dp,height=800dp,dpi=240")
+@Preview(device = "spec:width=673dp,height=841dp")
+@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL)
+@Preview(fontScale = 1.6f, locale = "fr")
+@Composable
+private fun ConfirmLoginBottomSheetContentPreview() = SecurityTheme {
+ val scope = rememberCoroutineScope()
+ val confirmRequest = remember {
+ CallableState().also { scope.launch(start = CoroutineStart.UNDISPATCHED) { it.awaitOneCall() } }
+ }
+ ConfirmLoginBottomSheetContent(
+ attemptTimeMark = TimeSource.Monotonic.markNow() + 5.seconds - 1.minutes,
+ connectionAttemptInfo = ConnectionAttemptInfo(
+ targetAccount = ConnectionAttemptInfo.TargetAccount(
+ avatarUrl = "https://picsum.photos/id/140/200/200",
+ fullName = "Ellen Ripley",
+ initials = "ER",
+ email = "ellen.ripley@domaine.ch",
+ id = 2,
+ ),
+ deviceOrBrowserName = "Google Pixel Tablet",
+ deviceType = Tablet,
+ location = "Genève, Suisse",
+ ),
+ confirmRequest = confirmRequest
+ )
+}
diff --git a/TwoFactorAuth/Front/src/main/kotlin/TimeStuff.kt b/TwoFactorAuth/Front/src/main/kotlin/TimeStuff.kt
new file mode 100644
index 000000000..eeab0430a
--- /dev/null
+++ b/TwoFactorAuth/Front/src/main/kotlin/TimeStuff.kt
@@ -0,0 +1,63 @@
+@file:OptIn(ExperimentalSplittiesApi::class)
+
+package com.infomaniak.core.twofactorauth.front
+
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.produceState
+import kotlinx.coroutines.delay
+import splitties.coroutines.repeatWhileActive
+import splitties.experimental.ExperimentalSplittiesApi
+import com.infomaniak.core.time.timeToNextMinute
+import kotlin.time.Duration
+import kotlin.time.TimeMark
+
+/*
+ * Infomaniak Core - Android
+ * Copyright (C) 2025 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+@Composable
+fun TimeAgoText(timeMark: TimeMark) {
+ val text by timeMark.minutesAgoState()
+ Text(text)
+}
+
+@Composable
+fun TimeMark.minutesAgoState(): State {
+ var now = elapsedNow()
+ return produceState(initialValue = now.elapsedTimePerMinuteFormatted()) {
+ repeatWhileActive {
+ delay(now.timeToNextMinute())
+ //TODO: Race with next onStart
+ now = elapsedNow()
+ value = now.elapsedTimePerMinuteFormatted()
+ }
+ }
+}
+
+fun Duration.elapsedTimePerMinuteFormatted(): String {
+ val minutes = inWholeMinutes
+ return if (minutes == 0L) {
+ "Just now"
+ } else if (minutes < 0) {
+ "Dans $minutes minutes"
+ } else {
+ "Il y a $minutes minutes"
+ }
+}
diff --git a/TwoFactorAuth/Front/src/main/kotlin/components/Button.kt b/TwoFactorAuth/Front/src/main/kotlin/components/Button.kt
new file mode 100644
index 000000000..bb05e665a
--- /dev/null
+++ b/TwoFactorAuth/Front/src/main/kotlin/components/Button.kt
@@ -0,0 +1,67 @@
+/*
+ * Infomaniak Core - Android
+ * Copyright (C) 2025 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.core.twofactorauth.front.components
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.ButtonColors
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ButtonElevation
+import androidx.compose.material3.ProvideTextStyle
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.infomaniak.core.compose.basicbutton.BasicButton
+import com.infomaniak.core.compose.basicbutton.BasicButtonDelay
+import com.infomaniak.core.compose.basics.Typography
+import kotlin.time.Duration
+
+@Composable
+internal fun Button(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ colors: ButtonColors = ButtonDefaults.buttonColors(),
+ elevation: ButtonElevation? = ButtonDefaults.buttonElevation(),
+ border: BorderStroke? = null,
+ enabled: () -> Boolean = { true },
+ showIndeterminateProgress: () -> Boolean = { false },
+ indeterminateProgressDelay: Duration = BasicButtonDelay.Instantaneous,
+ progress: (() -> Float)? = null,
+ contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
+ content: @Composable () -> Unit,
+) {
+ BasicButton(
+ onClick = onClick,
+ modifier = modifier.heightIn(min = 56.dp),
+ shape = RoundedCornerShape(16.dp),
+ colors = colors,
+ elevation = elevation,
+ border = border,
+ enabled = enabled,
+ showIndeterminateProgress = showIndeterminateProgress,
+ indeterminateProgressDelay = indeterminateProgressDelay,
+ progress = progress,
+ contentPadding = contentPadding,
+ ) {
+ ProvideTextStyle(Typography.bodyMedium) {
+ content()
+ }
+ }
+}
diff --git a/TwoFactorAuth/Front/src/main/kotlin/components/SecurityTheme.kt b/TwoFactorAuth/Front/src/main/kotlin/components/SecurityTheme.kt
new file mode 100644
index 000000000..72667c7bb
--- /dev/null
+++ b/TwoFactorAuth/Front/src/main/kotlin/components/SecurityTheme.kt
@@ -0,0 +1,55 @@
+/*
+ * Infomaniak Core - Android
+ * Copyright (C) 2025 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.core.twofactorauth.front.components
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+
+@Composable
+fun SecurityTheme(
+ isInDarkTheme: Boolean = isSystemInDarkTheme(),
+ content: @Composable () -> Unit
+) = MaterialTheme(
+ colorScheme = when {
+ isInDarkTheme -> darkColorScheme(
+ primary = Color(0xFF_5869D9),
+ onPrimary = Color(0xFF_EAECFA),
+ secondary = Color.Red,
+ secondaryContainer = Color(0xFF_ABB4EC),
+ onSecondaryContainer = Color(0xFF_121B53),
+ surface = Color(0xFF_191919),
+ surfaceContainerLow = Color(0xFF_191919),
+ surfaceContainerHighest = Color(0xFF_191919),
+ )
+ else -> lightColorScheme(
+ primary = Color(0xFF_3243AE),
+ onPrimary = Color(0xFF_EAECFA),
+ secondaryContainer = Color(0xFF_D5D9F5),
+ onSecondaryContainer = Color(0xFF_121B53),
+ surface = Color(0xFF_F4F6FD),
+ surfaceContainerLow = Color(0xFF_F4F6FD),
+ surfaceContainer = Color.White,
+ surfaceContainerHighest = Color.White,
+ )
+ },
+ content = content
+)
diff --git a/TwoFactorAuth/Front/src/main/kotlin/components/TwoFactorAuthAvatar.kt b/TwoFactorAuth/Front/src/main/kotlin/components/TwoFactorAuthAvatar.kt
new file mode 100644
index 000000000..7f2caf2f3
--- /dev/null
+++ b/TwoFactorAuth/Front/src/main/kotlin/components/TwoFactorAuthAvatar.kt
@@ -0,0 +1,88 @@
+/*
+ * Infomaniak Core - Android
+ * Copyright (C) 2025 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.core.twofactorauth.front.components
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.infomaniak.core.avatar.components.Avatar
+import com.infomaniak.core.avatar.getBackgroundColorResBasedOnId
+import com.infomaniak.core.avatar.models.AvatarColors
+import com.infomaniak.core.avatar.models.AvatarType
+import com.infomaniak.core.avatar.models.AvatarUrlData
+import com.infomaniak.core.coil.ImageLoaderProvider
+import com.infomaniak.core.twofactorauth.back.ConnectionAttemptInfo
+
+@Composable
+internal fun TwoFactorAuthAvatar(
+ modifier: Modifier = Modifier,
+ account: ConnectionAttemptInfo.TargetAccount,
+ strokeColor: Color? = null,
+) {
+ val context = LocalContext.current
+ val unauthenticatedImageLoader = remember(context) { ImageLoaderProvider.newImageLoader(context) }
+
+ Avatar(
+ avatarType = AvatarType.getUrlOrInitials(
+ account.avatarUrl?.let { AvatarUrlData(it, unauthenticatedImageLoader) },
+ initials = account.initials,
+ colors = AvatarColors(
+ // TODO: Adapt colors correctly for each app
+ containerColor = Color(context.getBackgroundColorResBasedOnId(account.id.toInt())),
+ // TODO: Adapt colors correctly for each app
+ contentColor = getDefaultIconColor(),
+ ),
+ ),
+ modifier = modifier,
+ border = strokeColor?.let { BorderStroke(width = 1.dp, color = it) },
+ )
+}
+
+// Copied from old UserAvatar module to keep old behavior
+// TODO: Remove this when using CoreUi
+private const val iconColorDark = 0xFF333333
+
+@Composable
+private fun getDefaultIconColor() = if (isSystemInDarkTheme()) Color(iconColorDark) else Color.White
+
+@Preview
+@Composable
+private fun Preview() {
+ MaterialTheme {
+ Surface {
+ TwoFactorAuthAvatar(
+ account = ConnectionAttemptInfo.TargetAccount(
+ avatarUrl = "https://picsum.photos/id/140/200/200",
+ fullName = "John Doe",
+ initials = "JD",
+ email = "john.doe@ik.me",
+ id = 2,
+ ),
+ strokeColor = MaterialTheme.colorScheme.surface,
+ )
+ }
+ }
+}
diff --git a/TwoFactorAuth/Front/src/main/kotlin/components/VirtualBorder.kt b/TwoFactorAuth/Front/src/main/kotlin/components/VirtualBorder.kt
new file mode 100644
index 000000000..fb13276dd
--- /dev/null
+++ b/TwoFactorAuth/Front/src/main/kotlin/components/VirtualBorder.kt
@@ -0,0 +1,238 @@
+/*
+ * Infomaniak Core - Android
+ * Copyright (C) 2025 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.core.twofactorauth.front.components
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.shape.CornerBasedShape
+import androidx.compose.foundation.shape.CornerSize
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardColors
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CardElevation
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithCache
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.drawOutline
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.drawscope.translate
+import androidx.compose.ui.layout.onPlaced
+import androidx.compose.ui.layout.positionInParent
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+
+enum class CardKind {
+ Outlined,
+ Elevated,
+ Normal,
+}
+
+@Composable
+fun rememberVirtualCardState(kind: CardKind): VirtualCardState = rememberVirtualCardState(
+ shape = when (kind) {
+ CardKind.Outlined -> CardDefaults.outlinedShape
+ CardKind.Normal -> CardDefaults.shape
+ CardKind.Elevated -> CardDefaults.elevatedShape
+ },
+ colors = when (kind) {
+ CardKind.Outlined -> CardDefaults.outlinedCardColors()
+ CardKind.Elevated -> CardDefaults.elevatedCardColors()
+ CardKind.Normal -> CardDefaults.cardColors()
+ },
+ elevation = when (kind) {
+ CardKind.Outlined -> CardDefaults.outlinedCardElevation()
+ CardKind.Elevated -> CardDefaults.elevatedCardElevation()
+ CardKind.Normal -> CardDefaults.cardElevation()
+ },
+ border = when (kind) {
+ CardKind.Outlined -> CardDefaults.outlinedCardBorder()
+ else -> null
+ }
+)
+
+@Composable
+fun rememberVirtualCardState(
+ shape: Shape = CardDefaults.shape,
+ colors: CardColors = CardDefaults.outlinedCardColors(),
+ elevation: CardElevation = CardDefaults.outlinedCardElevation(),
+ border: BorderStroke?
+): VirtualCardState {
+ val colorsState = rememberUpdatedState(colors)
+ val elevationState = rememberUpdatedState(elevation)
+ val borderState = rememberUpdatedState(border)
+ return remember(shape) {
+ VirtualCardStateImpl(shape, colorsState, elevationState, borderState)
+ }
+}
+
+/**
+ * # WARNING: Make sure you put this after any padding to get the desired placement.
+ */
+fun Modifier.virtualCardHost(borderState: VirtualCardState): Modifier = drawWithCache {
+ when (borderState) {
+ is VirtualCardStateImpl -> Unit
+ }
+ val border = borderState.borderState.value ?: return@drawWithCache onDrawBehind {}
+
+ val drawStyle = Stroke(width = border.width.toPx())
+ val outline = borderState.shape.createOutline(
+ size = borderState.size.offsetSize(borderState.topLeft),
+ layoutDirection = layoutDirection,
+ density = this
+ )
+ onDrawWithContent {
+ drawContent()
+ translate(left = borderState.topLeft.x, top = borderState.topLeft.y) {
+ drawOutline(
+ outline = outline,
+ brush = border.brush,
+ style = drawStyle
+ )
+ }
+ }
+}
+
+enum class CardElementPosition {
+ First,
+ Middle,
+ Last,
+}
+
+@Composable
+fun CardElement(
+ virtualCardState: VirtualCardState,
+ modifier: Modifier = Modifier,
+ elementPosition: CardElementPosition = CardElementPosition.Middle,
+ content: @Composable ColumnScope.() -> Unit = {}
+) {
+ val shape = when (elementPosition) {
+ CardElementPosition.First -> virtualCardState.firstElementShape
+ CardElementPosition.Middle -> virtualCardState.middleElementsShape
+ CardElementPosition.Last -> virtualCardState.lastElementShape
+ }
+ Card(
+ modifier = when (elementPosition) {
+ CardElementPosition.First -> modifier.virtualBorderTopLeftCorner(virtualCardState)
+ CardElementPosition.Middle -> modifier
+ CardElementPosition.Last -> modifier.virtualBorderBottomLeftCorner(virtualCardState)
+ },
+ shape = shape,
+ colors = virtualCardState.colors,
+ elevation = virtualCardState.elevation,
+ content = content
+ )
+}
+
+fun Modifier.virtualBorderTopLeftCorner(borderState: VirtualCardState): Modifier = onPlaced {
+ when (borderState) {
+ is VirtualCardStateImpl -> Unit
+ }
+ borderState.topLeft = it.positionInParent()
+}
+
+fun Modifier.virtualBorderBottomLeftCorner(borderState: VirtualCardState): Modifier = onPlaced {
+ when (borderState) {
+ is VirtualCardStateImpl -> Unit
+ }
+ borderState.size = it.positionInParent() + it.size
+}
+
+sealed interface VirtualCardState {
+ val firstElementShape: Shape
+ val lastElementShape: Shape
+ val middleElementsShape: Shape
+
+ val colors: CardColors
+ val elevation: CardElevation
+
+ val topLeft: Offset
+ val size: Size
+}
+
+private operator fun IntSize.plus(offset: Offset): Size {
+ return Size(width = width + offset.x, height = height + offset.y)
+}
+
+private operator fun Offset.plus(size: IntSize): Size {
+ return Size(width = x + size.width, height = y + size.height)
+}
+
+/** Helper method to offset the provided size with the offset in box width and height */
+private fun Size.offsetSize(offset: Offset): Size =
+ Size(this.width - offset.x, this.height - offset.y)
+
+@Stable
+private class VirtualCardStateImpl(
+ val shape: Shape,
+ colorsState: State,
+ elevationState: State,
+ val borderState: State,
+) : VirtualCardState {
+
+ override val firstElementShape: Shape = (shape as? CornerBasedShape)?.removeCorners(
+ keepTopCorners = true,
+ keepBottomCorners = false
+ ) ?: shape
+
+ override val lastElementShape = (shape as? CornerBasedShape)?.removeCorners(
+ keepTopCorners = false,
+ keepBottomCorners = true
+ ) ?: shape
+
+ override val middleElementsShape = RectangleShape
+
+ override val colors: CardColors by colorsState
+ override val elevation: CardElevation by elevationState
+ private var topLeftValue by mutableLongStateOf(0L)
+ private var sizeValue by mutableLongStateOf(0L)
+
+ override var topLeft: Offset
+ get() = Offset(packedValue = topLeftValue)
+ set(value) {
+ topLeftValue = value.packedValue
+ }
+
+ override var size: Size
+ get() = Size(packedValue = sizeValue)
+ set(value) {
+ sizeValue = value.packedValue
+ }
+}
+
+private fun CornerBasedShape.removeCorners(
+ keepTopCorners: Boolean,
+ keepBottomCorners: Boolean
+): CornerBasedShape = copy(
+ topStart = if (keepTopCorners) topStart else zeroCorner,
+ topEnd = if (keepTopCorners) topEnd else zeroCorner,
+ bottomStart = if (keepBottomCorners) bottomStart else zeroCorner,
+ bottomEnd = if (keepBottomCorners) bottomEnd else zeroCorner,
+)
+
+private val zeroCorner = CornerSize(0.dp)
diff --git a/TwoFactorAuth/Front/src/main/kotlin/elements/LightSourceBehind.kt b/TwoFactorAuth/Front/src/main/kotlin/elements/LightSourceBehind.kt
new file mode 100644
index 000000000..480478019
--- /dev/null
+++ b/TwoFactorAuth/Front/src/main/kotlin/elements/LightSourceBehind.kt
@@ -0,0 +1,47 @@
+/*
+ * Infomaniak Core - Android
+ * Copyright (C) 2025 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.core.twofactorauth.front.elements
+
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.res.imageResource
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.toIntSize
+import com.infomaniak.core.twofactorauth.front.R
+
+fun Modifier.lightSourceBehind(
+ maxWidth: Dp,
+ cardOuterPadding: Dp,
+): Modifier = composed {
+ val img = ImageBitmap.imageResource(R.drawable.background_light_source)
+ drawBehind {
+ val h = 192.dp.toPx()
+ val paddingPx = cardOuterPadding.toPx()
+ val imgSize = Size((size.width * 2 + paddingPx * 2).coerceAtMost(maxWidth.toPx()), height = h)
+ drawImage(
+ image = img,
+ dstOffset = IntOffset(x = 0, (-h / 2f).toInt()),
+ dstSize = imgSize.toIntSize()
+ )
+ }
+}
diff --git a/TwoFactorAuth/Front/src/main/kotlin/elements/ShieldK.kt b/TwoFactorAuth/Front/src/main/kotlin/elements/ShieldK.kt
new file mode 100644
index 000000000..84f80511d
--- /dev/null
+++ b/TwoFactorAuth/Front/src/main/kotlin/elements/ShieldK.kt
@@ -0,0 +1,46 @@
+/*
+ * Infomaniak Core - Android
+ * Copyright (C) 2025 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.core.twofactorauth.front.elements
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+
+@Preview
+@Composable
+fun ShieldK(modifier: Modifier = Modifier) = Box(
+ modifier = modifier
+ .size(48.dp)
+ .background(MaterialTheme.colorScheme.primary, CircleShape),
+ contentAlignment = Alignment.Center
+) {
+ Image(
+ imageVector = shieldKIcon,
+ contentDescription = null,
+ modifier = Modifier
+ .size(24.dp)
+ )
+}
diff --git a/TwoFactorAuth/Front/src/main/kotlin/elements/ShieldKIcon.kt b/TwoFactorAuth/Front/src/main/kotlin/elements/ShieldKIcon.kt
new file mode 100644
index 000000000..5dc19c8f1
--- /dev/null
+++ b/TwoFactorAuth/Front/src/main/kotlin/elements/ShieldKIcon.kt
@@ -0,0 +1,69 @@
+package com.infomaniak.core.twofactorauth.front.elements
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.StrokeJoin
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.graphics.vector.path
+import androidx.compose.ui.unit.dp
+
+val shieldKIcon: ImageVector
+ get() {
+ if (_shieldK != null) return _shieldK!!
+
+ _shieldK = ImageVector.Builder(
+ name = "shieldk",
+ defaultWidth = 23.dp,
+ defaultHeight = 26.dp,
+ viewportWidth = 23f,
+ viewportHeight = 26f
+ ).apply {
+ path(
+ stroke = SolidColor(Color(0xFFFFFFFF)),
+ strokeLineWidth = 1.7185f,
+ strokeLineCap = StrokeCap.Round,
+ strokeLineJoin = StrokeJoin.Round
+ ) {
+ moveTo(1.02884f, 4.32734f)
+ verticalLineTo(12.5052f)
+ curveTo(1.02887f, 15.0902f, 1.81218f, 17.6143f, 3.27552f, 19.7452f)
+ curveTo(4.73887f, 21.8759f, 6.81355f, 23.5133f, 9.22608f, 24.4413f)
+ lineTo(10.3442f, 24.8709f)
+ curveTo(11.0888f, 25.1573f, 11.9131f, 25.1573f, 12.6577f, 24.8709f)
+ lineTo(13.7758f, 24.4413f)
+ curveTo(16.1883f, 23.5133f, 18.263f, 21.8759f, 19.7264f, 19.7452f)
+ curveTo(21.1897f, 17.6143f, 21.973f, 15.0902f, 21.973f, 12.5052f)
+ verticalLineTo(4.32734f)
+ curveTo(21.9751f, 4.0196f, 21.8882f, 3.71782f, 21.7229f, 3.45829f)
+ curveTo(21.5575f, 3.19878f, 21.3206f, 2.99256f, 21.0407f, 2.86447f)
+ curveTo(18.0333f, 1.55174f, 14.7824f, 0.888899f, 11.5009f, 0.919338f)
+ curveTo(8.21954f, 0.888899f, 4.96869f, 1.55174f, 1.96113f, 2.86447f)
+ curveTo(1.68132f, 2.99256f, 1.44446f, 3.19878f, 1.27908f, 3.45829f)
+ curveTo(1.11368f, 3.71782f, 1.02679f, 4.0196f, 1.02884f, 4.32734f)
+ close()
+ }
+ path(
+ fill = SolidColor(Color(0xFFFFFFFF))
+ ) {
+ moveTo(7.07764f, 5.99896f)
+ horizontalLineTo(10.4208f)
+ verticalLineTo(12.0345f)
+ lineTo(12.8638f, 9.22432f)
+ horizontalLineTo(16.8901f)
+ lineTo(13.8282f, 12.1942f)
+ lineTo(17.0669f, 17.7029f)
+ horizontalLineTo(13.3782f)
+ lineTo(11.6503f, 14.3019f)
+ lineTo(10.4208f, 15.4994f)
+ verticalLineTo(17.7029f)
+ horizontalLineTo(7.07764f)
+ verticalLineTo(5.99896f)
+ close()
+ }
+ }.build()
+
+ return _shieldK!!
+ }
+
+private var _shieldK: ImageVector? = null
diff --git a/TwoFactorAuth/Front/src/main/res/drawable/background_light_source.webp b/TwoFactorAuth/Front/src/main/res/drawable/background_light_source.webp
new file mode 100644
index 000000000..a1d6fb3dd
Binary files /dev/null and b/TwoFactorAuth/Front/src/main/res/drawable/background_light_source.webp differ
diff --git a/TwoFactorAuth/Front/src/main/res/drawable/computer_24dp.xml b/TwoFactorAuth/Front/src/main/res/drawable/computer_24dp.xml
new file mode 100644
index 000000000..551fba552
--- /dev/null
+++ b/TwoFactorAuth/Front/src/main/res/drawable/computer_24dp.xml
@@ -0,0 +1,27 @@
+
+
+
+
diff --git a/TwoFactorAuth/Front/src/main/res/drawable/mobile_24dp.xml b/TwoFactorAuth/Front/src/main/res/drawable/mobile_24dp.xml
new file mode 100644
index 000000000..0c8a325f3
--- /dev/null
+++ b/TwoFactorAuth/Front/src/main/res/drawable/mobile_24dp.xml
@@ -0,0 +1,27 @@
+
+
+
+
diff --git a/TwoFactorAuth/Front/src/main/res/drawable/tablet_24dp.xml b/TwoFactorAuth/Front/src/main/res/drawable/tablet_24dp.xml
new file mode 100644
index 000000000..bbe8d51ba
--- /dev/null
+++ b/TwoFactorAuth/Front/src/main/res/drawable/tablet_24dp.xml
@@ -0,0 +1,27 @@
+
+
+
+
diff --git a/TwoFactorAuth/Front/src/main/res/values-de/strings.xml b/TwoFactorAuth/Front/src/main/res/values-de/strings.xml
new file mode 100644
index 000000000..341925f27
--- /dev/null
+++ b/TwoFactorAuth/Front/src/main/res/values-de/strings.xml
@@ -0,0 +1,16 @@
+
+
+ Genehmigen Sie
+ Verweigern
+ Passwort ändern
+ Durch die Bestätigung dieser Verbindung wird diesem Gerät der Zugriff auf Ihr Infomaniak-Konto gestattet.
+ Diese Verbindung wurde bereits von einem anderen Gerät aus bestätigt
+ Diese Verbindungsanfrage ist abgelaufen
+ Sie haben einen Anmeldeversuch abgelehnt. Um Ihr Konto zu sichern, ändern Sie Ihr Passwort.nWenn Sie diesen Versuch veranlasst haben, versuchen Sie es erneut, sich anzumelden.
+ Verbindung Abgelehnt
+ Sie haben die Validierung nicht veranlasst? Sehen Sie sich die Aktivitäten Ihrer zuletzt verwendeten Geräte an.
+ Gerät
+ Ort
+ Versuchen Sie, sich anzumelden?
+ Wenn
+
diff --git a/TwoFactorAuth/Front/src/main/res/values-es/strings.xml b/TwoFactorAuth/Front/src/main/res/values-es/strings.xml
new file mode 100644
index 000000000..0a535c312
--- /dev/null
+++ b/TwoFactorAuth/Front/src/main/res/values-es/strings.xml
@@ -0,0 +1,16 @@
+
+
+ Aprobar
+ Denegar
+ Cambiar contraseña
+ Al confirmar esta conexión, autorizas a este dispositivo a acceder a tu cuenta Infomaniak.
+ Esta conexión ya ha sido validada desde otro dispositivo
+ Esta solicitud de conexión ha expirado
+ Ha rechazado un intento de conexión. Para asegurar su cuenta, cambie su contraseña. Si usted es el origen de este intento, intente conectarse de nuevo.
+ Conexión rechazada
+ ¿No has iniciado esta validación? Comprueba la actividad de tus últimos dispositivos utilizados.
+ Dispositivo
+ Lugar
+ ¿Está intentando conectarse?
+ En
+
diff --git a/TwoFactorAuth/Front/src/main/res/values-fr/strings.xml b/TwoFactorAuth/Front/src/main/res/values-fr/strings.xml
new file mode 100644
index 000000000..13a536103
--- /dev/null
+++ b/TwoFactorAuth/Front/src/main/res/values-fr/strings.xml
@@ -0,0 +1,16 @@
+
+
+ Approuver
+ Refuser
+ Modifier le mot de passe
+ Confirmer cette connexion autorisera cet appareil à accéder à votre compte Infomaniak
+ Cette connexion a déjà été validée depuis un autre appareil
+ Cette demande de connexion a expiré
+ Vous avez refusé une tentative de connexion. Pour sécuriser votre compte, changez votre mot de passe.\\nSi vous êtes à l’origine de cette tentative, réessayez de vous connecter.
+ Connexion Refusée
+ Vous n’êtes pas à l’origine de cette validation ? Consultez l’activité de vos derniers appareils utilisés.
+ Appareil
+ Lieu
+ Êtes-vous en train d’essayer de vous connecter ?
+ Quand
+
diff --git a/TwoFactorAuth/Front/src/main/res/values-it/strings.xml b/TwoFactorAuth/Front/src/main/res/values-it/strings.xml
new file mode 100644
index 000000000..fe290b5fb
--- /dev/null
+++ b/TwoFactorAuth/Front/src/main/res/values-it/strings.xml
@@ -0,0 +1,16 @@
+
+
+ Approvare
+ Negare
+ Modifica della password
+ Confermando questa connessione, autorizzi questo dispositivo ad accedere al tuo account Infomaniak.
+ Questa connessione è già stata convalidata da un altro dispositivo
+ Questa richiesta di connessione è scaduta
+ È stato rifiutato un tentativo di connessione. Per proteggere il vostro account, cambiate la password. Se siete voi la fonte di questo tentativo, provate ad accedere di nuovo.
+ Connessione rifiutata
+ Non avete avviato la convalida? Controllare l\'attività degli ultimi dispositivi utilizzati.
+ Dispositivo
+ Luogo
+ Stai cercando di accedere?
+ Quando
+
diff --git a/TwoFactorAuth/Front/src/main/res/values/strings.xml b/TwoFactorAuth/Front/src/main/res/values/strings.xml
new file mode 100644
index 000000000..389be9adb
--- /dev/null
+++ b/TwoFactorAuth/Front/src/main/res/values/strings.xml
@@ -0,0 +1,16 @@
+
+
+ Approve
+ Deny
+ Modify password
+ Confirming this login attempt will allow that device to access your Infomaniak account
+ This connection has already been validated from another device
+ This connection request has expired
+ You have refused a login attempt. To secure your account, change your password. If you are the source of this attempt, try logging in again.
+ Connection Refused
+ You didn\'t initiate this validation? Check the activity of your last used devices.
+ Device
+ Location
+ Are you trying to login?
+ When
+
diff --git a/gradle/core.versions.toml b/gradle/core.versions.toml
index 08d9ee758..5fd51ffa5 100644
--- a/gradle/core.versions.toml
+++ b/gradle/core.versions.toml
@@ -56,6 +56,7 @@ androidx-datastore-preferences = { module = "androidx.datastore:datastore-prefer
androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycleRuntimeKtx" }
+androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerView" }
androidx-work-runtime = { module = "androidx.work:work-runtime", version.ref = "workManager" }
diff --git a/src/main/kotlin/com/infomaniak/core/time/TimeToNextWhole.kt b/src/main/kotlin/com/infomaniak/core/time/TimeToNextWhole.kt
new file mode 100644
index 000000000..c5c9e772d
--- /dev/null
+++ b/src/main/kotlin/com/infomaniak/core/time/TimeToNextWhole.kt
@@ -0,0 +1,34 @@
+/*
+ * Infomaniak Core - Android
+ * Copyright (C) 2025 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.core.time
+
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.days
+import kotlin.time.Duration.Companion.hours
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Duration.Companion.seconds
+
+fun Duration.timeOffFromWholeDay(): Duration = this - this.inWholeDays.days
+fun Duration.timeOffFromWholeHour(): Duration = this - this.inWholeHours.hours
+fun Duration.timeOffFromWholeMinute(): Duration = this - this.inWholeMinutes.minutes
+fun Duration.timeOffFromWholeSecond(): Duration = this - this.inWholeSeconds.seconds
+
+fun Duration.timeToNextDay(): Duration = 1.days - timeOffFromWholeDay()
+fun Duration.timeToNextHour(): Duration = 1.hours - timeOffFromWholeHour()
+fun Duration.timeToNextMinute(): Duration = 1.minutes - timeOffFromWholeMinute()
+fun Duration.timeToNextSecond(): Duration = 1.seconds - timeOffFromWholeSecond()