Allow export to JPEG (#68)

* SettingScreen: export format

* Allow export to JPEG

* Adjust messages in UI to work with PDF and JPEG

* Message for file size should depend on number of files

* Fix call to MediaScanner to avoid crash when scanning multiple files

* Fix strange handling of Open button
This commit is contained in:
pynicolas
2025-11-30 16:55:36 +01:00
committed by GitHub
parent 7fbda5339a
commit 4453eb1be0
21 changed files with 508 additions and 299 deletions

View File

@@ -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()
)

View File

@@ -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()
}
}

View File

@@ -28,13 +28,13 @@ fun interface PdfWriter {
fun writePdfFromJpegs(jpegs: Sequence<ByteArray>, 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

View File

@@ -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? {

View File

@@ -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),
)
}
}

View File

@@ -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<String>,
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<String>,
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") },

View File

@@ -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<SavedItem>,
val exportDir: Uri? = null,
val exportDirName: String? = null,
)

View File

@@ -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<ExportEvent>()
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<PdfGenerationUiState> = _pdfUiState.asStateFlow()
private val _uiState = MutableStateFlow(ExportUiState())
val uiState: StateFlow<ExportUiState> = _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<SavedItem>()
val filesForMediaScan = mutableListOf<File>()
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<File>
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<File>,
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<PdfGenerationUiState>,// 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,
)

View File

@@ -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<String?> =
context.dataStore.data.map { prefs ->
prefs[EXPORT_DIR_URI]
}
val exportFormat: Flow<ExportFormat> =
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"),
}

View File

@@ -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 = {}
)
}
}

View File

@@ -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<SettingsUiState> =
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)
}
}
}

View File

@@ -11,7 +11,7 @@
<string name="contact">Kontakt</string>
<string name="copied_logs">Protokoly zkopírovány do schránky</string>
<string name="copy_logs">Kopírovat protokoly</string>
<string name="creating_pdf">Vytváření PDF</string>
<string name="creating_export">Příprava exportu</string>
<string name="delete_page">Smazat stránku</string>
<string name="delete_page_warning">Chcete smazat tuto stránku?</string>
<string name="developer">Vývojář</string>
@@ -19,12 +19,14 @@
<string name="download_dirname">stažených</string>
<string name="end_scan">Ukončit skenování</string>
<string name="error">Chyba: %1$s</string>
<string name="error_no_app">Nebyla nalezena žádná aplikace pro otevření tohoto souboru</string>
<string name="error_no_document">Nebyl rozpoznán žádná dokument</string>
<string name="error_no_pdf_app">Nebyla nazelena žádná aplikace pro otevření PDF</string>
<string name="error_save">Chyba při ukládání PDF</string>
<string name="error_save">Soubor se nepodařilo uložit</string>
<string name="export">Exportovat</string>
<string name="export_as">Exportovat jako %1$s</string>
<string name="export_directory">Složka pro export</string>
<string name="export_pdf">Exportovat PDF</string>
<string name="file_size">Velikost souboru: %1$s</string>
<string name="file_size_total">Celková velikost: %1$s</string>
<string name="filename">Název souboru</string>
<string name="grant_permission">Povolit přístup</string>
<string name="last_saved_pdf_files">Poslední PDF uložené v tomto zařízení:</string>
@@ -36,8 +38,7 @@
<string name="menu">Menu</string>
<string name="new_document_warning">Toto skenování bude ztraceno. Chcete pokračovat?</string>
<string name="open">Otevřít</string>
<string name="open_pdf">Otevřít PDF</string>
<string name="pdf_saved_to">PDF bylo uloženo do %1s</string>
<string name="open_file">Otevřít soubor</string>
<string name="reset_to_default">Obnovit výchozí</string>
<string name="resume">Obnovit</string>
<string name="save">Uložit</string>
@@ -45,8 +46,8 @@
<string name="scan_in_progress">Probíhá skenování</string>
<string name="settings">Nastavení</string>
<string name="share">Sdílet</string>
<string name="share_pdf">Sdílet PDF</string>
<string name="storage_permission_denied">Nelze uložit PDF: přístup zakázán</string>
<string name="share_document">Sdílet dokument</string>
<string name="storage_permission_denied">Nelze uložit soubor: oprávnění bylo odmítnuto</string>
<string name="turn_off_torch">Vypnout svítilnu</string>
<string name="turn_on_torch">Zapnout svítilnu</string>
<string name="unknown_size">Neznámá velikost</string>
@@ -54,6 +55,12 @@
<string name="view_full_list">Zobrazit úplný seznam</string>
<string name="view_the_full_license">Zobrazit úplnou licenci</string>
<string name="yes">Ano</string>
<plurals name="files_saved_to">
<item quantity="one">%2$s uložen do %3$s</item>
<item quantity="few">%1$d soubory uloženy do %3$s</item>
<item quantity="many">%1$d souborů uloženo do %3$s</item>
<item quantity="other">%1$d souborů uloženo do %3$s</item>
</plurals>
<plurals name="page_count">
<item quantity="one">%d stránka</item> <!-- 1 -->
<item quantity="few">%d stránky</item> <!-- 24 -->

