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
FairScan uses:

View File

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

View File

@@ -21,16 +21,13 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
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.FileManager
import org.fairscan.app.data.LogRepository
import org.fairscan.app.data.recentDocumentsDataStore
import org.fairscan.app.domain.ImageSegmentationService
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.camera.CameraViewModel
import org.fairscan.app.ui.screens.export.ExportViewModel
import org.fairscan.app.ui.screens.home.HomeViewModel
import org.fairscan.app.ui.screens.settings.SettingsRepository
import org.fairscan.app.ui.screens.settings.SettingsViewModel
@@ -42,15 +39,14 @@ class FairScanApp : Application() {
override fun onCreate() {
super.onCreate()
appContainer = AppContainer(this)
appContainer.cleanOrphanSessions()
}
}
const val THUMBNAIL_SIZE_DP = 120
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)
private val cacheDir = context.cacheDir
val preparationDir = File(context.cacheDir, "pdfs")
val fileManager = FileManager(
preparationDir,
@@ -72,10 +68,30 @@ class AppContainer(context: Context) {
}
}
val mainViewModelFactory = viewModelFactory { MainViewModel(it) }
val homeViewModelFactory = viewModelFactory { HomeViewModel(it, context) }
val cameraViewModelFactory = viewModelFactory { CameraViewModel(it) }
val exportViewModelFactory = viewModelFactory { ExportViewModel(it) }
val aboutViewModelFactory = viewModelFactory { AboutViewModel(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.theme.FairScanTheme
import org.opencv.android.OpenCVLoader
import java.io.File
import java.util.UUID
class MainActivity : ComponentActivity() {
private lateinit var sessionDir: File
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
initLibraries()
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 cameraViewModel: CameraViewModel by viewModels { appContainer.cameraViewModelFactory }
val exportViewModel: ExportViewModel by viewModels { appContainer.exportViewModelFactory }
val aboutViewModel: AboutViewModel by viewModels { appContainer.aboutViewModelFactory }
val settingsViewModel: SettingsViewModel
by viewModels { appContainer.settingsViewModelFactory }
@@ -102,7 +121,7 @@ class MainActivity : ComponentActivity() {
CollectAboutEvents(context, aboutViewModel)
FairScanTheme {
val navigation = navigation(viewModel)
val navigation = navigation(viewModel, launchMode)
when (val screen = currentScreen) {
is Screen.Main.Home -> {
val recentDocs by homeViewModel.recentDocuments.collectAsStateWithLifecycle()
@@ -131,6 +150,19 @@ class MainActivity : ComponentActivity() {
document = document,
initialPage = screen.initialPage,
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) },
onRotateImage = { id, clockwise -> viewModel.rotateImage(id, clockwise) },
onPageReorder = { id, newIndex -> viewModel.movePage(id, newIndex) },
@@ -145,12 +177,12 @@ class MainActivity : ComponentActivity() {
setFilename = exportViewModel::setFilename,
share = { share(exportViewModel.applyRenaming(), exportViewModel) },
save = { exportViewModel.onSaveClicked() },
open = { item -> openUri(item.uri, item.format.mimeType) }
open = { item -> openUri(item.uri, item.format.mimeType) },
),
onCloseScan = {
viewModel.startNewDocument()
viewModel.navigateTo(Screen.Main.Home)
},
}
)
}
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
private fun SettingsScreenWrapper(settingsViewModel: SettingsViewModel, nav: Navigation) {
val launcher = rememberLauncherForActivityResult(
@@ -265,10 +311,7 @@ class MainActivity : ComponentActivity() {
viewModel.setAsShared()
val authority = "${applicationContext.packageName}.fileprovider"
val uris = result.files.map { file ->
FileProvider.getUriForFile(this, authority, file)
}
val uris = result.files.map(::uriForFile)
val intent = Intent().apply {
action = if (uris.size == 1) Intent.ACTION_SEND else Intent.ACTION_SEND_MULTIPLE
type = result.format.mimeType
@@ -293,6 +336,24 @@ class MainActivity : ComponentActivity() {
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(
requestPermissionLauncher: ManagedActivityResultLauncher<String, Boolean>,
action: () -> Unit
@@ -312,8 +373,7 @@ class MainActivity : ComponentActivity() {
if (fileUri.scheme == ContentResolver.SCHEME_CONTENT) {
fileUri
} else {
val authority = "${applicationContext.packageName}.fileprovider"
FileProvider.getUriForFile(this, authority, fileUri.toFile())
uriForFile(fileUri.toFile())
}
val openIntent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uriToOpen, mimeType)
@@ -335,15 +395,28 @@ class MainActivity : ComponentActivity() {
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) },
toCameraScreen = { viewModel.navigateTo(Screen.Main.Camera) },
toDocumentScreen = { viewModel.navigateTo(Screen.Main.Document()) },
toExportScreen = { viewModel.navigateTo(Screen.Main.Export) },
toAboutScreen = { viewModel.navigateTo(Screen.Overlay.About) },
toLibrariesScreen = { viewModel.navigateTo(Screen.Overlay.Libraries) },
toSettingsScreen = { viewModel.navigateTo(Screen.Overlay.Settings) },
back = { viewModel.navigateBack() }
)
toSettingsScreen = if (launchMode == LaunchMode.EXTERNAL_SCAN_TO_PDF) null else {
{
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.update
import kotlinx.coroutines.launch
import org.fairscan.app.data.ImageRepository
import org.fairscan.app.ui.NavigationState
import org.fairscan.app.ui.Screen
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())
private val _navigationState = MutableStateFlow(NavigationState.initial(launchMode))
val currentScreen: StateFlow<Screen> = _navigationState.map { it.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"
class ImageRepository(
appFilesDir: File,
scanRootDir: File,
val transformations: ImageTransformations,
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()
}
private val thumbnailDir: File = File(appFilesDir, THUMBNAIL_DIR_NAME).apply {
private val thumbnailDir: File = File(scanRootDir, THUMBNAIL_DIR_NAME).apply {
if (!exists()) mkdirs()
}

View File

@@ -14,6 +14,8 @@
*/
package org.fairscan.app.ui
import org.fairscan.app.LaunchMode
sealed class Screen {
sealed class Main : Screen() {
object Home : Main()
@@ -35,15 +37,24 @@ data class Navigation(
val toExportScreen: () -> Unit,
val toAboutScreen: () -> Unit,
val toLibrariesScreen: () -> Unit,
val toSettingsScreen: () -> Unit,
val toSettingsScreen: (() -> 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
data class NavigationState private constructor(val stack: List<Screen>) {
data class NavigationState private constructor(val stack: List<Screen>, val root: Screen.Main) {
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()
@@ -58,6 +69,7 @@ data class NavigationState private constructor(val stack: List<Screen>) {
fun navigateBack(): NavigationState {
return when (current) {
root -> 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.Document -> copy(stack = listOf(Screen.Main.Camera))

View File

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

View File

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

View File

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

View File

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

View File

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