Allow other apps to call FairScan to scan a document to PDF (#81)

* Intent for external calls

* External calls: start on camera screen

* Isolate captured images for each scan session

* Remove access to settings when called externally
This commit is contained in:
pynicolas
2025-12-22 10:03:38 +01:00
committed by GitHub
parent 8d87a8a430
commit b5bf93b7ec
15 changed files with 254 additions and 60 deletions

View File

@@ -62,6 +62,29 @@ FairScan works on any device that:
--- ---
## Experimental: Scan to PDF via intent
FairScan can be invoked by other Android applications to perform a document scan and return a generated PDF.
This feature is **experimental** and intended for developers who want to rely on FairScan as a
simple, privacy-respecting scanning tool.
The intent contract and behavior may change between versions, and backward compatibility
is not guaranteed at this stage.
Intent action: `org.fairscan.app.action.SCAN_TO_PDF`
This is an **implicit intent** that launches FairScan in a dedicated external mode.
When started via this intent:
- FairScan opens directly in scan mode
- the user scans one or more pages
- FairScan generates a single PDF
- the resulting PDF is returned to the calling application as a URI with a limited lifetime
- the calling application should immediately copy the content of the URI as FairScan deletes it later
---
## Technical details ## Technical details
FairScan uses: FairScan uses:

View File

@@ -27,9 +27,12 @@
android:theme="@style/Theme.FairScan"> android:theme="@style/Theme.FairScan">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="org.fairscan.app.action.SCAN_TO_PDF" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity> </activity>
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"

View File

@@ -21,16 +21,13 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.CreationExtras import androidx.lifecycle.viewmodel.CreationExtras
import org.fairscan.app.data.FileLogger import org.fairscan.app.data.FileLogger
import org.fairscan.app.data.ImageRepository
import org.fairscan.app.data.LogRepository
import org.fairscan.app.data.FileManager import org.fairscan.app.data.FileManager
import org.fairscan.app.data.LogRepository
import org.fairscan.app.data.recentDocumentsDataStore import org.fairscan.app.data.recentDocumentsDataStore
import org.fairscan.app.domain.ImageSegmentationService import org.fairscan.app.domain.ImageSegmentationService
import org.fairscan.app.platform.AndroidPdfWriter import org.fairscan.app.platform.AndroidPdfWriter
import org.fairscan.app.platform.OpenCvTransformations
import org.fairscan.app.ui.screens.about.AboutViewModel import org.fairscan.app.ui.screens.about.AboutViewModel
import org.fairscan.app.ui.screens.camera.CameraViewModel import org.fairscan.app.ui.screens.camera.CameraViewModel
import org.fairscan.app.ui.screens.export.ExportViewModel
import org.fairscan.app.ui.screens.home.HomeViewModel import org.fairscan.app.ui.screens.home.HomeViewModel
import org.fairscan.app.ui.screens.settings.SettingsRepository import org.fairscan.app.ui.screens.settings.SettingsRepository
import org.fairscan.app.ui.screens.settings.SettingsViewModel import org.fairscan.app.ui.screens.settings.SettingsViewModel
@@ -42,15 +39,14 @@ class FairScanApp : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
appContainer = AppContainer(this) appContainer = AppContainer(this)
appContainer.cleanOrphanSessions()
} }
} }
const val THUMBNAIL_SIZE_DP = 120 const val THUMBNAIL_SIZE_DP = 120
class AppContainer(context: Context) { class AppContainer(context: Context) {
private val density = context.resources.displayMetrics.density private val cacheDir = context.cacheDir
private val thumbnailSizePx = (THUMBNAIL_SIZE_DP * density).toInt()
val imageRepository = ImageRepository(context.filesDir, OpenCvTransformations(), thumbnailSizePx)
val preparationDir = File(context.cacheDir, "pdfs") val preparationDir = File(context.cacheDir, "pdfs")
val fileManager = FileManager( val fileManager = FileManager(
preparationDir, preparationDir,
@@ -72,10 +68,30 @@ class AppContainer(context: Context) {
} }
} }
val mainViewModelFactory = viewModelFactory { MainViewModel(it) }
val homeViewModelFactory = viewModelFactory { HomeViewModel(it, context) } val homeViewModelFactory = viewModelFactory { HomeViewModel(it, context) }
val cameraViewModelFactory = viewModelFactory { CameraViewModel(it) } val cameraViewModelFactory = viewModelFactory { CameraViewModel(it) }
val exportViewModelFactory = viewModelFactory { ExportViewModel(it) }
val aboutViewModelFactory = viewModelFactory { AboutViewModel(it) } val aboutViewModelFactory = viewModelFactory { AboutViewModel(it) }
val settingsViewModelFactory = viewModelFactory { SettingsViewModel(it) } val settingsViewModelFactory = viewModelFactory { SettingsViewModel(it) }
fun cleanOrphanSessions() {
val sessionsRoot = sessionsRoot()
if (!sessionsRoot.exists()) return
val now = System.currentTimeMillis()
sessionsRoot.listFiles()
?.filter { it.isDirectory }
?.forEach { dir ->
if (isOldSession(dir, now)) {
dir.deleteRecursively()
}
}
}
fun sessionsRoot(): File = File(cacheDir, "sessions")
private fun isOldSession(dir: File, now: Long): Boolean {
val lastModified = dir.lastModified()
return now - lastModified > 24 * 60 * 60 * 1000 // 24h
}
} }

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2025 Pierre-Yves Nicolas
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app
enum class LaunchMode {
NORMAL,
EXTERNAL_SCAN_TO_PDF
}