View File

@@ -11,7 +11,7 @@
<string name="contact">Kontakt</string>
<string name="copied_logs">Logs in die Zwischenablage kopiert</string>
<string name="copy_logs">Logs kopieren</string>
<string name="creating_pdf">PDF wird erstellt…</string>
<string name="creating_export">Export wird vorbereitet…</string>
<string name="delete_page">Seite löschen</string>
<string name="delete_page_warning">Möchten Sie diese Seite löschen?</string>
<string name="developer">Entwickler</string>
@@ -19,12 +19,14 @@
<string name="download_dirname">Downloads</string>
<string name="end_scan">Scan beenden</string>
<string name="error">Fehler: %1$s</string>
<string name="error_no_app">Keine App zum Öffnen dieser Datei gefunden</string>
<string name="error_no_document">Kein Dokument erkannt</string>
<string name="error_no_pdf_app">Keine App zum Öffnen von PDF gefunden</string>
<string name="error_save">PDF konnte nicht gespeichert werden</string>
<string name="error_save">Datei konnte nicht gespeichert werden</string>
<string name="export">Exportieren</string>
<string name="export_as">Als %1$s exportieren</string>
<string name="export_directory">Exportordner</string>
<string name="export_pdf">PDF exportieren</string>
<string name="file_size">Dateigröße: %1$s</string>
<string name="file_size_total">Gesamtgröße: %1$s</string>
<string name="filename">Dateiname</string>
<string name="grant_permission">Berechtigung erteilen</string>
<string name="last_saved_pdf_files">Zuletzt auf diesem Gerät gespeicherte PDFs:</string>
@@ -36,8 +38,7 @@
<string name="menu">Menü</string>
<string name="new_document_warning">Das aktuelle Dokument geht verloren. Möchten Sie fortfahren?</string>
<string name="open">Öffnen</string>
<string name="open_pdf">PDF öffnen</string>
<string name="pdf_saved_to">PDF gespeichert in %1s</string>
<string name="open_file">Datei öffnen</string>
<string name="reset_to_default">Auf Standard zurücksetzen</string>
<string name="resume">Fortsetzen</string>
<string name="save">Speichern</string>
@@ -45,8 +46,8 @@
<string name="scan_in_progress">Scan läuft</string>
<string name="settings">Einstellungen</string>
<string name="share">Teilen</string>
<string name="share_pdf">PDF teilen</string>
<string name="storage_permission_denied">PDF-Datei kann nicht gespeichert werden: Berechtigung verweigert</string>
<string name="share_document">Dokument teilen</string>
<string name="storage_permission_denied">Datei kann nicht gespeichert werden: Berechtigung verweigert</string>
<string name="turn_off_torch">Taschenlampe ausschalten</string>
<string name="turn_on_torch">Taschenlampe einschalten</string>
<string name="unknown_size">Unbekannte Größe</string>
@@ -54,6 +55,10 @@
<string name="view_full_list">Vollständige Liste anzeigen</string>
<string name="view_the_full_license">Vollständige Lizenz anzeigen</string>
<string name="yes">Ja</string>
<plurals name="files_saved_to">
<item quantity="one">%2$s gespeichert in %3$s</item>
<item quantity="other">%1$d Dateien gespeichert in %3$s</item>
</plurals>
<plurals name="page_count">
<item quantity="one">%d Seite</item>
<item quantity="other">%d Seiten</item>

View File

