diff --git a/app/src/androidTest/kotlin/ee/ria/DigiDoc/domain/preferences/DataStoreTest.kt b/app/src/androidTest/kotlin/ee/ria/DigiDoc/domain/preferences/DataStoreTest.kt index 675257d18..ff18f0e30 100644 --- a/app/src/androidTest/kotlin/ee/ria/DigiDoc/domain/preferences/DataStoreTest.kt +++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/domain/preferences/DataStoreTest.kt @@ -309,6 +309,32 @@ class DataStoreTest { assertEquals(false, result) } + @Test + fun dataStore_getSettingsDefaultLTA_returnFalseByDefault() { + val result = dataStore.getSettingsDefaultLTA() + + assertFalse(result) + } + + @Test + fun dataStore_setSettingsDefaultLTA_returnTrueWhenEnabled() { + dataStore.setSettingsDefaultLTA(true) + + val result = dataStore.getSettingsDefaultLTA() + + assertTrue(result) + } + + @Test + fun dataStore_setSettingsDefaultLTA_returnFalseWhenDisabled() { + dataStore.setSettingsDefaultLTA(true) + dataStore.setSettingsDefaultLTA(false) + + val result = dataStore.getSettingsDefaultLTA() + + assertFalse(result) + } + @Test fun dataStore_getSettingsAllowScreenshots_success() { val result = dataStore.getSettingsAllowScreenshots() diff --git a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/SigningViewModelTest.kt b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/SigningViewModelTest.kt index 03400e2d7..5484f2a4d 100644 --- a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/SigningViewModelTest.kt +++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/SigningViewModelTest.kt @@ -65,7 +65,6 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mock -import org.mockito.Mockito.atLeastOnce import org.mockito.Mockito.mock import org.mockito.Mockito.verify import org.mockito.Mockito.`when` @@ -73,6 +72,7 @@ import org.mockito.MockitoAnnotations import org.mockito.junit.MockitoJUnitRunner import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.atLeastOnce import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import java.io.File @@ -545,6 +545,13 @@ class SigningViewModelTest { assertFalse(isContainerWithTimestamps) } + @Test + fun signingViewModel_isContainerWithTimestamps_returnFalseWhenContainerIsNull() { + val isContainerWithTimestamps = viewModel.isContainerWithTimestamps(null) + + assertFalse(isContainerWithTimestamps) + } + @Test fun signingViewModel_getMimetype_success() = runTest { @@ -596,4 +603,49 @@ class SigningViewModelTest { assertEquals("text/plain", mimetype) } + + @Test + fun signingViewModel_isExtendSignaturesButtonShown_returnTrueWithSignedContainerNotNested() = + runTest { + val file = getResourceFileAsFile(context, "example.asice", R.raw.example) + val container = SignedContainer.openOrCreate(context, file, listOf(file), true) + + val isExtendSignaturesButtonShown = viewModel.isExtendSignaturesButtonShown(container, false) + + assertTrue(isExtendSignaturesButtonShown) + } + + @Test + fun signingViewModel_isExtendSignaturesButtonShown_returnFalseWhenContainerIsNull() { + val isExtendSignaturesButtonShown = viewModel.isExtendSignaturesButtonShown(null, false) + + assertFalse(isExtendSignaturesButtonShown) + } + + @Test + fun signingViewModel_isExtendSignaturesButtonShown_returnFalseWhenContainerIsUnsigned() = + runTest { + val file = + getResourceFileAsFile( + context, + "example_no_signatures.asice", + R.raw.example_no_signatures, + ) + val container = SignedContainer.openOrCreate(context, file, listOf(file), true) + + val isExtendSignaturesButtonShown = viewModel.isExtendSignaturesButtonShown(container, false) + + assertFalse(isExtendSignaturesButtonShown) + } + + @Test + fun signingViewModel_isExtendSignaturesButtonShown_returnFalseWhenContainerIsNested() = + runTest { + val file = getResourceFileAsFile(context, "example.asice", R.raw.example) + val container = SignedContainer.openOrCreate(context, file, listOf(file), true) + + val isExtendSignaturesButtonShown = viewModel.isExtendSignaturesButtonShown(container, true) + + assertFalse(isExtendSignaturesButtonShown) + } } diff --git a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedSettingsViewModelTest.kt b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedSettingsViewModelTest.kt index bde08cda1..dd5fbb3ee 100644 --- a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedSettingsViewModelTest.kt +++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedSettingsViewModelTest.kt @@ -176,6 +176,7 @@ class SharedSettingsViewModelTest { assertEquals(DEFAULT_UUID_VALUE, dataStore.getSettingsUUID()) assertEquals("", dataStore.getSettingsTSAUrl()) assertFalse(dataStore.getSettingsAskRoleAndAddress()) + assertFalse(dataStore.getSettingsDefaultLTA()) assertFalse(dataStore.getIsTsaCertificateViewVisible()) assertEquals("", dataStore.getTSACertName()) @@ -197,6 +198,15 @@ class SharedSettingsViewModelTest { assertEquals("", dataStore.getProxyPassword()) } + @Test + fun sharedSettingsViewModel_resetToDefaultSettings_resetsDefaultLTAToFalse() { + dataStore.setSettingsDefaultLTA(true) + + viewModel.resetToDefaultSettings() + + assertFalse(dataStore.getSettingsDefaultLTA()) + } + @Test fun sharedSettingsViewModel_saveProxySettings_savesManualProxySettings() { dataStore.setProxySetting(ProxySetting.MANUAL_PROXY) diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/domain/preferences/DataStore.kt b/app/src/main/kotlin/ee/ria/DigiDoc/domain/preferences/DataStore.kt index c1ee73a04..cb13b8417 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/domain/preferences/DataStore.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/domain/preferences/DataStore.kt @@ -497,6 +497,21 @@ class DataStore } } + fun getSettingsDefaultLTA(): Boolean = + preferences.getBoolean( + resources.getString(R.string.main_settings_default_lta_key), + false, + ) + + fun setSettingsDefaultLTA(isEnabled: Boolean) { + preferences.edit { + putBoolean( + resources.getString(R.string.main_settings_default_lta_key), + isEnabled, + ) + } + } + fun getRoles(): String = preferences.getString( resources.getString(R.string.main_settings_role_key), diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/EncryptionServicesSettingsScreen.kt b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/EncryptionServicesSettingsScreen.kt index e16ecff18..2a3327a97 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/EncryptionServicesSettingsScreen.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/EncryptionServicesSettingsScreen.kt @@ -58,6 +58,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -104,7 +105,6 @@ import ee.ria.DigiDoc.viewmodel.EncryptionServicesViewModel import ee.ria.DigiDoc.viewmodel.shared.SharedCertificateViewModel import ee.ria.DigiDoc.viewmodel.shared.SharedMenuViewModel import ee.ria.DigiDoc.viewmodel.shared.SharedSettingsViewModel -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.launch @@ -126,6 +126,7 @@ fun EncryptionServicesSettingsScreen( navController: NavHostController, ) { val context = LocalContext.current + val scope = rememberCoroutineScope() val isSettingsMenuBottomSheetVisible = rememberSaveable { mutableStateOf(false) } @@ -265,7 +266,7 @@ fun EncryptionServicesSettingsScreen( navController.popBackStack() return@rememberLauncherForActivityResult } - CoroutineScope(Dispatchers.IO).launch { + scope.launch(Dispatchers.IO) { sharedSettingsViewModel.handleCryptoCertFile(uri) withContext(Main) { sharedSettingsViewModel.updateCryptoCertData(context) diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/ProxyServicesSettingsScreen.kt b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/ProxyServicesSettingsScreen.kt index c9d04abd5..70e6da8e8 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/ProxyServicesSettingsScreen.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/ProxyServicesSettingsScreen.kt @@ -157,6 +157,17 @@ fun ProxyServicesSettingsScreen( val isValidPortNumber = sharedSettingsViewModel.dataStore::isValidPortNumber + val saveManualProxySettings = { + sharedSettingsViewModel.saveProxySettings( + ManualProxy( + host = proxyHost.text, + port = proxyPort.text.toIntOrNull() ?: 80, + username = proxyUsername.text, + password = proxyPassword.text, + ), + ) + } + val proxyPortErrorText = if (proxyPort.text.isNotEmpty()) { if (!isValidPortNumber(proxyPort.text)) { @@ -401,6 +412,7 @@ fun ProxyServicesSettingsScreen( onValueChange = { proxyHost = it setProxyHost(it.text) + saveManualProxySettings() }, singleLine = true, label = stringResource(R.string.main_settings_proxy_host), @@ -428,6 +440,7 @@ fun ProxyServicesSettingsScreen( if (isValidPortNumber(it.text)) { setProxyPort(it.text.toInt()) } + saveManualProxySettings() }, singleLine = true, label = stringResource(R.string.main_settings_proxy_port), @@ -455,6 +468,7 @@ fun ProxyServicesSettingsScreen( onValueChange = { proxyUsername = it setProxyUsername(it.text) + saveManualProxySettings() }, singleLine = true, label = stringResource(R.string.main_settings_proxy_username), @@ -480,6 +494,7 @@ fun ProxyServicesSettingsScreen( onValueChange = { proxyPassword = it setProxyPassword(it.text) + saveManualProxySettings() }, singleLine = true, label = stringResource(R.string.main_settings_proxy_password), diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/SigningServicesSettingsScreen.kt b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/SigningServicesSettingsScreen.kt index 949e8eb8d..f086437be 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/SigningServicesSettingsScreen.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/SigningServicesSettingsScreen.kt @@ -44,6 +44,7 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController import ee.ria.DigiDoc.R import ee.ria.DigiDoc.ui.component.menu.SettingsMenuBottomSheet +import ee.ria.DigiDoc.ui.component.settings.advanced.signingservices.LTAComponent import ee.ria.DigiDoc.ui.component.settings.advanced.signingservices.MobileIdAndSmartIdServicesComponent import ee.ria.DigiDoc.ui.component.settings.advanced.signingservices.TimestampServicesComponent import ee.ria.DigiDoc.ui.component.shared.InvisibleElement @@ -130,6 +131,14 @@ fun SigningServicesSettingsScreen( sharedSettingsViewModel = sharedSettingsViewModel, ) }, + TabItem( + stringResource(R.string.main_settings_default_lta_tab_title), + ) { + LTAComponent( + modifier, + sharedSettingsViewModel = sharedSettingsViewModel, + ) + }, ), ) diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/ValidationServicesSettingsScreen.kt b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/ValidationServicesSettingsScreen.kt index 24d625750..d2158d66c 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/ValidationServicesSettingsScreen.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/ValidationServicesSettingsScreen.kt @@ -23,13 +23,10 @@ package ee.ria.DigiDoc.fragment.screen import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -38,10 +35,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -54,13 +48,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription @@ -75,6 +65,7 @@ import androidx.navigation.NavHostController import ee.ria.DigiDoc.R import ee.ria.DigiDoc.network.siva.SivaSetting import ee.ria.DigiDoc.ui.component.menu.SettingsMenuBottomSheet +import ee.ria.DigiDoc.ui.component.settings.shared.SettingsRadioCard import ee.ria.DigiDoc.ui.component.shared.InvisibleElement import ee.ria.DigiDoc.ui.component.shared.PrimaryTextField import ee.ria.DigiDoc.ui.component.shared.StatusSnackbarHost @@ -82,15 +73,10 @@ import ee.ria.DigiDoc.ui.component.shared.TopBar import ee.ria.DigiDoc.ui.component.support.textFieldValueSaver import ee.ria.DigiDoc.ui.theme.Dimensions.LPadding import ee.ria.DigiDoc.ui.theme.Dimensions.SPadding -import ee.ria.DigiDoc.ui.theme.Dimensions.XSBorder -import ee.ria.DigiDoc.ui.theme.Dimensions.XSPadding -import ee.ria.DigiDoc.ui.theme.buttonRoundedCornerShape import ee.ria.DigiDoc.utils.Route -import ee.ria.DigiDoc.utils.extensions.notAccessible import ee.ria.DigiDoc.viewmodel.shared.SharedCertificateViewModel import ee.ria.DigiDoc.viewmodel.shared.SharedMenuViewModel import ee.ria.DigiDoc.viewmodel.shared.SharedSettingsViewModel -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.launch @@ -106,11 +92,8 @@ fun ValidationServicesSettingsScreen( navController: NavHostController, ) { val context = LocalContext.current - val focusManager = LocalFocusManager.current val scope = rememberCoroutineScope() - val focusRequester = remember { FocusRequester() } - val isSettingsMenuBottomSheetVisible = rememberSaveable { mutableStateOf(false) } val configuration = sharedSettingsViewModel.updatedConfiguration.value @@ -149,7 +132,7 @@ fun ValidationServicesSettingsScreen( navController.popBackStack() return@rememberLauncherForActivityResult } - CoroutineScope(Dispatchers.IO).launch { + scope.launch(Dispatchers.IO) { sharedSettingsViewModel.handleSivaFile(uri) withContext(Main) { sharedSettingsViewModel.updateSivaData(settingsSivaServiceUrl.text, context) @@ -158,8 +141,6 @@ fun ValidationServicesSettingsScreen( }, ) - var urlText by remember { mutableStateOf(defaultSivaServiceUrl) } - val issuedToTitleText = stringResource(R.string.main_settings_timestamp_cert_issued_to_title) val validToTitleText = stringResource(R.string.main_settings_timestamp_cert_valid_to_title) val showCertificateButtonText = stringResource(R.string.main_settings_timestamp_cert_show_certificate_button) @@ -169,7 +150,6 @@ fun ValidationServicesSettingsScreen( val useDefaultAccessText = stringResource(R.string.main_settings_siva_default_access_title) val useManualAccessText = stringResource(R.string.main_settings_siva_default_manual_access_title) - val clearButtonText = stringResource(R.string.clear_text) val buttonName = stringResource(id = R.string.button_name) // Reset SiVa URL when the user navigates away from this screen and has set default choice @@ -226,207 +206,130 @@ fun ValidationServicesSettingsScreen( }, ) - Card( - modifier = - modifier - .fillMaxWidth() - .padding(top = XSPadding, bottom = SPadding), - shape = buttonRoundedCornerShape, - border = - BorderStroke( - width = XSBorder, - color = MaterialTheme.colorScheme.onSurface, - ), - colors = CardDefaults.cardColors(containerColor = Color.Transparent), + SettingsRadioCard( + modifier = modifier, + label = useDefaultAccessText, + selected = settingsSivaServiceChoice.value == SivaSetting.DEFAULT.name, + onClick = { + settingsSivaServiceChoice.value = SivaSetting.DEFAULT.name + setSivaSetting(SivaSetting.DEFAULT) + }, + ) + + SettingsRadioCard( + modifier = modifier, + label = useManualAccessText, + selected = settingsSivaServiceChoice.value == SivaSetting.MANUAL.name, + onClick = { + settingsSivaServiceChoice.value = SivaSetting.MANUAL.name + setSivaSetting(SivaSetting.MANUAL) + }, ) { - Row( - modifier = - modifier - .fillMaxWidth() - .padding(SPadding) - .clickable { - settingsSivaServiceChoice.value = SivaSetting.DEFAULT.name - setSivaSetting(SivaSetting.DEFAULT) - }, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = useDefaultAccessText, + if (settingsSivaServiceChoice.value == SivaSetting.MANUAL.name) { + PrimaryTextField( modifier = - modifier - .weight(1f) - .notAccessible(), + Modifier + .padding(vertical = LPadding), + value = settingsSivaServiceUrl, + onValueChange = { + settingsSivaServiceUrl = it + setSettingsSivaUrl(it.text) + }, + singleLine = true, + label = stringResource(R.string.main_settings_siva_service_url), + enabled = settingsSivaServiceChoice.value == SivaSetting.MANUAL.name, + keyboardOptions = + KeyboardOptions.Default.copy( + imeAction = ImeAction.Done, + keyboardType = KeyboardType.Uri, + ), + testTag = "validationServicesComponentTextField", + removeIconTestTag = "validationServicesRemoveIconButton", ) - RadioButton( + + Spacer(modifier = modifier.height(SPadding)) + + Text( modifier = modifier + .fillMaxWidth() .semantics { - contentDescription = useDefaultAccessText + heading() }, - selected = settingsSivaServiceChoice.value == SivaSetting.DEFAULT.name, - onClick = { - settingsSivaServiceChoice.value = SivaSetting.DEFAULT.name - setSivaSetting(SivaSetting.DEFAULT) - }, + text = stringResource(R.string.main_settings_siva_certificate_title), + style = MaterialTheme.typography.bodyLarge, ) - } - } - Card( - modifier = - modifier - .fillMaxWidth() - .padding(top = XSPadding, bottom = SPadding), - shape = buttonRoundedCornerShape, - border = - BorderStroke( - width = XSBorder, - color = MaterialTheme.colorScheme.onSurface, - ), - colors = CardDefaults.cardColors(containerColor = Color.Transparent), - ) { - Column( - modifier = - modifier - .padding(SPadding) - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Row( - modifier = - modifier - .clickable { - settingsSivaServiceChoice.value = SivaSetting.MANUAL.name - setSivaSetting(SivaSetting.MANUAL) - }, - verticalAlignment = Alignment.CenterVertically, - ) { + if (sivaCertificate != null) { Text( - text = useManualAccessText, - style = MaterialTheme.typography.bodyLarge, - modifier = - modifier - .weight(1f) - .notAccessible(), - ) - RadioButton( - modifier = - modifier - .semantics { - contentDescription = useManualAccessText - }, - selected = settingsSivaServiceChoice.value == SivaSetting.MANUAL.name, - onClick = { - settingsSivaServiceChoice.value = SivaSetting.MANUAL.name - setSivaSetting(SivaSetting.MANUAL) - }, + modifier = modifier.fillMaxWidth(), + text = "$issuedToTitleText $issuedTo", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - } - if (settingsSivaServiceChoice.value == SivaSetting.MANUAL.name) { - PrimaryTextField( - modifier = - Modifier - .padding(vertical = LPadding), - value = settingsSivaServiceUrl, - onValueChange = { - settingsSivaServiceUrl = it - setSettingsSivaUrl(it.text) - }, - singleLine = true, - label = stringResource(R.string.main_settings_siva_service_url), - enabled = settingsSivaServiceChoice.value == SivaSetting.MANUAL.name, - keyboardOptions = - KeyboardOptions.Default.copy( - imeAction = ImeAction.Done, - keyboardType = KeyboardType.Uri, - ), - testTag = "validationServicesComponentTextField", - removeIconTestTag = "validationServicesRemoveIconButton", + Text( + modifier = modifier.fillMaxWidth(), + text = "$validToTitleText $validTo", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - + } else { Text( - modifier = - modifier - .fillMaxWidth() - .semantics { - heading() - }, - text = stringResource(R.string.main_settings_siva_certificate_title), - style = MaterialTheme.typography.bodyLarge, + modifier = modifier.fillMaxWidth(), + text = noCertificateFoundText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) + } - if (sivaCertificate != null) { - Text( - modifier = modifier.fillMaxWidth(), - text = "$issuedToTitleText $issuedTo", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Text( - modifier = modifier.fillMaxWidth(), - text = "$validToTitleText $validTo", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - Text( - modifier = modifier.fillMaxWidth(), - text = noCertificateFoundText, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - Spacer(modifier = modifier.height(SPadding)) + Spacer(modifier = modifier.height(SPadding)) - FlowRow( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalArrangement = Arrangement.Center, - ) { - if (sivaCertificate != null) { - TextButton(onClick = { - sivaCertificate?.let { - sharedCertificateViewModel.setCertificate( - it, - ) - navController.navigate( - Route.CertificateDetail.route, - ) - } - }) { - Text( - modifier = - modifier - .semantics { - contentDescription = - "$showCertificateButtonText $buttonName" - testTagsAsResourceId = true - }.testTag("validationServicesShowCertificateActionButton"), - text = showCertificateButtonText, - color = MaterialTheme.colorScheme.primary, + FlowRow( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalArrangement = Arrangement.Center, + ) { + if (sivaCertificate != null) { + TextButton(onClick = { + sivaCertificate?.let { + sharedCertificateViewModel.setCertificate( + it, + ) + navController.navigate( + Route.CertificateDetail.route, ) } - } - - TextButton(onClick = { - filePicker.launch("*/*") }) { Text( modifier = modifier .semantics { contentDescription = - "$addCertificateButtonText $buttonName" + "$showCertificateButtonText $buttonName" testTagsAsResourceId = true - }.testTag("validationServicesAddCertificateActionButton"), - text = addCertificateButtonText, + }.testTag("validationServicesShowCertificateActionButton"), + text = showCertificateButtonText, color = MaterialTheme.colorScheme.primary, ) } } + + TextButton(onClick = { + filePicker.launch("*/*") + }) { + Text( + modifier = + modifier + .semantics { + contentDescription = + "$addCertificateButtonText $buttonName" + testTagsAsResourceId = true + }.testTag("validationServicesAddCertificateActionButton"), + text = addCertificateButtonText, + color = MaterialTheme.colorScheme.primary, + ) + } } } } diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/crypto/EncryptNavigation.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/crypto/EncryptNavigation.kt index ec24e6537..1b5faf532 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/crypto/EncryptNavigation.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/crypto/EncryptNavigation.kt @@ -1286,6 +1286,7 @@ private fun handleBackButtonClick( } else { sharedContainerViewModel.resetSignedContainer() sharedContainerViewModel.resetCryptoContainer() + sharedContainerViewModel.resetContainerNotifications() sharedContainerViewModel.clearContainers() encryptViewModel.handleBackButton() if (!navController.popBackStack(Route.Home.route, inclusive = false)) { diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/settings/advanced/signingservices/LTAComponent.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/settings/advanced/signingservices/LTAComponent.kt new file mode 100644 index 000000000..93d823fa1 --- /dev/null +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/settings/advanced/signingservices/LTAComponent.kt @@ -0,0 +1,97 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@file:Suppress("PackageName", "FunctionName") + +package ee.ria.DigiDoc.ui.component.settings.advanced.signingservices + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import ee.ria.DigiDoc.R +import ee.ria.DigiDoc.ui.component.settings.shared.SettingsRadioCard +import ee.ria.DigiDoc.ui.component.shared.InvisibleElement +import ee.ria.DigiDoc.ui.theme.Dimensions.SPadding +import ee.ria.DigiDoc.viewmodel.shared.SharedSettingsViewModel + +@Composable +fun LTAComponent( + modifier: Modifier = Modifier, + sharedSettingsViewModel: SharedSettingsViewModel, +) { + val getDefaultLTA = sharedSettingsViewModel.dataStore::getSettingsDefaultLTA + val setDefaultLTA = sharedSettingsViewModel.dataStore::setSettingsDefaultLTA + + val isEnabled = rememberSaveable { mutableStateOf(getDefaultLTA()) } + // No DisposableEffect reset needed: the boolean requires no URL cleanup on navigation away. + + val titleText = stringResource(R.string.main_settings_default_lta_tab_title) + val disabledText = stringResource(R.string.main_settings_default_lta_disabled) + val enabledText = stringResource(R.string.main_settings_default_lta_enabled) + + Column( + modifier = + modifier + .fillMaxSize() + .padding(SPadding) + .padding(top = SPadding), + ) { + Text( + text = titleText, + style = MaterialTheme.typography.titleLarge, + modifier = + Modifier + .padding(bottom = SPadding) + .semantics { + heading() + }, + ) + + SettingsRadioCard( + modifier = modifier, + label = disabledText, + selected = !isEnabled.value, + onClick = { + isEnabled.value = false + setDefaultLTA(false) + }, + ) + + SettingsRadioCard( + modifier = modifier, + label = enabledText, + selected = isEnabled.value, + onClick = { + isEnabled.value = true + setDefaultLTA(true) + }, + ) + + InvisibleElement(modifier = modifier) + } +} diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/settings/advanced/signingservices/MobileIdAndSmartIdServicesComponent.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/settings/advanced/signingservices/MobileIdAndSmartIdServicesComponent.kt index 16f400d22..1c9f12f67 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/settings/advanced/signingservices/MobileIdAndSmartIdServicesComponent.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/settings/advanced/signingservices/MobileIdAndSmartIdServicesComponent.kt @@ -21,19 +21,12 @@ package ee.ria.DigiDoc.ui.component.settings.advanced.signingservices -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -42,15 +35,11 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextRange @@ -60,15 +49,12 @@ import androidx.compose.ui.text.input.TextFieldValue import ee.ria.DigiDoc.R import ee.ria.DigiDoc.common.Constant.Defaults.DEFAULT_UUID_VALUE import ee.ria.DigiDoc.domain.model.settings.UUIDSetting +import ee.ria.DigiDoc.ui.component.settings.shared.SettingsRadioCard import ee.ria.DigiDoc.ui.component.shared.InvisibleElement import ee.ria.DigiDoc.ui.component.shared.PrimaryTextField import ee.ria.DigiDoc.ui.component.support.textFieldValueSaver import ee.ria.DigiDoc.ui.theme.Dimensions.LPadding import ee.ria.DigiDoc.ui.theme.Dimensions.SPadding -import ee.ria.DigiDoc.ui.theme.Dimensions.XSBorder -import ee.ria.DigiDoc.ui.theme.Dimensions.XSPadding -import ee.ria.DigiDoc.ui.theme.buttonRoundedCornerShape -import ee.ria.DigiDoc.utils.extensions.notAccessible import ee.ria.DigiDoc.viewmodel.shared.SharedSettingsViewModel @OptIn(ExperimentalLayoutApi::class, ExperimentalComposeUiApi::class) @@ -77,8 +63,6 @@ fun MobileIdAndSmartIdServicesComponent( modifier: Modifier = Modifier, sharedSettingsViewModel: SharedSettingsViewModel, ) { - val context = LocalContext.current - val focusRequester = remember { FocusRequester() } val getSettingsUUID = sharedSettingsViewModel.dataStore::getSettingsUUID @@ -86,7 +70,7 @@ fun MobileIdAndSmartIdServicesComponent( val setSettingsUuid = sharedSettingsViewModel.dataStore::setSettingsUUID val setUuidSetting = sharedSettingsViewModel.dataStore::setUuidSetting val defaultUuid = getSettingsUUID() - val settingsUuidChoice = remember { mutableStateOf(getUuidSetting().name) } + val settingsUuidChoice = rememberSaveable { mutableStateOf(getUuidSetting().name) } var settingsUuid by rememberSaveable(stateSaver = textFieldValueSaver) { mutableStateOf( TextFieldValue( @@ -95,8 +79,6 @@ fun MobileIdAndSmartIdServicesComponent( ), ) } - sharedSettingsViewModel.updateTsaData(settingsUuid.text, context) - val useDefaultAccessText = stringResource(R.string.main_settings_siva_default_access_title) val useManualAccessText = stringResource(R.string.main_settings_siva_default_manual_access_title) val accessToMobileAndSmartIdServicesText = stringResource(R.string.main_settings_uuid_title) @@ -118,139 +100,60 @@ fun MobileIdAndSmartIdServicesComponent( .padding(top = SPadding), ) { Text( - text = stringResource(R.string.main_settings_uuid_title), + text = accessToMobileAndSmartIdServicesText, style = MaterialTheme.typography.titleLarge, modifier = - modifier + Modifier .padding(bottom = SPadding) .semantics { heading() }, ) - Card( - modifier = - modifier - .fillMaxWidth() - .padding(top = XSPadding, bottom = SPadding), - shape = buttonRoundedCornerShape, - border = - BorderStroke( - width = XSBorder, - color = MaterialTheme.colorScheme.onSurface, - ), - colors = CardDefaults.cardColors(containerColor = Color.Transparent), + SettingsRadioCard( + modifier = modifier, + label = useDefaultAccessText, + selected = settingsUuidChoice.value == UUIDSetting.DEFAULT.name, + onClick = { + settingsUuidChoice.value = UUIDSetting.DEFAULT.name + setUuidSetting(UUIDSetting.DEFAULT) + }, + ) + + SettingsRadioCard( + modifier = modifier, + label = useManualAccessText, + selected = settingsUuidChoice.value == UUIDSetting.MANUAL.name, + onClick = { + settingsUuidChoice.value = UUIDSetting.MANUAL.name + setUuidSetting(UUIDSetting.MANUAL) + }, ) { - Row( - modifier = - modifier - .fillMaxWidth() - .padding(SPadding) - .clickable { - settingsUuidChoice.value = UUIDSetting.DEFAULT.name - setUuidSetting(UUIDSetting.DEFAULT) - }, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = useDefaultAccessText, - modifier = - modifier - .weight(1f) - .notAccessible(), - ) - RadioButton( - modifier = - modifier - .semantics { - contentDescription = useDefaultAccessText - }, - selected = settingsUuidChoice.value == UUIDSetting.DEFAULT.name, - onClick = { - settingsUuidChoice.value = UUIDSetting.DEFAULT.name - setUuidSetting(UUIDSetting.DEFAULT) + if (settingsUuidChoice.value == UUIDSetting.MANUAL.name) { + PrimaryTextField( + modifier = Modifier.padding(vertical = LPadding), + focusRequester = focusRequester, + value = settingsUuid, + onValueChange = { + settingsUuid = it + setSettingsUuid(it.text) }, + singleLine = true, + enabled = settingsUuidChoice.value == UUIDSetting.MANUAL.name, + label = accessToMobileAndSmartIdServicesText, + isPasswordText = true, + keyboardOptions = + KeyboardOptions.Default.copy( + imeAction = ImeAction.Done, + keyboardType = KeyboardType.Password, + ), + testTag = "mobileIdAndSmartIdServicesComponentTextField", + removeIconTestTag = "mobileIdAndSmartIdServicesComponentRemoveIconButton", + showIconTestTag = "mobileIdAndSmartIdServicesComponentPasswordVisibleButton", ) } } - Card( - modifier = - modifier - .fillMaxWidth() - .padding(top = XSPadding, bottom = SPadding), - shape = buttonRoundedCornerShape, - border = - BorderStroke( - width = XSBorder, - color = MaterialTheme.colorScheme.onSurface, - ), - colors = CardDefaults.cardColors(containerColor = Color.Transparent), - ) { - Column( - modifier = - modifier - .padding(SPadding) - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Row( - modifier = - modifier - .clickable { - settingsUuidChoice.value = UUIDSetting.MANUAL.name - setUuidSetting(UUIDSetting.MANUAL) - }, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = useManualAccessText, - style = MaterialTheme.typography.bodyLarge, - modifier = - modifier - .weight(1f) - .notAccessible(), - ) - RadioButton( - modifier = - modifier - .semantics { - contentDescription = useManualAccessText - }, - selected = settingsUuidChoice.value == UUIDSetting.MANUAL.name, - onClick = { - settingsUuidChoice.value = UUIDSetting.MANUAL.name - setUuidSetting(UUIDSetting.MANUAL) - }, - ) - } - - if (settingsUuidChoice.value == UUIDSetting.MANUAL.name) { - PrimaryTextField( - modifier = Modifier.padding(vertical = LPadding), - focusRequester = focusRequester, - value = settingsUuid, - onValueChange = { - settingsUuid = it - setSettingsUuid(it.text) - }, - singleLine = true, - enabled = settingsUuidChoice.value == UUIDSetting.MANUAL.name, - label = accessToMobileAndSmartIdServicesText, - isPasswordText = true, - keyboardOptions = - KeyboardOptions.Default.copy( - imeAction = ImeAction.Done, - keyboardType = KeyboardType.Password, - ), - testTag = "mobileIdAndSmartIdServicesComponentTextField", - removeIconTestTag = "mobileIdAndSmartIdServicesComponentRemoveIconButton", - showIconTestTag = "mobileIdAndSmartIdServicesComponentPasswordVisibleButton", - ) - } - } - } - InvisibleElement(modifier = modifier) } } diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/settings/advanced/signingservices/TimestampServicesComponent.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/settings/advanced/signingservices/TimestampServicesComponent.kt index 383455809..aec2f735d 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/settings/advanced/signingservices/TimestampServicesComponent.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/settings/advanced/signingservices/TimestampServicesComponent.kt @@ -23,23 +23,17 @@ package ee.ria.DigiDoc.ui.component.settings.advanced.signingservices import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer 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.text.KeyboardOptions -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -47,17 +41,12 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription @@ -71,16 +60,13 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.navigation.NavHostController import ee.ria.DigiDoc.R import ee.ria.DigiDoc.domain.model.settings.TSASetting +import ee.ria.DigiDoc.ui.component.settings.shared.SettingsRadioCard import ee.ria.DigiDoc.ui.component.shared.InvisibleElement import ee.ria.DigiDoc.ui.component.shared.PrimaryTextField import ee.ria.DigiDoc.ui.component.support.textFieldValueSaver import ee.ria.DigiDoc.ui.theme.Dimensions.LPadding import ee.ria.DigiDoc.ui.theme.Dimensions.SPadding -import ee.ria.DigiDoc.ui.theme.Dimensions.XSBorder -import ee.ria.DigiDoc.ui.theme.Dimensions.XSPadding -import ee.ria.DigiDoc.ui.theme.buttonRoundedCornerShape import ee.ria.DigiDoc.utils.Route -import ee.ria.DigiDoc.utils.extensions.notAccessible import ee.ria.DigiDoc.viewmodel.shared.SharedCertificateViewModel import ee.ria.DigiDoc.viewmodel.shared.SharedSettingsViewModel import kotlinx.coroutines.Dispatchers.IO @@ -97,9 +83,7 @@ fun TimestampServicesComponent( navController: NavHostController, ) { val context = LocalContext.current - val focusManager = LocalFocusManager.current val scope = rememberCoroutineScope() - val focusRequester = remember { FocusRequester() } val configuration = sharedSettingsViewModel.updatedConfiguration.value @@ -108,7 +92,7 @@ fun TimestampServicesComponent( val setSettingsTsaUrl = sharedSettingsViewModel.dataStore::setSettingsTSAUrl val setTsaSetting = sharedSettingsViewModel.dataStore::setTsaSetting val defaultTsaServiceUrl = getSettingsTsaUrl().ifEmpty { configuration?.tsaUrl } ?: "" - val settingsTsaServiceChoice = remember { mutableStateOf(getTsaSetting().name) } + val settingsTsaServiceChoice = rememberSaveable { mutableStateOf(getTsaSetting().name) } var settingsTsaServiceUrl by rememberSaveable(stateSaver = textFieldValueSaver) { mutableStateOf( TextFieldValue( @@ -156,7 +140,6 @@ fun TimestampServicesComponent( val useDefaultAccessText = stringResource(R.string.main_settings_siva_default_access_title) val useManualAccessText = stringResource(R.string.main_settings_siva_default_manual_access_title) - val clearButtonText = stringResource(R.string.clear_text) val buttonName = stringResource(id = R.string.button_name) // Reset TSA URL when the user navigates away from this screen and has set default choice @@ -179,211 +162,134 @@ fun TimestampServicesComponent( text = accessToTimeStampingServicesTitleText, style = MaterialTheme.typography.titleLarge, modifier = - modifier + Modifier .padding(bottom = SPadding) .semantics { heading() }, ) - Card( - modifier = - modifier - .fillMaxWidth() - .padding(top = XSPadding, bottom = SPadding), - shape = buttonRoundedCornerShape, - border = - BorderStroke( - width = XSBorder, - color = MaterialTheme.colorScheme.onSurface, - ), - colors = CardDefaults.cardColors(containerColor = Color.Transparent), + SettingsRadioCard( + modifier = modifier, + label = useDefaultAccessText, + selected = settingsTsaServiceChoice.value == TSASetting.DEFAULT.name, + onClick = { + settingsTsaServiceChoice.value = TSASetting.DEFAULT.name + setTsaSetting(TSASetting.DEFAULT) + }, + ) + + SettingsRadioCard( + modifier = modifier, + label = useManualAccessText, + selected = settingsTsaServiceChoice.value == TSASetting.MANUAL.name, + onClick = { + settingsTsaServiceChoice.value = TSASetting.MANUAL.name + setTsaSetting(TSASetting.MANUAL) + }, ) { - Row( - modifier = - modifier - .fillMaxWidth() - .padding(SPadding) - .clickable { - settingsTsaServiceChoice.value = TSASetting.DEFAULT.name - setTsaSetting(TSASetting.DEFAULT) - }, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = useDefaultAccessText, - modifier = - modifier - .weight(1f) - .notAccessible(), + if (settingsTsaServiceChoice.value == TSASetting.MANUAL.name) { + PrimaryTextField( + modifier = Modifier.padding(vertical = LPadding), + value = settingsTsaServiceUrl, + onValueChange = { + settingsTsaServiceUrl = it + setSettingsTsaUrl(it.text) + }, + label = accessToTimeStampingServicesTitleText, + enabled = settingsTsaServiceChoice.value == TSASetting.MANUAL.name, + keyboardOptions = + KeyboardOptions.Default.copy( + imeAction = ImeAction.Done, + keyboardType = KeyboardType.Uri, + ), + testTag = "timestampServicesComponentTextField", + removeIconTestTag = "timestampServicesRemoveIconButton", ) - RadioButton( + + Spacer(modifier = Modifier.height(SPadding)) + + Text( modifier = modifier + .fillMaxWidth() .semantics { - contentDescription = useDefaultAccessText + heading() }, - selected = settingsTsaServiceChoice.value == TSASetting.DEFAULT.name, - onClick = { - settingsTsaServiceChoice.value = TSASetting.DEFAULT.name - setTsaSetting(TSASetting.DEFAULT) - }, + text = stringResource(R.string.main_settings_timestamp_cert_title), + style = MaterialTheme.typography.bodyLarge, ) - } - } - Card( - modifier = - modifier - .fillMaxWidth() - .padding(top = XSPadding, bottom = SPadding), - shape = buttonRoundedCornerShape, - border = - BorderStroke( - width = XSBorder, - color = MaterialTheme.colorScheme.onSurface, - ), - colors = CardDefaults.cardColors(containerColor = Color.Transparent), - ) { - Column( - modifier = - modifier - .padding(SPadding) - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Row( - modifier = - modifier - .clickable { - settingsTsaServiceChoice.value = TSASetting.MANUAL.name - setTsaSetting(TSASetting.MANUAL) - }, - verticalAlignment = Alignment.CenterVertically, - ) { + if (tsaCertificate != null) { Text( - text = useManualAccessText, - style = MaterialTheme.typography.bodyLarge, - modifier = - modifier - .weight(1f) - .notAccessible(), - ) - RadioButton( - modifier = - modifier - .semantics { - contentDescription = useManualAccessText - }, - selected = settingsTsaServiceChoice.value == TSASetting.MANUAL.name, - onClick = { - settingsTsaServiceChoice.value = TSASetting.MANUAL.name - setTsaSetting(TSASetting.MANUAL) - }, + modifier = modifier.fillMaxWidth(), + text = "$issuedToTitleText $issuedTo", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - } - if (settingsTsaServiceChoice.value == TSASetting.MANUAL.name) { - PrimaryTextField( - modifier = Modifier.padding(vertical = LPadding), - value = settingsTsaServiceUrl, - onValueChange = { - settingsTsaServiceUrl = it - setSettingsTsaUrl(it.text) - }, - label = accessToTimeStampingServicesTitleText, - enabled = settingsTsaServiceChoice.value == TSASetting.MANUAL.name, - keyboardOptions = - KeyboardOptions.Default.copy( - imeAction = ImeAction.Done, - keyboardType = KeyboardType.Uri, - ), - testTag = "timestampServicesComponentTextField", - removeIconTestTag = "timestampServicesRemoveIconButton", + Text( + modifier = modifier.fillMaxWidth(), + text = "$validToTitleText $validTo", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - + } else { Text( - modifier = - modifier - .fillMaxWidth() - .semantics { - heading() - }, - text = stringResource(R.string.main_settings_timestamp_cert_title), - style = MaterialTheme.typography.bodyLarge, + modifier = modifier.fillMaxWidth(), + text = noCertificateFoundText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) + } - if (tsaCertificate != null) { - Text( - modifier = modifier.fillMaxWidth(), - text = "$issuedToTitleText $issuedTo", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Text( - modifier = modifier.fillMaxWidth(), - text = "$validToTitleText $validTo", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - Text( - modifier = modifier.fillMaxWidth(), - text = noCertificateFoundText, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - Spacer(modifier = modifier.height(SPadding)) + Spacer(modifier = modifier.height(SPadding)) - FlowRow( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalArrangement = Arrangement.Center, - ) { - if (tsaCertificate != null) { - TextButton(onClick = { - tsaCertificate?.let { - sharedCertificateViewModel.setCertificate( - it, - ) - navController.navigate( - Route.CertificateDetail.route, - ) - } - }) { - Text( - modifier = - modifier - .semantics { - contentDescription = - "$showCertificateButtonText $buttonName" - testTagsAsResourceId = true - }.testTag("timestampServicesShowCertificateActionButton"), - text = showCertificateButtonText, - color = MaterialTheme.colorScheme.primary, + FlowRow( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalArrangement = Arrangement.Center, + ) { + if (tsaCertificate != null) { + TextButton(onClick = { + tsaCertificate?.let { + sharedCertificateViewModel.setCertificate( + it, + ) + navController.navigate( + Route.CertificateDetail.route, ) } - } - - TextButton(onClick = { - filePicker.launch("*/*") }) { Text( modifier = modifier .semantics { contentDescription = - "$addCertificateButtonText $buttonName" + "$showCertificateButtonText $buttonName" testTagsAsResourceId = true - }.testTag("timestampServicesAddCertificateActionButton"), - text = addCertificateButtonText, + }.testTag("timestampServicesShowCertificateActionButton"), + text = showCertificateButtonText, color = MaterialTheme.colorScheme.primary, ) } } + + TextButton(onClick = { + filePicker.launch("*/*") + }) { + Text( + modifier = + modifier + .semantics { + contentDescription = + "$addCertificateButtonText $buttonName" + testTagsAsResourceId = true + }.testTag("timestampServicesAddCertificateActionButton"), + text = addCertificateButtonText, + color = MaterialTheme.colorScheme.primary, + ) + } } } } diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/settings/shared/SettingsRadioCard.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/settings/shared/SettingsRadioCard.kt new file mode 100644 index 000000000..68b3e740c --- /dev/null +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/settings/shared/SettingsRadioCard.kt @@ -0,0 +1,127 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@file:Suppress("PackageName", "FunctionName") + +package ee.ria.DigiDoc.ui.component.settings.shared + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import ee.ria.DigiDoc.ui.theme.Dimensions.SPadding +import ee.ria.DigiDoc.ui.theme.Dimensions.XSBorder +import ee.ria.DigiDoc.ui.theme.Dimensions.XSPadding +import ee.ria.DigiDoc.ui.theme.buttonRoundedCornerShape +import ee.ria.DigiDoc.utils.extensions.notAccessible + +/** + * A styled Card containing a radio button row used in settings screens. + * + * When [content] is null the card renders a simple full-width row (label + radio button). + * When [content] is provided the card renders a Column with the label row on top followed by + * the supplied composable. The content lambda is always invoked when non-null; callers are + * responsible for any conditional rendering inside it (e.g. `if (selected) { ... }`). + */ +@Composable +fun SettingsRadioCard( + modifier: Modifier = Modifier, + label: String, + selected: Boolean, + onClick: () -> Unit, + content: @Composable (ColumnScope.() -> Unit)? = null, +) { + Card( + modifier = + modifier + .fillMaxWidth() + .padding(top = XSPadding, bottom = SPadding), + shape = buttonRoundedCornerShape, + border = + BorderStroke( + width = XSBorder, + color = MaterialTheme.colorScheme.onSurface, + ), + colors = CardDefaults.cardColors(containerColor = Color.Transparent), + ) { + if (content != null) { + Column( + modifier = + Modifier + .padding(SPadding) + .fillMaxWidth(), + horizontalAlignment = Alignment.Start, + ) { + RadioButtonRow(label = label, selected = selected, onClick = onClick, withPadding = false) + content() + } + } else { + RadioButtonRow(label = label, selected = selected, onClick = onClick, withPadding = true) + } + } +} + +@Composable +private fun RadioButtonRow( + label: String, + selected: Boolean, + onClick: () -> Unit, + withPadding: Boolean, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .then(if (withPadding) Modifier.padding(SPadding) else Modifier) + .clickable(onClick = onClick), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + modifier = + Modifier + .weight(1f) + .notAccessible(), + ) + RadioButton( + modifier = + Modifier + .semantics { + contentDescription = label + }, + selected = selected, + onClick = onClick, + ) + } +} diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/shared/dialog/SingleButtonDialog.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/shared/dialog/SingleButtonDialog.kt new file mode 100644 index 000000000..a796d20e8 --- /dev/null +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/shared/dialog/SingleButtonDialog.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@file:Suppress("PackageName", "FunctionName") + +package ee.ria.DigiDoc.ui.component.shared.dialog + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import ee.ria.DigiDoc.R + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun SingleButtonDialog( + title: String, + message: String, + buttonText: String, + onButtonClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val buttonName = stringResource(id = R.string.button_name) + + AlertDialog( + onDismissRequest = onButtonClick, + title = { + Text( + text = title, + modifier = + modifier + .fillMaxWidth() + .semantics { + heading() + this.contentDescription = title.lowercase() + testTagsAsResourceId = true + }.testTag("singleButtonDialogTitleText"), + ) + }, + text = { + Text( + text = message, + modifier = + modifier + .fillMaxWidth() + .semantics { + this.contentDescription = message.lowercase() + testTagsAsResourceId = true + }.verticalScroll(rememberScrollState()) + .testTag("singleButtonDialogMessageText"), + ) + }, + confirmButton = { + TextButton(onClick = onButtonClick) { + Text( + text = buttonText, + color = MaterialTheme.colorScheme.primary, + modifier = + modifier + .semantics { + this.contentDescription = "$buttonText $buttonName" + testTagsAsResourceId = true + }.testTag("singleButtonDialogButtonText"), + ) + } + }, + ) +} diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/ColoredSignedStatusText.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/ColoredSignedStatusText.kt index 95f31a9af..8c96dded2 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/ColoredSignedStatusText.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/ColoredSignedStatusText.kt @@ -30,12 +30,16 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview +import ee.ria.DigiDoc.R import ee.ria.DigiDoc.libdigidoclib.domain.model.ValidatorInterface import ee.ria.DigiDoc.ui.component.shared.TagBadge import ee.ria.DigiDoc.ui.theme.RIADigiDocTheme import ee.ria.DigiDoc.ui.theme.extendedColorScheme +import ee.ria.DigiDoc.utilsLib.date.DateUtil +import java.util.Date @OptIn(ExperimentalLayoutApi::class) @Composable @@ -43,6 +47,8 @@ fun ColoredSignedStatusText( text: String, status: ValidatorInterface.Status, modifier: Modifier = Modifier, + validUntil: Date? = null, + isSignatureExtended: Boolean = false, ) { val parts = text.split(" (", limit = 2) @@ -75,28 +81,57 @@ fun ColoredSignedStatusText( FlowRow( modifier = modifier, ) { - TagBadge( - text = parts[0], - backgroundColor = tagBackgroundColor, - contentColor = tagContentColor, - modifier = - modifier - .alignByBaseline() - .focusable() - .testTag("signatureUpdateListSignatureStatus"), - ) + if (validUntil != null) { + val isExpired = validUntil.before(Date()) || !isSignatureValidOrWarning + val validUntilBackgroundColor = + if (isExpired) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.extendedColorScheme.successContainer + } + val validUntilContentColor = + if (isExpired) { + MaterialTheme.colorScheme.onErrorContainer + } else { + MaterialTheme.extendedColorScheme.onSuccessContainer + } + val formattedDate = DateUtil.dateFormat.format(validUntil) + TagBadge( + text = stringResource(R.string.signature_valid_until, formattedDate), + backgroundColor = validUntilBackgroundColor, + contentColor = validUntilContentColor, + modifier = + modifier + .alignByBaseline() + .focusable() + .testTag("signatureUpdateListValidUntil"), + ) + } - if (parts.size > 1) { - Text( - text = " (${parts[1]}", - color = additionalTextColor, + if (!isSignatureExtended || validUntil == null || !isSignatureValidOrWarning) { + TagBadge( + text = parts[0], + backgroundColor = tagBackgroundColor, + contentColor = tagContentColor, modifier = modifier .alignByBaseline() .focusable() - .testTag("signatureUpdateListSignatureStatusCaution"), - style = TextStyle(fontSize = MaterialTheme.typography.bodyMedium.fontSize), + .testTag("signatureUpdateListSignatureStatus"), ) + + if (parts.size > 1) { + Text( + text = " (${parts[1]}", + color = additionalTextColor, + modifier = + modifier + .alignByBaseline() + .focusable() + .testTag("signatureUpdateListSignatureStatusCaution"), + style = TextStyle(fontSize = MaterialTheme.typography.bodyMedium.fontSize), + ) + } } } } diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/SignatureComponent.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/SignatureComponent.kt index 73998ad18..210e6190e 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/SignatureComponent.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/SignatureComponent.kt @@ -246,6 +246,9 @@ fun SignatureComponent( .padding(vertical = SBorder) .focusable(false) .notAccessible(), + validUntil = if (!isTimestamped) signature.validUntil else null, + isSignatureExtended = + !isTimestamped && signature.archiveTimeStampCertificateDer.isNotEmpty(), ) if (!signature.signerRoles.isEmpty()) { Text( diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/SigningNavigation.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/SigningNavigation.kt index c510c7788..916e1fe71 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/SigningNavigation.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/SigningNavigation.kt @@ -109,6 +109,7 @@ import ee.ria.DigiDoc.ui.component.shared.StatusSnackbarHost import ee.ria.DigiDoc.ui.component.shared.TabItem import ee.ria.DigiDoc.ui.component.shared.TabView import ee.ria.DigiDoc.ui.component.shared.TopBar +import ee.ria.DigiDoc.ui.component.shared.dialog.SingleButtonDialog import ee.ria.DigiDoc.ui.component.shared.dialog.SivaConfirmationDialog import ee.ria.DigiDoc.ui.component.shared.handler.containerFileOpeningHandler import ee.ria.DigiDoc.ui.component.signing.bottombar.SigningBottomBar @@ -133,11 +134,14 @@ import ee.ria.DigiDoc.utilsLib.extensions.isContainer import ee.ria.DigiDoc.utilsLib.extensions.isSignedPDF import ee.ria.DigiDoc.utilsLib.extensions.mimeType import ee.ria.DigiDoc.utilsLib.file.FileUtil.sanitizeString +import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.debugLog import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.errorLog import ee.ria.DigiDoc.viewmodel.EncryptViewModel +import ee.ria.DigiDoc.viewmodel.ExtendSignaturesResult import ee.ria.DigiDoc.viewmodel.SigningViewModel import ee.ria.DigiDoc.viewmodel.shared.SharedContainerViewModel import ee.ria.DigiDoc.viewmodel.shared.SharedMenuViewModel +import ee.ria.DigiDoc.viewmodel.shared.SharedSettingsViewModel import ee.ria.DigiDoc.viewmodel.shared.SharedSignatureViewModel import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main @@ -148,6 +152,8 @@ import org.apache.commons.io.FilenameUtils import java.io.File import kotlin.time.Duration.Companion.seconds +private const val LOG_TAG = "SigningNavigation" + @OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable fun SigningNavigation( @@ -158,6 +164,7 @@ fun SigningNavigation( sharedSignatureViewModel: SharedSignatureViewModel, signingViewModel: SigningViewModel = hiltViewModel(), encryptViewModel: EncryptViewModel = hiltViewModel(), + sharedSettingsViewModel: SharedSettingsViewModel = hiltViewModel(), ) { val signedContainer by sharedContainerViewModel.signedContainer.collectAsState() val shouldResetContainer by signingViewModel.shouldResetSignedContainer.asFlow().collectAsState(false) @@ -304,10 +311,15 @@ fun SigningNavigation( val listState = rememberLazyListState() val showContainerCloseConfirmationDialog = rememberSaveable { mutableStateOf(false) } + val showExtendSignaturesConfirmDialog = rememberSaveable { mutableStateOf(false) } val showSivaDialog = rememberSaveable { mutableStateOf(false) } val nestedFile = rememberSaveable { mutableStateOf(null) } + val showExtendSivaDialog = rememberSaveable { mutableStateOf(false) } + val showExtendWrappedDialog = rememberSaveable { mutableStateOf(false) } + val extendedContainerFile = rememberSaveable { mutableStateOf(null) } + val showContainerBottomSheet = rememberSaveable { mutableStateOf(false) } val showDataFileBottomSheet = rememberSaveable { mutableStateOf(false) } val showSignatureBottomSheet = rememberSaveable { mutableStateOf(false) } @@ -382,6 +394,29 @@ fun SigningNavigation( } } + val handleExtendResult: (Boolean) -> Unit = { isSivaConfirmed -> + extendedContainerFile.value?.let { file -> + showLoadingScreen.value = true + scope.launch(IO) { + try { + signingViewModel.openNestedContainer( + context, + file, + sharedContainerViewModel, + isSivaConfirmed, + overwriteContainer = true, + ) + } catch (e: Exception) { + errorLog(LOG_TAG, "Unable to open extended container", e) + } + withContext(Main) { + showLoadingScreen.value = false + showExtendWrappedDialog.value = true + } + } + } + } + val onDataFileClick: (DataFileInterface) -> Unit = { dataFile -> showDataFileBottomSheet.value = false @@ -444,6 +479,12 @@ fun SigningNavigation( } } + val onExtendSignaturesActionClick: () -> Unit = { + debugLog(LOG_TAG, "Extend signatures button clicked, showing confirmation dialog") + showContainerBottomSheet.value = false + showExtendSignaturesConfirmDialog.value = true + } + var isSaved by remember { mutableStateOf(false) } val selectedSignedContainerTabIndex = rememberSaveable { mutableIntStateOf(0) } @@ -504,6 +545,7 @@ fun SigningNavigation( sharedContainerViewModel.signedMidStatus.collect { status -> status?.let { if (status == MobileCreateSignatureProcessStatus.OK) { + extendToLTA(sharedSettingsViewModel, signedContainer) { showLoadingScreen.value = it } signatures = signedContainer?.getSignatures() ?: emptyList() withContext(Main) { selectedSignedContainerTabIndex.intValue = 1 @@ -521,6 +563,7 @@ fun SigningNavigation( sharedContainerViewModel.signedSidStatus.collect { status -> status?.let { if (status == SessionStatusResponseProcessStatus.OK) { + extendToLTA(sharedSettingsViewModel, signedContainer) { showLoadingScreen.value = it } signatures = signedContainer?.getSignatures() ?: emptyList() withContext(Main) { signatureAddedSuccess.value = true @@ -538,6 +581,7 @@ fun SigningNavigation( sharedContainerViewModel.signedNFCStatus.collect { status -> status?.let { if (status == true) { + extendToLTA(sharedSettingsViewModel, signedContainer) { showLoadingScreen.value = it } signatures = signedContainer?.getSignatures() ?: emptyList() withContext(Main) { signatureAddedSuccess.value = true @@ -555,6 +599,7 @@ fun SigningNavigation( sharedContainerViewModel.signedIDCardStatus.collect { status -> status?.let { if (status == true) { + extendToLTA(sharedSettingsViewModel, signedContainer) { showLoadingScreen.value = it } signatures = signedContainer?.getSignatures() ?: emptyList() withContext(Main) { signatureAddedSuccess.value = true @@ -1167,6 +1212,24 @@ fun SigningNavigation( onResult = handleResult, ) + SivaConfirmationDialog( + showDialog = showExtendSivaDialog, + modifier = modifier, + onResult = handleExtendResult, + ) + + if (showExtendWrappedDialog.value) { + SingleButtonDialog( + modifier = modifier, + title = stringResource(R.string.extend_signatures_wrapped_title), + message = stringResource(R.string.extend_signatures_wrapped_message), + buttonText = stringResource(R.string.ok_button), + onButtonClick = { + showExtendWrappedDialog.value = false + }, + ) + } + DataFileBottomSheet( modifier = modifier, showSheet = showDataFileBottomSheet.value, @@ -1208,8 +1271,14 @@ fun SigningNavigation( signedContainer, isNestedContainer, ), + isExtendSignaturesButtonShown = + signingViewModel.isExtendSignaturesButtonShown( + signedContainer, + isNestedContainer, + ), signedContainer = signedContainer, onEncryptClick = onEncryptActionClick, + onExtendSignaturesClick = onExtendSignaturesActionClick, saveFileLauncher = saveFileLauncher, saveFile = ::saveFile, ) @@ -1234,6 +1303,67 @@ fun SigningNavigation( LoadingScreen(modifier = modifier) } + if (showExtendSignaturesConfirmDialog.value) { + MessageDialog( + modifier = modifier, + title = stringResource(R.string.extend_signatures), + message = stringResource(R.string.extend_signatures_confirm_message), + showIcons = false, + dismissButtonText = stringResource(R.string.cancel_button), + confirmButtonText = stringResource(R.string.extend_button), + dismissButtonContentDescription = stringResource(R.string.cancel_button), + confirmButtonContentDescription = stringResource(R.string.extend_signatures), + onDismissRequest = { + showExtendSignaturesConfirmDialog.value = false + }, + onDismissButton = { + showExtendSignaturesConfirmDialog.value = false + }, + onConfirmButton = { + debugLog(LOG_TAG, "User confirmed signature extension") + showExtendSignaturesConfirmDialog.value = false + selectedSignedContainerTabIndex.intValue = 1 + val container = signedContainer + if (container != null) { + showLoadingScreen.value = true + scope.launch(IO) { + val result = + signingViewModel.extendSignatures(context, container, sharedContainerViewModel) + withContext(Main) { + showLoadingScreen.value = false + when (result) { + is ExtendSignaturesResult.ExtendedInPlace -> { + signatures = result.signatures + showMessage( + context, + R.string.extend_signatures_success, + SnackbarType.SUCCESS, + ) + } + + is ExtendSignaturesResult.Wrapped -> { + signatures = result.signatures + extendedContainerFile.value = result.file + if (result.needsSiva) { + showExtendSivaDialog.value = true + } else { + showExtendWrappedDialog.value = true + } + } + + ExtendSignaturesResult.NoInternet -> + showMessage(context, R.string.no_internet_connection) + + ExtendSignaturesResult.Error -> + showMessage(context, R.string.extend_signatures_error) + } + } + } + } + }, + ) + } + if (showContainerCloseConfirmationDialog.value) { MessageDialog( modifier = modifier, @@ -1318,6 +1448,9 @@ private fun handleBackButtonClick( } } } else { + sharedContainerViewModel.resetSignedContainer() + sharedContainerViewModel.resetCryptoContainer() + sharedContainerViewModel.resetContainerNotifications() sharedContainerViewModel.clearContainers() signingViewModel.handleBackButton() navController.navigateUp() @@ -1347,6 +1480,27 @@ private fun saveFile( } } +private suspend fun extendToLTA( + sharedSettingsViewModel: SharedSettingsViewModel, + signedContainer: SignedContainer?, + setLoading: (Boolean) -> Unit, +) { + val shouldExtend = + withContext(IO) { + sharedSettingsViewModel.dataStore.getSettingsDefaultLTA() && signedContainer?.isDdoc() != true + } + if (!shouldExtend) return + withContext(Main) { setLoading(true) } + try { + signedContainer?.extendSignature() + debugLog(LOG_TAG, "Auto LTA extension completed for: ${signedContainer?.getName()}") + } catch (e: Exception) { + errorLog(LOG_TAG, "Auto LTA extension failed for: ${signedContainer?.getName()}", e) + } finally { + withContext(Main) { setLoading(false) } + } +} + @Preview(showBackground = true) @Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/bottomsheet/ContainerBottomSheet.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/bottomsheet/ContainerBottomSheet.kt index 0f03a55da..1f6b73f2c 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/bottomsheet/ContainerBottomSheet.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/bottomsheet/ContainerBottomSheet.kt @@ -40,8 +40,10 @@ fun ContainerBottomSheet( isEditContainerButtonShown: Boolean = true, openEditContainerNameDialog: MutableState, isEncryptButtonShown: Boolean = true, + isExtendSignaturesButtonShown: Boolean = true, signedContainer: SignedContainer?, onEncryptClick: () -> Unit, + onExtendSignaturesClick: () -> Unit, saveFileLauncher: ActivityResultLauncher, saveFile: (File, String?, ActivityResultLauncher) -> Unit, ) { @@ -91,6 +93,16 @@ fun ContainerBottomSheet( isExtraActionButtonShown = true, onClick = onEncryptClick, ), + BottomSheetButton( + showButton = isExtendSignaturesButtonShown, + icon = R.drawable.ic_m3_more_time_48dp_wght400, + text = stringResource(R.string.extend_signatures), + contentDescription = "${stringResource( + R.string.extend_signatures, + )} ${signedContainer?.getName() ?: ""} $buttonName", + isExtraActionButtonShown = false, + onClick = onExtendSignaturesClick, + ), ), ) } diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/bottomsheet/SignedContainerBottomSheet.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/bottomsheet/SignedContainerBottomSheet.kt deleted file mode 100644 index 2b653b620..000000000 --- a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/bottomsheet/SignedContainerBottomSheet.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright 2017 - 2026 Riigi Infosüsteemi Amet - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 2.1 of the License, or (at your option) any later version. - * - * This library 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 - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - * - */ - -@file:Suppress("PackageName", "FunctionName") - -package ee.ria.DigiDoc.ui.component.signing.bottomsheet - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.navigation.NavHostController -import ee.ria.DigiDoc.R -import ee.ria.DigiDoc.domain.model.bottomSheet.BottomSheetButton -import ee.ria.DigiDoc.libdigidoclib.SignedContainer -import ee.ria.DigiDoc.ui.component.shared.BottomSheet -import ee.ria.DigiDoc.utils.Route -import ee.ria.DigiDoc.viewmodel.SigningViewModel - -@Composable -fun SignedContainerBottomSheet( - modifier: Modifier, - showSheet: MutableState, - signedContainer: SignedContainer?, - isNestedContainer: Boolean, - isXadesContainer: Boolean, - isCadesContainer: Boolean, - signingViewModel: SigningViewModel, - navController: NavHostController, - onEncryptClick: () -> Unit, - onExtendSignatureClick: () -> Unit, -) { - BottomSheet( - modifier = modifier, - showSheet = showSheet.value, - onDismiss = { - showSheet.value = false - }, - buttons = - listOf( - BottomSheetButton( - showButton = - signingViewModel.isSignButtonShown( - signedContainer, - isNestedContainer, - isXadesContainer, - isCadesContainer, - ), - icon = R.drawable.ic_m3_stylus_note_48dp_wght400, - text = stringResource(R.string.add_signature_button), - isExtraActionButtonShown = true, - onClick = { - navController.navigate( - Route.SignatureInputScreen.route, - ) - }, - ), - BottomSheetButton( - showButton = signingViewModel.isEncryptButtonShown(signedContainer, isNestedContainer), - icon = R.drawable.ic_m3_encrypted_48dp_wght400, - text = stringResource(R.string.main_menu_encrypt_container), - isExtraActionButtonShown = true, - onClick = onEncryptClick, - ), - BottomSheetButton( - showButton = !isNestedContainer, - icon = R.drawable.ic_m3_more_time_48dp_wght400, - text = stringResource(R.string.extend_signature_button), - isExtraActionButtonShown = true, - onClick = onExtendSignatureClick, - ), - ), - ) -} diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/certificate/ArchiveTimestampCertInfo.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/certificate/ArchiveTimestampCertInfo.kt new file mode 100644 index 000000000..569049942 --- /dev/null +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/certificate/ArchiveTimestampCertInfo.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.ui.component.signing.certificate + +data class ArchiveTimestampCertInfo( + val issuer: String, + val subject: String, +) diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/certificate/SignerDetailItem.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/certificate/SignerDetailItem.kt index cfbbd7582..27fb7bbe0 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/certificate/SignerDetailItem.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/certificate/SignerDetailItem.kt @@ -56,6 +56,7 @@ data class SignerDetailItem( ocspIssuerName: String?, tsSubjectName: String?, ocspSubjectName: String?, + archiveTimestampCertInfo: List, ): List { val signersCertificate = if (signature.isDigitalSeal) { @@ -82,189 +83,233 @@ data class SignerDetailItem( val ocspProducedAtUtc = DateUtil.getFormattedDateTime(signature.ocspProducedAt, true) val signersMobileTime = DateUtil.getFormattedDateTime(signature.claimedSigningTime, true) - return listOf( - SignerDetailItem( - icon = 0, - label = R.string.signer_certificate_issuer_label, - value = signerIssuerName, - contentDescription = - if (signerIssuerName != null) { + val detailItems = + listOf( + SignerDetailItem( + icon = 0, + label = R.string.signer_certificate_issuer_label, + value = signerIssuerName, + contentDescription = + if (signerIssuerName != null) { + "${stringResource( + id = R.string.signer_certificate_issuer_label, + )} $signerIssuerName" + } else { + "" + }, + testTag = "signersCertificateIssuer", + ), + SignerDetailItem( + label = R.string.signers_certificate_label, + value = signersCertificate, + certificate = signature.signingCertificateDer.x509Certificate(), + contentDescription = "${stringResource( - id = R.string.signer_certificate_issuer_label, - )} $signerIssuerName" - } else { - "" - }, - testTag = "signersCertificateIssuer", - ), - SignerDetailItem( - label = R.string.signers_certificate_label, - value = signersCertificate, - certificate = signature.signingCertificateDer.x509Certificate(), - contentDescription = - "${stringResource( - id = R.string.signers_certificate_label, - )}, $signersCertificate", - formatForAccessibility = true, - testTag = "signersCertificate", - ), - SignerDetailItem( - icon = R.drawable.ic_m3_open_in_new_48dp_wght400, - isLink = true, - label = R.string.signature_method_label, - value = signatureMethod, - contentDescription = - "${stringResource( - id = R.string.signature_method_label, - )}, link $signatureMethod", - testTag = "signatureDetailMethod", - ), - SignerDetailItem( - icon = 0, - label = R.string.container_format_label, - value = containerFormat, - contentDescription = - "${stringResource( - id = R.string.container_format_label, - )}, $containerFormat", - testTag = "containerDetailFormat", - ), - SignerDetailItem( - icon = 0, - label = R.string.signature_format_label, - value = signatureFormat, - contentDescription = - "${stringResource( - id = R.string.signature_format_label, - )}, $signatureFormat", - testTag = "signatureDetailFormat", - ), - SignerDetailItem( - icon = 0, - label = R.string.signed_file_count_label, - value = signedFileCount, - contentDescription = - "${stringResource( - id = R.string.signed_file_count_label, - )}, $signedFileCount", - testTag = "containerDetailSignedFileCount", - ), - SignerDetailItem( - icon = 0, - label = R.string.signature_timestamp_label, - value = timestampTime, - contentDescription = - "${stringResource( - id = R.string.signature_timestamp_label, - )}, $timestampTime", - testTag = "signatureDetailTimestamp", - ), - SignerDetailItem( - icon = 0, - label = R.string.signature_timestamp_utc_label, - value = timestampTimeUtc, - contentDescription = - "${stringResource( - id = R.string.signature_timestamp_utc_label, - )}, $timestampTimeUtc", - testTag = "signatureDetailTimestampUTC", - ), - SignerDetailItem( - icon = 0, - label = R.string.hash_value_of_signature_label, - value = hashValue, - contentDescription = - "${stringResource( - id = R.string.hash_value_of_signature_label, - )}, $hashValue", - testTag = "signatureDetailHashValue", - ), - SignerDetailItem( - icon = 0, - label = R.string.ts_certificate_issuer_label, - value = tsIssuerName, - contentDescription = - if (tsIssuerName != null) { + id = R.string.signers_certificate_label, + )}, $signersCertificate", + formatForAccessibility = true, + testTag = "signersCertificate", + ), + SignerDetailItem( + icon = R.drawable.ic_m3_open_in_new_48dp_wght400, + isLink = true, + label = R.string.signature_method_label, + value = signatureMethod, + contentDescription = "${stringResource( - id = R.string.ts_certificate_issuer_label, - )}, $tsIssuerName" - } else { - "" - }, - testTag = "signatureDetailTimestampCertificateIssuer", - ), - SignerDetailItem( - label = R.string.ts_certificate_label, - value = tsSubjectName, - certificate = signature.timeStampCertificateDer.x509Certificate(), - contentDescription = - if (tsSubjectName != null) { + id = R.string.signature_method_label, + )}, link $signatureMethod", + testTag = "signatureDetailMethod", + ), + SignerDetailItem( + icon = 0, + label = R.string.container_format_label, + value = containerFormat, + contentDescription = "${stringResource( - id = R.string.ts_certificate_label, - )}, $tsSubjectName" - } else { - "" - }, - testTag = "signatureDetailTimestampCertificate", - ), - SignerDetailItem( - icon = 0, - label = R.string.ocsp_certificate_issuer_label, - value = ocspIssuerName, - contentDescription = - if (ocspIssuerName != null) { + id = R.string.container_format_label, + )}, $containerFormat", + testTag = "containerDetailFormat", + ), + SignerDetailItem( + icon = 0, + label = R.string.signature_format_label, + value = signatureFormat, + contentDescription = "${stringResource( - id = R.string.ocsp_certificate_issuer_label, - )}, $ocspIssuerName" - } else { - "" - }, - testTag = "signatureDetailOCSPCertificateIssuer", - ), - SignerDetailItem( - label = R.string.ocsp_certificate_label, - value = ocspSubjectName, - certificate = signature.ocspCertificateDer.x509Certificate(), - contentDescription = - if (ocspSubjectName != null) { + id = R.string.signature_format_label, + )}, $signatureFormat", + testTag = "signatureDetailFormat", + ), + SignerDetailItem( + icon = 0, + label = R.string.signed_file_count_label, + value = signedFileCount, + contentDescription = "${stringResource( - id = R.string.ocsp_certificate_label, - )}, $ocspSubjectName" - } else { - "" - }, - testTag = "signatureDetailOCSPCertificate", - ), - SignerDetailItem( - icon = 0, - label = R.string.ocsp_time_label, - value = ocspProducedAt, - contentDescription = - "${stringResource( - id = R.string.ocsp_time_label, - )}, $ocspProducedAt", - testTag = "signatureDetailOCSPTime", - ), - SignerDetailItem( - icon = 0, - label = R.string.ocsp_time_utc_label, - value = ocspProducedAtUtc, - contentDescription = - "${stringResource( - id = R.string.ocsp_time_utc_label, - )}, $ocspProducedAtUtc", - testTag = "signatureDetailOCSPTimeUTC", - ), - SignerDetailItem( - icon = 0, - label = R.string.signers_mobile_time_label, - value = signersMobileTime, - contentDescription = - "${stringResource( - id = R.string.signers_mobile_time_label, - )}, $signersMobileTime", - testTag = "signatureDetailSignersMobileTimeUTC", - ), - ) + id = R.string.signed_file_count_label, + )}, $signedFileCount", + testTag = "containerDetailSignedFileCount", + ), + SignerDetailItem( + icon = 0, + label = R.string.signature_timestamp_label, + value = timestampTime, + contentDescription = + "${stringResource( + id = R.string.signature_timestamp_label, + )}, $timestampTime", + testTag = "signatureDetailTimestamp", + ), + SignerDetailItem( + icon = 0, + label = R.string.signature_timestamp_utc_label, + value = timestampTimeUtc, + contentDescription = + "${stringResource( + id = R.string.signature_timestamp_utc_label, + )}, $timestampTimeUtc", + testTag = "signatureDetailTimestampUTC", + ), + SignerDetailItem( + icon = 0, + label = R.string.hash_value_of_signature_label, + value = hashValue, + contentDescription = + "${stringResource( + id = R.string.hash_value_of_signature_label, + )}, $hashValue", + testTag = "signatureDetailHashValue", + ), + SignerDetailItem( + icon = 0, + label = R.string.ts_certificate_issuer_label, + value = tsIssuerName, + contentDescription = + if (tsIssuerName != null) { + "${stringResource( + id = R.string.ts_certificate_issuer_label, + )}, $tsIssuerName" + } else { + "" + }, + testTag = "signatureDetailTimestampCertificateIssuer", + ), + SignerDetailItem( + label = R.string.ts_certificate_label, + value = tsSubjectName, + certificate = signature.timeStampCertificateDer.x509Certificate(), + contentDescription = + if (tsSubjectName != null) { + "${stringResource( + id = R.string.ts_certificate_label, + )}, $tsSubjectName" + } else { + "" + }, + testTag = "signatureDetailTimestampCertificate", + ), + SignerDetailItem( + icon = 0, + label = R.string.ocsp_certificate_issuer_label, + value = ocspIssuerName, + contentDescription = + if (ocspIssuerName != null) { + "${stringResource( + id = R.string.ocsp_certificate_issuer_label, + )}, $ocspIssuerName" + } else { + "" + }, + testTag = "signatureDetailOCSPCertificateIssuer", + ), + SignerDetailItem( + label = R.string.ocsp_certificate_label, + value = ocspSubjectName, + certificate = signature.ocspCertificateDer.x509Certificate(), + contentDescription = + if (ocspSubjectName != null) { + "${stringResource( + id = R.string.ocsp_certificate_label, + )}, $ocspSubjectName" + } else { + "" + }, + testTag = "signatureDetailOCSPCertificate", + ), + SignerDetailItem( + icon = 0, + label = R.string.ocsp_time_label, + value = ocspProducedAt, + contentDescription = + "${stringResource( + id = R.string.ocsp_time_label, + )}, $ocspProducedAt", + testTag = "signatureDetailOCSPTime", + ), + SignerDetailItem( + icon = 0, + label = R.string.ocsp_time_utc_label, + value = ocspProducedAtUtc, + contentDescription = + "${stringResource( + id = R.string.ocsp_time_utc_label, + )}, $ocspProducedAtUtc", + testTag = "signatureDetailOCSPTimeUTC", + ), + SignerDetailItem( + icon = 0, + label = R.string.signers_mobile_time_label, + value = signersMobileTime, + contentDescription = + "${stringResource( + id = R.string.signers_mobile_time_label, + )}, $signersMobileTime", + testTag = "signatureDetailSignersMobileTimeUTC", + ), + ) + + return buildList { + addAll(detailItems) + signature.archiveTimeStamps.zip(archiveTimestampCertInfo).forEachIndexed { index, (ts, names) -> + val archiveTimestampTime = DateUtil.getFormattedDateTime(ts.time, false) + add( + SignerDetailItem( + icon = 0, + label = R.string.archive_timestamp_label, + value = archiveTimestampTime, + contentDescription = + "${stringResource( + id = R.string.archive_timestamp_label, + )}, $archiveTimestampTime", + testTag = "archiveTimestamp$index", + ), + ) + add( + SignerDetailItem( + icon = 0, + label = R.string.archive_ts_certificate_issuer_label, + value = names.issuer, + contentDescription = + "${stringResource( + id = R.string.archive_ts_certificate_issuer_label, + )}, ${names.issuer}", + testTag = "archiveTsCertificateIssuer$index", + ), + ) + add( + SignerDetailItem( + label = R.string.archive_ts_certificate_label, + value = names.subject, + certificate = ts.certificate, + contentDescription = + "${stringResource( + id = R.string.archive_ts_certificate_label, + )}, ${names.subject}", + testTag = "archiveTsCertificate$index", + ), + ) + } + } } } diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/certificate/SignerDetails.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/certificate/SignerDetails.kt index 7de90e38b..9c16b3b38 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/certificate/SignerDetails.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/certificate/SignerDetails.kt @@ -50,6 +50,7 @@ fun SignerDetails( ocspIssuerName: String, tsSubjectName: String, ocspSubjectName: String, + archiveTimestampCertInfo: List, sharedContainerViewModel: SharedContainerViewModel, sharedCertificateViewModel: SharedCertificateViewModel, navController: NavController, @@ -73,6 +74,7 @@ fun SignerDetails( ocspIssuerName = ocspIssuerName, tsSubjectName = tsSubjectName, ocspSubjectName = ocspSubjectName, + archiveTimestampCertInfo = archiveTimestampCertInfo, sharedContainerViewModel = sharedContainerViewModel, ).forEach { navigationItem -> if (!navigationItem.value.isNullOrEmpty()) { diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/certificate/SignerDetailsView.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/certificate/SignerDetailsView.kt index 6517ce536..ff6921f7d 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/certificate/SignerDetailsView.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/certificate/SignerDetailsView.kt @@ -142,6 +142,13 @@ fun SignerDetailsView( certificateDetailViewModel.getSubjectCommonName( signature?.timeStampCertificateDer?.x509Certificate(), ) + val archiveTimestampCertInfo = + (signature?.archiveTimeStamps ?: emptyList()).map { ts -> + ArchiveTimestampCertInfo( + issuer = certificateDetailViewModel.getIssuerCommonName(ts.certificate), + subject = certificateDetailViewModel.getSubjectCommonName(ts.certificate), + ) + } val ocspSubjectName = certificateDetailViewModel.getSubjectCommonName( signature?.ocspCertificateDer?.x509Certificate(), @@ -282,6 +289,9 @@ fun SignerDetailsView( .padding(vertical = SBorder) .focusable(false) .notAccessible(), + validUntil = if (!isTimestamp) signature.validUntil else null, + isSignatureExtended = + !isTimestamp && signature.archiveTimeStampCertificateDer.isNotEmpty(), ) } } @@ -344,6 +354,7 @@ fun SignerDetailsView( ocspIssuerName = ocspIssuerName, tsSubjectName = tsSubjectName, ocspSubjectName = ocspSubjectName, + archiveTimestampCertInfo = archiveTimestampCertInfo, sharedContainerViewModel = sharedContainerViewModel, sharedCertificateViewModel = sharedCertificateViewModel, navController = navController, diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/EncryptViewModel.kt b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/EncryptViewModel.kt index 325a5e45f..6eec81232 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/EncryptViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/EncryptViewModel.kt @@ -69,7 +69,7 @@ class EncryptViewModel val shouldResetCryptoContainer: LiveData = _shouldResetCryptoContainer fun handleBackButton() { - _shouldResetCryptoContainer.postValue(true) + _shouldResetCryptoContainer.value = true } fun isEncryptedContainer(cryptoContainer: CryptoContainer?): Boolean = cryptoContainer?.encrypted == true diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/SigningViewModel.kt b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/SigningViewModel.kt index 921485c20..c13902378 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/SigningViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/SigningViewModel.kt @@ -34,6 +34,7 @@ import ee.ria.DigiDoc.R import ee.ria.DigiDoc.common.Constant.ASICS_MIMETYPE import ee.ria.DigiDoc.common.Constant.UNSIGNABLE_CONTAINER_EXTENSIONS import ee.ria.DigiDoc.common.Constant.UNSIGNABLE_CONTAINER_MIMETYPES +import ee.ria.DigiDoc.common.exception.NoInternetConnectionException import ee.ria.DigiDoc.domain.repository.fileopening.FileOpeningRepository import ee.ria.DigiDoc.domain.repository.siva.SivaRepository import ee.ria.DigiDoc.libdigidoclib.SignedContainer @@ -41,10 +42,14 @@ import ee.ria.DigiDoc.libdigidoclib.domain.model.DataFileInterface import ee.ria.DigiDoc.libdigidoclib.domain.model.SignatureInterface import ee.ria.DigiDoc.utilsLib.container.ContainerUtil import ee.ria.DigiDoc.utilsLib.container.ContainerUtil.createContainerAction +import ee.ria.DigiDoc.utilsLib.extensions.containsDdoc +import ee.ria.DigiDoc.utilsLib.extensions.isCades import ee.ria.DigiDoc.utilsLib.extensions.mimeType +import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.debugLog import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.errorLog import ee.ria.DigiDoc.utilsLib.mimetype.MimeTypeResolver import ee.ria.DigiDoc.viewmodel.shared.SharedContainerViewModel +import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -54,6 +59,22 @@ import java.io.File import java.util.Locale import javax.inject.Inject +sealed interface ExtendSignaturesResult { + data class ExtendedInPlace( + val signatures: List, + ) : ExtendSignaturesResult + + data class Wrapped( + val signatures: List, + val file: File?, + val needsSiva: Boolean, + ) : ExtendSignaturesResult + + data object NoInternet : ExtendSignaturesResult + + data object Error : ExtendSignaturesResult +} + @HiltViewModel class SigningViewModel @Inject @@ -72,7 +93,7 @@ class SigningViewModel val hasEmptyFiles: StateFlow = _hasEmptyFiles fun handleBackButton() { - _shouldResetSignedContainer.postValue(true) + _shouldResetSignedContainer.value = true } fun isExistingContainerNoSignatures(signedContainer: SignedContainer?): Boolean = @@ -136,6 +157,11 @@ class SigningViewModel isNestedContainer: Boolean, ): Boolean = signedContainer?.isSigned() == false && !isNestedContainer + fun isExtendSignaturesButtonShown( + signedContainer: SignedContainer?, + isNestedContainer: Boolean, + ): Boolean = signedContainer?.isSigned() == true && !isNestedContainer + fun isRoleEmpty(signature: SignatureInterface): Boolean = signature.signerRoles.isEmpty() && signature.city.isEmpty() && @@ -202,6 +228,7 @@ class SigningViewModel nestedFile: File?, sharedContainerViewModel: SharedContainerViewModel, isSivaConfirmed: Boolean, + overwriteContainer: Boolean = false, ) { if (nestedFile != null) { val nestedContainer = @@ -215,9 +242,9 @@ class SigningViewModel if (ASICS_MIMETYPE == nestedFile.mimeType(context)) { val timestampedNestedContainer = getTimestampedContainer(context, nestedContainer, isSivaConfirmed) - sharedContainerViewModel.setSignedContainer(timestampedNestedContainer) + sharedContainerViewModel.setSignedContainer(timestampedNestedContainer, overwriteContainer) } else { - sharedContainerViewModel.setSignedContainer(nestedContainer) + sharedContainerViewModel.setSignedContainer(nestedContainer, overwriteContainer) } } } @@ -252,6 +279,39 @@ class SigningViewModel sivaRepository.isTimestampedContainer(signedContainer) && !signedContainer.isXades() + suspend fun extendSignatures( + context: Context, + signedContainer: SignedContainer, + sharedContainerViewModel: SharedContainerViewModel, + ): ExtendSignaturesResult = + withContext(IO) { + try { + debugLog(logTag, "Extending signatures of '${signedContainer.getName()}' to LTA") + val extended = signedContainer.extendSignatures() + val isWrapped = extended !== signedContainer + if (isWrapped) { + withContext(Main) { + sharedContainerViewModel.setSignedContainer(extended, overwriteContainer = true) + } + } + val signatures = extended.getSignatures() + if (!isWrapped) { + debugLog(logTag, "Extended ${signatures.size} signature(s) in place") + return@withContext ExtendSignaturesResult.ExtendedInPlace(signatures) + } + val file = extended.getContainerFile() + val needsSiva = file != null && (file.containsDdoc() || file.isCades(context)) + debugLog(logTag, "Wrapped into a new timestamped container '${file?.name}', with SiVa: $needsSiva") + ExtendSignaturesResult.Wrapped(signatures, file, needsSiva) + } catch (e: NoInternetConnectionException) { + errorLog(logTag, "No internet connection while extending signatures", e) + ExtendSignaturesResult.NoInternet + } catch (e: Exception) { + errorLog(logTag, "Failed to extend signatures: ${signedContainer.getName()}", e) + ExtendSignaturesResult.Error + } + } + suspend fun createContainerForSignedPDF( context: Context, signedContainer: SignedContainer, diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedContainerViewModel.kt b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedContainerViewModel.kt index 552c45760..8d65cc623 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedContainerViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedContainerViewModel.kt @@ -134,8 +134,14 @@ class SharedContainerViewModel _decryptIDCardStatus.value = decryptStatus } - fun setSignedContainer(signedContainer: SignedContainer?) { + fun setSignedContainer( + signedContainer: SignedContainer?, + overwriteContainer: Boolean = false, + ) { _signedContainer.value = signedContainer + if (overwriteContainer) { + removeLastContainer() + } addNestedContainer(signedContainer) } diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedSettingsViewModel.kt b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedSettingsViewModel.kt index 8566c0a72..03d8861e5 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedSettingsViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedSettingsViewModel.kt @@ -234,6 +234,7 @@ class SharedSettingsViewModel dataStore.setSettingsUUID(DEFAULT_UUID_VALUE) dataStore.setSettingsTSAUrl(updatedConfiguration.value?.tsaUrl ?: "") setSettingsAskRoleAndAddress(false) + dataStore.setSettingsDefaultLTA(false) dataStore.setIsTsaCertificateViewVisible(false) val certFile = FileUtil.getCertFile(context, dataStore.getTSACertName(), DIR_TSA_CERT) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 2af2ad564..40ee0c348 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -37,6 +37,14 @@ Allkirjastatud ümbrik Lisa veel faile Salvesta ümbrik + Pikenda allkirju + Pikenda + Kõik ümbriku allkirjad pikendatakse LTA formaati. + Allkirja(de) pikendamine õnnestus + Allkirja(de) pikendamine ebaõnnestus + Konteineri kehtivust ei saa pikendada + Allkirjade pikendamine ebaõnnestus ja konteiner pakiti ajatempliga konteinerisse. + Arhiivi ajatempel kehtib kuni: %1$s Salvesta Ümbrikus Veel valikuid @@ -365,7 +373,6 @@ Allkirjasta Edasi Lisa uus allkiri - Pikenda allkirja kehtivust Jaga Jaga ümbrikut JAH @@ -460,6 +467,9 @@ Kehtivuskinnituse aeg Kehtivuskinnituse aeg (UTC) Allkirjastaja telefoni kellaaeg (UTC) + Arhiivi ajatempel: + Arhiivi ajatempliteenuse sertifikaadi väljaandja: + Arhiivi ajatempliteenuse sertifikaat: Sertifikaadi detailid @@ -590,7 +600,10 @@ Krüpteerimisseaded Ajatempliteenus Mobiil-ID ja Smart-ID + Arhiveerimise ajatempel (LTA) Küsi rolli ja aadressi igal allkirjastamisel + Ei kasuta allkirjastamisel arhiveerimise ajatempli (LTA) vormingut + Kasuta allkirjastamisel arhiveerimise ajatempli (LTA) vormingut Roll / resolutsioon Linn Maakond diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index 7408cb72d..52076fc07 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -80,6 +80,7 @@ mainSettingsOpenAllFilesTypes mainSettingsAllowScreenshots mainSettingsAskRoleAndAddress + mainSettingsDefaultLTA mainSettingsTSACertView diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cf20de9d7..97b06d516 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -37,6 +37,14 @@ Signed container Add more files Save container + Extend signatures + Extend + All signatures in the container will be extended to LTA format. + Signature(s) extended successfully + Failed to extend signature(s) + Container validity cannot be extended + Failed to extend signatures and wrapped to timestamped container. + Archive timestamp valid until: %1$s Save Container has More options @@ -365,7 +373,6 @@ Sign Next Add new signature - Extend signature validity Share Share container YES @@ -460,6 +467,9 @@ OCSP time: OCSP time (UTC): Signer\'s mobile time: + Archive Timestamp: + Archive TS Certificate issuer: + Archive TS Certificate: Certificate details @@ -590,7 +600,10 @@ Encryption settings Time-Stamping service Mobile-ID and Smart-ID + Long Term Archival (LTA) Ask role and address info on signing + Do not use Long Term Archival (LTA) format when signing + Use Long Term Archival (LTA) format when signing Role / resolution City State diff --git a/commons-lib/src/main/kotlin/ee/ria/DigiDoc/common/Constant.kt b/commons-lib/src/main/kotlin/ee/ria/DigiDoc/common/Constant.kt index 76dff0efc..c5581ecbf 100644 --- a/commons-lib/src/main/kotlin/ee/ria/DigiDoc/common/Constant.kt +++ b/commons-lib/src/main/kotlin/ee/ria/DigiDoc/common/Constant.kt @@ -31,6 +31,7 @@ object Constant { object SignatureRequest { const val SIGNATURE_PROFILE_TS = "time-stamp" + const val SIGNATURE_PROFILE_TSA = "time-stamp-archive" private const val ESTONIAN_PHONE_CODE = "372" const val PLUS_PREFIXED_ESTONIAN_PHONE_CODE = "+$ESTONIAN_PHONE_CODE" const val FIRST_NUMBER_IN_ESTONIAN_MOBILE_NUMBER = "5" @@ -150,7 +151,8 @@ object Constant { const val CONTAINER_MIME_TYPE = "application/octet-stream" const val DEFAULT_MIME_TYPE = "text/plain" - private val ASICS_CONTAINER_EXTENSIONS: Set = setOf("asics", "scs") + const val ASICS_EXTENSION = "asics" + private val ASICS_CONTAINER_EXTENSIONS: Set = setOf(ASICS_EXTENSION, "scs") const val DDOC_EXTENSION = "ddoc" const val PDF_EXTENSION = "pdf" diff --git a/libdigidoc-lib/src/main/kotlin/ee/ria/DigiDoc/libdigidoclib/SignedContainer.kt b/libdigidoc-lib/src/main/kotlin/ee/ria/DigiDoc/libdigidoclib/SignedContainer.kt index c76f31b1c..750271dc0 100644 --- a/libdigidoc-lib/src/main/kotlin/ee/ria/DigiDoc/libdigidoclib/SignedContainer.kt +++ b/libdigidoc-lib/src/main/kotlin/ee/ria/DigiDoc/libdigidoclib/SignedContainer.kt @@ -23,12 +23,16 @@ package ee.ria.DigiDoc.libdigidoclib import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext +import ee.ria.DigiDoc.common.Constant.ASICE_MIMETYPE +import ee.ria.DigiDoc.common.Constant.ASICS_EXTENSION import ee.ria.DigiDoc.common.Constant.ASICS_MIMETYPE import ee.ria.DigiDoc.common.Constant.DDOC_EXTENSION import ee.ria.DigiDoc.common.Constant.DDOC_MIMETYPE import ee.ria.DigiDoc.common.Constant.DEFAULT_CONTAINER_EXTENSION import ee.ria.DigiDoc.common.Constant.DEFAULT_FILENAME import ee.ria.DigiDoc.common.Constant.NON_LEGACY_CONTAINER_EXTENSIONS +import ee.ria.DigiDoc.common.Constant.SignatureRequest.SIGNATURE_PROFILE_TS +import ee.ria.DigiDoc.common.Constant.SignatureRequest.SIGNATURE_PROFILE_TSA import ee.ria.DigiDoc.common.exception.NoInternetConnectionException import ee.ria.DigiDoc.libdigidoclib.domain.model.DataFileInterface import ee.ria.DigiDoc.libdigidoclib.domain.model.DataFileWrapper @@ -48,7 +52,9 @@ import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.debugLog import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.errorLog import ee.ria.libdigidocpp.Container import ee.ria.libdigidocpp.ContainerOpenCB +import ee.ria.libdigidocpp.ExternalSigner import ee.ria.libdigidocpp.Signature +import ee.ria.libdigidocpp.digidoc import kotlinx.coroutines.withContext import org.apache.commons.io.FilenameUtils import java.io.File @@ -79,8 +85,7 @@ class SignedContainer @Throws(Exception::class) suspend fun getNestedTimestampedContainer(isSivaConfirmed: Boolean): SignedContainer? { if ((containerMimetype().equals(ASICS_MIMETYPE, ignoreCase = true) && getDataFiles().size == 1) || - isCades() && - !isXades() + (isCades() && !isXades()) ) { val dataFile = container?.dataFiles()?.firstOrNull() val containerRawFile = containerFile @@ -162,6 +167,80 @@ class SignedContainer } } + @Throws(Exception::class) + suspend fun extendSignature() = + withContext(libdigidocppDispatcher) { + extendSignatures(listOfNotNull(container?.signatures()?.lastOrNull())) + } + + @Throws(Exception::class) + suspend fun extendSignatures(): SignedContainer = + withContext(libdigidocppDispatcher) { + val rawContainer = container ?: throw IllegalStateException("No container to extend") + val signatures = rawContainer.signatures() ?: emptyList() + if (canExtend(signatures)) { + extendSignatures(signatures) + this@SignedContainer + } else { + wrapIntoTimestampedAsics(rawContainer) + } + } + + private suspend fun canExtend(signatures: List): Boolean { + if (signatures.isEmpty()) return false + if (isCades() || isXades()) return false + val mediaType = containerMimetype() + if (mediaType.equals(ASICS_MIMETYPE, ignoreCase = true)) return true + if (!mediaType.equals(ASICE_MIMETYPE, ignoreCase = true)) return false + return signatures.all { it.profile().lowercase().contains(SIGNATURE_PROFILE_TS) } && + !hasInvalidSignatures() + } + + private suspend fun hasInvalidSignatures(): Boolean = + getSignatures().any { it.validator.status == ValidatorInterface.Status.Invalid } + + private suspend fun wrapIntoTimestampedAsics(rawContainer: Container): SignedContainer = + try { + val signingCert = + rawContainer + .signatures() + ?.lastOrNull() + ?.signingCertificate() + ?.encoded + ?: throw IllegalStateException("No signing certificate available to extend") + val signer = ExternalSigner(signingCert).apply { setUserAgent(digidoc.userAgent()) } + val wrappedContainer = + Container.extendContainerValidity(rawContainer, signer) + ?: throw IllegalStateException("Failed to wrap container into a timestamped ASiC-S") + val baseName = ContainerUtil.removeExtensionFromContainerFilename(getName()) + val asicsFile = + ContainerUtil.generateSignatureContainerFile(context, "$baseName.$ASICS_EXTENSION") + wrappedContainer.save(asicsFile.path) + debugLog(LOG_TAG, "Legacy container wrapped into ASiC-S: ${asicsFile.name}") + SignedContainer(context, wrappedContainer, asicsFile, isExistingContainer = true) + } catch (e: Exception) { + errorLog(LOG_TAG, "Unable to extend container validity", e) + handleContainerException(context, e) + } + + private fun extendSignatures(signatures: List) { + if (signatures.isEmpty()) { + debugLog(LOG_TAG, "No signatures to extend") + return + } + try { + signatures.forEachIndexed { index, signature -> + debugLog(LOG_TAG, "Extending signature ${index + 1}/${signatures.size}: ${signature.id()}") + signature.extendSignatureProfile(SIGNATURE_PROFILE_TSA) + } + container?.save() + debugLog(LOG_TAG, "Container saved after extending ${signatures.size} signature(s)") + } catch (e: Exception) { + errorLog(LOG_TAG, "Unable to extend signatures", e) + handleContainerException(context, e) + } + } + @Throws(Exception::class) fun getDataFile( dataFile: DataFileInterface, diff --git a/libdigidoc-lib/src/main/kotlin/ee/ria/DigiDoc/libdigidoclib/domain/model/ArchiveTimestamp.kt b/libdigidoc-lib/src/main/kotlin/ee/ria/DigiDoc/libdigidoclib/domain/model/ArchiveTimestamp.kt new file mode 100644 index 000000000..e185b06d8 --- /dev/null +++ b/libdigidoc-lib/src/main/kotlin/ee/ria/DigiDoc/libdigidoclib/domain/model/ArchiveTimestamp.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.libdigidoclib.domain.model + +import java.io.Serializable +import java.security.cert.X509Certificate + +data class ArchiveTimestamp( + val time: String, + val certificate: X509Certificate, +) : Serializable diff --git a/libdigidoc-lib/src/main/kotlin/ee/ria/DigiDoc/libdigidoclib/domain/model/SignatureInterface.kt b/libdigidoc-lib/src/main/kotlin/ee/ria/DigiDoc/libdigidoclib/domain/model/SignatureInterface.kt index 9cba8be9b..8cc696c63 100644 --- a/libdigidoc-lib/src/main/kotlin/ee/ria/DigiDoc/libdigidoclib/domain/model/SignatureInterface.kt +++ b/libdigidoc-lib/src/main/kotlin/ee/ria/DigiDoc/libdigidoclib/domain/model/SignatureInterface.kt @@ -22,6 +22,7 @@ package ee.ria.DigiDoc.libdigidoclib.domain.model import java.io.Serializable +import java.util.Date interface SignatureInterface : Serializable { val id: String @@ -51,4 +52,6 @@ interface SignatureInterface : Serializable { val isDigitalSeal: Boolean val validator: ValidatorInterface + val validUntil: Date? get() = null + val archiveTimeStamps: List get() = emptyList() } diff --git a/libdigidoc-lib/src/main/kotlin/ee/ria/DigiDoc/libdigidoclib/domain/model/SignatureWrapper.kt b/libdigidoc-lib/src/main/kotlin/ee/ria/DigiDoc/libdigidoclib/domain/model/SignatureWrapper.kt index fe86eea25..8be74085b 100644 --- a/libdigidoc-lib/src/main/kotlin/ee/ria/DigiDoc/libdigidoclib/domain/model/SignatureWrapper.kt +++ b/libdigidoc-lib/src/main/kotlin/ee/ria/DigiDoc/libdigidoclib/domain/model/SignatureWrapper.kt @@ -29,6 +29,7 @@ import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.errorLog import ee.ria.libdigidocpp.Signature import java.io.IOException import java.io.Serializable +import java.util.Date class SignatureWrapper( signature: Signature, @@ -107,6 +108,23 @@ class SignatureWrapper( override val validator: ValidatorInterface = ValidatorWrapper(Signature.Validator(signature)) + override val archiveTimeStamps: List = + try { + signature.ArchiveTimeStamps().mapNotNull { tsaInfo -> + try { + ArchiveTimestamp(time = tsaInfo.time, certificate = tsaInfo.cert) + } catch (e: Exception) { + errorLog(logTag, "Unable to parse archive timestamp entry", e) + null + } + } + } catch (e: Exception) { + errorLog(logTag, "Unable to get archive timestamps", e) + emptyList() + } + + override val validUntil: Date? = archiveTimeStamps.lastOrNull()?.certificate?.notAfter + private fun signatureName(signature: Signature): String { var commonName: String? try { diff --git a/libdigidoc-lib/src/main/res/values/strings.xml b/libdigidoc-lib/src/main/res/values/strings.xml index 9ff7a5345..df05bd429 100644 --- a/libdigidoc-lib/src/main/res/values/strings.xml +++ b/libdigidoc-lib/src/main/res/values/strings.xml @@ -7,6 +7,6 @@ (Restrictions) Signature is invalid Signature is unknown - Timestamp is not valid + Timestamp is invalid Timestamp is unknown \ No newline at end of file diff --git a/utils-lib/src/main/kotlin/ee/ria/DigiDoc/utilsLib/extensions/FileExtensions.kt b/utils-lib/src/main/kotlin/ee/ria/DigiDoc/utilsLib/extensions/FileExtensions.kt index a260c97a1..60fa397d8 100644 --- a/utils-lib/src/main/kotlin/ee/ria/DigiDoc/utilsLib/extensions/FileExtensions.kt +++ b/utils-lib/src/main/kotlin/ee/ria/DigiDoc/utilsLib/extensions/FileExtensions.kt @@ -88,6 +88,23 @@ fun File.isCades(context: Context): Boolean { } } +fun File.containsDdoc(): Boolean = + try { + ZipFile + .Builder() + .setFile(this) + .get() + .use { zip -> + zip.entries + .asSequence() + .filterNot { it.isDirectory } + .any { File(it.name).extension.equals(DDOC_EXTENSION, ignoreCase = true) } + } + } catch (e: IOException) { + debugLog(FILE_EXTENSIONS_LOG_TAG, "File is not a container", e) + false + } + fun File.mimeType(context: Context): String { val extension = extension.lowercase() diff --git a/utils-lib/src/test/kotlin/ee/ria/DigiDoc/utilsLib/extensions/FileExtensionsTest.kt b/utils-lib/src/test/kotlin/ee/ria/DigiDoc/utilsLib/extensions/FileExtensionsTest.kt new file mode 100644 index 000000000..4af1c76f6 --- /dev/null +++ b/utils-lib/src/test/kotlin/ee/ria/DigiDoc/utilsLib/extensions/FileExtensionsTest.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.utilsLib.extensions + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +class FileExtensionsTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private fun zipContaining(vararg entryNames: String): File { + val zipFile = temporaryFolder.newFile("container-${entryNames.size}-${entryNames.hashCode()}.asics") + ZipOutputStream(zipFile.outputStream()).use { zip -> + entryNames.forEach { name -> + zip.putNextEntry(ZipEntry(name)) + zip.write("content".toByteArray()) + zip.closeEntry() + } + } + return zipFile + } + + @Test + fun fileExtensions_containsDdoc_returnTrueWhenContainerHasDdocEntry() { + val container = zipContaining("mimetype", "sample.ddoc") + + assertTrue(container.containsDdoc()) + } + + @Test + fun fileExtensions_containsDdoc_returnFalseWhenContainerHasNoDdocEntry() { + val container = zipContaining("mimetype", "sample.bdoc", "META-INF/signatures0.xml") + + assertFalse(container.containsDdoc()) + } + + @Test + fun fileExtensions_containsDdoc_returnTrueWhenDdocExtensionIsUppercase() { + val container = zipContaining("SAMPLE.DDOC") + + assertTrue(container.containsDdoc()) + } + + @Test + fun fileExtensions_containsDdoc_returnTrueWhenDdocIsInNestedDirectory() { + val container = zipContaining("nested/folder/sample.ddoc") + + assertTrue(container.containsDdoc()) + } + + @Test + fun fileExtensions_containsDdoc_returnFalseWhenNameOnlyContainsDdocAsSubstring() { + val container = zipContaining("notaddoc.txt", "ddoc.xml") + + assertFalse(container.containsDdoc()) + } + + @Test + fun fileExtensions_containsDdoc_returnFalseWhenFileIsNotZip() { + val notAZip = temporaryFolder.newFile("plain.ddoc") + notAZip.writeText("this is not a zip archive") + + assertFalse(notAZip.containsDdoc()) + } + + @Test + fun fileExtensions_containsDdoc_returnFalseWhenContainerIsEmpty() { + val container = zipContaining() + + assertFalse(container.containsDdoc()) + } +}