View File

@@ -72,17 +72,36 @@ import org.fairscan.app.ui.screens.settings.SettingsScreen
import org.fairscan.app.ui.screens.settings.SettingsViewModel import org.fairscan.app.ui.screens.settings.SettingsViewModel
import org.fairscan.app.ui.theme.FairScanTheme import org.fairscan.app.ui.theme.FairScanTheme
import org.opencv.android.OpenCVLoader import org.opencv.android.OpenCVLoader
import java.io.File
import java.util.UUID
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private lateinit var sessionDir: File
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
initLibraries() initLibraries()
val appContainer = (application as FairScanApp).appContainer val appContainer = (application as FairScanApp).appContainer
val viewModel: MainViewModel by viewModels { appContainer.mainViewModelFactory } val launchMode = resolveLaunchMode(intent)
sessionDir = when (launchMode) {
LaunchMode.NORMAL -> filesDir
LaunchMode.EXTERNAL_SCAN_TO_PDF ->
File(appContainer.sessionsRoot(), UUID.randomUUID().toString()).apply { mkdirs() }
}
val sessionContainer = ScanSessionContainer(this, sessionDir)
val viewModel: MainViewModel by viewModels {
appContainer.viewModelFactory {
MainViewModel(sessionContainer.imageRepository, launchMode)
}
}
val exportViewModel: ExportViewModel by viewModels {
appContainer.viewModelFactory {
ExportViewModel(appContainer, sessionContainer.imageRepository)
}
}
val homeViewModel: HomeViewModel by viewModels { appContainer.homeViewModelFactory } val homeViewModel: HomeViewModel by viewModels { appContainer.homeViewModelFactory }
val cameraViewModel: CameraViewModel by viewModels { appContainer.cameraViewModelFactory } val cameraViewModel: CameraViewModel by viewModels { appContainer.cameraViewModelFactory }
val exportViewModel: ExportViewModel by viewModels { appContainer.exportViewModelFactory }
val aboutViewModel: AboutViewModel by viewModels { appContainer.aboutViewModelFactory } val aboutViewModel: AboutViewModel by viewModels { appContainer.aboutViewModelFactory }
val settingsViewModel: SettingsViewModel val settingsViewModel: SettingsViewModel
by viewModels { appContainer.settingsViewModelFactory } by viewModels { appContainer.settingsViewModelFactory }
@@ -102,7 +121,7 @@ class MainActivity : ComponentActivity() {
CollectAboutEvents(context, aboutViewModel) CollectAboutEvents(context, aboutViewModel)
FairScanTheme { FairScanTheme {
val navigation = navigation(viewModel) val navigation = navigation(viewModel, launchMode)
when (val screen = currentScreen) { when (val screen = currentScreen) {
is Screen.Main.Home -> { is Screen.Main.Home -> {
val recentDocs by homeViewModel.recentDocuments.collectAsStateWithLifecycle() val recentDocs by homeViewModel.recentDocuments.collectAsStateWithLifecycle()
@@ -131,6 +150,19 @@ class MainActivity : ComponentActivity() {
document = document, document = document,
initialPage = screen.initialPage, initialPage = screen.initialPage,
navigation = navigation, navigation = navigation,
onExportClick = if (launchMode == LaunchMode.EXTERNAL_SCAN_TO_PDF) {
{
lifecycleScope.launch {
val result = exportViewModel.generatePdfForExternalCall()
sendActivityResult(result)
viewModel.startNewDocument()
finish()
}
Unit
}
} else {
navigation.toExportScreen
},
onDeleteImage = { id -> viewModel.deletePage(id) }, onDeleteImage = { id -> viewModel.deletePage(id) },
onRotateImage = { id, clockwise -> viewModel.rotateImage(id, clockwise) }, onRotateImage = { id, clockwise -> viewModel.rotateImage(id, clockwise) },
onPageReorder = { id, newIndex -> viewModel.movePage(id, newIndex) }, onPageReorder = { id, newIndex -> viewModel.movePage(id, newIndex) },
@@ -145,12 +177,12 @@ class MainActivity : ComponentActivity() {
setFilename = exportViewModel::setFilename, setFilename = exportViewModel::setFilename,
share = { share(exportViewModel.applyRenaming(), exportViewModel) }, share = { share(exportViewModel.applyRenaming(), exportViewModel) },
save = { exportViewModel.onSaveClicked() }, save = { exportViewModel.onSaveClicked() },
open = { item -> openUri(item.uri, item.format.mimeType) } open = { item -> openUri(item.uri, item.format.mimeType) },
), ),
onCloseScan = { onCloseScan = {
viewModel.startNewDocument() viewModel.startNewDocument()
viewModel.navigateTo(Screen.Main.Home) viewModel.navigateTo(Screen.Main.Home)
}, }
) )
} }
is Screen.Overlay.About -> { is Screen.Overlay.About -> {
@@ -170,6 +202,20 @@ class MainActivity : ComponentActivity() {
} }
} }
override fun onDestroy() {
super.onDestroy()
if (resolveLaunchMode(intent) == LaunchMode.EXTERNAL_SCAN_TO_PDF) {
sessionDir.deleteRecursively()
}
}
private fun resolveLaunchMode(intent: Intent?): LaunchMode {
return when (intent?.action) {
"org.fairscan.app.action.SCAN_TO_PDF" -> LaunchMode.EXTERNAL_SCAN_TO_PDF
else -> LaunchMode.NORMAL
}
}
@Composable @Composable
private fun SettingsScreenWrapper(settingsViewModel: SettingsViewModel, nav: Navigation) { private fun SettingsScreenWrapper(settingsViewModel: SettingsViewModel, nav: Navigation) {
val launcher = rememberLauncherForActivityResult( val launcher = rememberLauncherForActivityResult(
@@ -265,10 +311,7 @@ class MainActivity : ComponentActivity() {
viewModel.setAsShared() viewModel.setAsShared()
val authority = "${applicationContext.packageName}.fileprovider" val uris = result.files.map(::uriForFile)
val uris = result.files.map { file ->
FileProvider.getUriForFile(this, authority, file)
}
val intent = Intent().apply { val intent = Intent().apply {
action = if (uris.size == 1) Intent.ACTION_SEND else Intent.ACTION_SEND_MULTIPLE action = if (uris.size == 1) Intent.ACTION_SEND else Intent.ACTION_SEND_MULTIPLE
type = result.format.mimeType type = result.format.mimeType
@@ -293,6 +336,24 @@ class MainActivity : ComponentActivity() {
startActivity(chooser) startActivity(chooser)
} }
private fun sendActivityResult(result: ExportResult?) {
val pdf = result as? ExportResult.Pdf ?: return
val uri = uriForFile(pdf.file)
val resultIntent = Intent().apply {
data = uri
clipData = ClipData.newRawUri(null, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
setResult(RESULT_OK, resultIntent)
}
private fun uriForFile(file: File): Uri {
val authority = "${applicationContext.packageName}.fileprovider"
return FileProvider.getUriForFile(this, authority, file)
}
private fun checkPermissionThen( private fun checkPermissionThen(
requestPermissionLauncher: ManagedActivityResultLauncher<String, Boolean>, requestPermissionLauncher: ManagedActivityResultLauncher<String, Boolean>,
action: () -> Unit action: () -> Unit
@@ -312,8 +373,7 @@ class MainActivity : ComponentActivity() {
if (fileUri.scheme == ContentResolver.SCHEME_CONTENT) { if (fileUri.scheme == ContentResolver.SCHEME_CONTENT) {
fileUri fileUri
} else { } else {
val authority = "${applicationContext.packageName}.fileprovider" uriForFile(fileUri.toFile())
FileProvider.getUriForFile(this, authority, fileUri.toFile())
} }
val openIntent = Intent(Intent.ACTION_VIEW).apply { val openIntent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uriToOpen, mimeType) setDataAndType(uriToOpen, mimeType)
@@ -335,15 +395,28 @@ class MainActivity : ComponentActivity() {
Log.d("OpenCV", "Initialization successful") Log.d("OpenCV", "Initialization successful")
} }
} }
}
private fun navigation(viewModel: MainViewModel): Navigation = Navigation( private fun navigation(viewModel: MainViewModel, launchMode: LaunchMode): Navigation = Navigation(
toHomeScreen = { viewModel.navigateTo(Screen.Main.Home) }, toHomeScreen = { viewModel.navigateTo(Screen.Main.Home) },
toCameraScreen = { viewModel.navigateTo(Screen.Main.Camera) }, toCameraScreen = { viewModel.navigateTo(Screen.Main.Camera) },
toDocumentScreen = { viewModel.navigateTo(Screen.Main.Document()) }, toDocumentScreen = { viewModel.navigateTo(Screen.Main.Document()) },
toExportScreen = { viewModel.navigateTo(Screen.Main.Export) }, toExportScreen = { viewModel.navigateTo(Screen.Main.Export) },
toAboutScreen = { viewModel.navigateTo(Screen.Overlay.About) }, toAboutScreen = { viewModel.navigateTo(Screen.Overlay.About) },
toLibrariesScreen = { viewModel.navigateTo(Screen.Overlay.Libraries) }, toLibrariesScreen = { viewModel.navigateTo(Screen.Overlay.Libraries) },
toSettingsScreen = { viewModel.navigateTo(Screen.Overlay.Settings) }, toSettingsScreen = if (launchMode == LaunchMode.EXTERNAL_SCAN_TO_PDF) null else {
back = { viewModel.navigateBack() } {
) viewModel.navigateTo(Screen.Overlay.Settings)
}
},
back = {
val origin = viewModel.currentScreen.value
viewModel.navigateBack()
val destination = viewModel.currentScreen.value
if (destination == origin && launchMode == LaunchMode.EXTERNAL_SCAN_TO_PDF) {
setResult(RESULT_CANCELED)
finish()
}
}
)
}

View File

@@ -26,15 +26,14 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.fairscan.app.data.ImageRepository
import org.fairscan.app.ui.NavigationState import org.fairscan.app.ui.NavigationState
import org.fairscan.app.ui.Screen import org.fairscan.app.ui.Screen
import org.fairscan.app.ui.state.DocumentUiModel import org.fairscan.app.ui.state.DocumentUiModel
class MainViewModel(appContainer: AppContainer): ViewModel() { class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode): ViewModel() {
private val imageRepository = appContainer.imageRepository private val _navigationState = MutableStateFlow(NavigationState.initial(launchMode))
private val _navigationState = MutableStateFlow(NavigationState.initial())
val currentScreen: StateFlow<Screen> = _navigationState.map { it.current } val currentScreen: StateFlow<Screen> = _navigationState.map { it.current }
.stateIn(viewModelScope, SharingStarted.Eagerly, _navigationState.value.current) .stateIn(viewModelScope, SharingStarted.Eagerly, _navigationState.value.current)

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2025 Pierre-Yves Nicolas
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app
import android.content.Context
import org.fairscan.app.data.ImageRepository
import org.fairscan.app.platform.OpenCvTransformations
import java.io.File
class ScanSessionContainer(
context: Context,
scanRootDir: File
) {
private val density = context.resources.displayMetrics.density
private val thumbnailSizePx = (THUMBNAIL_SIZE_DP * density).toInt()
val imageRepository = ImageRepository(
scanRootDir,
OpenCvTransformations(),
thumbnailSizePx
)
}

View File

@@ -23,16 +23,16 @@ const val SCAN_DIR_NAME = "scanned_pages"
const val THUMBNAIL_DIR_NAME = "thumbnails" const val THUMBNAIL_DIR_NAME = "thumbnails"
class ImageRepository( class ImageRepository(
appFilesDir: File, scanRootDir: File,
val transformations: ImageTransformations, val transformations: ImageTransformations,
private val thumbnailSizePx: Int, private val thumbnailSizePx: Int,
) { ) {
private val scanDir: File = File(appFilesDir, SCAN_DIR_NAME).apply { private val scanDir: File = File(scanRootDir, SCAN_DIR_NAME).apply {
if (!exists()) mkdirs() if (!exists()) mkdirs()
} }
private val thumbnailDir: File = File(appFilesDir, THUMBNAIL_DIR_NAME).apply { private val thumbnailDir: File = File(scanRootDir, THUMBNAIL_DIR_NAME).apply {
if (!exists()) mkdirs() if (!exists()) mkdirs()
} }

View File

@@ -14,6 +14,8 @@
*/ */
package org.fairscan.app.ui package org.fairscan.app.ui
import org.fairscan.app.LaunchMode
sealed class Screen { sealed class Screen {
sealed class Main : Screen() { sealed class Main : Screen() {
object Home : Main() object Home : Main()
@@ -35,15 +37,24 @@ data class Navigation(
val toExportScreen: () -> Unit, val toExportScreen: () -> Unit,
val toAboutScreen: () -> Unit, val toAboutScreen: () -> Unit,
val toLibrariesScreen: () -> Unit, val toLibrariesScreen: () -> Unit,
val toSettingsScreen: () -> Unit, val toSettingsScreen: (() -> Unit)?,
val back: () -> Unit, val back: () -> Unit,
) )
fun startScreenFor(mode: LaunchMode): Screen.Main =
when (mode) {
LaunchMode.NORMAL -> Screen.Main.Home
LaunchMode.EXTERNAL_SCAN_TO_PDF -> Screen.Main.Camera
}
@ConsistentCopyVisibility @ConsistentCopyVisibility
data class NavigationState private constructor(val stack: List<Screen>) { data class NavigationState private constructor(val stack: List<Screen>, val root: Screen.Main) {
companion object { companion object {
fun initial() = NavigationState(listOf(Screen.Main.Home)) fun initial(mode: LaunchMode): NavigationState {
val root = startScreenFor(mode)
return NavigationState(listOf(root), root)
}
} }
val current: Screen get() = stack.last() val current: Screen get() = stack.last()
@@ -58,6 +69,7 @@ data class NavigationState private constructor(val stack: List<Screen>) {
fun navigateBack(): NavigationState { fun navigateBack(): NavigationState {
return when (current) { return when (current) {
root -> this // Back handled by system
is Screen.Main.Home -> this // Back handled by system is Screen.Main.Home -> this // Back handled by system
is Screen.Main.Camera -> copy(stack = listOf(Screen.Main.Home)) is Screen.Main.Camera -> copy(stack = listOf(Screen.Main.Home))
is Screen.Main.Document -> copy(stack = listOf(Screen.Main.Camera)) is Screen.Main.Document -> copy(stack = listOf(Screen.Main.Camera))

View File

@@ -181,14 +181,16 @@ fun AppOverflowMenu(
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.surface)
) { ) {
navigation.toSettingsScreen?.let { toSettings ->
DropdownMenuItem( DropdownMenuItem(
leadingIcon = { Icon(Icons.Default.Settings, contentDescription = null) }, leadingIcon = { Icon(Icons.Default.Settings, contentDescription = null) },
text = { Text(stringResource(R.string.settings)) }, text = { Text(stringResource(R.string.settings)) },
onClick = { onClick = {
expanded = false expanded = false
navigation.toSettingsScreen() toSettings()
} }
) )
}
DropdownMenuItem( DropdownMenuItem(
leadingIcon = { Icon(Icons.Default.Info, contentDescription = null) }, leadingIcon = { Icon(Icons.Default.Info, contentDescription = null) },

View File

@@ -73,6 +73,7 @@ fun DocumentScreen(
document: DocumentUiModel, document: DocumentUiModel,
initialPage: Int, initialPage: Int,
navigation: Navigation, navigation: Navigation,
onExportClick: () -> Unit,
onDeleteImage: (String) -> Unit, onDeleteImage: (String) -> Unit,
onRotateImage: (String, Boolean) -> Unit, onRotateImage: (String, Boolean) -> Unit,
onPageReorder: (String, Int) -> Unit, onPageReorder: (String, Int) -> Unit,
@@ -105,7 +106,7 @@ fun DocumentScreen(
), ),
onBack = navigation.back, onBack = navigation.back,
bottomBar = { bottomBar = {
BottomBar(navigation) BottomBar(onExportClick)
}, },
pageListButton = { pageListButton = {
SecondaryActionButton( SecondaryActionButton(
@@ -217,7 +218,7 @@ fun RotationButtons(
@Composable @Composable
private fun BottomBar( private fun BottomBar(
navigation: Navigation, onExportClick: () -> Unit,
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -225,7 +226,7 @@ private fun BottomBar(
horizontalArrangement = Arrangement.End horizontalArrangement = Arrangement.End
) { ) {
MainActionButton( MainActionButton(
onClick = navigation.toExportScreen, onClick = onExportClick,
icon = Icons.Default.Description, icon = Icons.Default.Description,
text = stringResource(R.string.export), text = stringResource(R.string.export),
) )
@@ -243,6 +244,7 @@ fun DocumentScreenPreview() {
), ),
initialPage = 1, initialPage = 1,
navigation = dummyNavigation(), navigation = dummyNavigation(),
onExportClick = {},
onDeleteImage = { _ -> }, onDeleteImage = { _ -> },
onRotateImage = { _,_ -> }, onRotateImage = { _,_ -> },
onPageReorder = { _,_ -> }, onPageReorder = { _,_ -> },

View File

@@ -75,7 +75,6 @@ import org.fairscan.app.ui.components.NewDocumentDialog
import org.fairscan.app.ui.components.isLandscape import org.fairscan.app.ui.components.isLandscape
import org.fairscan.app.ui.components.pageCountText import org.fairscan.app.ui.components.pageCountText
import org.fairscan.app.ui.dummyNavigation 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.screens.settings.ExportFormat.PDF
import org.fairscan.app.ui.theme.FairScanTheme import org.fairscan.app.ui.theme.FairScanTheme
import java.io.File import java.io.File

View File

@@ -21,7 +21,6 @@ data class ExportUiState(
val format: ExportFormat = ExportFormat.PDF, val format: ExportFormat = ExportFormat.PDF,
val isGenerating: Boolean = false, val isGenerating: Boolean = false,
val result: ExportResult? = null, val result: ExportResult? = null,
val desiredFilename: String = "",
val savedBundle: SavedBundle? = null, val savedBundle: SavedBundle? = null,
val hasShared: Boolean = false, val hasShared: Boolean = false,
val errorMessage: String? = null, val errorMessage: String? = null,

View File

@@ -35,6 +35,7 @@ import kotlinx.coroutines.withContext
import org.fairscan.app.AppContainer import org.fairscan.app.AppContainer
import org.fairscan.app.RecentDocument import org.fairscan.app.RecentDocument
import org.fairscan.app.data.FileManager import org.fairscan.app.data.FileManager
import org.fairscan.app.data.ImageRepository
import org.fairscan.app.ui.screens.settings.ExportFormat import org.fairscan.app.ui.screens.settings.ExportFormat
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
@@ -46,11 +47,10 @@ sealed interface ExportEvent {
data object SaveError : ExportEvent data object SaveError : ExportEvent
} }
class ExportViewModel(container: AppContainer): ViewModel() { class ExportViewModel(container: AppContainer, val imageRepository: ImageRepository): ViewModel() {
private val preparationDir = container.preparationDir private val preparationDir = container.preparationDir
private val fileManager = container.fileManager private val fileManager = container.fileManager
private val imageRepository = container.imageRepository
private val settingsRepository = container.settingsRepository private val settingsRepository = container.settingsRepository
private val recentDocumentsDataStore = container.recentDocumentsDataStore private val recentDocumentsDataStore = container.recentDocumentsDataStore
private val logger = container.logger private val logger = container.logger
@@ -66,6 +66,10 @@ class ExportViewModel(container: AppContainer): ViewModel() {
return@withContext ExportResult.Pdf(pdf.file, pdf.sizeInBytes, pdf.pageCount) return@withContext ExportResult.Pdf(pdf.file, pdf.sizeInBytes, pdf.pageCount)
} }
suspend fun generatePdfForExternalCall(): ExportResult.Pdf {
return generatePdf()
}
private val _uiState = MutableStateFlow(ExportUiState()) private val _uiState = MutableStateFlow(ExportUiState())
val uiState: StateFlow<ExportUiState> = _uiState.asStateFlow() val uiState: StateFlow<ExportUiState> = _uiState.asStateFlow()

View File

@@ -15,6 +15,7 @@
package org.fairscan.app.ui package org.fairscan.app.ui
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.fairscan.app.LaunchMode
import org.fairscan.app.ui.Screen.Main.Camera import org.fairscan.app.ui.Screen.Main.Camera
import org.fairscan.app.ui.Screen.Main.Document import org.fairscan.app.ui.Screen.Main.Document
import org.fairscan.app.ui.Screen.Main.Export import org.fairscan.app.ui.Screen.Main.Export
@@ -27,14 +28,14 @@ class NavigationTest {
@Test @Test
fun empty_ScreenStack() { fun empty_ScreenStack() {
val empty = NavigationState.initial() val empty = NavigationState.initial(LaunchMode.NORMAL)
assertThat(empty.current).isEqualTo(Home) assertThat(empty.current).isEqualTo(Home)
assertThat(empty.navigateBack()).isEqualTo(empty) assertThat(empty.navigateBack()).isEqualTo(empty)
} }
@Test @Test
fun navigate_between_fixed_screens() { fun navigate_between_fixed_screens() {
val atHome = NavigationState.initial() val atHome = NavigationState.initial(LaunchMode.NORMAL)
val atCamera = atHome.navigateTo(Camera) val atCamera = atHome.navigateTo(Camera)
val atDocument = atHome.navigateTo(Document()) val atDocument = atHome.navigateTo(Document())
val atExport = atHome.navigateTo(Export) val atExport = atHome.navigateTo(Export)
@@ -56,7 +57,7 @@ class NavigationTest {
@Test @Test
fun navigate_to_secondary_screens() { fun navigate_to_secondary_screens() {
val atHome = NavigationState.initial() val atHome = NavigationState.initial(LaunchMode.NORMAL)
val atCamera = atHome.navigateTo(Camera) val atCamera = atHome.navigateTo(Camera)
val atAboutAfterHome = atHome.navigateTo(About) val atAboutAfterHome = atHome.navigateTo(About)
@@ -71,4 +72,11 @@ class NavigationTest {
assertThat(atLibrariesAfterCameraAbout.current).isEqualTo(Libraries) assertThat(atLibrariesAfterCameraAbout.current).isEqualTo(Libraries)
assertThat(atLibrariesAfterCameraAbout.navigateBack()).isEqualTo(atAboutAfterCamera) assertThat(atLibrariesAfterCameraAbout.navigateBack()).isEqualTo(atAboutAfterCamera)
} }
@Test
fun external_call() {
val initial = NavigationState.initial(LaunchMode.EXTERNAL_SCAN_TO_PDF)
assertThat(initial.current).isEqualTo(Camera)
assertThat(initial.navigateBack().current).isEqualTo(Camera)
}
} }