@@ -11,7 +11,7 @@
<string name="contact">Contacto</string>
<string name="copied_logs">Registros copiados al portapapeles</string>
<string name="copy_logs">Copiar registros</string>
<string name="creating_pdf">Creando PDF</string>
<string name="creating_export">Preparando la exportación</string>
<string name="delete_page">Eliminar página</string>
<string name="delete_page_warning">¿Quieres eliminar esta página?</string>
<string name="developer">Desarrollador</string>
@@ -19,12 +19,14 @@
<string name="download_dirname">Descargas</string>
<string name="end_scan">Finalizar escaneo</string>
<string name="error">Error: %1$s</string>
<string name="error_no_app">No se encontró ninguna aplicación para abrir este archivo</string>
<string name="error_no_document">No se detectó ningún documento</string>
<string name="error_no_pdf_app">No se encontró ninguna aplicación para abrir PDF</string>
<string name="error_save">Error al guardar el PDF</string>
<string name="error_save">No se pudo guardar el archivo</string>
<string name="export">Exportar</string>
<string name="export_as">Exportar como %1$s</string>
<string name="export_directory">Carpeta de exportación</string>
<string name="export_pdf">Exportar PDF</string>
<string name="file_size">Tamaño del archivo: %1$s</string>
<string name="file_size_total">Tamaño total: %1$s</string>
<string name="filename">Nombre del archivo</string>
<string name="grant_permission">Conceder permiso</string>
<string name="last_saved_pdf_files">PDF recientes guardados en este dispositivo:</string>
@@ -36,8 +38,7 @@
<string name="menu">Menú</string>
<string name="new_document_warning">El escaneo actual se perderá. ¿Deseas continuar?</string>
<string name="open">Abrir</string>
<string name="open_pdf">Abrir PDF</string>
<string name="pdf_saved_to">PDF guardado en %1s</string>
<string name="open_file">Abrir archivo</string>
<string name="reset_to_default">Restablecer valores predeterminados</string>
<string name="resume">Reanudar</string>
<string name="save">Guardar</string>
@@ -45,8 +46,8 @@
<string name="scan_in_progress">Escaneo en curso</string>
<string name="settings">Ajustes</string>
<string name="share">Compartir</string>
<string name="share_pdf">Compartir PDF</string>
<string name="storage_permission_denied">No se puede guardar el archivo PDF: permiso denegado</string>
<string name="share_document">Compartir documento</string>
<string name="storage_permission_denied">No se puede guardar el archivo: permiso denegado</string>
<string name="turn_off_torch">Apagar linterna</string>
<string name="turn_on_torch">Encender linterna</string>
<string name="unknown_size">Tamaño desconocido</string>
@@ -54,6 +55,10 @@
<string name="view_full_list">Ver lista completa</string>
<string name="view_the_full_license">Ver la licencia completa</string>
<string name="yes"></string>
<plurals name="files_saved_to" tools:ignore="MissingQuantity">
<item quantity="one">%2$s guardado en %3$s</item>
<item quantity="other">%1$d archivos guardados en %3$s</item>
</plurals>
<plurals name="page_count" tools:ignore="MissingQuantity">
<item quantity="one">%d página</item>
<item quantity="other">%d páginas</item>

View File

