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