Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
2721ae3
feat(TwoFactorAuth): Add initial UI
LouisCAD Aug 25, 2025
029213e
feat(TwoFactorAuth): Add RemoteChallenge model for JSON
LouisCAD Aug 27, 2025
400da53
feat(TwoFactorAuth): Add abstraction for TwoFactorAuth challenges
LouisCAD Aug 27, 2025
29ef990
feat(TwoFactorAuth): Update TwoFactorAuth UI style
LouisCAD Aug 28, 2025
254a6aa
feat(TwoFactorAuth): Update TwoFactorAuth UI
LouisCAD Sep 1, 2025
d6f7656
feat(TwoFactorAuth): Implement TwoFactorAuth
LouisCAD Sep 4, 2025
2e31cf3
chore(TwoFactorAuth): Add TODOs
LouisCAD Sep 4, 2025
c113583
feat(TwoFactorAuth): Add AbstractTwoFactorAuthViewModel and update re…
LouisCAD Sep 8, 2025
a9151a9
chore(TwoFactorAuth): Update SecurityTheme for bottom sheet
LouisCAD Sep 9, 2025
048cb46
chore(TwoFactorAuth): Improve modal bottom sheet showing logic
LouisCAD Sep 9, 2025
ac25997
feat(TwoFactorAuth): Add addComposeOverlay and hostComposeOverlay hel…
LouisCAD Sep 9, 2025
2dabf52
refactor(TwoFactorAuth): Simplify implementation of
LouisCAD Sep 9, 2025
9fac068
chore(TwoFactorAuth): Add TwoFactorAuthTestImpl to test the UI
LouisCAD Sep 9, 2025
7b5e8f9
chore(TwoFactorAuth): Update RemoteChallenge backend model
LouisCAD Sep 22, 2025
f3161f0
chore(TwoFactorAuth): Switch back to real TwoFactorAuth impl (from te…
LouisCAD Sep 22, 2025
251fc0c
chore: Replace no longer needed UuidForKotlin2 with stdlib usage
LouisCAD Sep 23, 2025
aa24762
chore(TwoFactorAuth): Add capabilities and version info in DeviceInfo
LouisCAD Sep 23, 2025
861cbff
fix: Add correct user agent when sending device info
LouisCAD Sep 23, 2025
f8f4698
chore: Add Content-Type validation check
LouisCAD Sep 25, 2025
bd5c078
chore: Update JSON config to match DefaultJson from ktor
LouisCAD Sep 25, 2025
bbe49ad
chore(TwoFactorAuth): Add translations
LouisCAD Sep 25, 2025
1eb52a8
chore: Don't send versionCode to the backend
LouisCAD Sep 25, 2025
d651796
chore(TwoFactorAuth): Add device type icon
LouisCAD Sep 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/
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> 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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
@file:OptIn(ExperimentalCoroutinesApi::class, ExperimentalSplittiesApi::class, ExperimentalContracts::class)

package com.infomaniak.core.crossapplogin.back.internal.deviceinfo

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<Long, Deferred<Outcome>>(
cacheManager = { userId, deferred ->
Expand Down Expand Up @@ -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()) {
Expand All @@ -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
}
Expand All @@ -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 {
Expand All @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ internal data class DeviceInfo(
val type: Type,
@SerialName("uid")
val uuidV4: String,
val capabilities: List<String>,
val version: String?,
) {
@Serializable
enum class Type {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalSplittiesApi::class)

package com.infomaniak.core.crossapplogin.back.internal.deviceinfo

import android.os.Build.VERSION.SDK_INT
Expand All @@ -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
Expand All @@ -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)
}
}

Expand All @@ -68,6 +78,7 @@ class DeviceInfoUpdateManager private constructor() {
)
lastSyncedAppVersion = stream.readLong()
}
val currentAppVersion = currentAppAppVersions().versionCode
currentAppVersion == lastSyncedAppVersion && lastSyncedUuid == crossAppDeviceId
} catch (_: FileNotFoundException) {
false
Expand All @@ -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 ->
Expand Down

This file was deleted.

Loading
Loading