@@ -11,7 +11,7 @@
<string name="contact">Contact</string>
<string name="copied_logs">Logs copiés dans le presse-papiers</string>
<string name="copy_logs">Copier les logs</string>
<string name="creating_pdf">Création du PDF</string>
<string name="creating_export">Pparation de lexport</string>
<string name="delete_page">Supprimer la page</string>
<string name="delete_page_warning">Voulez-vous supprimer cette page ?</string>
<string name="developer">Développeur</string>
@@ -19,12 +19,14 @@
<string name="download_dirname">Téléchargements</string>
<string name="end_scan">Terminer le scan</string>
<string name="error">Erreur : %1$s</string>
<string name="error_no_app">Aucune application trouvée pour ouvrir ce fichier</string>
<string name="error_no_document">Aucun document détecté</string>
<string name="error_no_pdf_app">Aucune application trouvée pour ouvrir un PDF</string>
<string name="error_save">Échec de l\'enregistrement du PDF</string>
<string name="error_save">Échec de l\'enregistrement du fichier</string>
<string name="export">Exporter</string>
<string name="export_as">Exporter en %1$s</string>
<string name="export_directory">Dossier dexport</string>
<string name="export_pdf">Exporter en PDF</string>
<string name="file_size">Taille du fichier : %1$s</string>
<string name="file_size_total">Taille totale : %1$s</string>
<string name="filename">Nom de fichier</string>
<string name="grant_permission">Autoriser</string>
<string name="last_saved_pdf_files">Derniers PDF enregistrés sur lappareil :</string>
@@ -36,8 +38,7 @@
<string name="menu">Menu</string>
<string name="new_document_warning">Le scan en cours sera perdu. Voulez-vous continuer ?</string>
<string name="open">Ouvrir</string>
<string name="open_pdf">Ouvrir le PDF</string>
<string name="pdf_saved_to">PDF enregistré dans %1s</string>
<string name="open_file">Ouvrir le fichier</string>
<string name="reset_to_default">Réinitialiser par défaut</string>
<string name="resume">Reprendre</string>
<string name="save">Enregistrer</string>
@@ -45,8 +46,8 @@
<string name="scan_in_progress">Scan en cours</string>
<string name="settings">Paramètres</string>
<string name="share">Partager</string>
<string name="share_pdf">Partager le PDF</string>
<string name="storage_permission_denied">Impossible denregistrer le fichier PDF : permission refusée</string>
<string name="share_document">Partager le document</string>
<string name="storage_permission_denied">Impossible denregistrer le fichier : permission refusée</string>
<string name="turn_off_torch">Éteindre la torche</string>
<string name="turn_on_torch">Allumer la torche</string>
<string name="unknown_size">Taille inconnue</string>
@@ -54,6 +55,10 @@
<string name="view_full_list">Voir la liste complète</string>
<string name="view_the_full_license">Voir la licence complète</string>
<string name="yes">Oui</string>
<plurals name="files_saved_to" tools:ignore="MissingQuantity">
<item quantity="one">%2$s enregistré dans %3$s</item>
<item quantity="other">%1$d fichiers enregistrés dans %3$s</item>
</plurals>
<plurals name="page_count" tools:ignore="MissingQuantity">
<item quantity="one">%d page</item>
<item quantity="other">%d pages</item>

View File

@@ -1,4 +1,4 @@
<resources>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="about">Informazioni</string>
<string name="add_page">Aggiungi pagina</string>
<string name="app_tagline">Un\'app semplice e rispettosa per scansionare i tuoi documenti.</string>
@@ -11,7 +11,7 @@
<string name="contact">Contatti</string>
<string name="copied_logs">Log copiati negli appunti</string>
<string name="copy_logs">Copia log</string>
<string name="creating_pdf">Creazione PDF</string>
<string name="creating_export">Preparazione dellesportazione</string>
<string name="delete_page">Elimina pagina</string>
<string name="delete_page_warning">Vuoi eliminare questa pagina?</string>
<string name="developer">Sviluppatore</string>
@@ -19,12 +19,14 @@
<string name="download_dirname">Download</string>
<string name="end_scan">Termina scansione</string>
<string name="error">Errore: %1$s</string>
<string name="error_no_app">Nessuna app trovata per aprire questo file</string>
<string name="error_no_document">Nessun documento rilevato</string>
<string name="error_no_pdf_app">Nessuna app trovata per aprire PDF</string>
<string name="error_save">Salvataggio PDF fallito</string>
<string name="error_save">Impossibile salvare il file</string>
<string name="export">Esporta</string>
<string name="export_as">Esporta come %1$s</string>
<string name="export_directory">Cartella di esportazione</string>
<string name="export_pdf">Esporta PDF</string>
<string name="file_size">Dimensione file: %1$s</string>
<string name="file_size">Dimensione del file: %1$s</string>
<string name="file_size_total">Dimensione totale: %1$s</string>
<string name="filename">Nome file</string>
<string name="grant_permission">Concendi autorizzazione</string>
<string name="last_saved_pdf_files">PDF recenti salvati su questo dispositivo:</string>
@@ -36,8 +38,7 @@
<string name="menu">Menu</string>
<string name="new_document_warning">La scansiona attuale verrà persa. Vuoi continuare?</string>
<string name="open">Apri</string>
<string name="open_pdf">Apri PDF</string>
<string name="pdf_saved_to">PDF salvato in %1s</string>
<string name="open_file">Apri file</string>
<string name="reset_to_default">Ripristina impostazioni predefinite</string>
<string name="resume">Riprendi</string>
<string name="save">Salva</string>
@@ -45,8 +46,8 @@
<string name="scan_in_progress">Scansione in corso</string>
<string name="settings">Impostazioni</string>
<string name="share">Condividi</string>
<string name="share_pdf">Condividi PDF</string>
<string name="storage_permission_denied">Impossibile salvare il file PDF: autorizzazione negata</string>
<string name="share_document">Condividi documento</string>
<string name="storage_permission_denied">Impossibile salvare il file: permesso negato</string>
<string name="turn_off_torch">Spegni la torcia</string>
<string name="turn_on_torch">Accendi la torcia</string>
<string name="unknown_size">Dimensione sconosciuta</string>
@@ -54,7 +55,11 @@
<string name="view_full_list">Vedi l\'elenco completo</string>
<string name="view_the_full_license">Vedi la licenza completa</string>
<string name="yes"></string>
<plurals name="page_count">
<plurals name="files_saved_to" tools:ignore="MissingQuantity">
<item quantity="one">%2$s salvato in %3$s</item>
<item quantity="other">%1$d file salvati in %3$s</item>
</plurals>
<plurals name="page_count" tools:ignore="MissingQuantity">
<item quantity="one">%d pagina</item>
<item quantity="other">%d pagine</item>
</plurals>

