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:
23
README.md
23
README.md
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
app/src/main/java/org/fairscan/app/LaunchMode.kt
Normal file
20
app/src/main/java/org/fairscan/app/LaunchMode.kt
Normal 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
|
||||||
|
}
|
||||||
@@ -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, 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 = 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun navigation(viewModel: MainViewModel): 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() }
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
34
app/src/main/java/org/fairscan/app/ScanSessionContainer.kt
Normal file
34
app/src/main/java/org/fairscan/app/ScanSessionContainer.kt
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -181,14 +181,16 @@ fun AppOverflowMenu(
|
|||||||
.background(MaterialTheme.colorScheme.surface)
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
) {
|
) {
|
||||||
|
|
||||||
DropdownMenuItem(
|
navigation.toSettingsScreen?.let { toSettings ->
|
||||||
leadingIcon = { Icon(Icons.Default.Settings, contentDescription = null) },
|
DropdownMenuItem(
|
||||||
text = { Text(stringResource(R.string.settings)) },
|
leadingIcon = { Icon(Icons.Default.Settings, contentDescription = null) },
|
||||||
onClick = {
|
text = { Text(stringResource(R.string.settings)) },
|
||||||
expanded = false
|
onClick = {
|
||||||
navigation.toSettingsScreen()
|
expanded = false
|
||||||
}
|
toSettings()
|
||||||
)
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
leadingIcon = { Icon(Icons.Default.Info, contentDescription = null) },
|
leadingIcon = { Icon(Icons.Default.Info, contentDescription = null) },
|
||||||
|
|||||||
@@ -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 = { _,_ -> },
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user