From eb71a231c23ba82f69c0ff9370a4e8e20869b355 Mon Sep 17 00:00:00 2001 From: Marten Rebane Date: Wed, 7 Oct 2026 01:21:08 +0300 Subject: [PATCH] Fix saving container after removing a file --- .../shared/handler/SaveFileLauncherTest.kt | 260 ++++++++++++++++++ .../viewmodel/DiagnosticsViewModelTest.kt | 66 ----- .../EncryptRecipientViewModelTest.kt | 2 +- .../viewmodel/FileOpeningViewModelTest.kt | 2 +- .../viewmodel/RecentDocumentsViewModelTest.kt | 7 +- .../DigiDoc/viewmodel/SigningViewModelTest.kt | 5 +- .../shared/SharedContainerViewModelTest.kt | 53 +--- .../fragment/screen/DiagnosticsScreen.kt | 94 ++----- .../ui/component/crypto/EncryptNavigation.kt | 61 +--- .../bottomsheet/CryptoDataFileBottomSheet.kt | 7 +- .../EncryptContainerBottomSheet.kt | 6 +- .../shared/handler/SaveFileLauncher.kt | 185 +++++++++++++ .../ui/component/signing/SigningNavigation.kt | 66 +---- .../bottomsheet/ContainerBottomSheet.kt | 6 +- .../bottomsheet/DataFileBottomSheet.kt | 7 +- .../DigiDoc/viewmodel/DiagnosticsViewModel.kt | 20 -- .../shared/SharedContainerViewModel.kt | 24 -- .../shared/handler/SaveFileLauncherTest.kt | 26 ++ ...edContainerViewModelEncryptionStateTest.kt | 6 +- 19 files changed, 518 insertions(+), 385 deletions(-) create mode 100644 app/src/androidTest/kotlin/ee/ria/DigiDoc/ui/component/shared/handler/SaveFileLauncherTest.kt create mode 100644 app/src/main/kotlin/ee/ria/DigiDoc/ui/component/shared/handler/SaveFileLauncher.kt create mode 100644 app/src/test/kotlin/ee/ria/DigiDoc/ui/component/shared/handler/SaveFileLauncherTest.kt diff --git a/app/src/androidTest/kotlin/ee/ria/DigiDoc/ui/component/shared/handler/SaveFileLauncherTest.kt b/app/src/androidTest/kotlin/ee/ria/DigiDoc/ui/component/shared/handler/SaveFileLauncherTest.kt new file mode 100644 index 000000000..6bac016f4 --- /dev/null +++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/ui/component/shared/handler/SaveFileLauncherTest.kt @@ -0,0 +1,260 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: LGPL-2.1-or-later + +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.ui.component.shared.handler + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.ContentResolver +import android.content.Intent +import android.net.Uri +import android.webkit.MimeTypeMap +import androidx.activity.compose.LocalActivityResultRegistryOwner +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.ActivityResultRegistryOwner +import androidx.activity.result.contract.ActivityResultContract +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.core.app.ActivityOptionsCompat +import androidx.core.content.FileProvider +import androidx.test.platform.app.InstrumentationRegistry +import ee.ria.DigiDoc.R +import ee.ria.DigiDoc.common.Constant.ASICE_MIMETYPE +import ee.ria.DigiDoc.common.Constant.DDOC_MIMETYPE +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.io.File + +private const val MIME_TYPE_DEFAULT = "application/octet-stream" + +class SaveFileLauncherTest { + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var contentResolver: ContentResolver + private lateinit var directory: File + + @Before + fun setUp() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + contentResolver = context.contentResolver + directory = File(context.cacheDir, "saveFileLauncherTest").apply { mkdirs() } + } + + @After + fun tearDown() { + directory.deleteRecursively() + } + + @Test + fun saveToDocument_copiesFileContentToDocument() { + val source = File(directory, "source.asice").apply { writeText("container content") } + val destination = File(directory, "destination.asice").apply { createNewFile() } + + val isSaved = saveToDocument(contentResolver, source, Uri.fromFile(destination)) + + assertTrue(isSaved) + assertEquals("container content", destination.readText()) + } + + @Test + fun saveToDocument_leavesDestinationUntouchedWhenSourceIsMissing() { + val source = File(directory, "missing.asice") + val destination = File(directory, "destination.asice").apply { writeText("user content") } + + val isSaved = saveToDocument(contentResolver, source, Uri.fromFile(destination)) + + assertFalse(isSaved) + assertEquals("user content", destination.readText()) + } + + @Test + fun saveToDocument_overwritesLongerExistingDocumentWithExactContent() { + val source = File(directory, "source.asice").apply { writeText("short") } + val destination = File(directory, "destination.asice").apply { writeText("a much longer existing content") } + + val isSaved = saveToDocument(contentResolver, source, contentUri(destination)) + + assertTrue(isSaved) + assertEquals("short", destination.readText()) + } + + @Test + fun saveToDocument_writesFullContentToNewEmptyDocument() { + val source = File(directory, "source.asice").apply { writeText("container content") } + val destination = File(directory, "destination.asice").apply { createNewFile() } + + val isSaved = saveToDocument(contentResolver, source, contentUri(destination)) + + assertTrue(isSaved) + assertEquals("container content", destination.readText()) + } + + @Test + fun createDocumentMimeType_keepsContainerExtensionBeforeTheDuplicateCounter() { + val title = "ymbrik ooo.asice" + + val nameOnDisk = nameWhenDestinationExists(title, createDocumentMimeType(title, ASICE_MIMETYPE)) + + assertEquals("ymbrik ooo (1).asice", nameOnDisk) + } + + @Test + fun createDocumentMimeType_keepsEveryContainerAndDataFileExtensionBeforeTheDuplicateCounter() { + val titles = + mapOf( + "container.asice" to ASICE_MIMETYPE, + "container.bdoc" to ASICE_MIMETYPE, + "container.ddoc" to DDOC_MIMETYPE, + "container.cdoc" to MIME_TYPE_DEFAULT, + "document.pdf" to "application/pdf", + "notes.txt" to "text/plain", + ) + + titles.forEach { (title, mimetype) -> + val expected = title.substringBeforeLast(".") + " (1)." + title.substringAfterLast(".") + assertEquals(expected, nameWhenDestinationExists(title, createDocumentMimeType(title, mimetype))) + } + } + + @Test + fun createDocumentMimeType_usesTypeOfKnownExtensionInsteadOfTheGivenType() { + assertEquals("application/pdf", createDocumentMimeType("document.pdf", "text/plain")) + } + + @Test + fun createDocumentMimeType_keepsGivenTypeWhenNameHasNoExtension() { + assertEquals("application/pdf", createDocumentMimeType("document", "application/pdf")) + } + + @Test + fun saveFile_ignoresSecondSaveWhilePickerIsOpen() { + val registry = RecordingActivityResultRegistry() + val saveFile = setSaveFileLauncher(registry) + + composeTestRule.runOnIdle { + saveFile(File(directory, "container.asice"), "application/vnd.etsi.asic-e+zip") + saveFile(File(directory, "container.asice"), "application/vnd.etsi.asic-e+zip") + } + + assertEquals(1, registry.launchedRequestCodes.size) + } + + @Test + fun saveFile_allowsNextSaveAfterPickerReturns() { + val registry = RecordingActivityResultRegistry() + val saveFile = setSaveFileLauncher(registry) + + composeTestRule.runOnIdle { + saveFile(File(directory, "container.asice"), "application/vnd.etsi.asic-e+zip") + registry.dispatchResult(registry.launchedRequestCodes.first(), Activity.RESULT_CANCELED, null) + saveFile(File(directory, "container.asice"), "application/vnd.etsi.asic-e+zip") + } + + assertEquals(2, registry.launchedRequestCodes.size) + } + + @Test + fun saveFile_allowsNextSaveAfterPickerFailsToLaunch() { + val registry = RecordingActivityResultRegistry(launchError = ActivityNotFoundException()) + val saveFile = setSaveFileLauncher(registry) + + composeTestRule.runOnIdle { + saveFile(File(directory, "container.asice"), "application/vnd.etsi.asic-e+zip") + registry.launchError = null + saveFile(File(directory, "container.asice"), "application/vnd.etsi.asic-e+zip") + } + + assertEquals(1, registry.launchedRequestCodes.size) + } + + @Test + fun saveFile_reportsSavedFileAfterSuccessfulSave() { + val registry = RecordingActivityResultRegistry() + var savedFile: File? = null + val saveFile = setSaveFileLauncher(registry) { savedFile = it } + val source = File(directory, "source.asice").apply { writeText("container content") } + val destination = File(directory, "destination.asice").apply { createNewFile() } + + composeTestRule.runOnIdle { + saveFile(source, "application/vnd.etsi.asic-e+zip") + registry.dispatchResult( + registry.launchedRequestCodes.first(), + Activity.RESULT_OK, + Intent().setData(contentUri(destination)), + ) + } + + assertEquals(source, savedFile) + assertEquals("container content", destination.readText()) + } + + private fun nameWhenDestinationExists( + title: String, + mimeType: String?, + ): String { + val mimeTypeMap = MimeTypeMap.getSingleton() + val lastDot = title.lastIndexOf('.') + var name = if (lastDot >= 0) title.substring(0, lastDot) else title + var extension = if (lastDot >= 0) title.substring(lastDot + 1) else null + val mimeTypeFromExtension = + extension?.let { mimeTypeMap.getMimeTypeFromExtension(it.lowercase()) } + ?: MIME_TYPE_DEFAULT + val extensionFromMimeType = + if (mimeType == MIME_TYPE_DEFAULT) { + null + } else { + mimeType?.let { mimeTypeMap.getExtensionFromMimeType(it) } + } + if (mimeType != mimeTypeFromExtension && extension != extensionFromMimeType) { + name = title + extension = extensionFromMimeType + } + return if (extension.isNullOrEmpty()) "$name (1)" else "$name (1).$extension" + } + + private fun contentUri(file: File): Uri { + val context = InstrumentationRegistry.getInstrumentation().targetContext + return FileProvider.getUriForFile(context, context.getString(R.string.file_provider_authority), file) + } + + private fun setSaveFileLauncher( + registry: ActivityResultRegistry, + onSaved: (File) -> Unit = {}, + ): (File, String?) -> Unit { + lateinit var saveFile: (File, String?) -> Unit + val owner = + object : ActivityResultRegistryOwner { + override val activityResultRegistry: ActivityResultRegistry = registry + } + composeTestRule.setContent { + CompositionLocalProvider(LocalActivityResultRegistryOwner provides owner) { + saveFile = rememberSaveFileLauncher(onSaved = onSaved) + } + } + return saveFile + } + + private class RecordingActivityResultRegistry( + var launchError: RuntimeException? = null, + ) : ActivityResultRegistry() { + val launchedRequestCodes = mutableListOf() + + override fun onLaunch( + requestCode: Int, + contract: ActivityResultContract, + input: I, + options: ActivityOptionsCompat?, + ) { + launchError?.let { throw it } + launchedRequestCodes.add(requestCode) + } + } +} diff --git a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/DiagnosticsViewModelTest.kt b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/DiagnosticsViewModelTest.kt index 2a116bc54..f72539643 100644 --- a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/DiagnosticsViewModelTest.kt +++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/DiagnosticsViewModelTest.kt @@ -5,11 +5,7 @@ package ee.ria.DigiDoc.viewmodel -import android.content.ContentResolver import android.content.Context -import android.content.Intent -import android.net.Uri -import androidx.activity.result.ActivityResult import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.MutableLiveData import androidx.test.platform.app.InstrumentationRegistry @@ -45,14 +41,11 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mock -import org.mockito.Mockito.mock import org.mockito.Mockito.verify import org.mockito.MockitoAnnotations import org.mockito.junit.MockitoJUnitRunner import java.io.File import java.io.FileNotFoundException -import java.nio.charset.Charset -import java.nio.file.Files import java.text.SimpleDateFormat import java.util.Calendar import java.util.Locale @@ -63,9 +56,6 @@ class DiagnosticsViewModelTest { @get:Rule val instantExecutorRule = InstantTaskExecutorRule() - @Mock - lateinit var contentResolver: ContentResolver - @Mock lateinit var configurationLoader: ConfigurationLoader @@ -159,7 +149,6 @@ class DiagnosticsViewModelTest { dataStore, configurationLoader, configurationRepository, - contentResolver, ) proxySetting = ProxySetting.NO_PROXY manualProxy = ManualProxy("", 80, "", "") @@ -317,51 +306,6 @@ class DiagnosticsViewModelTest { verify(configurationLoader).loadCentralConfiguration(context, proxySetting, manualProxy) } - @Test - fun diagnosticsViewModel_saveFile_success() { - val file = createTempFileWithStringContent("test", "Test content") - val intent = Intent() - val uri = Uri.fromFile(file) - intent.data = uri - val activityResult = ActivityResult(-1, intent) - viewModel.saveFile(file, activityResult) - } - - @Test - fun diagnosticsViewModel_saveFile_intentNull() { - val file = createTempFileWithStringContent("test", "Test content") - val intent = Intent() - intent.data = Uri.fromFile(file) - val activityResult = ActivityResult(-1, null) - viewModel.saveFile(file, activityResult) - } - - @Test - fun diagnosticsViewModel_saveFile_intentDataNull() { - val file = createTempFileWithStringContent("test", "Test content") - val intent = Intent() - val activityResult = ActivityResult(-1, intent) - viewModel.saveFile(file, activityResult) - } - - @Test(expected = FileNotFoundException::class) - fun diagnosticsViewModel_saveFile_throwsFileNotFoundException() { - val file = createTempFileWithStringContent("test", "Test content") - val intent = Intent() - intent.data = mock(Uri::class.java) - val activityResult = ActivityResult(-1, intent) - viewModel.saveFile(file, activityResult) - } - - @Test(expected = NullPointerException::class) - fun diagnosticsViewModel_saveFile_throwsNullPointerException() { - val file = mock(File::class.java) - val intent = Intent() - intent.data = mock(Uri::class.java) - val activityResult = ActivityResult(-1, intent) - viewModel.saveFile(file, activityResult) - } - @Test fun diagnosticsViewModel_isCdoc2Selected_returnTrue() { dataStore.setCdocSetting(CDOCSetting.CDOC2) @@ -518,14 +462,4 @@ class DiagnosticsViewModelTest { assertFalse(result) } - - @Suppress("SameParameterValue") - private fun createTempFileWithStringContent( - filename: String, - content: String, - ): File { - val tempFile = File.createTempFile(filename, ".txt", context.cacheDir) - Files.write(tempFile.toPath(), content.toByteArray(Charset.defaultCharset())) - return tempFile - } } diff --git a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/EncryptRecipientViewModelTest.kt b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/EncryptRecipientViewModelTest.kt index 6e26e56d2..128148a38 100644 --- a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/EncryptRecipientViewModelTest.kt +++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/EncryptRecipientViewModelTest.kt @@ -57,7 +57,7 @@ class EncryptRecipientViewModelTest { fun setUp() { MockitoAnnotations.openMocks(this) context = InstrumentationRegistry.getInstrumentation().targetContext - sharedContainerViewModel = SharedContainerViewModel(context, context.contentResolver) + sharedContainerViewModel = SharedContainerViewModel(context) viewModel = EncryptRecipientViewModel( context, diff --git a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/FileOpeningViewModelTest.kt b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/FileOpeningViewModelTest.kt index 2651263d5..93a1ce7f7 100644 --- a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/FileOpeningViewModelTest.kt +++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/FileOpeningViewModelTest.kt @@ -943,7 +943,7 @@ class FileOpeningViewModelTest { @Test fun fileOpeningViewModel_resetExternalFileState_externalFileStateIsReset() = runTest { - val sharedContainerViewModel = SharedContainerViewModel(context, contentResolver) + val sharedContainerViewModel = SharedContainerViewModel(context) viewModel.resetExternalFileState(sharedContainerViewModel) assertEquals(listOf(), sharedContainerViewModel.externalFileUris.value) diff --git a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/RecentDocumentsViewModelTest.kt b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/RecentDocumentsViewModelTest.kt index 6697d8eac..433fa1762 100644 --- a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/RecentDocumentsViewModelTest.kt +++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/RecentDocumentsViewModelTest.kt @@ -5,7 +5,6 @@ package ee.ria.DigiDoc.viewmodel -import android.content.ContentResolver import android.content.Context import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.test.platform.app.InstrumentationRegistry @@ -45,7 +44,6 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mock -import org.mockito.Mockito.mock import org.mockito.Mockito.`when` import org.mockito.MockitoAnnotations import org.mockito.junit.MockitoJUnitRunner @@ -124,10 +122,7 @@ class RecentDocumentsViewModelTest { } sharedContainerViewModel = - SharedContainerViewModel( - context, - mock(ContentResolver::class.java), - ) + SharedContainerViewModel(context) } @Test 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 b09b383a8..aa58e8179 100644 --- a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/SigningViewModelTest.kt +++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/SigningViewModelTest.kt @@ -131,10 +131,7 @@ class SigningViewModelTest { ) viewModel.shouldResetSignedContainer.observeForever(shouldResetSignedContainerObserver) sharedContainerViewModel = - SharedContainerViewModel( - mock(Context::class.java), - contentResolver, - ) + SharedContainerViewModel(mock(Context::class.java)) } @Test diff --git a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedContainerViewModelTest.kt b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedContainerViewModelTest.kt index 09adbac0b..b9bf54a6a 100644 --- a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedContainerViewModelTest.kt +++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedContainerViewModelTest.kt @@ -7,9 +7,7 @@ package ee.ria.DigiDoc.viewmodel.shared import android.content.ContentResolver import android.content.Context -import android.content.Intent import android.net.Uri -import androidx.activity.result.ActivityResult import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.test.platform.app.InstrumentationRegistry import com.google.gson.Gson @@ -53,10 +51,7 @@ import org.mockito.Mockito.mock import org.mockito.Mockito.`when` import org.mockito.MockitoAnnotations import org.mockito.junit.MockitoJUnitRunner -import org.mockito.kotlin.whenever -import java.io.ByteArrayOutputStream import java.io.File -import java.io.FileNotFoundException import java.nio.charset.Charset import java.nio.file.Files @@ -107,53 +102,7 @@ class SharedContainerViewModelTest { @Before fun setup() { MockitoAnnotations.openMocks(this) - viewModel = SharedContainerViewModel(context, contentResolver) - } - - @Test - fun sharedContainerViewModel_saveContainerFile_success() { - val file = createTempFileWithStringContent("test", "Test content") - val intent = Intent() - val uri = Uri.fromFile(file) - intent.data = uri - whenever(contentResolver.openOutputStream(uri)).thenReturn(ByteArrayOutputStream()) - val activityResult = ActivityResult(-1, intent) - viewModel.saveContainerFile(file, activityResult) - } - - @Test - fun sharedContainerViewModel_saveContainerFile_intentNull() { - val file = createTempFileWithStringContent("test", "Test content") - val intent = Intent() - intent.data = Uri.fromFile(file) - val activityResult = ActivityResult(-1, null) - viewModel.saveContainerFile(file, activityResult) - } - - @Test - fun sharedContainerViewModel_saveContainerFile_intentDataNull() { - val file = createTempFileWithStringContent("test", "Test content") - val intent = Intent() - val activityResult = ActivityResult(-1, intent) - viewModel.saveContainerFile(file, activityResult) - } - - @Test(expected = FileNotFoundException::class) - fun sharedContainerViewModel_saveContainerFile_throwsFileNotFoundException() { - val file = createTempFileWithStringContent("test", "Test content") - val intent = Intent() - intent.data = mock(Uri::class.java) - val activityResult = ActivityResult(-1, intent) - viewModel.saveContainerFile(file, activityResult) - } - - @Test(expected = NullPointerException::class) - fun sharedContainerViewModel_saveContainerFile_throwsNullPointerException() { - val file = mock(File::class.java) - val intent = Intent() - intent.data = mock(Uri::class.java) - val activityResult = ActivityResult(-1, intent) - viewModel.saveContainerFile(file, activityResult) + viewModel = SharedContainerViewModel(context) } @Test diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/DiagnosticsScreen.kt b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/DiagnosticsScreen.kt index 318dcc818..6c6ee610b 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/DiagnosticsScreen.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/DiagnosticsScreen.kt @@ -5,13 +5,8 @@ package ee.ria.DigiDoc.fragment.screen -import android.app.Activity -import android.content.ActivityNotFoundException -import android.content.Intent import android.content.res.Configuration import android.os.Build -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -64,6 +59,7 @@ import ee.ria.DigiDoc.ui.component.shared.PrimaryOutlinedButton import ee.ria.DigiDoc.ui.component.shared.SpannableBoldText import ee.ria.DigiDoc.ui.component.shared.StatusSnackbarHost import ee.ria.DigiDoc.ui.component.shared.TopBar +import ee.ria.DigiDoc.ui.component.shared.handler.rememberSaveFileLauncher import ee.ria.DigiDoc.ui.component.shared.keyboard.keyboardScrollable import ee.ria.DigiDoc.ui.theme.Dimensions.SPadding import ee.ria.DigiDoc.ui.theme.Dimensions.XSPadding @@ -73,7 +69,7 @@ import ee.ria.DigiDoc.ui.theme.buttonRoundCornerShape import ee.ria.DigiDoc.utils.accessibility.AccessibilityUtil.Companion.sendAccessibilityEvent import ee.ria.DigiDoc.utils.snackbar.SnackBarManager.showMessage import ee.ria.DigiDoc.utils.snackbar.SnackbarType -import ee.ria.DigiDoc.utilsLib.file.FileUtil.sanitizeString +import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.debugLog import ee.ria.DigiDoc.viewmodel.DiagnosticsViewModel import ee.ria.DigiDoc.viewmodel.shared.SharedMenuViewModel import ee.ria.DigiDoc.viewmodel.shared.SharedSettingsViewModel @@ -83,6 +79,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File +private const val LOG_TAG = "DiagnosticsScreen" + @OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable fun DiagnosticsScreen( @@ -103,7 +101,6 @@ fun DiagnosticsScreen( diagnosticsViewModel.updatedConfiguration.asFlow().collectAsState( null, ) - var actionFile by remember { mutableStateOf(null) } var enableOneTimeLogGeneration by remember { mutableStateOf(diagnosticsViewModel.dataStore.getIsLogFileGenerationEnabled()) } @@ -120,31 +117,22 @@ fun DiagnosticsScreen( closeRestartConfirmationDialog() sendAccessibilityEvent(context, settingValueChangeCancelled) } - val saveFileLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == Activity.RESULT_OK) { - actionFile?.let { file -> - diagnosticsViewModel.saveFile(file, result) - } - showMessage(context, R.string.file_saved, SnackbarType.SUCCESS) - } - } + var logFile by rememberSaveable { mutableStateOf(null) } - val saveLogFileLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == Activity.RESULT_OK) { - actionFile?.let { file -> - diagnosticsViewModel.saveFile(file, result) + val saveFile = + rememberSaveFileLauncher( + onSaved = { savedFile -> + if (savedFile == logFile) { + debugLog(LOG_TAG, "Log file saved, ending logging session") + enableOneTimeLogGeneration = false + diagnosticsViewModel.dataStore.setIsLogFileGenerationEnabled(false) + diagnosticsViewModel.dataStore.setIsLogFileGenerationRunning(false) + diagnosticsViewModel.resetLogs(context) + sendAccessibilityEvent(context, settingValueChanged) + sharedSettingsViewModel.recreateActivity(true) } - showMessage(context, R.string.file_saved, SnackbarType.SUCCESS) - enableOneTimeLogGeneration = false - diagnosticsViewModel.dataStore.setIsLogFileGenerationEnabled(false) - diagnosticsViewModel.dataStore.setIsLogFileGenerationRunning(false) - diagnosticsViewModel.resetLogs(context) - sendAccessibilityEvent(context, settingValueChanged) - sharedSettingsViewModel.recreateActivity(true) - } - } + }, + ) Scaffold( modifier = @@ -231,25 +219,11 @@ fun DiagnosticsScreen( title = R.string.main_diagnostics_configuration_save_diagnostics_button, iconRes = R.drawable.ic_m3_download_48dp_wght400, onClickItem = { - try { - val diagnosticsFile = - diagnosticsViewModel.createDiagnosticsFile(context, currentConfiguration) - actionFile = diagnosticsFile - val saveIntent = - Intent.createChooser( - Intent(Intent.ACTION_CREATE_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .putExtra( - Intent.EXTRA_TITLE, - sanitizeString(diagnosticsFile.name, ""), - ).setType("text/plain") - .addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION), - null, - ) - saveFileLauncher.launch(saveIntent) - } catch (_: ActivityNotFoundException) { - // no Activity to handle this kind of files - } + debugLog(LOG_TAG, "Saving diagnostics file") + saveFile( + diagnosticsViewModel.createDiagnosticsFile(context, currentConfiguration), + "text/plain", + ) }, ) @@ -301,24 +275,10 @@ fun DiagnosticsScreen( title = R.string.main_diagnostics_save_log, iconRes = R.drawable.ic_m3_download_48dp_wght400, onClickItem = { - try { - val logFile = diagnosticsViewModel.createLogFile(context) - actionFile = logFile - val saveIntent = - Intent.createChooser( - Intent(Intent.ACTION_CREATE_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .putExtra( - Intent.EXTRA_TITLE, - sanitizeString(logFile.name, ""), - ).setType("text/plain") - .addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION), - null, - ) - saveLogFileLauncher.launch(saveIntent) - } catch (_: ActivityNotFoundException) { - // no Activity to handle this kind of files - } + debugLog(LOG_TAG, "Saving log file") + val createdLogFile = diagnosticsViewModel.createLogFile(context) + logFile = createdLogFile + saveFile(createdLogFile, "text/plain") }, ) } 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 d48192d1b..47d1ac1b2 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 @@ -5,15 +5,10 @@ package ee.ria.DigiDoc.ui.component.crypto -import android.app.Activity -import android.content.ActivityNotFoundException import android.content.Intent import android.content.res.Configuration import android.widget.Toast import androidx.activity.compose.BackHandler -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Arrangement @@ -94,6 +89,7 @@ import ee.ria.DigiDoc.ui.component.shared.TopBar import ee.ria.DigiDoc.ui.component.shared.dialog.OptionChooserDialog import ee.ria.DigiDoc.ui.component.shared.dialog.SivaConfirmationDialog import ee.ria.DigiDoc.ui.component.shared.handler.containerFileOpeningHandler +import ee.ria.DigiDoc.ui.component.shared.handler.rememberSaveFileLauncher import ee.ria.DigiDoc.ui.theme.Dimensions.SPadding import ee.ria.DigiDoc.ui.theme.Dimensions.XLPadding import ee.ria.DigiDoc.ui.theme.Dimensions.XSPadding @@ -111,7 +107,6 @@ import ee.ria.DigiDoc.utilsLib.container.ContainerUtil.removeExtensionFromContai 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.EncryptRecipientViewModel @@ -420,31 +415,10 @@ fun EncryptNavigation( var isSaved by remember { mutableStateOf(false) } - val fileToSave = remember { mutableStateOf(null) } - - val saveFile: (File, String?, ActivityResultLauncher) -> Unit = { file, mimetype, launcher -> - fileToSave.value = file - launchSaveFileChooser(file, mimetype, launcher) - } + val saveFile = rememberSaveFileLauncher(onSaved = { isSaved = true }) val selectedCryptoContainerTabIndex = rememberSaveable { mutableIntStateOf(0) } - val saveFileLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == Activity.RESULT_OK) { - try { - fileToSave.value?.let { file -> - sharedContainerViewModel.saveContainerFile(file, result) - showMessage(context, R.string.file_saved, SnackbarType.SUCCESS) - isSaved = true - } ?: showMessage(context, R.string.file_saved_error) - } catch (_: Exception) { - showMessage(context, R.string.file_saved_error) - } - } - fileToSave.value = null - } - val handleClose = { if (isEncrypting) { encryptRecipientViewModel.cancelEncryption() @@ -1156,7 +1130,6 @@ fun EncryptNavigation( showSivaDialog = showSivaDialog, handleSivaConfirmation = handleSivaConfirmation, context = context, - saveFileLauncher = saveFileLauncher, saveFile = saveFile, openRemoveFileDialog = openRemoveFileDialog, onBackButtonClick = { @@ -1181,7 +1154,6 @@ fun EncryptNavigation( ) ), cryptoContainer = cryptoContainer, - saveFileLauncher = saveFileLauncher, saveFile = saveFile, ) @@ -1224,11 +1196,7 @@ fun EncryptNavigation( onDismissButton = { val file = cryptoContainer?.file if (file != null) { - saveFile( - file, - cryptoContainer?.containerMimetype(), - saveFileLauncher, - ) + saveFile(file, cryptoContainer?.containerMimetype()) } }, onConfirmButton = { @@ -1279,29 +1247,6 @@ private fun handleBackButtonClick( } } -private fun launchSaveFileChooser( - file: File, - mimetype: String?, - saveFileLauncher: ActivityResultLauncher, -) { - try { - val saveIntent = - Intent.createChooser( - Intent(Intent.ACTION_CREATE_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .putExtra( - Intent.EXTRA_TITLE, - sanitizeString(file.name, ""), - ).setType(mimetype) - .addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION), - null, - ) - saveFileLauncher.launch(saveIntent) - } catch (_: ActivityNotFoundException) { - // No activity to handle this kind of files - } -} - @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/crypto/bottomsheet/CryptoDataFileBottomSheet.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/crypto/bottomsheet/CryptoDataFileBottomSheet.kt index dcaa14805..de5b489e9 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/crypto/bottomsheet/CryptoDataFileBottomSheet.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/crypto/bottomsheet/CryptoDataFileBottomSheet.kt @@ -6,8 +6,6 @@ package ee.ria.DigiDoc.ui.component.crypto.bottomsheet import android.content.Context -import android.content.Intent -import androidx.activity.result.ActivityResultLauncher import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf @@ -39,8 +37,7 @@ fun CryptoDataFileBottomSheet( showSivaDialog: MutableState, handleSivaConfirmation: () -> Unit, context: Context, - saveFileLauncher: ActivityResultLauncher, - saveFile: (File, String, ActivityResultLauncher) -> Unit, + saveFile: (File, String?) -> Unit, openRemoveFileDialog: MutableState, onBackButtonClick: () -> Unit, ) { @@ -93,7 +90,7 @@ fun CryptoDataFileBottomSheet( try { val file = sharedContainerViewModel.getCryptoContainerDataFile(cryptoContainer, dataFile) if (file != null) { - saveFile(file, dataFile.mimeType(context), saveFileLauncher) + saveFile(file, dataFile.mimeType(context)) } } catch (ex: Exception) { errorLog("SigningContainer", "Unable to save file. Unable to get datafile", ex) diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/crypto/bottomsheet/EncryptContainerBottomSheet.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/crypto/bottomsheet/EncryptContainerBottomSheet.kt index c9b3cda1e..d0d48ff7f 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/crypto/bottomsheet/EncryptContainerBottomSheet.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/crypto/bottomsheet/EncryptContainerBottomSheet.kt @@ -5,8 +5,6 @@ package ee.ria.DigiDoc.ui.component.crypto.bottomsheet -import android.content.Intent -import androidx.activity.result.ActivityResultLauncher import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier @@ -25,8 +23,7 @@ fun EncryptContainerBottomSheet( openEditContainerNameDialog: MutableState, isSaveButtonShown: Boolean = true, cryptoContainer: CryptoContainer?, - saveFileLauncher: ActivityResultLauncher, - saveFile: (File, String?, ActivityResultLauncher) -> Unit, + saveFile: (File, String?) -> Unit, ) { val buttonName = stringResource(id = R.string.button_name) @@ -61,7 +58,6 @@ fun EncryptContainerBottomSheet( saveFile( file, cryptoContainer.containerMimetype(), - saveFileLauncher, ) } }, diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/shared/handler/SaveFileLauncher.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/shared/handler/SaveFileLauncher.kt new file mode 100644 index 000000000..e2baf69ab --- /dev/null +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/shared/handler/SaveFileLauncher.kt @@ -0,0 +1,185 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: LGPL-2.1-or-later + +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.ui.component.shared.handler + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.ContentResolver +import android.content.Intent +import android.net.Uri +import android.provider.DocumentsContract +import android.provider.OpenableColumns +import android.webkit.MimeTypeMap +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.platform.LocalContext +import ee.ria.DigiDoc.R +import ee.ria.DigiDoc.utils.snackbar.SnackBarManager.showMessage +import ee.ria.DigiDoc.utils.snackbar.SnackbarType +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 java.io.File +import java.io.FileInputStream +import java.io.FileNotFoundException + +private const val LOG_TAG = "SaveFileLauncher" + +private const val DEFAULT_EXTENSION_MIME_TYPE = "application/octet-stream" + +@Composable +fun rememberSaveFileLauncher(onSaved: (File) -> Unit): (File, String?) -> Unit { + val context = LocalContext.current + val fileToSave = rememberSaveable { mutableStateOf(null) } + + val launcher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val file = fileToSave.value + fileToSave.value = null + debugLog(LOG_TAG, "Save file picker closed. Result code: ${result.resultCode}") + if (result.resultCode != Activity.RESULT_OK) { + debugLog(LOG_TAG, "Saving file cancelled") + return@rememberLauncherForActivityResult + } + val destination = result.data?.data + if (file == null || destination == null) { + errorLog( + LOG_TAG, + "Unable to save file. Has file: ${file != null}, has destination: ${destination != null}", + ) + destination?.let { deleteIfEmpty(context.contentResolver, it) } + showMessage(context, R.string.file_saved_error) + return@rememberLauncherForActivityResult + } + if (saveToDocument(context.contentResolver, file, destination)) { + debugLog(LOG_TAG, "File saved, running follow-up action") + showMessage(context, R.string.file_saved, SnackbarType.SUCCESS) + onSaved(file) + } else { + showMessage(context, R.string.file_saved_error) + } + } + + return remember(launcher) { + launch@{ file, mimetype -> + if (fileToSave.value != null) { + debugLog(LOG_TAG, "Save file picker already open, ignoring save request") + return@launch + } + fileToSave.value = file + val title = sanitizeString(file.name, "") + debugLog(LOG_TAG, "Opening save file picker. File size: ${file.length()} bytes") + try { + launcher.launch( + Intent.createChooser( + Intent(Intent.ACTION_CREATE_DOCUMENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .putExtra(Intent.EXTRA_TITLE, title) + .setType(createDocumentMimeType(title, mimetype)) + .addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION), + null, + ), + ) + } catch (anfe: ActivityNotFoundException) { + fileToSave.value = null + errorLog(LOG_TAG, "No activity to save files: ${anfe.javaClass.simpleName}") + } catch (e: Exception) { + fileToSave.value = null + errorLog(LOG_TAG, "Unable to open save file picker: ${e.javaClass.simpleName}") + throw e + } + } + } +} + +internal fun createDocumentMimeType( + title: String, + mimetype: String?, +): String? { + val extension = title.substringAfterLast('.', "").lowercase() + if (extension.isEmpty()) { + return mimetype + } + return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) ?: DEFAULT_EXTENSION_MIME_TYPE +} + +internal fun saveToDocument( + contentResolver: ContentResolver, + file: File, + document: Uri, +): Boolean { + val documentSize = documentSize(contentResolver, document) + val isEmptyDocument = documentSize == 0L + val mode = openMode(documentSize) + debugLog(LOG_TAG, "Saving file. Destination size before save: ${sizeText(documentSize)}, open mode: $mode") + return try { + val bytesWritten = + FileInputStream(file).use { input -> + val output = + contentResolver.openOutputStream(document, mode) + ?: throw FileNotFoundException("Unable to open output stream") + output.use { input.copyTo(it) } + } + debugLog(LOG_TAG, "File written. Bytes written: $bytesWritten") + true + } catch (e: Exception) { + errorLog(LOG_TAG, "Unable to save file: ${e.javaClass.simpleName}") + if (isEmptyDocument) { + deleteDocument(contentResolver, document) + } else { + debugLog(LOG_TAG, "Keeping destination after failed save. Size before save: ${sizeText(documentSize)}") + } + false + } +} + +internal fun openMode(documentSize: Long): String = if (documentSize > 0L) "wt" else "w" + +private fun deleteIfEmpty( + contentResolver: ContentResolver, + document: Uri, +) { + val documentSize = documentSize(contentResolver, document) + if (documentSize == 0L) { + deleteDocument(contentResolver, document) + } else { + debugLog(LOG_TAG, "Keeping destination without a file to save. Destination size: ${sizeText(documentSize)}") + } +} + +private fun documentSize( + contentResolver: ContentResolver, + document: Uri, +): Long = + try { + contentResolver + .query(document, arrayOf(OpenableColumns.SIZE), null, null, null) + ?.use { cursor -> + val index = cursor.getColumnIndex(OpenableColumns.SIZE) + if (cursor.moveToFirst() && index >= 0 && !cursor.isNull(index)) cursor.getLong(index) else -1L + } ?: -1L + } catch (e: Exception) { + errorLog(LOG_TAG, "Unable to get document size: ${e.javaClass.simpleName}") + -1L + } + +private fun sizeText(size: Long): String = if (size < 0L) "unknown" else "$size bytes" + +private fun deleteDocument( + contentResolver: ContentResolver, + document: Uri, +) { + try { + val isDeleted = DocumentsContract.deleteDocument(contentResolver, document) + debugLog(LOG_TAG, "Deleted empty unsaved document: $isDeleted") + } catch (e: Exception) { + errorLog(LOG_TAG, "Unable to delete unsaved document: ${e.javaClass.simpleName}") + } +} 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 b01f68f26..f9f6b299b 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 @@ -5,16 +5,11 @@ package ee.ria.DigiDoc.ui.component.signing -import android.app.Activity -import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.content.res.Configuration import android.widget.Toast import androidx.activity.compose.BackHandler -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Arrangement @@ -96,6 +91,7 @@ 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.shared.handler.rememberSaveFileLauncher import ee.ria.DigiDoc.ui.component.signing.bottombar.SigningBottomBar import ee.ria.DigiDoc.ui.component.signing.bottomsheet.ContainerBottomSheet import ee.ria.DigiDoc.ui.component.signing.bottomsheet.DataFileBottomSheet @@ -117,7 +113,6 @@ import ee.ria.DigiDoc.utilsLib.container.ContainerUtil.removeExtensionFromContai 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 @@ -475,29 +470,7 @@ fun SigningNavigation( val containerNotifications by sharedContainerViewModel.containerNotifications.collectAsState() - val saveFileLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == Activity.RESULT_OK) { - try { - clickedDataFile.value?.let { datafile -> - sharedContainerViewModel - .getContainerDataFile(signedContainer, datafile) - ?.let { sharedContainerViewModel.saveContainerFile(it, result) } - showMessage(context, R.string.file_saved, SnackbarType.SUCCESS) - clickedDataFile.value = null - isSaved = true - } ?: run { - signedContainer?.getContainerFile()?.let { - sharedContainerViewModel.saveContainerFile(it, result) - showMessage(context, R.string.file_saved, SnackbarType.SUCCESS) - isSaved = true - } ?: showMessage(context, R.string.file_saved_error) - } - } catch (_: Exception) { - showMessage(context, R.string.file_saved_error) - } - } - } + val saveFile = rememberSaveFileLauncher(onSaved = { isSaved = true }) BackHandler { if (!isNestedContainer) { @@ -1229,8 +1202,7 @@ fun SigningNavigation( showSivaDialog = showSivaDialog, handleSivaConfirmation = handleSivaConfirmation, context = context, - saveFileLauncher = saveFileLauncher, - saveFile = ::saveFile, + saveFile = saveFile, openRemoveFileDialog = openRemoveFileDialog, onBackButtonClick = { handleBackButtonClick( @@ -1264,8 +1236,7 @@ fun SigningNavigation( signedContainer = signedContainer, onEncryptClick = onEncryptActionClick, onExtendSignaturesClick = onExtendSignaturesActionClick, - saveFileLauncher = saveFileLauncher, - saveFile = ::saveFile, + saveFile = saveFile, ) SignatureBottomSheet( @@ -1367,11 +1338,7 @@ fun SigningNavigation( onDismissButton = { val file = signedContainer?.getContainerFile() if (file != null) { - saveFile( - file, - signedContainer?.containerMimetype(), - saveFileLauncher, - ) + saveFile(file, signedContainer?.containerMimetype()) } }, onConfirmButton = { @@ -1442,29 +1409,6 @@ private fun handleBackButtonClick( } } -private fun saveFile( - file: File, - mimetype: String?, - saveFileLauncher: ActivityResultLauncher, -) { - try { - val saveIntent = - Intent.createChooser( - Intent(Intent.ACTION_CREATE_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .putExtra( - Intent.EXTRA_TITLE, - sanitizeString(file.name, ""), - ).setType(mimetype) - .addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION), - null, - ) - saveFileLauncher.launch(saveIntent) - } catch (_: ActivityNotFoundException) { - // No activity to handle this kind of files - } -} - private suspend fun extendToLTA( sharedSettingsViewModel: SharedSettingsViewModel, signedContainer: SignedContainer?, 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 b87d47d73..5f6e3cd17 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 @@ -5,8 +5,6 @@ package ee.ria.DigiDoc.ui.component.signing.bottomsheet -import android.content.Intent -import androidx.activity.result.ActivityResultLauncher import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier @@ -28,8 +26,7 @@ fun ContainerBottomSheet( signedContainer: SignedContainer?, onEncryptClick: () -> Unit, onExtendSignaturesClick: () -> Unit, - saveFileLauncher: ActivityResultLauncher, - saveFile: (File, String?, ActivityResultLauncher) -> Unit, + saveFile: (File, String?) -> Unit, ) { val buttonName = stringResource(id = R.string.button_name) @@ -63,7 +60,6 @@ fun ContainerBottomSheet( saveFile( file, signedContainer.containerMimetype(), - saveFileLauncher, ) } }, diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/bottomsheet/DataFileBottomSheet.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/bottomsheet/DataFileBottomSheet.kt index 18a1eb863..77458eb89 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/bottomsheet/DataFileBottomSheet.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/bottomsheet/DataFileBottomSheet.kt @@ -6,8 +6,6 @@ package ee.ria.DigiDoc.ui.component.signing.bottomsheet import android.content.Context -import android.content.Intent -import androidx.activity.result.ActivityResultLauncher import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier @@ -39,8 +37,7 @@ fun DataFileBottomSheet( showSivaDialog: MutableState, handleSivaConfirmation: () -> Unit, context: Context, - saveFileLauncher: ActivityResultLauncher, - saveFile: (File, String, ActivityResultLauncher) -> Unit, + saveFile: (File, String?) -> Unit, openRemoveFileDialog: MutableState, onBackButtonClick: () -> Unit, ) { @@ -99,7 +96,7 @@ fun DataFileBottomSheet( } if (file != null) { - saveFile(file, mimeType, saveFileLauncher) + saveFile(file, mimeType) } } catch (ex: Exception) { errorLog("DataFileBottomSheet", "Unable to save file. Unable to get data file", ex) diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/DiagnosticsViewModel.kt b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/DiagnosticsViewModel.kt index b821a6714..7b3446b59 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/DiagnosticsViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/DiagnosticsViewModel.kt @@ -5,15 +5,12 @@ package ee.ria.DigiDoc.viewmodel -import android.content.ContentResolver import android.content.Context import android.os.Build -import androidx.activity.result.ActivityResult import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.google.common.io.ByteStreams import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import ee.ria.DigiDoc.BuildConfig @@ -58,7 +55,6 @@ class DiagnosticsViewModel val dataStore: DataStore, private val configurationLoader: ConfigurationLoader, private val configurationRepository: ConfigurationRepository, - private val contentResolver: ContentResolver, ) : ViewModel() { private val logTag = "SettingsViewModel" private val logsFileName = @@ -220,22 +216,6 @@ class DiagnosticsViewModel } } - fun saveFile( - documentFile: File, - activityResult: ActivityResult, - ) { - FileInputStream(documentFile).use { inputStream -> - activityResult.data?.data?.let { - contentResolver - .openOutputStream(it) - .use { outputStream -> - outputStream ?: throw FileNotFoundException("Unable to open output stream for URI: $it") - ByteStreams.copy(inputStream, outputStream) - } - } - } - } - fun createLogFile(context: Context): File { if (FileUtil.logsExist(FileUtil.getLogsDirectory(context))) { return FileUtil.combineLogFiles( 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 0056f4eff..81dd1375a 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 @@ -5,14 +5,11 @@ package ee.ria.DigiDoc.viewmodel.shared -import android.content.ContentResolver import android.content.Context import android.net.Uri import android.os.Build -import androidx.activity.result.ActivityResult import androidx.compose.runtime.mutableStateListOf import androidx.lifecycle.ViewModel -import com.google.common.io.ByteStreams import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import ee.ria.DigiDoc.common.Constant.PDF_MIMETYPE @@ -40,9 +37,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import java.io.File -import java.io.FileInputStream -import java.io.FileNotFoundException -import java.io.IOException import javax.inject.Inject @HiltViewModel @@ -50,7 +44,6 @@ class SharedContainerViewModel @Inject constructor( @param:ApplicationContext private val context: Context, - private val contentResolver: ContentResolver, ) : ViewModel() { private val _signedContainer = MutableStateFlow(null) val signedContainer: StateFlow = _signedContainer.asStateFlow() @@ -348,23 +341,6 @@ class SharedContainerViewModel _signedContainer.value = signedContainer } - @Throws(FileNotFoundException::class, IOException::class) - fun saveContainerFile( - documentFile: File, - activityResult: ActivityResult, - ) { - FileInputStream(documentFile).use { inputStream -> - activityResult.data?.data?.let { - contentResolver - .openOutputStream(it) - .use { outputStream -> - outputStream ?: throw FileNotFoundException("Unable to open output stream for URI: $it") - ByteStreams.copy(inputStream, outputStream) - } - } - } - } - suspend fun removeSignature( signedContainer: SignedContainer?, signature: SignatureInterface?, diff --git a/app/src/test/kotlin/ee/ria/DigiDoc/ui/component/shared/handler/SaveFileLauncherTest.kt b/app/src/test/kotlin/ee/ria/DigiDoc/ui/component/shared/handler/SaveFileLauncherTest.kt new file mode 100644 index 000000000..084cf2e47 --- /dev/null +++ b/app/src/test/kotlin/ee/ria/DigiDoc/ui/component/shared/handler/SaveFileLauncherTest.kt @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: LGPL-2.1-or-later + +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.ui.component.shared.handler + +import org.junit.Assert.assertEquals +import org.junit.Test + +class SaveFileLauncherTest { + @Test + fun openMode_truncatesDocumentKnownToHaveContent() { + assertEquals("wt", openMode(30L)) + } + + @Test + fun openMode_keepsDefaultModeForEmptyDocument() { + assertEquals("w", openMode(0L)) + } + + @Test + fun openMode_keepsDefaultModeWhenSizeIsUnknown() { + assertEquals("w", openMode(-1L)) + } +} diff --git a/app/src/test/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedContainerViewModelEncryptionStateTest.kt b/app/src/test/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedContainerViewModelEncryptionStateTest.kt index 2b287efd3..1519b0d0f 100644 --- a/app/src/test/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedContainerViewModelEncryptionStateTest.kt +++ b/app/src/test/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedContainerViewModelEncryptionStateTest.kt @@ -5,7 +5,6 @@ package ee.ria.DigiDoc.viewmodel.shared -import android.content.ContentResolver import android.content.Context import androidx.arch.core.executor.testing.InstantTaskExecutorRule import org.junit.Assert.assertNull @@ -26,15 +25,12 @@ class SharedContainerViewModelEncryptionStateTest { @Mock private lateinit var context: Context - @Mock - private lateinit var contentResolver: ContentResolver - private lateinit var viewModel: SharedContainerViewModel @Before fun setUp() { MockitoAnnotations.openMocks(this) - viewModel = SharedContainerViewModel(context, contentResolver) + viewModel = SharedContainerViewModel(context) } @Test