View File

@@ -11,7 +11,7 @@
<string name="contact">Contato</string>
<string name="copied_logs">Registros copiados para a área de transferência</string>
<string name="copy_logs">Copiar registros</string>
<string name="creating_pdf">Criando PDF</string>
<string name="creating_export">Preparando exportação</string>
<string name="delete_page">Excluir página</string>
<string name="delete_page_warning">Deseja excluir esta página?</string>
<string name="developer">Desenvolvedor</string>
@@ -19,12 +19,14 @@
<string name="download_dirname">Downloads</string>
<string name="end_scan">Finalizar digitalização</string>
<string name="error">Erro: %1$s</string>
<string name="error_no_app">Nenhum app encontrado para abrir este arquivo</string>
<string name="error_no_document">Nenhum documento detectado</string>
<string name="error_no_pdf_app">Nenhum aplicativo encontrado para abrir PDF</string>
<string name="error_save">Falha ao salvar PDF</string>
<string name="error_save">Falha ao salvar o arquivo</string>
<string name="export">Exportar</string>
<string name="export_as">Exportar como %1$s</string>
<string name="export_directory">Diretório de exportação</string>
<string name="export_pdf">Exportar PDF</string>
<string name="file_size">Tamanho do arquivo: %1$s</string>
<string name="file_size_total">Tamanho total: %1$s</string>
<string name="filename">Nome do arquivo</string>
<string name="grant_permission">Conceder permissão</string>
<string name="last_saved_pdf_files">PDFs recentes salvos neste dispositivo:</string>
@@ -36,8 +38,7 @@
<string name="menu">Menu</string>
<string name="new_document_warning">A digitalização atual será perdida. Deseja continuar?</string>
<string name="open">Abrir</string>
<string name="open_pdf">Abrir PDF</string>
<string name="pdf_saved_to">PDF salvo em %1s</string>
<string name="open_file">Abrir arquivo</string>
<string name="reset_to_default">Restaurar padrão</string>
<string name="resume">Retomar</string>
<string name="save">Salvar</string>
@@ -45,8 +46,8 @@
<string name="scan_in_progress">Digitalização em andamento</string>
<string name="settings">Configurações</string>
<string name="share">Compartilhar</string>
<string name="share_pdf">Compartilhar PDF</string>
<string name="storage_permission_denied">Não foi possível salvar o arquivo PDF: permissão negada</string>
<string name="share_document">Compartilhar documento</string>
<string name="storage_permission_denied">Não foi possível salvar o arquivo: permissão negada</string>
<string name="turn_off_torch">Desligar lanterna</string>
<string name="turn_on_torch">Ligar lanterna</string>
<string name="unknown_size">Tamanho desconhecido</string>
@@ -54,6 +55,10 @@
<string name="view_full_list">Ver lista completa</string>
<string name="view_the_full_license">Ver licença completa</string>
<string name="yes">Sim</string>
<plurals name="files_saved_to" tools:ignore="MissingQuantity">
<item quantity="one">%2$s salvo em %3$s</item>
<item quantity="other">%1$d arquivos salvos em %3$s</item>
</plurals>
<plurals name="page_count" tools:ignore="MissingQuantity">
<item quantity="one">%d página</item>
<item quantity="other">%d páginas</item>

View File

