From 6929bf2c7390ed45e009a83ff1f159d7b14271b8 Mon Sep 17 00:00:00 2001 From: Jakub Zerko Date: Wed, 29 Apr 2026 19:51:46 +0200 Subject: [PATCH 1/8] feat: add e2ei expiration control in debug tools and shake routing by flavor --- .../android/di/accountScoped/DebugModule.kt | 13 ++ .../com/wire/android/ui/WireActivity.kt | 19 ++- .../wire/android/ui/debug/DebugDataOptions.kt | 111 +++++++++++++++--- .../android/ui/debug/DebugDataOptionsState.kt | 1 + .../ui/debug/DebugDataOptionsViewModel.kt | 42 +++++++ app/src/main/res/values/strings.xml | 7 +- .../debug/DebugDataOptionsViewModelTest.kt | 53 ++++++++- kalium | 2 +- 8 files changed, 226 insertions(+), 22 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/DebugModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/DebugModule.kt index 347a4bbc6ed..039c6c53c29 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/DebugModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/DebugModule.kt @@ -23,9 +23,11 @@ import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.debug.BreakSessionUseCase import com.wire.kalium.logic.feature.debug.DebugScope +import com.wire.kalium.logic.feature.debug.GetDebugE2EICertificateExpirationUseCase import com.wire.kalium.logic.feature.debug.GetFeatureConfigUseCase import com.wire.kalium.logic.feature.debug.GetConversationEpochFromCCUseCase import com.wire.kalium.logic.feature.debug.RepairFaultyRemovalKeysUseCase +import com.wire.kalium.logic.feature.debug.SetDebugE2EICertificateExpirationUseCase import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -34,6 +36,7 @@ import dagger.hilt.android.scopes.ViewModelScoped @Module @InstallIn(ViewModelComponent::class) +@Suppress("TooManyFunctions") class DebugModule { @ViewModelScoped @@ -80,6 +83,16 @@ class DebugModule { @Provides fun provideFeatureConfigUseCase(debugScope: DebugScope): GetFeatureConfigUseCase = debugScope.getFeatureConfig + @ViewModelScoped + @Provides + fun provideGetDebugE2EICertificateExpirationUseCase(debugScope: DebugScope): GetDebugE2EICertificateExpirationUseCase = + debugScope.getDebugE2EICertificateExpiration + + @ViewModelScoped + @Provides + fun provideSetDebugE2EICertificateExpirationUseCase(debugScope: DebugScope): SetDebugE2EICertificateExpirationUseCase = + debugScope.setDebugE2EICertificateExpiration + @ViewModelScoped @Provides fun provideGetConversationEpochFromCCUseCase(debugScope: DebugScope): GetConversationEpochFromCCUseCase = diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt index e8739b95654..2bd547acbbb 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt @@ -55,6 +55,7 @@ import androidx.navigation.NavController import androidx.navigation.compose.currentBackStackEntryAsState import com.ramcosta.composedestinations.generated.app.destinations.E2EIEnrollmentScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.E2EiCertificateDetailsScreenDestination +import com.ramcosta.composedestinations.generated.app.destinations.DebugScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.HomeScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.LogManagementScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.LoginScreenDestination @@ -385,7 +386,7 @@ class WireActivity : BaseActivity() { shakeDetector.observeShakes() .flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED) .collectLatest { - handleLogManagementShake(currentNavigator) + handleShakeShortcut(currentNavigator) } } } @@ -695,12 +696,22 @@ class WireActivity : BaseActivity() { } } - private fun handleLogManagementShake(navigator: Navigator) { + private fun handleShakeShortcut(navigator: Navigator) { runOnUiThread { val currentRoute = navigator.navController.currentDestination?.route?.getBaseRoute() - val targetRoute = LogManagementScreenDestination.baseRoute + val shouldOpenDebugTools = BuildConfig.PRIVATE_BUILD && BuildConfig.DEBUG_SCREEN_ENABLED + val targetRoute = if (shouldOpenDebugTools) { + DebugScreenDestination.baseRoute + } else { + LogManagementScreenDestination.baseRoute + } if (currentRoute == targetRoute) return@runOnUiThread - navigator.navigate(NavigationCommand(LogManagementScreenDestination, BackStackMode.UPDATE_EXISTED)) + val target = if (shouldOpenDebugTools) { + DebugScreenDestination + } else { + LogManagementScreenDestination + } + navigator.navigate(NavigationCommand(target, BackStackMode.UPDATE_EXISTED)) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt index 339d0d10aba..ad35f99c52f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt @@ -18,13 +18,30 @@ package com.wire.android.ui.debug import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.background +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp import com.wire.android.BuildConfig import com.wire.android.R import com.wire.android.di.hiltViewModelScoped @@ -67,6 +84,8 @@ fun DebugDataOptions( onForceUpdateApiVersions = viewModel::forceUpdateApiVersions, onDisableEventProcessingChange = viewModel::disableEventProcessing, enrollE2EICertificate = viewModel::enrollE2EICertificate, + e2eiCertificateExpirationSeconds = viewModel.state.e2eiCertificateExpirationSeconds, + onE2EICertificateExpirationChange = viewModel::updateE2EICertificateExpiration, handleE2EIEnrollmentResult = viewModel::handleE2EIEnrollmentResult, dismissCertificateDialog = viewModel::dismissCertificateDialog, checkCrlRevocationList = viewModel::checkCrlRevocationList, @@ -89,6 +108,8 @@ fun DebugDataOptionsContent( onRestartSlowSyncForRecovery: () -> Unit, onForceUpdateApiVersions: () -> Unit, enrollE2EICertificate: () -> Unit, + e2eiCertificateExpirationSeconds: Long, + onE2EICertificateExpirationChange: (Long) -> Unit, handleE2EIEnrollmentResult: (FinalizeEnrollmentResult) -> Unit, dismissCertificateDialog: () -> Unit, checkCrlRevocationList: () -> Unit, @@ -181,9 +202,11 @@ fun DebugDataOptionsContent( trailingIcon = R.drawable.ic_arrow_right, ) - if (BuildConfig.DEBUG) { + if (BuildConfig.PRIVATE_BUILD && BuildConfig.DEBUG_SCREEN_ENABLED) { GetE2EICertificateSwitch( - enrollE2EI = enrollE2EICertificate + expirationSeconds = e2eiCertificateExpirationSeconds, + onExpirationChange = onE2EICertificateExpirationChange, + enrollE2EI = enrollE2EICertificate, ) if (state.showCertificate) { @@ -235,30 +258,86 @@ fun DebugDataOptionsContent( @Composable private fun GetE2EICertificateSwitch( + expirationSeconds: Long, + onExpirationChange: (Long) -> Unit, enrollE2EI: () -> Unit ) { + val minExpirationMinutes = 6L + val expirationMinutes = expirationSeconds / 60 + var expirationInput by remember { mutableStateOf(expirationMinutes.toString()) } + + LaunchedEffect(expirationSeconds) { + val minutesFromState = (expirationSeconds / 60).toString() + if (expirationInput != minutesFromState) { + expirationInput = minutesFromState + } + } + Column { SectionHeader(stringResource(R.string.debug_settings_e2ei_enrollment_title)) - RowItemTemplate( - modifier = Modifier.wrapContentWidth(), - title = { - Text( - style = MaterialTheme.wireTypography.body01, - color = MaterialTheme.wireColorScheme.onBackground, - text = stringResource(R.string.label_get_e2ei_cetificate), - modifier = Modifier.padding(start = dimensions().spacing8x) + Text( + style = MaterialTheme.wireTypography.label03, + color = MaterialTheme.wireColorScheme.secondaryText, + text = stringResource(R.string.debug_settings_e2ei_expiration_hint), + modifier = Modifier.padding( + start = dimensions().spacing8x, + end = dimensions().spacing8x, + bottom = dimensions().spacing4x + ) + ) + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.wireColorScheme.surface) + ) { + Text( + style = MaterialTheme.wireTypography.body01, + color = MaterialTheme.wireColorScheme.onBackground, + text = stringResource(R.string.debug_settings_e2ei_expiration_time), + modifier = Modifier.padding(start = dimensions().spacing8x, top = dimensions().spacing8x, bottom = dimensions().spacing4x) + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + start = dimensions().spacing8x, + end = dimensions().spacing8x, + bottom = dimensions().spacing8x + ), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + modifier = Modifier.width(128.dp), + value = expirationInput, + onValueChange = { input -> + val digitsOnly = input.filter { it.isDigit() } + expirationInput = digitsOnly + digitsOnly.toLongOrNull()?.let { minutes -> + if (minutes >= minExpirationMinutes) { + onExpirationChange(minutes * 60) + } + } + }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + suffix = { Text(text = stringResource(R.string.debug_settings_e2ei_expiration_minutes_suffix)) } ) - }, - actions = { + Spacer(modifier = Modifier.weight(1f)) WirePrimaryButton( onClick = { + val normalizedMinutes = expirationInput.toLongOrNull()?.coerceAtLeast(minExpirationMinutes) ?: minExpirationMinutes + expirationInput = normalizedMinutes.toString() + onExpirationChange(normalizedMinutes * 60) enrollE2EI() }, - text = stringResource(R.string.label_get_e2ei_cetificate), - fillMaxWidth = false + text = stringResource(R.string.debug_settings_e2ei_enroll_button), + fillMaxWidth = false, + minSize = MaterialTheme.wireDimensions.buttonMediumMinSize, + minClickableSize = MaterialTheme.wireDimensions.buttonMinClickableSize ) } - ) + HorizontalDivider(modifier = Modifier.height(1.dp)) + } } } @@ -342,6 +421,8 @@ fun PreviewOtherDebugOptions() = WireTheme { onDisableEventProcessingChange = {}, onRestartSlowSyncForRecovery = {}, enrollE2EICertificate = {}, + e2eiCertificateExpirationSeconds = 360L, + onE2EICertificateExpirationChange = {}, handleE2EIEnrollmentResult = {}, dismissCertificateDialog = {}, checkCrlRevocationList = {}, diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsState.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsState.kt index 86057cd198f..c32d10f8792 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsState.kt @@ -25,6 +25,7 @@ data class DebugDataOptionsState( val certificate: String = "null", val showCertificate: Boolean = false, val startGettingE2EICertificate: Boolean = false, + val e2eiCertificateExpirationSeconds: Long = 360L, val analyticsTrackingId: String = "null", val isFederationEnabled: Boolean = false, val currentApiVersion: String = "null", diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt index 256762e58c4..343e42ec52d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt @@ -41,6 +41,8 @@ import com.wire.kalium.logic.feature.debug.RepairFaultyRemovalKeysUseCase import com.wire.kalium.logic.feature.debug.RepairResult import com.wire.kalium.logic.feature.debug.StartUsingAsyncNotificationsResult import com.wire.kalium.logic.feature.debug.StartUsingAsyncNotificationsUseCase +import com.wire.kalium.logic.feature.debug.GetDebugE2EICertificateExpirationUseCase +import com.wire.kalium.logic.feature.debug.SetDebugE2EICertificateExpirationUseCase import com.wire.kalium.logic.feature.debug.TargetedRepairParam import com.wire.kalium.logic.feature.e2ei.CheckCrlRevocationListUseCase import com.wire.kalium.logic.feature.e2ei.usecase.FinalizeEnrollmentResult @@ -62,6 +64,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject +@Suppress("TooManyFunctions") @ViewModelScopedPreview interface DebugDataOptionsViewModel { val infoMessage: SharedFlow get() = MutableSharedFlow() @@ -70,6 +73,9 @@ interface DebugDataOptionsViewModel { fun checkCrlRevocationList() {} fun restartSlowSyncForRecovery() {} fun enrollE2EICertificate() {} + fun updateE2EICertificateExpiration(seconds: Long) {} + fun increaseE2EICertificateExpiration() {} + fun decreaseE2EICertificateExpiration() {} fun handleE2EIEnrollmentResult(result: FinalizeEnrollmentResult) {} fun dismissCertificateDialog() {} fun forceUpdateApiVersions() {} @@ -98,6 +104,8 @@ class DebugDataOptionsViewModelImpl private val observeAsyncNotificationsEnabled: ObserveIsConsumableNotificationsEnabledUseCase, private val startUsingAsyncNotifications: StartUsingAsyncNotificationsUseCase, private val repairFaultyRemovalKeys: RepairFaultyRemovalKeysUseCase, + private val getDebugE2EICertificateExpiration: GetDebugE2EICertificateExpirationUseCase, + private val setDebugE2EICertificateExpiration: SetDebugE2EICertificateExpirationUseCase, ) : ViewModel(), DebugDataOptionsViewModel { override var state by mutableStateOf( @@ -107,6 +115,11 @@ class DebugDataOptionsViewModelImpl private val _infoMessage = MutableSharedFlow() override val infoMessage = _infoMessage.asSharedFlow() + private companion object { + const val MIN_E2EI_CERTIFICATE_EXPIRATION_SECONDS = 360L + const val E2EI_CERTIFICATE_EXPIRATION_STEP_SECONDS = 60L + } + init { observeAsyncNotificationsEnabledData() observeMlsMetadata() @@ -114,6 +127,7 @@ class DebugDataOptionsViewModelImpl setAnalyticsTrackingId() setServerConfigData() setDefaultProtocol() + loadDebugE2EICertificateExpiration() } private fun observeAsyncNotificationsEnabledData() { @@ -190,6 +204,18 @@ class DebugDataOptionsViewModelImpl state = state.copy(startGettingE2EICertificate = true) } + override fun updateE2EICertificateExpiration(seconds: Long) { + setE2EICertificateExpiration(seconds) + } + + override fun increaseE2EICertificateExpiration() { + setE2EICertificateExpiration(state.e2eiCertificateExpirationSeconds + E2EI_CERTIFICATE_EXPIRATION_STEP_SECONDS) + } + + override fun decreaseE2EICertificateExpiration() { + setE2EICertificateExpiration(state.e2eiCertificateExpirationSeconds - E2EI_CERTIFICATE_EXPIRATION_STEP_SECONDS) + } + override fun handleE2EIEnrollmentResult(result: FinalizeEnrollmentResult) { state = when (result) { is FinalizeEnrollmentResult.Failure.OAuthError -> { @@ -332,6 +358,22 @@ class DebugDataOptionsViewModelImpl } } } + + private fun loadDebugE2EICertificateExpiration() { + viewModelScope.launch { + state = state.copy( + e2eiCertificateExpirationSeconds = getDebugE2EICertificateExpiration() + ) + } + } + + private fun setE2EICertificateExpiration(seconds: Long) { + val expiration = seconds.coerceAtLeast(MIN_E2EI_CERTIFICATE_EXPIRATION_SECONDS) + state = state.copy(e2eiCertificateExpirationSeconds = expiration) + viewModelScope.launch { + setDebugE2EICertificateExpiration(expiration) + } + } //endregion } //endregion diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0ad37001b66..0ce0b7fd98d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1825,7 +1825,12 @@ In group conversations, the group admin can overwrite this setting. Register FCM push token Debug Settings API VERSIONING - E2EI Manual Enrollment + E2EI Enrollment + This TTL is used for certificate enroll/update in private builds. + Expiration time + s + min + Enroll Force API versioning update ⚠️ Break Session Update diff --git a/app/src/test/kotlin/com/wire/android/ui/settings/debug/DebugDataOptionsViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/settings/debug/DebugDataOptionsViewModelTest.kt index ad533d966a3..d7826c5a180 100644 --- a/app/src/test/kotlin/com/wire/android/ui/settings/debug/DebugDataOptionsViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/settings/debug/DebugDataOptionsViewModelTest.kt @@ -40,6 +40,8 @@ import com.wire.kalium.logic.feature.debug.ObserveIsConsumableNotificationsEnabl import com.wire.kalium.logic.feature.debug.RepairFaultyRemovalKeysUseCase import com.wire.kalium.logic.feature.debug.StartUsingAsyncNotificationsResult import com.wire.kalium.logic.feature.debug.StartUsingAsyncNotificationsUseCase +import com.wire.kalium.logic.feature.debug.GetDebugE2EICertificateExpirationUseCase +import com.wire.kalium.logic.feature.debug.SetDebugE2EICertificateExpirationUseCase import com.wire.kalium.logic.feature.e2ei.CheckCrlRevocationListUseCase import com.wire.kalium.logic.feature.keypackage.MLSKeyPackageCountResult import com.wire.kalium.logic.feature.keypackage.MLSKeyPackageCountUseCase @@ -242,6 +244,41 @@ class DebugDataOptionsViewModelTest { assertEquals(true, viewModel.state.isAsyncNotificationsEnabled) coVerify(exactly = 0) { arrangement.startUsingAsyncNotifications() } } + + @Test + fun `given e2ei expiration is loaded, view state should contain loaded value`() = runTest { + val (_, viewModel) = DebugDataOptionsHiltArrangement() + .withDebugE2EICertificateExpiration(999) + .arrange() + + assertEquals(999, viewModel.state.e2eiCertificateExpirationSeconds) + } + + @Test + fun `given expiration below minimum, when updating e2ei expiration, then minimum value is used`() = runTest { + val (arrangement, viewModel) = DebugDataOptionsHiltArrangement().arrange() + + viewModel.updateE2EICertificateExpiration(120) + + assertEquals(360, viewModel.state.e2eiCertificateExpirationSeconds) + coVerify(exactly = 1) { arrangement.setDebugE2EICertificateExpiration(360) } + } + + @Test + fun `given expiration value, when increasing and then decreasing, then value is updated and use case is called`() = runTest { + val (arrangement, viewModel) = DebugDataOptionsHiltArrangement() + .withDebugE2EICertificateExpiration(360) + .arrange() + + viewModel.increaseE2EICertificateExpiration() + assertEquals(420, viewModel.state.e2eiCertificateExpirationSeconds) + + viewModel.decreaseE2EICertificateExpiration() + assertEquals(360, viewModel.state.e2eiCertificateExpirationSeconds) + + coVerify(exactly = 1) { arrangement.setDebugE2EICertificateExpiration(420) } + coVerify(exactly = 1) { arrangement.setDebugE2EICertificateExpiration(360) } + } } internal class DebugDataOptionsHiltArrangement { @@ -284,6 +321,12 @@ internal class DebugDataOptionsHiltArrangement { @MockK lateinit var repairFaultyRemovalKeysUseCase: RepairFaultyRemovalKeysUseCase + @MockK + lateinit var getDebugE2EICertificateExpiration: GetDebugE2EICertificateExpirationUseCase + + @MockK + lateinit var setDebugE2EICertificateExpiration: SetDebugE2EICertificateExpirationUseCase + private val viewModel by lazy { DebugDataOptionsViewModelImpl( context = context, @@ -299,7 +342,9 @@ internal class DebugDataOptionsHiltArrangement { getDefaultProtocolUseCase = getDefaultProtocolUseCase, startUsingAsyncNotifications = startUsingAsyncNotifications, observeAsyncNotificationsEnabled = observeIsConsumableNotificationsEnabled, - repairFaultyRemovalKeys = repairFaultyRemovalKeysUseCase + repairFaultyRemovalKeys = repairFaultyRemovalKeysUseCase, + getDebugE2EICertificateExpiration = getDebugE2EICertificateExpiration, + setDebugE2EICertificateExpiration = setDebugE2EICertificateExpiration ) } @@ -338,9 +383,15 @@ internal class DebugDataOptionsHiltArrangement { } returns SupportedProtocol.PROTEUS withObserveIsConsumableNotificationsEnabled(false) + coEvery { getDebugE2EICertificateExpiration() } returns 360 + coEvery { setDebugE2EICertificateExpiration(any()) } returns Unit } } + fun withDebugE2EICertificateExpiration(expiration: Long) = apply { + coEvery { getDebugE2EICertificateExpiration() } returns expiration + } + suspend fun withObserveIsConsumableNotificationsEnabled(isEnabled: Boolean = false) = apply { coEvery { observeIsConsumableNotificationsEnabled() diff --git a/kalium b/kalium index f7d8e70352c..e696293f363 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit f7d8e70352c9826bfdd4714ddb4704675d06cb77 +Subproject commit e696293f36336b4d51070272c78512f3e368806e From 86ec82c2073ff84fd5c85bd7c1823002c9025ba9 Mon Sep 17 00:00:00 2001 From: Jakub Zerko Date: Thu, 30 Apr 2026 10:33:48 +0200 Subject: [PATCH 2/8] review fixes --- .../wire/android/ui/debug/DebugDataOptions.kt | 29 ++++++++++--------- .../ui/debug/DebugDataOptionsViewModel.kt | 18 ++---------- app/src/main/res/values/strings.xml | 2 +- kalium | 2 +- 4 files changed, 20 insertions(+), 31 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt index ad35f99c52f..c908b3eac07 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt @@ -63,6 +63,7 @@ import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography import com.wire.android.util.ui.PreviewMultipleThemes +import com.wire.kalium.logic.feature.debug.MIN_DEBUG_E2EI_CERTIFICATE_EXPIRATION_SECONDS import com.wire.kalium.logic.feature.e2ei.usecase.FinalizeEnrollmentResult @Composable @@ -203,7 +204,7 @@ fun DebugDataOptionsContent( ) if (BuildConfig.PRIVATE_BUILD && BuildConfig.DEBUG_SCREEN_ENABLED) { - GetE2EICertificateSwitch( + E2EICertificateEnrollmentSection( expirationSeconds = e2eiCertificateExpirationSeconds, onExpirationChange = onE2EICertificateExpirationChange, enrollE2EI = enrollE2EICertificate, @@ -257,14 +258,15 @@ fun DebugDataOptionsContent( } @Composable -private fun GetE2EICertificateSwitch( +private fun E2EICertificateEnrollmentSection( expirationSeconds: Long, onExpirationChange: (Long) -> Unit, enrollE2EI: () -> Unit ) { - val minExpirationMinutes = 6L + val minExpirationMinutes = MIN_DEBUG_E2EI_CERTIFICATE_EXPIRATION_SECONDS / 60 val expirationMinutes = expirationSeconds / 60 var expirationInput by remember { mutableStateOf(expirationMinutes.toString()) } + val isInputBelowMinimum = expirationInput.toLongOrNull()?.let { it < minExpirationMinutes } == true LaunchedEffect(expirationSeconds) { val minutesFromState = (expirationSeconds / 60).toString() @@ -275,16 +277,6 @@ private fun GetE2EICertificateSwitch( Column { SectionHeader(stringResource(R.string.debug_settings_e2ei_enrollment_title)) - Text( - style = MaterialTheme.wireTypography.label03, - color = MaterialTheme.wireColorScheme.secondaryText, - text = stringResource(R.string.debug_settings_e2ei_expiration_hint), - modifier = Modifier.padding( - start = dimensions().spacing8x, - end = dimensions().spacing8x, - bottom = dimensions().spacing4x - ) - ) Column( modifier = Modifier .fillMaxWidth() @@ -318,8 +310,19 @@ private fun GetE2EICertificateSwitch( } } }, + isError = isInputBelowMinimum, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + supportingText = { + if (isInputBelowMinimum) { + Text( + text = stringResource( + R.string.debug_settings_e2ei_expiration_min_error, + minExpirationMinutes + ) + ) + } + }, suffix = { Text(text = stringResource(R.string.debug_settings_e2ei_expiration_minutes_suffix)) } ) Spacer(modifier = Modifier.weight(1f)) diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt index 343e42ec52d..6c0c54cac35 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt @@ -42,6 +42,7 @@ import com.wire.kalium.logic.feature.debug.RepairResult import com.wire.kalium.logic.feature.debug.StartUsingAsyncNotificationsResult import com.wire.kalium.logic.feature.debug.StartUsingAsyncNotificationsUseCase import com.wire.kalium.logic.feature.debug.GetDebugE2EICertificateExpirationUseCase +import com.wire.kalium.logic.feature.debug.MIN_DEBUG_E2EI_CERTIFICATE_EXPIRATION_SECONDS import com.wire.kalium.logic.feature.debug.SetDebugE2EICertificateExpirationUseCase import com.wire.kalium.logic.feature.debug.TargetedRepairParam import com.wire.kalium.logic.feature.e2ei.CheckCrlRevocationListUseCase @@ -74,8 +75,6 @@ interface DebugDataOptionsViewModel { fun restartSlowSyncForRecovery() {} fun enrollE2EICertificate() {} fun updateE2EICertificateExpiration(seconds: Long) {} - fun increaseE2EICertificateExpiration() {} - fun decreaseE2EICertificateExpiration() {} fun handleE2EIEnrollmentResult(result: FinalizeEnrollmentResult) {} fun dismissCertificateDialog() {} fun forceUpdateApiVersions() {} @@ -115,11 +114,6 @@ class DebugDataOptionsViewModelImpl private val _infoMessage = MutableSharedFlow() override val infoMessage = _infoMessage.asSharedFlow() - private companion object { - const val MIN_E2EI_CERTIFICATE_EXPIRATION_SECONDS = 360L - const val E2EI_CERTIFICATE_EXPIRATION_STEP_SECONDS = 60L - } - init { observeAsyncNotificationsEnabledData() observeMlsMetadata() @@ -208,14 +202,6 @@ class DebugDataOptionsViewModelImpl setE2EICertificateExpiration(seconds) } - override fun increaseE2EICertificateExpiration() { - setE2EICertificateExpiration(state.e2eiCertificateExpirationSeconds + E2EI_CERTIFICATE_EXPIRATION_STEP_SECONDS) - } - - override fun decreaseE2EICertificateExpiration() { - setE2EICertificateExpiration(state.e2eiCertificateExpirationSeconds - E2EI_CERTIFICATE_EXPIRATION_STEP_SECONDS) - } - override fun handleE2EIEnrollmentResult(result: FinalizeEnrollmentResult) { state = when (result) { is FinalizeEnrollmentResult.Failure.OAuthError -> { @@ -368,7 +354,7 @@ class DebugDataOptionsViewModelImpl } private fun setE2EICertificateExpiration(seconds: Long) { - val expiration = seconds.coerceAtLeast(MIN_E2EI_CERTIFICATE_EXPIRATION_SECONDS) + val expiration = seconds.coerceAtLeast(MIN_DEBUG_E2EI_CERTIFICATE_EXPIRATION_SECONDS) state = state.copy(e2eiCertificateExpirationSeconds = expiration) viewModelScope.launch { setDebugE2EICertificateExpiration(expiration) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0ce0b7fd98d..1f10098067f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1826,10 +1826,10 @@ In group conversations, the group admin can overwrite this setting. Debug Settings API VERSIONING E2EI Enrollment - This TTL is used for certificate enroll/update in private builds. Expiration time s min + Minimum is %1$d min Enroll Force API versioning update ⚠️ Break Session diff --git a/kalium b/kalium index e696293f363..461fc6e7091 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit e696293f36336b4d51070272c78512f3e368806e +Subproject commit 461fc6e70915b051acbac8c428a4ba26942368aa From 18b28c29b61ef92f9460bd445f9f8b612570f8f6 Mon Sep 17 00:00:00 2001 From: Jakub Zerko Date: Thu, 30 Apr 2026 10:38:33 +0200 Subject: [PATCH 3/8] exipration time field improvements --- .../kotlin/com/wire/android/ui/debug/DebugDataOptions.kt | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt index c908b3eac07..7cd55d71a88 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt @@ -282,18 +282,13 @@ private fun E2EICertificateEnrollmentSection( .fillMaxWidth() .background(MaterialTheme.wireColorScheme.surface) ) { - Text( - style = MaterialTheme.wireTypography.body01, - color = MaterialTheme.wireColorScheme.onBackground, - text = stringResource(R.string.debug_settings_e2ei_expiration_time), - modifier = Modifier.padding(start = dimensions().spacing8x, top = dimensions().spacing8x, bottom = dimensions().spacing4x) - ) Row( modifier = Modifier .fillMaxWidth() .padding( start = dimensions().spacing8x, end = dimensions().spacing8x, + top = dimensions().spacing8x, bottom = dimensions().spacing8x ), verticalAlignment = Alignment.CenterVertically @@ -313,6 +308,7 @@ private fun E2EICertificateEnrollmentSection( isError = isInputBelowMinimum, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + label = { Text(text = stringResource(R.string.debug_settings_e2ei_expiration_time)) }, supportingText = { if (isInputBelowMinimum) { Text( From 62615d041635c5292f54c4867af0c69a45d581ab Mon Sep 17 00:00:00 2001 From: Jakub Zerko Date: Thu, 30 Apr 2026 11:01:52 +0200 Subject: [PATCH 4/8] test fixes --- .../debug/DebugDataOptionsViewModelTest.kt | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/app/src/test/kotlin/com/wire/android/ui/settings/debug/DebugDataOptionsViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/settings/debug/DebugDataOptionsViewModelTest.kt index d7826c5a180..7c78b3dc71c 100644 --- a/app/src/test/kotlin/com/wire/android/ui/settings/debug/DebugDataOptionsViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/settings/debug/DebugDataOptionsViewModelTest.kt @@ -41,6 +41,7 @@ import com.wire.kalium.logic.feature.debug.RepairFaultyRemovalKeysUseCase import com.wire.kalium.logic.feature.debug.StartUsingAsyncNotificationsResult import com.wire.kalium.logic.feature.debug.StartUsingAsyncNotificationsUseCase import com.wire.kalium.logic.feature.debug.GetDebugE2EICertificateExpirationUseCase +import com.wire.kalium.logic.feature.debug.MIN_DEBUG_E2EI_CERTIFICATE_EXPIRATION_SECONDS import com.wire.kalium.logic.feature.debug.SetDebugE2EICertificateExpirationUseCase import com.wire.kalium.logic.feature.e2ei.CheckCrlRevocationListUseCase import com.wire.kalium.logic.feature.keypackage.MLSKeyPackageCountResult @@ -260,24 +261,20 @@ class DebugDataOptionsViewModelTest { viewModel.updateE2EICertificateExpiration(120) - assertEquals(360, viewModel.state.e2eiCertificateExpirationSeconds) - coVerify(exactly = 1) { arrangement.setDebugE2EICertificateExpiration(360) } + assertEquals(MIN_DEBUG_E2EI_CERTIFICATE_EXPIRATION_SECONDS, viewModel.state.e2eiCertificateExpirationSeconds) + coVerify(exactly = 1) { arrangement.setDebugE2EICertificateExpiration(MIN_DEBUG_E2EI_CERTIFICATE_EXPIRATION_SECONDS) } } @Test - fun `given expiration value, when increasing and then decreasing, then value is updated and use case is called`() = runTest { + fun `given valid expiration value, when updating e2ei expiration, then value is updated and use case is called`() = runTest { val (arrangement, viewModel) = DebugDataOptionsHiltArrangement() .withDebugE2EICertificateExpiration(360) .arrange() - viewModel.increaseE2EICertificateExpiration() - assertEquals(420, viewModel.state.e2eiCertificateExpirationSeconds) - - viewModel.decreaseE2EICertificateExpiration() - assertEquals(360, viewModel.state.e2eiCertificateExpirationSeconds) + viewModel.updateE2EICertificateExpiration(420) coVerify(exactly = 1) { arrangement.setDebugE2EICertificateExpiration(420) } - coVerify(exactly = 1) { arrangement.setDebugE2EICertificateExpiration(360) } + assertEquals(420, viewModel.state.e2eiCertificateExpirationSeconds) } } From 50147c3a745264d28e283d7e69db6b6f82ee1b84 Mon Sep 17 00:00:00 2001 From: Jakub Zerko Date: Mon, 4 May 2026 09:49:43 +0200 Subject: [PATCH 5/8] review fixes --- .../wire/android/ui/debug/DebugDataOptions.kt | 116 ++++++++---------- .../ui/debug/DebugDataOptionsViewModel.kt | 49 +++++++- .../debug/DebugDataOptionsViewModelTest.kt | 11 ++ 3 files changed, 106 insertions(+), 70 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt index 7cd55d71a88..6c1e788a4cd 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt @@ -26,22 +26,17 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.background -import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp import com.wire.android.BuildConfig import com.wire.android.R import com.wire.android.di.hiltViewModelScoped @@ -52,6 +47,9 @@ import com.wire.android.ui.common.WireDialogButtonProperties import com.wire.android.ui.common.WireDialogButtonType import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.textfield.maxLengthDigits +import com.wire.android.ui.common.textfield.WireTextField +import com.wire.android.ui.common.textfield.WireTextFieldState import com.wire.android.ui.common.rowitem.RowItemTemplate import com.wire.android.ui.common.rowitem.SectionHeader import com.wire.android.ui.common.snackbar.LocalSnackbarHostState @@ -65,6 +63,7 @@ import com.wire.android.ui.theme.wireTypography import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.feature.debug.MIN_DEBUG_E2EI_CERTIFICATE_EXPIRATION_SECONDS import com.wire.kalium.logic.feature.e2ei.usecase.FinalizeEnrollmentResult +import kotlinx.coroutines.flow.collectLatest @Composable fun DebugDataOptions( @@ -85,8 +84,8 @@ fun DebugDataOptions( onForceUpdateApiVersions = viewModel::forceUpdateApiVersions, onDisableEventProcessingChange = viewModel::disableEventProcessing, enrollE2EICertificate = viewModel::enrollE2EICertificate, - e2eiCertificateExpirationSeconds = viewModel.state.e2eiCertificateExpirationSeconds, - onE2EICertificateExpirationChange = viewModel::updateE2EICertificateExpiration, + e2eiCertificateExpirationInputState = viewModel.e2eiCertificateExpirationInputState, + onE2EICertificateExpirationInputChange = viewModel::updateE2EICertificateExpirationInput, handleE2EIEnrollmentResult = viewModel::handleE2EIEnrollmentResult, dismissCertificateDialog = viewModel::dismissCertificateDialog, checkCrlRevocationList = viewModel::checkCrlRevocationList, @@ -109,8 +108,8 @@ fun DebugDataOptionsContent( onRestartSlowSyncForRecovery: () -> Unit, onForceUpdateApiVersions: () -> Unit, enrollE2EICertificate: () -> Unit, - e2eiCertificateExpirationSeconds: Long, - onE2EICertificateExpirationChange: (Long) -> Unit, + e2eiCertificateExpirationInputState: TextFieldState, + onE2EICertificateExpirationInputChange: (String) -> Unit, handleE2EIEnrollmentResult: (FinalizeEnrollmentResult) -> Unit, dismissCertificateDialog: () -> Unit, checkCrlRevocationList: () -> Unit, @@ -205,8 +204,8 @@ fun DebugDataOptionsContent( if (BuildConfig.PRIVATE_BUILD && BuildConfig.DEBUG_SCREEN_ENABLED) { E2EICertificateEnrollmentSection( - expirationSeconds = e2eiCertificateExpirationSeconds, - onExpirationChange = onE2EICertificateExpirationChange, + expirationInputState = e2eiCertificateExpirationInputState, + onExpirationInputChange = onE2EICertificateExpirationInputChange, enrollE2EI = enrollE2EICertificate, ) @@ -259,20 +258,17 @@ fun DebugDataOptionsContent( @Composable private fun E2EICertificateEnrollmentSection( - expirationSeconds: Long, - onExpirationChange: (Long) -> Unit, + expirationInputState: TextFieldState, + onExpirationInputChange: (String) -> Unit, enrollE2EI: () -> Unit ) { val minExpirationMinutes = MIN_DEBUG_E2EI_CERTIFICATE_EXPIRATION_SECONDS / 60 - val expirationMinutes = expirationSeconds / 60 - var expirationInput by remember { mutableStateOf(expirationMinutes.toString()) } - val isInputBelowMinimum = expirationInput.toLongOrNull()?.let { it < minExpirationMinutes } == true + val enteredMinutes = expirationInputState.text.toString().toLongOrNull() + val isInputBelowMinimum = enteredMinutes?.let { it < minExpirationMinutes } == true - LaunchedEffect(expirationSeconds) { - val minutesFromState = (expirationSeconds / 60).toString() - if (expirationInput != minutesFromState) { - expirationInput = minutesFromState - } + LaunchedEffect(expirationInputState) { + snapshotFlow { expirationInputState.text.toString() } + .collectLatest(onExpirationInputChange) } Column { @@ -285,57 +281,45 @@ private fun E2EICertificateEnrollmentSection( Row( modifier = Modifier .fillMaxWidth() - .padding( - start = dimensions().spacing8x, - end = dimensions().spacing8x, - top = dimensions().spacing8x, - bottom = dimensions().spacing8x - ), + .padding(dimensions().spacing8x), verticalAlignment = Alignment.CenterVertically ) { - OutlinedTextField( - modifier = Modifier.width(128.dp), - value = expirationInput, - onValueChange = { input -> - val digitsOnly = input.filter { it.isDigit() } - expirationInput = digitsOnly - digitsOnly.toLongOrNull()?.let { minutes -> - if (minutes >= minExpirationMinutes) { - onExpirationChange(minutes * 60) - } - } - }, - isError = isInputBelowMinimum, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - label = { Text(text = stringResource(R.string.debug_settings_e2ei_expiration_time)) }, - supportingText = { - if (isInputBelowMinimum) { - Text( - text = stringResource( - R.string.debug_settings_e2ei_expiration_min_error, - minExpirationMinutes - ) - ) - } - }, - suffix = { Text(text = stringResource(R.string.debug_settings_e2ei_expiration_minutes_suffix)) } + WireTextField( + modifier = Modifier + .width(dimensions().spacing156x), + textState = expirationInputState, + labelText = stringResource(R.string.debug_settings_e2ei_expiration_time), + inputTransformation = InputTransformation.maxLengthDigits(maxLength = 6), + state = if (isInputBelowMinimum) WireTextFieldState.Error() else WireTextFieldState.Default, + trailingIcon = { + Text( + text = stringResource(R.string.debug_settings_e2ei_expiration_minutes_suffix), + modifier = Modifier.padding(horizontal = dimensions().spacing8x) + ) + } ) Spacer(modifier = Modifier.weight(1f)) WirePrimaryButton( - onClick = { - val normalizedMinutes = expirationInput.toLongOrNull()?.coerceAtLeast(minExpirationMinutes) ?: minExpirationMinutes - expirationInput = normalizedMinutes.toString() - onExpirationChange(normalizedMinutes * 60) - enrollE2EI() - }, + onClick = enrollE2EI, text = stringResource(R.string.debug_settings_e2ei_enroll_button), fillMaxWidth = false, minSize = MaterialTheme.wireDimensions.buttonMediumMinSize, minClickableSize = MaterialTheme.wireDimensions.buttonMinClickableSize ) } - HorizontalDivider(modifier = Modifier.height(1.dp)) + if (isInputBelowMinimum) { + Text( + text = stringResource(R.string.debug_settings_e2ei_expiration_min_error, minExpirationMinutes), + style = MaterialTheme.wireTypography.label04, + color = MaterialTheme.wireColorScheme.error, + modifier = Modifier.padding( + start = dimensions().spacing8x, + end = dimensions().spacing8x, + bottom = dimensions().spacing8x + ) + ) + } + HorizontalDivider(modifier = Modifier.height(dimensions().spacing1x)) } } } @@ -420,8 +404,8 @@ fun PreviewOtherDebugOptions() = WireTheme { onDisableEventProcessingChange = {}, onRestartSlowSyncForRecovery = {}, enrollE2EICertificate = {}, - e2eiCertificateExpirationSeconds = 360L, - onE2EICertificateExpirationChange = {}, + e2eiCertificateExpirationInputState = TextFieldState("6"), + onE2EICertificateExpirationInputChange = {}, handleE2EIEnrollmentResult = {}, dismissCertificateDialog = {}, checkCrlRevocationList = {}, diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt index 6c0c54cac35..1da3166960b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt @@ -18,6 +18,8 @@ package com.wire.android.ui.debug import android.content.Context +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -64,17 +66,20 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject +import kotlin.time.Duration.Companion.days @Suppress("TooManyFunctions") @ViewModelScopedPreview interface DebugDataOptionsViewModel { val infoMessage: SharedFlow get() = MutableSharedFlow() val state: DebugDataOptionsState get() = DebugDataOptionsState() + val e2eiCertificateExpirationInputState: TextFieldState get() = TextFieldState("6") fun currentAccount(): UserId = UserId("value", "domain") fun checkCrlRevocationList() {} fun restartSlowSyncForRecovery() {} fun enrollE2EICertificate() {} fun updateE2EICertificateExpiration(seconds: Long) {} + fun updateE2EICertificateExpirationInput(minutes: String) {} fun handleE2EIEnrollmentResult(result: FinalizeEnrollmentResult) {} fun dismissCertificateDialog() {} fun forceUpdateApiVersions() {} @@ -106,10 +111,18 @@ class DebugDataOptionsViewModelImpl private val getDebugE2EICertificateExpiration: GetDebugE2EICertificateExpirationUseCase, private val setDebugE2EICertificateExpiration: SetDebugE2EICertificateExpirationUseCase, ) : ViewModel(), DebugDataOptionsViewModel { + private companion object { + val DEFAULT_DEBUG_E2EI_CERTIFICATE_EXPIRATION_SECONDS = 90.days.inWholeSeconds + const val SECONDS_PER_MINUTE = 60L + const val MINUTES_ROUNDING_OFFSET_SECONDS = SECONDS_PER_MINUTE - 1 + val MIN_DEBUG_E2EI_CERTIFICATE_EXPIRATION_MINUTES = + MIN_DEBUG_E2EI_CERTIFICATE_EXPIRATION_SECONDS / SECONDS_PER_MINUTE + } override var state by mutableStateOf( DebugDataOptionsState() ) + override val e2eiCertificateExpirationInputState = TextFieldState("6") private val _infoMessage = MutableSharedFlow() override val infoMessage = _infoMessage.asSharedFlow() @@ -195,6 +208,11 @@ class DebugDataOptionsViewModelImpl } override fun enrollE2EICertificate() { + val normalizedMinutes = e2eiCertificateExpirationInputState.text.toString() + .toLongOrNull() + ?.coerceAtLeast(MIN_DEBUG_E2EI_CERTIFICATE_EXPIRATION_MINUTES) + ?: MIN_DEBUG_E2EI_CERTIFICATE_EXPIRATION_MINUTES + setE2EICertificateExpiration(normalizedMinutes * SECONDS_PER_MINUTE) state = state.copy(startGettingE2EICertificate = true) } @@ -202,6 +220,17 @@ class DebugDataOptionsViewModelImpl setE2EICertificateExpiration(seconds) } + override fun updateE2EICertificateExpirationInput(minutes: String) { + if (e2eiCertificateExpirationInputState.text.toString() != minutes) { + e2eiCertificateExpirationInputState.setTextAndPlaceCursorAtEnd(minutes) + } + minutes.toLongOrNull()?.let { value -> + if (value >= MIN_DEBUG_E2EI_CERTIFICATE_EXPIRATION_MINUTES) { + setE2EICertificateExpiration(value * SECONDS_PER_MINUTE) + } + } + } + override fun handleE2EIEnrollmentResult(result: FinalizeEnrollmentResult) { state = when (result) { is FinalizeEnrollmentResult.Failure.OAuthError -> { @@ -347,15 +376,27 @@ class DebugDataOptionsViewModelImpl private fun loadDebugE2EICertificateExpiration() { viewModelScope.launch { - state = state.copy( - e2eiCertificateExpirationSeconds = getDebugE2EICertificateExpiration() - ) + val currentExpiration = getDebugE2EICertificateExpiration() + if (currentExpiration == DEFAULT_DEBUG_E2EI_CERTIFICATE_EXPIRATION_SECONDS) { + // For debug UX we default to the minimum test-friendly value instead of 90 days. + setE2EICertificateExpiration(MIN_DEBUG_E2EI_CERTIFICATE_EXPIRATION_SECONDS) + } else { + val minutes = (currentExpiration + MINUTES_ROUNDING_OFFSET_SECONDS) / SECONDS_PER_MINUTE + e2eiCertificateExpirationInputState.setTextAndPlaceCursorAtEnd(minutes.toString()) + state = state.copy( + e2eiCertificateExpirationSeconds = currentExpiration + ) + } } } private fun setE2EICertificateExpiration(seconds: Long) { val expiration = seconds.coerceAtLeast(MIN_DEBUG_E2EI_CERTIFICATE_EXPIRATION_SECONDS) - state = state.copy(e2eiCertificateExpirationSeconds = expiration) + val minutes = (expiration + MINUTES_ROUNDING_OFFSET_SECONDS) / SECONDS_PER_MINUTE + e2eiCertificateExpirationInputState.setTextAndPlaceCursorAtEnd(minutes.toString()) + state = state.copy( + e2eiCertificateExpirationSeconds = expiration + ) viewModelScope.launch { setDebugE2EICertificateExpiration(expiration) } diff --git a/app/src/test/kotlin/com/wire/android/ui/settings/debug/DebugDataOptionsViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/settings/debug/DebugDataOptionsViewModelTest.kt index 7c78b3dc71c..53c6be6c51c 100644 --- a/app/src/test/kotlin/com/wire/android/ui/settings/debug/DebugDataOptionsViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/settings/debug/DebugDataOptionsViewModelTest.kt @@ -70,6 +70,7 @@ import kotlinx.coroutines.test.setMain import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +import kotlin.time.Duration.Companion.days @ExtendWith(ScopedArgsTestExtension::class) @ExtendWith(CoroutineTestExtension::class) @@ -255,6 +256,16 @@ class DebugDataOptionsViewModelTest { assertEquals(999, viewModel.state.e2eiCertificateExpirationSeconds) } + @Test + fun `given default e2ei expiration is loaded, then minimum debug value is applied`() = runTest { + val (arrangement, viewModel) = DebugDataOptionsHiltArrangement() + .withDebugE2EICertificateExpiration(90.days.inWholeSeconds) + .arrange() + + assertEquals(MIN_DEBUG_E2EI_CERTIFICATE_EXPIRATION_SECONDS, viewModel.state.e2eiCertificateExpirationSeconds) + coVerify(exactly = 1) { arrangement.setDebugE2EICertificateExpiration(MIN_DEBUG_E2EI_CERTIFICATE_EXPIRATION_SECONDS) } + } + @Test fun `given expiration below minimum, when updating e2ei expiration, then minimum value is used`() = runTest { val (arrangement, viewModel) = DebugDataOptionsHiltArrangement().arrange() From 51cf9522dd61fde9382279da49efb6e8b2b5726b Mon Sep 17 00:00:00 2001 From: Jakub Zerko Date: Mon, 4 May 2026 10:00:55 +0200 Subject: [PATCH 6/8] code improvements --- .../wire/android/ui/debug/DebugDataOptions.kt | 13 --------- .../ui/debug/DebugDataOptionsViewModel.kt | 28 +++++++++++++++---- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt index 6c1e788a4cd..942109cba2b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt @@ -32,8 +32,6 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -63,7 +61,6 @@ import com.wire.android.ui.theme.wireTypography import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.feature.debug.MIN_DEBUG_E2EI_CERTIFICATE_EXPIRATION_SECONDS import com.wire.kalium.logic.feature.e2ei.usecase.FinalizeEnrollmentResult -import kotlinx.coroutines.flow.collectLatest @Composable fun DebugDataOptions( @@ -85,7 +82,6 @@ fun DebugDataOptions( onDisableEventProcessingChange = viewModel::disableEventProcessing, enrollE2EICertificate = viewModel::enrollE2EICertificate, e2eiCertificateExpirationInputState = viewModel.e2eiCertificateExpirationInputState, - onE2EICertificateExpirationInputChange = viewModel::updateE2EICertificateExpirationInput, handleE2EIEnrollmentResult = viewModel::handleE2EIEnrollmentResult, dismissCertificateDialog = viewModel::dismissCertificateDialog, checkCrlRevocationList = viewModel::checkCrlRevocationList, @@ -109,7 +105,6 @@ fun DebugDataOptionsContent( onForceUpdateApiVersions: () -> Unit, enrollE2EICertificate: () -> Unit, e2eiCertificateExpirationInputState: TextFieldState, - onE2EICertificateExpirationInputChange: (String) -> Unit, handleE2EIEnrollmentResult: (FinalizeEnrollmentResult) -> Unit, dismissCertificateDialog: () -> Unit, checkCrlRevocationList: () -> Unit, @@ -205,7 +200,6 @@ fun DebugDataOptionsContent( if (BuildConfig.PRIVATE_BUILD && BuildConfig.DEBUG_SCREEN_ENABLED) { E2EICertificateEnrollmentSection( expirationInputState = e2eiCertificateExpirationInputState, - onExpirationInputChange = onE2EICertificateExpirationInputChange, enrollE2EI = enrollE2EICertificate, ) @@ -259,18 +253,12 @@ fun DebugDataOptionsContent( @Composable private fun E2EICertificateEnrollmentSection( expirationInputState: TextFieldState, - onExpirationInputChange: (String) -> Unit, enrollE2EI: () -> Unit ) { val minExpirationMinutes = MIN_DEBUG_E2EI_CERTIFICATE_EXPIRATION_SECONDS / 60 val enteredMinutes = expirationInputState.text.toString().toLongOrNull() val isInputBelowMinimum = enteredMinutes?.let { it < minExpirationMinutes } == true - LaunchedEffect(expirationInputState) { - snapshotFlow { expirationInputState.text.toString() } - .collectLatest(onExpirationInputChange) - } - Column { SectionHeader(stringResource(R.string.debug_settings_e2ei_enrollment_title)) Column( @@ -405,7 +393,6 @@ fun PreviewOtherDebugOptions() = WireTheme { onRestartSlowSyncForRecovery = {}, enrollE2EICertificate = {}, e2eiCertificateExpirationInputState = TextFieldState("6"), - onE2EICertificateExpirationInputChange = {}, handleE2EIEnrollmentResult = {}, dismissCertificateDialog = {}, checkCrlRevocationList = {}, diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt index 1da3166960b..6d7842c07c2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt @@ -63,6 +63,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject @@ -130,6 +133,7 @@ class DebugDataOptionsViewModelImpl init { observeAsyncNotificationsEnabledData() observeMlsMetadata() + observeE2EICertificateExpirationInput() setGitHashAndDeviceId() setAnalyticsTrackingId() setServerConfigData() @@ -224,11 +228,6 @@ class DebugDataOptionsViewModelImpl if (e2eiCertificateExpirationInputState.text.toString() != minutes) { e2eiCertificateExpirationInputState.setTextAndPlaceCursorAtEnd(minutes) } - minutes.toLongOrNull()?.let { value -> - if (value >= MIN_DEBUG_E2EI_CERTIFICATE_EXPIRATION_MINUTES) { - setE2EICertificateExpiration(value * SECONDS_PER_MINUTE) - } - } } override fun handleE2EIEnrollmentResult(result: FinalizeEnrollmentResult) { @@ -390,10 +389,29 @@ class DebugDataOptionsViewModelImpl } } + private fun observeE2EICertificateExpirationInput() { + viewModelScope.launch { + androidx.compose.runtime.snapshotFlow { e2eiCertificateExpirationInputState.text.toString() } + .drop(1) + .distinctUntilChanged() + .collectLatest { minutesText -> + val minutes = minutesText.toLongOrNull() ?: return@collectLatest + if (minutes >= MIN_DEBUG_E2EI_CERTIFICATE_EXPIRATION_MINUTES) { + applyE2EICertificateExpiration(minutes * SECONDS_PER_MINUTE) + } + } + } + } + private fun setE2EICertificateExpiration(seconds: Long) { val expiration = seconds.coerceAtLeast(MIN_DEBUG_E2EI_CERTIFICATE_EXPIRATION_SECONDS) val minutes = (expiration + MINUTES_ROUNDING_OFFSET_SECONDS) / SECONDS_PER_MINUTE e2eiCertificateExpirationInputState.setTextAndPlaceCursorAtEnd(minutes.toString()) + applyE2EICertificateExpiration(expiration) + } + + private fun applyE2EICertificateExpiration(seconds: Long) { + val expiration = seconds.coerceAtLeast(MIN_DEBUG_E2EI_CERTIFICATE_EXPIRATION_SECONDS) state = state.copy( e2eiCertificateExpirationSeconds = expiration ) From 3fab3c71cd99c7c69604f7f867e481b614caa1a8 Mon Sep 17 00:00:00 2001 From: Jakub Zerko Date: Mon, 4 May 2026 11:16:09 +0200 Subject: [PATCH 7/8] kalium update, test fixes --- .../kotlin/com/wire/android/ui/debug/DebugScreenComposeTest.kt | 1 + kalium | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/androidTest/kotlin/com/wire/android/ui/debug/DebugScreenComposeTest.kt b/app/src/androidTest/kotlin/com/wire/android/ui/debug/DebugScreenComposeTest.kt index 2cd630be72a..761b5d161d9 100644 --- a/app/src/androidTest/kotlin/com/wire/android/ui/debug/DebugScreenComposeTest.kt +++ b/app/src/androidTest/kotlin/com/wire/android/ui/debug/DebugScreenComposeTest.kt @@ -41,6 +41,7 @@ class DebugScreenComposeTest { onDeleteLogs = {}, onDatabaseLoggerEnabledChanged = {}, onShowFeatureFlags = {}, + onShowCryptoStats = {}, onFlushLogs = { CompletableDeferred(Unit) }, ) } diff --git a/kalium b/kalium index 8efe11644e2..7e26ec0e5af 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 8efe11644e29ffc2e10e07a6afc353ddaed41ebc +Subproject commit 7e26ec0e5af7941c4fc522d13c3b80f424e78049 From b1d183c6301f60b1742657c3d9357492b262853f Mon Sep 17 00:00:00 2001 From: Jakub Zerko Date: Mon, 4 May 2026 12:07:12 +0200 Subject: [PATCH 8/8] typo fix --- .../kotlin/com/wire/android/ui/debug/DebugScreenComposeTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/androidTest/kotlin/com/wire/android/ui/debug/DebugScreenComposeTest.kt b/app/src/androidTest/kotlin/com/wire/android/ui/debug/DebugScreenComposeTest.kt index 260da5195a2..761b5d161d9 100644 --- a/app/src/androidTest/kotlin/com/wire/android/ui/debug/DebugScreenComposeTest.kt +++ b/app/src/androidTest/kotlin/com/wire/android/ui/debug/DebugScreenComposeTest.kt @@ -43,7 +43,6 @@ class DebugScreenComposeTest { onShowFeatureFlags = {}, onShowCryptoStats = {}, onFlushLogs = { CompletableDeferred(Unit) }, - onShowCryptoStats = {} ) } }