diff --git a/app/src/main/java/org/fairscan/app/FairScanApp.kt b/app/src/main/java/org/fairscan/app/FairScanApp.kt index aa33775..e065002 100644 --- a/app/src/main/java/org/fairscan/app/FairScanApp.kt +++ b/app/src/main/java/org/fairscan/app/FairScanApp.kt @@ -23,7 +23,7 @@ import androidx.lifecycle.viewmodel.CreationExtras import org.fairscan.app.data.FileLogger import org.fairscan.app.data.ImageRepository import org.fairscan.app.data.LogRepository -import org.fairscan.app.data.PdfFileManager +import org.fairscan.app.data.FileManager import org.fairscan.app.data.recentDocumentsDataStore import org.fairscan.app.domain.ImageSegmentationService import org.fairscan.app.platform.AndroidPdfWriter @@ -51,8 +51,9 @@ class AppContainer(context: Context) { private val density = context.resources.displayMetrics.density private val thumbnailSizePx = (THUMBNAIL_SIZE_DP * density).toInt() val imageRepository = ImageRepository(context.filesDir, OpenCvTransformations(), thumbnailSizePx) - val pdfFileManager = PdfFileManager( - File(context.cacheDir, "pdfs"), + val preparationDir = File(context.cacheDir, "pdfs") + val fileManager = FileManager( + preparationDir, Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), AndroidPdfWriter() ) diff --git a/app/src/main/java/org/fairscan/app/MainActivity.kt b/app/src/main/java/org/fairscan/app/MainActivity.kt index ebe7a0a..1c90299 100644 --- a/app/src/main/java/org/fairscan/app/MainActivity.kt +++ b/app/src/main/java/org/fairscan/app/MainActivity.kt @@ -45,12 +45,10 @@ import androidx.compose.ui.res.stringResource import androidx.core.content.ContextCompat.checkSelfPermission import androidx.core.content.FileProvider import androidx.core.net.toFile -import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import org.fairscan.app.data.GeneratedPdf import org.fairscan.app.ui.Navigation import org.fairscan.app.ui.Screen import org.fairscan.app.ui.components.rememberCameraPermissionState @@ -63,18 +61,18 @@ import org.fairscan.app.ui.screens.camera.CameraEvent import org.fairscan.app.ui.screens.camera.CameraScreen import org.fairscan.app.ui.screens.camera.CameraViewModel import org.fairscan.app.ui.screens.export.ExportEvent +import org.fairscan.app.ui.screens.export.ExportResult import org.fairscan.app.ui.screens.export.ExportScreenWrapper import org.fairscan.app.ui.screens.export.ExportViewModel -import org.fairscan.app.ui.screens.export.PdfGenerationActions +import org.fairscan.app.ui.screens.export.ExportActions import org.fairscan.app.ui.screens.home.HomeScreen import org.fairscan.app.ui.screens.home.HomeViewModel +import org.fairscan.app.ui.screens.settings.ExportFormat import org.fairscan.app.ui.screens.settings.SettingsScreen import org.fairscan.app.ui.screens.settings.SettingsViewModel import org.fairscan.app.ui.theme.FairScanTheme import org.opencv.android.OpenCVLoader -private const val PDF_MIME_TYPE = "application/pdf" - class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -89,7 +87,7 @@ class MainActivity : ComponentActivity() { val settingsViewModel: SettingsViewModel by viewModels { appContainer.settingsViewModelFactory } lifecycleScope.launch(Dispatchers.IO) { - exportViewModel.cleanUpOldPdfs(1000 * 3600) + exportViewModel.cleanUpOldPreparedFiles(1000 * 3600) } enableEdgeToEdge() setContent { @@ -97,6 +95,7 @@ class MainActivity : ComponentActivity() { val currentScreen by viewModel.currentScreen.collectAsStateWithLifecycle() val liveAnalysisState by cameraViewModel.liveAnalysisState.collectAsStateWithLifecycle() val document by viewModel.documentUiModel.collectAsStateWithLifecycle() + val exportUiState by exportViewModel.uiState.collectAsStateWithLifecycle() val cameraPermission = rememberCameraPermissionState() CollectCameraEvents(cameraViewModel, viewModel) CollectExportEvents(context, exportViewModel) @@ -113,7 +112,7 @@ class MainActivity : ComponentActivity() { navigation = navigation, onClearScan = { viewModel.startNewDocument() }, recentDocuments = recentDocs, - onOpenPdf = { fileUri -> openPdf(fileUri) } + onOpenPdf = { fileUri -> openUri(fileUri, ExportFormat.PDF.mimeType) } ) } is Screen.Main.Camera -> { @@ -140,13 +139,13 @@ class MainActivity : ComponentActivity() { is Screen.Main.Export -> { ExportScreenWrapper( navigation = navigation, - pdfActions = PdfGenerationActions( - startGeneration = exportViewModel::startPdfGeneration, + uiState = exportUiState, + pdfActions = ExportActions( + initializeExportScreen = exportViewModel::initializeExportScreen, setFilename = exportViewModel::setFilename, - uiStateFlow = exportViewModel.pdfUiState, - sharePdf = { sharePdf(exportViewModel.getFinalPdf(), exportViewModel) }, - savePdf = { exportViewModel.onSavePdfClicked() }, - openPdf = { openPdf(exportViewModel.pdfUiState.value.savedFileUri) } + share = { share(exportViewModel.applyRenaming(), exportViewModel) }, + save = { exportViewModel.onSaveClicked() }, + open = { item -> openUri(item.uri, item.format.mimeType) } ), onCloseScan = { viewModel.startNewDocument() @@ -188,7 +187,8 @@ class MainActivity : ComponentActivity() { settingsUiState, onChooseDirectoryClick = { launcher.launch(null) }, onResetExportDirClick = { settingsViewModel.setExportDirUri(null) }, - onBack = nav.back + onExportFormatChanged = { format -> settingsViewModel.setExportFormat(format) }, + onBack = nav.back, ) } @@ -222,7 +222,7 @@ class MainActivity : ComponentActivity() { ActivityResultContracts.RequestPermission() ) { isGranted -> if (isGranted) { - exportViewModel.onSavePdfClicked() + exportViewModel.onSaveClicked() } else { val message = getString(R.string.storage_permission_denied) Toast.makeText(context, message, Toast.LENGTH_SHORT).show() @@ -231,9 +231,9 @@ class MainActivity : ComponentActivity() { LaunchedEffect(Unit) { exportViewModel.events.collect { event -> when (event) { - ExportEvent.RequestSavePdf -> { + ExportEvent.RequestSave -> { checkPermissionThen(storagePermissionLauncher) { - exportViewModel.onRequestPdfSave(context) + exportViewModel.onRequestSave(context) } } @@ -260,25 +260,36 @@ class MainActivity : ComponentActivity() { } } - private fun sharePdf(generatedPdf: GeneratedPdf?, viewModel: ExportViewModel) { - if (generatedPdf == null) - return - viewModel.setPdfAsShared() - val file = generatedPdf.file + private fun share(result: ExportResult?, viewModel: ExportViewModel) { + if (result == null || result.files.isEmpty()) return + + viewModel.setAsShared() + val authority = "${applicationContext.packageName}.fileprovider" - val fileUri = FileProvider.getUriForFile(this, authority, file) - val shareIntent = Intent(Intent.ACTION_SEND).apply { - type = PDF_MIME_TYPE - putExtra(Intent.EXTRA_STREAM, fileUri) + val uris = result.files.map { file -> + FileProvider.getUriForFile(this, authority, file) + } + val intent = Intent().apply { + action = if (uris.size == 1) Intent.ACTION_SEND else Intent.ACTION_SEND_MULTIPLE + type = result.format.mimeType addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + + if (uris.size == 1) { + putExtra(Intent.EXTRA_STREAM, uris[0]) + } else { + putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris)) + } + } + val chooser = Intent.createChooser(intent, getString(R.string.share_document)) + + val resolveInfos = packageManager.queryIntentActivities(chooser, PackageManager.MATCH_DEFAULT_ONLY) + for (info in resolveInfos) { + val pkg = info.activityInfo.packageName + for (uri in uris) { + grantUriPermission(pkg, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } } - val chooser = Intent.createChooser(shareIntent, getString(R.string.share_pdf)) - val resInfoList = packageManager.queryIntentActivities(chooser, PackageManager.MATCH_DEFAULT_ONLY) - for (resInfo in resInfoList) { - val packageName = resInfo.activityInfo.packageName - grantUriPermission(packageName, fileUri, Intent.FLAG_GRANT_READ_URI_PERMISSION) - } startActivity(chooser) } @@ -295,7 +306,7 @@ class MainActivity : ComponentActivity() { } } - private fun openPdf(fileUri: Uri?) { + private fun openUri(fileUri: Uri?, mimeType: String) { if (fileUri == null) return val uriToOpen: Uri = if (fileUri.scheme == ContentResolver.SCHEME_CONTENT) { @@ -305,13 +316,13 @@ class MainActivity : ComponentActivity() { FileProvider.getUriForFile(this, authority, fileUri.toFile()) } val openIntent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(uriToOpen, PDF_MIME_TYPE) + setDataAndType(uriToOpen, mimeType) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } try { - startActivity(Intent.createChooser(openIntent, getString(R.string.open_pdf))) + startActivity(Intent.createChooser(openIntent, getString(R.string.open_file))) } catch (_: ActivityNotFoundException) { - Toast.makeText(this, getString(R.string.error_no_pdf_app), Toast.LENGTH_SHORT).show() + Toast.makeText(this, getString(R.string.error_no_app), Toast.LENGTH_SHORT).show() } } diff --git a/app/src/main/java/org/fairscan/app/data/PdfFileManager.kt b/app/src/main/java/org/fairscan/app/data/FileManager.kt similarity index 97% rename from app/src/main/java/org/fairscan/app/data/PdfFileManager.kt rename to app/src/main/java/org/fairscan/app/data/FileManager.kt index 5ab082f..211cc59 100644 --- a/app/src/main/java/org/fairscan/app/data/PdfFileManager.kt +++ b/app/src/main/java/org/fairscan/app/data/FileManager.kt @@ -28,13 +28,13 @@ fun interface PdfWriter { fun writePdfFromJpegs(jpegs: Sequence, outputStream: OutputStream): Int } -class PdfFileManager( +class FileManager( private val pdfDir: File, private val externalDir: File, private val pdfWriter: PdfWriter ) { companion object { - fun addExtensionIfMissing(fileName: String): String { + fun addPdfExtensionIfMissing(fileName: String): String { return if (fileName.lowercase().endsWith(".pdf")) fileName else diff --git a/app/src/main/java/org/fairscan/app/data/ImageRepository.kt b/app/src/main/java/org/fairscan/app/data/ImageRepository.kt index 4d28b17..5eb1469 100644 --- a/app/src/main/java/org/fairscan/app/data/ImageRepository.kt +++ b/app/src/main/java/org/fairscan/app/data/ImageRepository.kt @@ -111,11 +111,12 @@ class ImageRepository( } fun getContent(id: String): ByteArray? { + return getFileFor(id)?.readBytes() + } + + fun getFileFor(id: String): File? { val file = File(scanDir, id) - if (file.exists()) { - return file.readBytes() - } - return null + return if (file.exists()) file else null } fun getThumbnail(id: String): ByteArray? { diff --git a/app/src/main/java/org/fairscan/app/ui/screens/DocumentScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/DocumentScreen.kt index 9366cca..7274126 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/DocumentScreen.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/DocumentScreen.kt @@ -32,7 +32,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.RotateLeft import androidx.compose.material.icons.automirrored.filled.RotateRight import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.PictureAsPdf +import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.outlined.Delete import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme @@ -55,8 +55,8 @@ import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.toImmutableList import net.engawapg.lib.zoomable.ZoomState import net.engawapg.lib.zoomable.zoomable -import org.fairscan.app.ui.Navigation import org.fairscan.app.R +import org.fairscan.app.ui.Navigation import org.fairscan.app.ui.components.CommonPageListState import org.fairscan.app.ui.components.ConfirmationDialog import org.fairscan.app.ui.components.MainActionButton @@ -225,8 +225,8 @@ private fun BottomBar( ) { MainActionButton( onClick = navigation.toExportScreen, - icon = Icons.Default.PictureAsPdf, - text = stringResource(R.string.export_pdf), + icon = Icons.Default.Description, + text = stringResource(R.string.export), ) } } diff --git a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt index 95eb934..1ce858a 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt @@ -48,7 +48,6 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -61,13 +60,13 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.net.toUri import org.fairscan.app.R -import org.fairscan.app.data.GeneratedPdf import org.fairscan.app.ui.Navigation import org.fairscan.app.ui.components.AppOverflowMenu import org.fairscan.app.ui.components.BackButton @@ -76,6 +75,8 @@ import org.fairscan.app.ui.components.NewDocumentDialog import org.fairscan.app.ui.components.isLandscape import org.fairscan.app.ui.components.pageCountText import org.fairscan.app.ui.dummyNavigation +import org.fairscan.app.ui.screens.settings.ExportFormat +import org.fairscan.app.ui.screens.settings.ExportFormat.PDF import org.fairscan.app.ui.theme.FairScanTheme import java.io.File import java.text.SimpleDateFormat @@ -85,17 +86,17 @@ import java.util.Locale @Composable fun ExportScreenWrapper( navigation: Navigation, - pdfActions: PdfGenerationActions, + uiState: ExportUiState, + pdfActions: ExportActions, onCloseScan: () -> Unit, ) { BackHandler { navigation.back() } val showConfirmationDialog = rememberSaveable { mutableStateOf(false) } val filename = remember { mutableStateOf(defaultFilename()) } - val uiState by pdfActions.uiStateFlow.collectAsState() LaunchedEffect(Unit) { pdfActions.setFilename(filename.value) - pdfActions.startGeneration() + pdfActions.initializeExportScreen() } val onFilenameChange = { newName:String -> @@ -116,15 +117,15 @@ fun ExportScreenWrapper( navigation = navigation, onShare = { ensureCorrectFileName() - pdfActions.sharePdf() + pdfActions.share() }, onSave = { ensureCorrectFileName() - pdfActions.savePdf() + pdfActions.save() }, - onOpen = { pdfActions.openPdf() }, + onOpen = pdfActions.open, onCloseScan = { - if (uiState.hasSavedOrSharedPdf) + if (uiState.hasSavedOrShared) onCloseScan() else showConfirmationDialog.value = true @@ -141,17 +142,17 @@ fun ExportScreenWrapper( fun ExportScreen( filename: MutableState, onFilenameChange: (String) -> Unit, - uiState: PdfGenerationUiState, + uiState: ExportUiState, navigation: Navigation, onShare: () -> Unit, onSave: () -> Unit, - onOpen: () -> Unit, + onOpen: (SavedItem) -> Unit, onCloseScan: () -> Unit, ) { Scaffold( topBar = { TopAppBar( - title = { Text(stringResource(R.string.export_pdf)) }, + title = { Text(stringResource(R.string.export_as, uiState.format)) }, navigationIcon = { BackButton(navigation.back) }, actions = { AppOverflowMenu(navigation) @@ -195,12 +196,12 @@ fun ExportScreen( private fun TextFieldAndPdfInfos( filename: MutableState, onFilenameChange: (String) -> Unit, - uiState: PdfGenerationUiState, - onOpen: () -> Unit, + uiState: ExportUiState, + onOpen: (SavedItem) -> Unit, ) { FilenameTextField(filename, onFilenameChange) - val pdf = uiState.generatedPdf + val result = uiState.result // PDF infos Column( @@ -208,20 +209,22 @@ private fun TextFieldAndPdfInfos( ) { if (uiState.isGenerating) { - Text(stringResource(R.string.creating_pdf), fontStyle = FontStyle.Italic) - } else if (pdf != null) { + Text(stringResource(R.string.creating_export), fontStyle = FontStyle.Italic) + } else if (result != null) { val context = LocalContext.current - val formattedFileSize = formatFileSize(pdf.sizeInBytes, context) - Text(text = pageCountText(pdf.pageCount)) + val formattedFileSize = formatFileSize(result.sizeInBytes, context) + Text(text = pageCountText(result.pageCount)) + val sizeMessageKey = + if (result.files.size == 1) R.string.file_size else R.string.file_size_total Text( - text = stringResource(R.string.file_size, formattedFileSize), + text = stringResource(sizeMessageKey, formattedFileSize), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) } } - if (uiState.savedFileUri != null) { - SavedPdfBar(uiState, onOpen) + if (uiState.savedBundle != null) { + SaveInfoBar(uiState.savedBundle, onOpen) } if (uiState.errorMessage != null) { ErrorBar(uiState.errorMessage) @@ -257,7 +260,7 @@ private fun FilenameTextField( @Composable private fun MainActions( - uiState: PdfGenerationUiState, + uiState: ExportUiState, onShare: () -> Unit, onSave: () -> Unit, onCloseScan: () -> Unit, @@ -271,16 +274,16 @@ private fun MainActions( ) { ExportButton( onClick = onShare, - enabled = uiState.generatedPdf != null, - isPrimary = !uiState.hasSavedOrSharedPdf, + enabled = uiState.result != null, + isPrimary = !uiState.hasSavedOrShared, icon = Icons.Default.Share, text = stringResource(R.string.share), modifier = Modifier.weight(1f) ) ExportButton( onClick = onSave, - enabled = uiState.generatedPdf != null, - isPrimary = !uiState.hasSavedOrSharedPdf, + enabled = uiState.result != null, + isPrimary = !uiState.hasSavedOrShared, icon = Icons.Default.Download, text = stringResource(R.string.save), modifier = Modifier.weight(1f) @@ -291,7 +294,7 @@ private fun MainActions( text = stringResource(R.string.end_scan), onClick = onCloseScan, modifier = Modifier.fillMaxWidth(), - isPrimary = uiState.hasSavedOrSharedPdf, + isPrimary = uiState.hasSavedOrShared, ) } } @@ -335,8 +338,8 @@ fun ExportButton( } @Composable -private fun SavedPdfBar(uiState: PdfGenerationUiState, onOpen: () -> Unit) { - val dirName = uiState.exportDirName?:stringResource(R.string.download_dirname) +private fun SaveInfoBar(savedBundle: SavedBundle, onOpen: (SavedItem) -> Unit) { + val dirName = savedBundle.exportDirName?:stringResource(R.string.download_dirname) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Absolute.SpaceBetween, @@ -345,17 +348,26 @@ private fun SavedPdfBar(uiState: PdfGenerationUiState, onOpen: () -> Unit) { .background(MaterialTheme.colorScheme.surfaceVariant) .padding(vertical = 8.dp, horizontal = 16.dp), ) { + val items = savedBundle.items + val nbFiles = items.size + val firstFileName = items[0].fileName Text( - text = stringResource(R.string.pdf_saved_to, dirName), + text = LocalResources.current.getQuantityString( + R.plurals.files_saved_to, + nbFiles, + nbFiles, firstFileName, dirName + ), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f), ) - Spacer(Modifier.width(8.dp)) - MainActionButton( - onClick = onOpen, - text = stringResource(R.string.open), - icon = Icons.AutoMirrored.Filled.OpenInNew, - ) + if (nbFiles == 1) { + Spacer(Modifier.width(8.dp)) + MainActionButton( + onClick = { onOpen(items[0]) }, + text = stringResource(R.string.open), + icon = Icons.AutoMirrored.Filled.OpenInNew, + ) + } } } @@ -386,7 +398,7 @@ fun formatFileSize(sizeInBytes: Long?, context: Context): String { @Composable fun PreviewExportScreenDuringGeneration() { ExportPreviewToCustomize( - uiState = PdfGenerationUiState(isGenerating = true) + uiState = ExportUiState(isGenerating = true) ) } @@ -395,8 +407,8 @@ fun PreviewExportScreenDuringGeneration() { fun PreviewExportScreenAfterGeneration() { val file = File("fake.pdf") ExportPreviewToCustomize( - uiState = PdfGenerationUiState( - generatedPdf = GeneratedPdf(file, 442897L, 3), + uiState = ExportUiState( + result = ExportResult.Pdf(file, 442897L, 3), ), ) } @@ -406,9 +418,11 @@ fun PreviewExportScreenAfterGeneration() { fun PreviewExportScreenAfterSave() { val file = File("fake.pdf") ExportPreviewToCustomize( - uiState = PdfGenerationUiState( - generatedPdf = GeneratedPdf(file, 442897L, 3), - savedFileUri = file.toUri(), + uiState = ExportUiState( + result = ExportResult.Pdf(file, 442897L, 3), + savedBundle = SavedBundle( + listOf(SavedItem(file.toUri(), defaultFilename() + ".pdf", PDF)) + ), ), ) } @@ -417,7 +431,7 @@ fun PreviewExportScreenAfterSave() { @Composable fun ExportScreenPreviewWithError() { ExportPreviewToCustomize( - PdfGenerationUiState(errorMessage = "PDF generation failed") + ExportUiState(errorMessage = "PDF generation failed") ) } @@ -426,16 +440,17 @@ fun ExportScreenPreviewWithError() { fun PreviewExportScreenAfterSaveHorizontal() { val file = File("fake.pdf") ExportPreviewToCustomize( - uiState = PdfGenerationUiState( - generatedPdf = GeneratedPdf(file, 442897L, 3), - savedFileUri = file.toUri(), - exportDirName = "MyVeryVeryLongDirectoryName" + uiState = ExportUiState( + result = ExportResult.Pdf(file, 442897L, 3), + savedBundle = SavedBundle( + listOf(SavedItem(file.toUri(), "my_file.pdf", PDF)), + exportDirName="MyVeryVeryLongDirectoryName"), ), ) } @Composable -fun ExportPreviewToCustomize(uiState: PdfGenerationUiState) { +fun ExportPreviewToCustomize(uiState: ExportUiState) { FairScanTheme { ExportScreen( filename = remember { mutableStateOf("Scan 2025-07-02 17.40.42") }, diff --git a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportUiState.kt b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportUiState.kt index 13bb64a..bb6e422 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportUiState.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportUiState.kt @@ -15,16 +15,28 @@ package org.fairscan.app.ui.screens.export import android.net.Uri -import org.fairscan.app.data.GeneratedPdf +import org.fairscan.app.ui.screens.settings.ExportFormat -data class PdfGenerationUiState( +data class ExportUiState( + val format: ExportFormat = ExportFormat.PDF, val isGenerating: Boolean = false, - val generatedPdf: GeneratedPdf? = null, + val result: ExportResult? = null, val desiredFilename: String = "", - val savedFileUri: Uri? = null, - val exportDirName: String? = null, - val hasSharedPdf: Boolean = false, + val savedBundle: SavedBundle? = null, + val hasShared: Boolean = false, val errorMessage: String? = null, ) { - val hasSavedOrSharedPdf get() = savedFileUri != null || hasSharedPdf + val hasSavedOrShared get() = savedBundle != null || hasShared } + +data class SavedItem( + val uri: Uri, + val fileName: String, + val format: ExportFormat, +) + +data class SavedBundle( + val items: List, + val exportDir: Uri? = null, + val exportDirName: String? = null, +) diff --git a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt index 3f722ee..aa02526 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt @@ -22,7 +22,6 @@ import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -32,26 +31,25 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import org.fairscan.app.AppContainer import org.fairscan.app.RecentDocument -import org.fairscan.app.data.GeneratedPdf -import org.fairscan.app.data.PdfFileManager -import org.fairscan.app.ui.screens.home.HomeViewModel +import org.fairscan.app.data.FileManager +import org.fairscan.app.ui.screens.settings.ExportFormat import java.io.File import java.io.FileInputStream - -private const val PDF_MIME_TYPE = "application/pdf" +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine sealed interface ExportEvent { - data object RequestSavePdf : ExportEvent + data object RequestSave : ExportEvent data object SaveError : ExportEvent } class ExportViewModel(container: AppContainer): ViewModel() { - private val pdfFileManager = container.pdfFileManager + private val preparationDir = container.preparationDir + private val fileManager = container.fileManager private val imageRepository = container.imageRepository private val settingsRepository = container.settingsRepository private val recentDocumentsDataStore = container.recentDocumentsDataStore @@ -60,134 +58,166 @@ class ExportViewModel(container: AppContainer): ViewModel() { private val _events = MutableSharedFlow() val events = _events.asSharedFlow() - private suspend fun generatePdf(): GeneratedPdf = withContext(Dispatchers.IO) { + private suspend fun generatePdf(): ExportResult.Pdf = withContext(Dispatchers.IO) { val imageIds = imageRepository.imageIds() val jpegs = imageIds.asSequence() - .map { id -> imageRepository.getContent(id) } - .filterNotNull() - return@withContext pdfFileManager.generatePdf(jpegs) + .mapNotNull { id -> imageRepository.getContent(id) } + val pdf = fileManager.generatePdf(jpegs) + return@withContext ExportResult.Pdf(pdf.file, pdf.sizeInBytes, pdf.pageCount) } - private val _pdfUiState = MutableStateFlow(PdfGenerationUiState()) - val pdfUiState: StateFlow = _pdfUiState.asStateFlow() + private val _uiState = MutableStateFlow(ExportUiState()) + val uiState: StateFlow = _uiState.asStateFlow() - private var generationJob: Job? = null + private var preparationJob: Job? = null private var desiredFilename: String = "" + private var exportFormat = ExportFormat.PDF fun setFilename(name: String) { desiredFilename = name } - fun startPdfGeneration() { - cancelPdfGeneration() - generationJob = viewModelScope.launch { + fun initializeExportScreen() { + cancelPreparation() + + preparationJob = viewModelScope.launch { + exportFormat = settingsRepository.exportFormat.first() + _uiState.update { it.copy(format = exportFormat) } try { - val result = generatePdf() - _pdfUiState.update { - it.copy( - isGenerating = false, - generatedPdf = result - ) + val result = if (exportFormat == ExportFormat.JPEG) { + val jpegFiles = imageRepository.imageIds() + .mapNotNull { id -> imageRepository.getFileFor(id) } + .map { f -> f.copyTo(File(preparationDir, f.name), overwrite = true) } + val sizeInBytes = jpegFiles.sumOf { it.length() } + ExportResult.Jpeg(jpegFiles, sizeInBytes) + } else { + generatePdf() + } + _uiState.update { + it.copy(isGenerating = false, result = result) } } catch (e: Exception) { - logger.e("FairScan", "PDF generation failed", e) - _pdfUiState.update { + val message = "Failed to prepare $exportFormat export" + logger.e("FairScan", message, e) + _uiState.update { it.copy( isGenerating = false, - errorMessage = "PDF generation failed" + errorMessage = message ) } } } } - fun cancelPdfGeneration() { - generationJob?.cancel() - _pdfUiState.value = PdfGenerationUiState() + fun cancelPreparation() { + preparationJob?.cancel() + _uiState.value = ExportUiState() } - fun setPdfAsShared() { - _pdfUiState.update { it.copy(hasSharedPdf = true) } + fun setAsShared() { + _uiState.update { it.copy(hasShared = true) } } - fun getFinalPdf(): GeneratedPdf? { - val tempPdf = _pdfUiState.value.generatedPdf ?: return null - val tempFile = tempPdf.file - val fileName = PdfFileManager.addExtensionIfMissing(desiredFilename) - val newFile = File(tempFile.parentFile, fileName) - if (tempFile.absolutePath != newFile.absolutePath) { - if (newFile.exists()) newFile.delete() - val success = tempFile.renameTo(newFile) - if (!success) return null - _pdfUiState.update { - it.copy(generatedPdf = GeneratedPdf( - newFile, tempPdf.sizeInBytes, tempPdf.pageCount) - ) + fun applyRenaming(): ExportResult? { + val result = _uiState.value.result ?: return null + when (result) { + is ExportResult.Pdf -> { + val fileName = FileManager.addPdfExtensionIfMissing(desiredFilename) + val newFile = File(result.file.parentFile, fileName) + val tempFile = result.file + if (tempFile.absolutePath != newFile.absolutePath) { + if (newFile.exists()) newFile.delete() + val success = tempFile.renameTo(newFile) + if (!success) return null + _uiState.update { + it.copy(result = ExportResult.Pdf( + newFile, result.sizeInBytes, result.pageCount) + ) + } + } + } + is ExportResult.Jpeg -> { + val base = desiredFilename.removeSuffix(".jpg") + val renamedFiles = result.files.mapIndexed { index, file -> + val newFile = File(file.parentFile, "${base}_${index + 1}.jpg") + if (newFile.exists()) newFile.delete() + file.renameTo(newFile) + newFile + } + val updated = result.copy(jpegFiles = renamedFiles) + _uiState.update { it.copy(result = updated) } } } - return _pdfUiState.value.generatedPdf + return _uiState.value.result } - fun onSavePdfClicked() { + fun onSaveClicked() { viewModelScope.launch { - _events.emit(ExportEvent.RequestSavePdf) + _events.emit(ExportEvent.RequestSave) } } - fun onRequestPdfSave(context: Context) { + fun onRequestSave(context: Context) { viewModelScope.launch { - performPdfSave(context) + try { + save(context) + } catch (e: Exception) { + logger.e("FairScan", "Failed to save PDF", e) + _events.emit(ExportEvent.SaveError) + } } } - private suspend fun performPdfSave(context: Context) { - try { - val pdf = getFinalPdf() ?: return + private suspend fun save(context:Context) { + val result = applyRenaming() ?: return + val exportDir = settingsRepository.exportDirUri.first()?.toUri() + val savedItems = mutableListOf() + val filesForMediaScan = mutableListOf() - val exportDir = settingsRepository.exportDirUri.first() - var fileInDownloads: File? = null - - var savedName: String - val savedUri: Uri - if (exportDir == null) { - fileInDownloads = pdfFileManager.copyToExternalDir(pdf.file) - savedUri = fileInDownloads.toUri() - savedName = fileInDownloads.name + for (file in result.files) { + val saved = if (exportDir == null) { + val out = fileManager.copyToExternalDir(file) + filesForMediaScan.add(out) + SavedItem(out.toUri(), out.name, exportFormat) } else { - val saved = copyViaSaf(context, pdf.file, exportDir.toUri()) - savedUri = saved.uri - savedName = saved.name?:pdf.file.name + val safFile = copyViaSaf(context, file, exportDir, exportFormat) + SavedItem(safFile.uri, safFile.name ?: file.name, exportFormat) } - - _pdfUiState.update { - it.copy( - savedFileUri = savedUri, - exportDirName = resolveExportDirName(context, exportDir?.toUri())) - } - - fileInDownloads?.let { mediaScan(context, it) } - - addRecentDocument(savedUri, savedName, pdf.pageCount) - } catch (e: Exception) { - logger.e("FairScan", "Failed to save PDF", e) - _events.emit(ExportEvent.SaveError) + savedItems += saved } + + val exportDirName = resolveExportDirName(context, exportDir) + val bundle = SavedBundle(savedItems, exportDir, exportDirName) + _uiState.update { it.copy(savedBundle = bundle) } + + if (exportFormat == ExportFormat.PDF) { + savedItems.forEach { item -> + addRecentDocument(item.uri, item.fileName, result.pageCount) + } + } + + filesForMediaScan.forEach { f -> mediaScan(context, f, exportFormat.mimeType) } } - @OptIn(ExperimentalCoroutinesApi::class) - private suspend fun mediaScan(context: Context, file: File) = - suspendCancellableCoroutine { continuation -> - MediaScannerConnection.scanFile( - context, - arrayOf(file.absolutePath), - arrayOf(PDF_MIME_TYPE) - ) { _, _ -> continuation.resume(Unit) {} } + private suspend fun mediaScan( + context: Context, + file: File, + mimeType: String + ): Uri? = suspendCoroutine { cont -> + MediaScannerConnection.scanFile( + context, + arrayOf(file.absolutePath), + arrayOf(mimeType) + ) { _, uri -> + cont.resume(uri) } + } private fun copyViaSaf( context: Context, source: File, exportDirUri: Uri, + exportFormat: ExportFormat, ): DocumentFile { val resolver = context.contentResolver @@ -195,7 +225,7 @@ class ExportViewModel(container: AppContainer): ViewModel() { ?: throw IllegalStateException("Invalid SAF directory") // Name collisions are handled automatically by SAF provider - val target = tree.createFile(PDF_MIME_TYPE, source.name) + val target = tree.createFile(exportFormat.mimeType, source.name) ?: throw IllegalStateException("Unable to create SAF file") resolver.openOutputStream(target.uri)?.use { output -> @@ -207,8 +237,8 @@ class ExportViewModel(container: AppContainer): ViewModel() { return target } - fun cleanUpOldPdfs(thresholdInMillis: Int) { - pdfFileManager.cleanUpOldFiles(thresholdInMillis) + fun cleanUpOldPreparedFiles(thresholdInMillis: Int) { + fileManager.cleanUpOldFiles(thresholdInMillis) } private fun resolveExportDirName(context: Context, exportDirUri: Uri?): String? { @@ -241,11 +271,35 @@ class ExportViewModel(container: AppContainer): ViewModel() { } } -data class PdfGenerationActions( - val startGeneration: () -> Unit, +sealed class ExportResult { + abstract val files: List + abstract val sizeInBytes: Long + abstract val pageCount: Int + abstract val format: ExportFormat + + data class Pdf( + val file: File, + override val sizeInBytes: Long, + override val pageCount: Int, + ) : ExportResult() { + override val files get() = listOf(file) + override val format: ExportFormat = ExportFormat.PDF + } + + data class Jpeg( + val jpegFiles: List, + override val sizeInBytes: Long, + ) : ExportResult() { + override val files get() = jpegFiles + override val pageCount get() = jpegFiles.size + override val format: ExportFormat = ExportFormat.JPEG + } +} + +data class ExportActions( + val initializeExportScreen: () -> Unit, val setFilename: (String) -> Unit, - val uiStateFlow: StateFlow,// TODO is it ok to have that here? - val sharePdf: () -> Unit, - val savePdf: () -> Unit, - val openPdf: () -> Unit, + val share: () -> Unit, + val save: () -> Unit, + val open: (SavedItem) -> Unit, ) diff --git a/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsRepository.kt b/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsRepository.kt index 4d32da1..183f6f4 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsRepository.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsRepository.kt @@ -26,12 +26,22 @@ private val Context.dataStore by preferencesDataStore(name = "fairscan_settings" class SettingsRepository(private val context: Context) { private val EXPORT_DIR_URI = stringPreferencesKey("export_dir_uri") + private val EXPORT_FORMAT = stringPreferencesKey("export_format") val exportDirUri: Flow = context.dataStore.data.map { prefs -> prefs[EXPORT_DIR_URI] } + val exportFormat: Flow = + context.dataStore.data.map { prefs -> + when (prefs[EXPORT_FORMAT]) { + "JPEG" -> ExportFormat.JPEG + "PDF", null -> ExportFormat.PDF + else -> ExportFormat.PDF + } + } + suspend fun setExportDirUri(uri: String?) { context.dataStore.edit { prefs -> if (uri == null) { @@ -41,4 +51,15 @@ class SettingsRepository(private val context: Context) { } } } + + suspend fun setExportFormat(format: ExportFormat) { + context.dataStore.edit { prefs -> + prefs[EXPORT_FORMAT] = format.name + } + } +} + +enum class ExportFormat(val mimeType: String) { + PDF("application/pdf"), + JPEG("image/jpeg"), } diff --git a/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsScreen.kt index be3a04f..385deb0 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsScreen.kt @@ -27,6 +27,8 @@ 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.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Folder import androidx.compose.material3.Card @@ -34,6 +36,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -56,6 +59,7 @@ fun SettingsScreen( uiState: SettingsUiState, onChooseDirectoryClick: () -> Unit, onResetExportDirClick: () -> Unit, + onExportFormatChanged: (ExportFormat) -> Unit, onBack: () -> Unit, ) { BackHandler { onBack() } @@ -67,10 +71,13 @@ fun SettingsScreen( ) } ) { paddingValues -> - SettingsContent(uiState, onChooseDirectoryClick, onResetExportDirClick, modifier = Modifier.padding(paddingValues)) + SettingsContent( + uiState, + onChooseDirectoryClick, + onResetExportDirClick, + onExportFormatChanged, + modifier = Modifier.padding(paddingValues)) } - - } @Composable @@ -78,6 +85,7 @@ private fun SettingsContent( uiState: SettingsUiState, onChooseDirectoryClick: () -> Unit, onResetExportDirClick: () -> Unit, + onExportFormatChanged: (ExportFormat) -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -89,6 +97,7 @@ private fun SettingsContent( modifier .fillMaxSize() .padding(20.dp) + .verticalScroll(rememberScrollState()) ) { DirectorySettingItem( label = stringResource(R.string.export_directory), @@ -106,6 +115,26 @@ private fun SettingsContent( Text(stringResource(R.string.reset_to_default)) } } + + Spacer(Modifier.height(32.dp)) + + Text("Export format", style = MaterialTheme.typography.titleLarge) + + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton( + selected = uiState.exportFormat == ExportFormat.PDF, + onClick = { onExportFormatChanged(ExportFormat.PDF) }, + ) + Text("PDF") + } + + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton( + selected = uiState.exportFormat == ExportFormat.JPEG, + onClick = { onExportFormatChanged(ExportFormat.JPEG) }, + ) + Text("JPEG") + } } } @@ -173,6 +202,12 @@ fun SettingsScreenPreviewWithDir() { @Composable fun SettingsScreenPreview(uiState: SettingsUiState) { FairScanTheme { - SettingsScreen(uiState, onChooseDirectoryClick = {}, onResetExportDirClick = {}, onBack= {}) + SettingsScreen( + uiState, + onChooseDirectoryClick = {}, + onResetExportDirClick = {}, + onExportFormatChanged = {}, + onBack = {} + ) } } diff --git a/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsViewModel.kt b/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsViewModel.kt index a01bae2..a9dbec7 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsViewModel.kt @@ -21,25 +21,37 @@ import kotlinx.coroutines.launch import org.fairscan.app.AppContainer data class SettingsUiState( - val exportDirUri: String? = null + val exportDirUri: String? = null, + val exportFormat: ExportFormat = ExportFormat.PDF, ) class SettingsViewModel(container: AppContainer) : ViewModel() { private val repo = container.settingsRepository - val uiState: StateFlow = - repo.exportDirUri - .map { uri -> SettingsUiState(exportDirUri = uri) } - .stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(5000), - SettingsUiState() - ) + val uiState = combine( + repo.exportDirUri, + repo.exportFormat, + ) { dir, format -> + SettingsUiState( + exportDirUri = dir, + exportFormat = format, + ) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + SettingsUiState() + ) fun setExportDirUri(uri: String?) { viewModelScope.launch { repo.setExportDirUri(uri) } } + + fun setExportFormat(format: ExportFormat) { + viewModelScope.launch { + repo.setExportFormat(format) + } + } } diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index c697d1d..9dbd2b5 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -11,7 +11,7 @@ Kontakt Protokoly zkopírovány do schránky Kopírovat protokoly - Vytváření PDF… + Příprava exportu… Smazat stránku Chcete smazat tuto stránku? Vývojář @@ -19,12 +19,14 @@ stažených Ukončit skenování Chyba: %1$s + Nebyla nalezena žádná aplikace pro otevření tohoto souboru Nebyl rozpoznán žádná dokument - Nebyla nazelena žádná aplikace pro otevření PDF - Chyba při ukládání PDF + Soubor se nepodařilo uložit + Exportovat + Exportovat jako %1$s Složka pro export - Exportovat PDF Velikost souboru: %1$s + Celková velikost: %1$s Název souboru Povolit přístup Poslední PDF uložené v tomto zařízení: @@ -36,8 +38,7 @@ Menu Toto skenování bude ztraceno. Chcete pokračovat? Otevřít - Otevřít PDF - PDF bylo uloženo do %1s + Otevřít soubor Obnovit výchozí Obnovit Uložit @@ -45,8 +46,8 @@ Probíhá skenování Nastavení Sdílet - Sdílet PDF - Nelze uložit PDF: přístup zakázán + Sdílet dokument + Nelze uložit soubor: oprávnění bylo odmítnuto Vypnout svítilnu Zapnout svítilnu Neznámá velikost @@ -54,6 +55,12 @@ Zobrazit úplný seznam Zobrazit úplnou licenci Ano + + %2$s uložen do %3$s + %1$d soubory uloženy do %3$s + %1$d souborů uloženo do %3$s + %1$d souborů uloženo do %3$s + %d stránka %d stránky diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 53107d6..5c5fe6e 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -11,7 +11,7 @@ Kontakt Logs in die Zwischenablage kopiert Logs kopieren - PDF wird erstellt… + Export wird vorbereitet… Seite löschen Möchten Sie diese Seite löschen? Entwickler @@ -19,12 +19,14 @@ Downloads Scan beenden Fehler: %1$s + Keine App zum Öffnen dieser Datei gefunden Kein Dokument erkannt - Keine App zum Öffnen von PDF gefunden - PDF konnte nicht gespeichert werden + Datei konnte nicht gespeichert werden + Exportieren + Als %1$s exportieren Exportordner - PDF exportieren Dateigröße: %1$s + Gesamtgröße: %1$s Dateiname Berechtigung erteilen Zuletzt auf diesem Gerät gespeicherte PDFs: @@ -36,8 +38,7 @@ Menü Das aktuelle Dokument geht verloren. Möchten Sie fortfahren? Öffnen - PDF öffnen - PDF gespeichert in %1s + Datei öffnen Auf Standard zurücksetzen Fortsetzen Speichern @@ -45,8 +46,8 @@ Scan läuft Einstellungen Teilen - PDF teilen - PDF-Datei kann nicht gespeichert werden: Berechtigung verweigert + Dokument teilen + Datei kann nicht gespeichert werden: Berechtigung verweigert Taschenlampe ausschalten Taschenlampe einschalten Unbekannte Größe @@ -54,6 +55,10 @@ Vollständige Liste anzeigen Vollständige Lizenz anzeigen Ja + + %2$s gespeichert in %3$s + %1$d Dateien gespeichert in %3$s + %d Seite %d Seiten diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index ad2a319..4a404d5 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -11,7 +11,7 @@ Contacto Registros copiados al portapapeles Copiar registros - Creando PDF… + Preparando la exportación… Eliminar página ¿Quieres eliminar esta página? Desarrollador @@ -19,12 +19,14 @@ Descargas Finalizar escaneo Error: %1$s + No se encontró ninguna aplicación para abrir este archivo No se detectó ningún documento - No se encontró ninguna aplicación para abrir PDF - Error al guardar el PDF + No se pudo guardar el archivo + Exportar + Exportar como %1$s Carpeta de exportación - Exportar PDF Tamaño del archivo: %1$s + Tamaño total: %1$s Nombre del archivo Conceder permiso PDF recientes guardados en este dispositivo: @@ -36,8 +38,7 @@ Menú El escaneo actual se perderá. ¿Deseas continuar? Abrir - Abrir PDF - PDF guardado en %1s + Abrir archivo Restablecer valores predeterminados Reanudar Guardar @@ -45,8 +46,8 @@ Escaneo en curso Ajustes Compartir - Compartir PDF - No se puede guardar el archivo PDF: permiso denegado + Compartir documento + No se puede guardar el archivo: permiso denegado Apagar linterna Encender linterna Tamaño desconocido @@ -54,6 +55,10 @@ Ver lista completa Ver la licencia completa + + %2$s guardado en %3$s + %1$d archivos guardados en %3$s + %d página %d páginas diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index e250870..dfdf74a 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -11,7 +11,7 @@ Contact Logs copiés dans le presse-papiers Copier les logs - Création du PDF… + Préparation de l’export… Supprimer la page Voulez-vous supprimer cette page ? Développeur @@ -19,12 +19,14 @@ Téléchargements Terminer le scan Erreur : %1$s + Aucune application trouvée pour ouvrir ce fichier Aucun document détecté - Aucune application trouvée pour ouvrir un PDF - Échec de l\'enregistrement du PDF + Échec de l\'enregistrement du fichier + Exporter + Exporter en %1$s Dossier d’export - Exporter en PDF Taille du fichier : %1$s + Taille totale : %1$s Nom de fichier Autoriser Derniers PDF enregistrés sur l’appareil : @@ -36,8 +38,7 @@ Menu Le scan en cours sera perdu. Voulez-vous continuer ? Ouvrir - Ouvrir le PDF - PDF enregistré dans %1s + Ouvrir le fichier Réinitialiser par défaut Reprendre Enregistrer @@ -45,8 +46,8 @@ Scan en cours Paramètres Partager - Partager le PDF - Impossible d’enregistrer le fichier PDF : permission refusée + Partager le document + Impossible d’enregistrer le fichier : permission refusée Éteindre la torche Allumer la torche Taille inconnue @@ -54,6 +55,10 @@ Voir la liste complète Voir la licence complète Oui + + %2$s enregistré dans %3$s + %1$d fichiers enregistrés dans %3$s + %d page %d pages diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index ddf51ff..78245cb 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1,4 +1,4 @@ - + Informazioni Aggiungi pagina Un\'app semplice e rispettosa per scansionare i tuoi documenti. @@ -11,7 +11,7 @@ Contatti Log copiati negli appunti Copia log - Creazione PDF… + Preparazione dell’esportazione… Elimina pagina Vuoi eliminare questa pagina? Sviluppatore @@ -19,12 +19,14 @@ Download Termina scansione Errore: %1$s + Nessuna app trovata per aprire questo file Nessun documento rilevato - Nessuna app trovata per aprire PDF - Salvataggio PDF fallito + Impossibile salvare il file + Esporta + Esporta come %1$s Cartella di esportazione - Esporta PDF - Dimensione file: %1$s + Dimensione del file: %1$s + Dimensione totale: %1$s Nome file Concendi autorizzazione PDF recenti salvati su questo dispositivo: @@ -36,8 +38,7 @@ Menu La scansiona attuale verrà persa. Vuoi continuare? Apri - Apri PDF - PDF salvato in %1s + Apri file Ripristina impostazioni predefinite Riprendi Salva @@ -45,8 +46,8 @@ Scansione in corso Impostazioni Condividi - Condividi PDF - Impossibile salvare il file PDF: autorizzazione negata + Condividi documento + Impossibile salvare il file: permesso negato Spegni la torcia Accendi la torcia Dimensione sconosciuta @@ -54,7 +55,11 @@ Vedi l\'elenco completo Vedi la licenza completa - + + %2$s salvato in %3$s + %1$d file salvati in %3$s + + %d pagina %d pagine diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 0676630..b254313 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -11,7 +11,7 @@ Contato Registros copiados para a área de transferência Copiar registros - Criando PDF… + Preparando exportação… Excluir página Deseja excluir esta página? Desenvolvedor @@ -19,12 +19,14 @@ Downloads Finalizar digitalização Erro: %1$s + Nenhum app encontrado para abrir este arquivo Nenhum documento detectado - Nenhum aplicativo encontrado para abrir PDF - Falha ao salvar PDF + Falha ao salvar o arquivo + Exportar + Exportar como %1$s Diretório de exportação - Exportar PDF Tamanho do arquivo: %1$s + Tamanho total: %1$s Nome do arquivo Conceder permissão PDFs recentes salvos neste dispositivo: @@ -36,8 +38,7 @@ Menu A digitalização atual será perdida. Deseja continuar? Abrir - Abrir PDF - PDF salvo em %1s + Abrir arquivo Restaurar padrão Retomar Salvar @@ -45,8 +46,8 @@ Digitalização em andamento Configurações Compartilhar - Compartilhar PDF - Não foi possível salvar o arquivo PDF: permissão negada + Compartilhar documento + Não foi possível salvar o arquivo: permissão negada Desligar lanterna Ligar lanterna Tamanho desconhecido @@ -54,6 +55,10 @@ Ver lista completa Ver licença completa Sim + + %2$s salvo em %3$s + %1$d arquivos salvos em %3$s + %d página %d páginas diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 43e5835..a059dc6 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -11,7 +11,7 @@ Контакты Журналы скопированы в буфер обмена Копировать журналы - Создание PDF… + Подготовка экспорта… Удалить страницу Вы желаете удалить эту страницу? Разработчик @@ -19,12 +19,14 @@ Download Закончить Ошибка: %1$s + Не найдено приложение для открытия этого файла Документ не обнаружен - Приложения для работы с PDF не обнаружено - Сбой при сохранении PDF + Не удалось сохранить файл + Экспорт + Экспортировать как %1$s Папка экспорта - Экспорт PDF Размер файла: %1$s + Общий размер: %1$s Имя файла Предоставить разрешение Последние PDF, сохранённые на этом устройстве: @@ -36,8 +38,7 @@ Меню Результаты текущего сканирования будут потеряны. Желаете продолжить? Открыть - Открыть PDF - PDF сохранен в %1s + Открыть файл Сбросить по умолчанию Продолжить Сохранить @@ -45,8 +46,8 @@ Сканирование выполняется Настройки Поделиться - Поделиться PDF - Не удается сохранить файл PDF: в разрешении отказано + Поделиться документом + Невозможно сохранить файл: доступ запрещён Выключить фонарик Включить фонарик Неизвестный размер @@ -54,6 +55,12 @@ Просмотреть полный список Просмотреть полную лицензию Да + + %2$s сохранён в %3$s + %1$d файла сохранены в %3$s + %1$d файлов сохранено в %3$s + %1$d файла сохранено в %3$s + %d страница %d страницы diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 72d389a..ba5e026 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -11,7 +11,7 @@ 联系人 日志已复制到剪贴板 复制日志 - 正在创建 PDF… + 正在准备导出… 删除页面 是否要删除此页面? 开发者 @@ -19,12 +19,14 @@ 下载 结束扫描 错误: %1$s + 未找到可打开此文件的应用 未检测到任何文档 - 未找到可打开PDF的应用 - 保存PDF失败 + 无法保存文件 + 导出 + 导出为 %1$s 导出目录 - 导出PDF - 文件大小: %1$s + 文件大小:%1$s + 总大小:%1$s 文件名字 授予权限 最近保存在此设备上的 PDF: @@ -36,8 +38,7 @@ 菜单 当前扫描将丢失。是否继续? 打开 - 打开 PDF - PDF 已保存到 %1$s + 打开文件 恢复默认设置 恢复 保存 @@ -45,8 +46,8 @@ 正在进行扫描 设置 共享 - 共享 PDF - 无法保存PDF文件:权限被拒绝 + 分享文档 + 无法保存文件:权限被拒绝 关闭手电筒 打开手电筒 未知大小 @@ -54,8 +55,10 @@ 查看完整列表 查看完整许可证 + + %1$d 个文件已保存到 %3$s + - %d 页 %d 页 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 649002f..aa2fb4b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -12,7 +12,7 @@ Contact Logs copied to clipboard Copy logs - Creating PDF… + Preparing export… Delete page Do you want to delete this page? Developer @@ -20,12 +20,14 @@ Downloads End scan Error: %1$s + No app found to open this file No document detected - No app found to open PDF - Failed to save PDF + Failed to save file + Export + Export as %1$s Export directory - Export PDF File size: %1$s + Total size: %1$s Filename Grant permission Recent PDFs saved on this device: @@ -37,8 +39,7 @@ Menu The current scan will be lost. Do you want to continue? Open - Open PDF - PDF saved in %1s + Open file Reset to default Resume Save @@ -46,8 +47,8 @@ Scan in progress Settings Share - Share PDF - Cannot save PDF file: permission was denied + Share document + Cannot save file: permission was denied Turn off torch Turn on torch Unknown size @@ -55,6 +56,10 @@ View full list View the full license Yes + + %2$s saved to %3$s + %1$d files saved to %3$s + %d page %d pages diff --git a/app/src/test/java/org/fairscan/app/data/PdfFileManagerTest.kt b/app/src/test/java/org/fairscan/app/data/FileManagerTest.kt similarity index 86% rename from app/src/test/java/org/fairscan/app/data/PdfFileManagerTest.kt rename to app/src/test/java/org/fairscan/app/data/FileManagerTest.kt index 3d4e11c..037b3c5 100644 --- a/app/src/test/java/org/fairscan/app/data/PdfFileManagerTest.kt +++ b/app/src/test/java/org/fairscan/app/data/FileManagerTest.kt @@ -20,7 +20,7 @@ import java.io.File import java.io.OutputStream import kotlin.io.path.createTempDirectory -class PdfFileManagerTest { +class FileManagerTest { val pdfDir: File = createTempDirectory().toFile() val externalDir: File = createTempDirectory().toFile() @@ -33,7 +33,7 @@ class PdfFileManagerTest { val f = File(externalDir, "f.pdf") assertThat(f).doesNotExist() - val manager = PdfFileManager(pdfDir, externalDir, dummyPdfWriter) + val manager = FileManager(pdfDir, externalDir, dummyPdfWriter) assertThat(manager.copyToExternalDir(original)) .isEqualTo(f) .hasContent("original content") @@ -48,7 +48,7 @@ class PdfFileManagerTest { @Test fun cleanUpOldFiles() { val subDir = File(pdfDir,"subDir") - val manager = PdfFileManager(subDir, externalDir, dummyPdfWriter) + val manager = FileManager(subDir, externalDir, dummyPdfWriter) manager.cleanUpOldFiles(10) assertThat(subDir).doesNotExist() @@ -76,7 +76,7 @@ class PdfFileManagerTest { return list.size } } - val manager = PdfFileManager(pdfDir, externalDir, fakePdfWriter) + val manager = FileManager(pdfDir, externalDir, fakePdfWriter) val jpegs = listOf(byteArrayOf(0x01, 0x02), byteArrayOf(0x11)).asSequence() val pdf = manager.generatePdf(jpegs) assertThat(pdf.pageCount).isEqualTo(2) @@ -87,9 +87,9 @@ class PdfFileManagerTest { @Test fun addExtensionIfMissing() { - assertThat(PdfFileManager.addExtensionIfMissing("f1.pdf")).isEqualTo("f1.pdf") - assertThat(PdfFileManager.addExtensionIfMissing("f2.PDF")).isEqualTo("f2.PDF") - assertThat(PdfFileManager.addExtensionIfMissing("f3")).isEqualTo("f3.pdf") - assertThat(PdfFileManager.addExtensionIfMissing("f4.txt")).isEqualTo("f4.txt.pdf") + assertThat(FileManager.addPdfExtensionIfMissing("f1.pdf")).isEqualTo("f1.pdf") + assertThat(FileManager.addPdfExtensionIfMissing("f2.PDF")).isEqualTo("f2.PDF") + assertThat(FileManager.addPdfExtensionIfMissing("f3")).isEqualTo("f3.pdf") + assertThat(FileManager.addPdfExtensionIfMissing("f4.txt")).isEqualTo("f4.txt.pdf") } }