@@ -11,7 +11,7 @@
<string name="contact">Контакты</string>
<string name="copied_logs">Журналы скопированы в буфер обмена</string>
<string name="copy_logs">Копировать журналы</string>
<string name="creating_pdf">Создание PDF</string>
<string name="creating_export">Подготовка экспорта</string>
<string name="delete_page">Удалить страницу</string>
<string name="delete_page_warning">Вы желаете удалить эту страницу?</string>
<string name="developer">Разработчик</string>
@@ -19,12 +19,14 @@
<string name="download_dirname">Download</string>
<string name="end_scan">Закончить</string>
<string name="error">Ошибка: %1$s</string>
<string name="error_no_app">Не найдено приложение для открытия этого файла</string>
<string name="error_no_document">Документ не обнаружен</string>
<string name="error_no_pdf_app">Приложения для работы с PDF не обнаружено</string>
<string name="error_save">Сбой при сохранении PDF</string>
<string name="error_save">Не удалось сохранить файл</string>
<string name="export">Экспорт</string>
<string name="export_as">Экспортировать как %1$s</string>
<string name="export_directory">Папка экспорта</string>
<string name="export_pdf">Экспорт PDF</string>
<string name="file_size">Размер файла: %1$s</string>
<string name="file_size_total">Общий размер: %1$s</string>
<string name="filename">Имя файла</string>
<string name="grant_permission">Предоставить разрешение</string>
<string name="last_saved_pdf_files">Последние PDF, сохранённые на этом устройстве:</string>
@@ -36,8 +38,7 @@
<string name="menu">Меню</string>
<string name="new_document_warning">Результаты текущего сканирования будут потеряны. Желаете продолжить?</string>
<string name="open">Открыть</string>
<string name="open_pdf">Открыть PDF</string>
<string name="pdf_saved_to">PDF сохранен в %1s</string>
<string name="open_file">Открыть файл</string>
<string name="reset_to_default">Сбросить по умолчанию</string>
<string name="resume">Продолжить</string>
<string name="save">Сохранить</string>
@@ -45,8 +46,8 @@
<string name="scan_in_progress">Сканирование выполняется</string>
<string name="settings">Настройки</string>
<string name="share">Поделиться</string>
<string name="share_pdf">Поделиться PDF</string>
<string name="storage_permission_denied">Не удается сохранить файл PDF: в разрешении отказано</string>
<string name="share_document">Поделиться документом</string>
<string name="storage_permission_denied">Невозможно сохранить файл: доступ запрещён</string>
<string name="turn_off_torch">Выключить фонарик</string>
<string name="turn_on_torch">Включить фонарик</string>
<string name="unknown_size">Неизвестный размер</string>
@@ -54,6 +55,12 @@
<string name="view_full_list">Просмотреть полный список</string>
<string name="view_the_full_license">Просмотреть полную лицензию</string>
<string name="yes">Да</string>
<plurals name="files_saved_to">
<item quantity="one">%2$s сохранён в %3$s</item>
<item quantity="few">%1$d файла сохранены в %3$s</item>
<item quantity="many">%1$d файлов сохранено в %3$s</item>
<item quantity="other">%1$d файла сохранено в %3$s</item>
</plurals>
<plurals name="page_count">
<item quantity="one">%d страница</item>
<item quantity="few">%d страницы</item>

View File

@@ -11,7 +11,7 @@
<string name="contact">联系人</string>
<string name="copied_logs">日志已复制到剪贴板</string>
<string name="copy_logs">复制日志</string>
<string name="creating_pdf">正在创建 PDF</string>
<string name="creating_export">正在准备导出</string>
<string name="delete_page">删除页面</string>
<string name="delete_page_warning">是否要删除此页面?</string>
<string name="developer">开发者</string>
@@ -19,12 +19,14 @@
<string name="download_dirname">下载</string>
<string name="end_scan">结束扫描</string>
<string name="error">错误: %1$s</string>
<string name="error_no_app">未找到可打开此文件的应用</string>
<string name="error_no_document">未检测到任何文档</string>
<string name="error_no_pdf_app">未找到可打开PDF的应用</string>
<string name="error_save">保存PDF失败</string>
<string name="error_save">无法保存文件</string>
<string name="export">导出</string>
<string name="export_as">导出为 %1$s</string>
<string name="export_directory">导出目录</string>
<string name="export_pdf">导出PDF</string>
<string name="file_size">文件大小: %1$s</string>
<string name="file_size">文件大小:%1$s</string>
<string name="file_size_total">总大小:%1$s</string>
<string name="filename">文件名字</string>
<string name="grant_permission">授予权限</string>
<string name="last_saved_pdf_files">最近保存在此设备上的 PDF</string>
@@ -36,8 +38,7 @@
<string name="menu">菜单</string>
<string name="new_document_warning">当前扫描将丢失。是否继续?</string>
<string name="open">打开</string>
<string name="open_pdf">打开 PDF</string>
<string name="pdf_saved_to">PDF 已保存到 %1$s</string>
<string name="open_file">打开文件</string>
<string name="reset_to_default">恢复默认设置</string>
<string name="resume">恢复</string>
<string name="save">保存</string>
@@ -45,8 +46,8 @@
<string name="scan_in_progress">正在进行扫描</string>
<string name="settings">设置</string>
<string name="share">共享</string>
<string name="share_pdf">共享 PDF</string>
<string name="storage_permission_denied">无法保存PDF文件:权限被拒绝</string>
<string name="share_document">分享文档</string>
<string name="storage_permission_denied">无法保存文件:权限被拒绝</string>
<string name="turn_off_torch">关闭手电筒</string>
<string name="turn_on_torch">打开手电筒</string>
<string name="unknown_size">未知大小</string>
@@ -54,8 +55,10 @@
<string name="view_full_list">查看完整列表</string>
<string name="view_the_full_license">查看完整许可证</string>
<string name="yes"></string>
<plurals name="files_saved_to">
<item quantity="other">%1$d 个文件已保存到 %3$s</item>
</plurals>
<plurals name="page_count">
<item quantity="one">%d 页</item>
<item quantity="other">%d 页</item>
</plurals>
</resources>

View File

@@ -12,7 +12,7 @@
<string name="contact">Contact</string>
<string name="copied_logs">Logs copied to clipboard</string>
<string name="copy_logs">Copy logs</string>
<string name="creating_pdf">Creating PDF</string>
<string name="creating_export">Preparing export</string>
<string name="delete_page">Delete page</string>
<string name="delete_page_warning">Do you want to delete this page?</string>
<string name="developer">Developer</string>
@@ -20,12 +20,14 @@
<string name="download_dirname">Downloads</string>
<string name="end_scan">End scan</string>
<string name="error">Error: %1$s</string>
<string name="error_no_app">No app found to open this file</string>
<string name="error_no_document">No document detected</string>
<string name="error_no_pdf_app">No app found to open PDF</string>
<string name="error_save">Failed to save PDF</string>
<string name="error_save">Failed to save file</string>
<string name="export">Export</string>
<string name="export_as">Export as %1$s</string>
<string name="export_directory">Export directory</string>
<string name="export_pdf">Export PDF</string>
<string name="file_size">File size: %1$s</string>
<string name="file_size_total">Total size: %1$s</string>
<string name="filename">Filename</string>
<string name="grant_permission">Grant permission</string>
<string name="last_saved_pdf_files">Recent PDFs saved on this device:</string>
@@ -37,8 +39,7 @@
<string name="menu">Menu</string>
<string name="new_document_warning">The current scan will be lost. Do you want to continue?</string>
<string name="open">Open</string>
<string name="open_pdf">Open PDF</string>
<string name="pdf_saved_to">PDF saved in %1s</string>
<string name="open_file">Open file</string>
<string name="reset_to_default">Reset to default</string>
<string name="resume">Resume</string>
<string name="save">Save</string>
@@ -46,8 +47,8 @@
<string name="scan_in_progress">Scan in progress</string>
<string name="settings">Settings</string>
<string name="share">Share</string>
<string name="share_pdf">Share PDF</string>
<string name="storage_permission_denied">Cannot save PDF file: permission was denied</string>
<string name="share_document">Share document</string>
<string name="storage_permission_denied">Cannot save file: permission was denied</string>
<string name="turn_off_torch">Turn off torch</string>
<string name="turn_on_torch">Turn on torch</string>
<string name="unknown_size">Unknown size</string>
@@ -55,6 +56,10 @@
<string name="view_full_list">View full list</string>
<string name="view_the_full_license">View the full license</string>
<string name="yes">Yes</string>
<plurals name="files_saved_to">
<item quantity="one">%2$s saved to %3$s</item>
<item quantity="other">%1$d files saved to %3$s</item>
</plurals>
<plurals name="page_count">
<item quantity="one">%d page</item>
<item quantity="other">%d pages</item>