Compare commits
@@ -32,3 +32,12 @@ jobs:
|
||||
|
||||
- name: Run Gradle
|
||||
run: ./gradlew clean license check assembleRelease --no-daemon
|
||||
|
||||
- name: Upload APKs
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: FairScan-APKs-PR-${{ github.event.pull_request.number }}
|
||||
path: |
|
||||
app/build/outputs/apk/release/*.apk
|
||||
retention-days: 30
|
||||
|
||||
Generated
+1
-1
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="21" />
|
||||
<bytecodeTargetLevel target="11" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+2
@@ -11,6 +11,8 @@
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
<option value="$PROJECT_DIR$/evaluation" />
|
||||
<option value="$PROJECT_DIR$/imageprocessing" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
|
||||
Generated
+2
-1
@@ -1,6 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="version" value="2.1.0" />
|
||||
<option name="externalSystemId" value="Gradle" />
|
||||
<option name="version" value="2.3.10" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+2
-1
@@ -1,6 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
## Contributing
|
||||
|
||||
Thanks for your interest in contributing to FairScan.
|
||||
|
||||
Before contributing, please take a moment to understand the scope of the project.
|
||||
FairScan is an app that focuses on simplicity. The goal is to provide a fast, reliable scanning experience without requiring manual adjustments.
|
||||
|
||||
- The goal is not to re-implement all features found in similar apps
|
||||
- The goal is not to give users control over every aspect of the generated PDFs
|
||||
|
||||
Recommended reading: https://fairscan.org/blog/fairscan-purpose/
|
||||
|
||||
### Issues
|
||||
|
||||
You're very welcome to describe the problems you face when using FairScan.
|
||||
|
||||
When the automatic processing gives a bad result, please share the original image whenever possible. You can get it by tapping "Report a problem with last captured image" in the "About" screen. You can either attach the image to the GitHub issue or send it privately to contact@fairscan.org. Screenshots are usually not helpful to understand image-processing issues.
|
||||
|
||||
The most useful issues focus on the problem rather than on a proposed solution. Understanding the underlying problem is essential to finding the right solution. A detailed description of the problem and its context is therefore usually much more valuable than discussing a specific change to the app.
|
||||
|
||||
- ❌ "Please add a button or an option to..."
|
||||
- ✅ "Here is the problem I face, the context in which it occurs, and why it is problematic..."
|
||||
|
||||
### Pull Requests
|
||||
|
||||
- Please avoid starting any significant work without discussing it first.
|
||||
- Small, focused PRs are much easier to review and integrate.
|
||||
- FairScan is primarily developed by its maintainer, and core parts of the app are implemented directly to ensure consistency with the overall vision. Large or architectural changes are unlikely to be accepted.
|
||||
|
||||
### Project direction
|
||||
|
||||
FairScan is currently maintained by a single person, and product and technical decisions are centralized.
|
||||
This helps keep the project consistent and aligned with its original vision, and may evolve in the future.
|
||||
|
||||
Thanks for your understanding.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
Copyright 2025 Pierre-Yves Nicolas
|
||||
Copyright 2025-2026 The FairScan authors
|
||||
|
||||
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
|
||||
|
||||
@@ -32,6 +32,23 @@ And **respectful**: open source, minimal permissions, no tracking, no ads.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome, but please read the guidelines first: [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
|
||||
---
|
||||
|
||||
## Translations
|
||||
|
||||
Translations are managed on Weblate, and everyone is welcome to contribute.
|
||||
|
||||
[](https://hosted.weblate.org/engage/fairscan/)
|
||||
|
||||
Start translating:
|
||||
https://hosted.weblate.org/projects/fairscan/
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
| Scan | Preview | Save & Share |
|
||||
@@ -43,17 +60,26 @@ And **respectful**: open source, minimal permissions, no tracking, no ads.
|
||||
## Features
|
||||
|
||||
- **Clear, distraction-free interface**
|
||||
- **Easy scanning process**: scan → preview → share
|
||||
- **Easy flow**: scan, review if needed, save or share
|
||||
- **Automatic document detection** using a custom segmentation model
|
||||
- **Automatic perspective correction**
|
||||
- **Automatic image enhancement**
|
||||
- **Fast PDF generation** with no manual adjustments
|
||||
- **Fully offline** – the app has *no* internet permission
|
||||
- **All document processing happens locally on your device**, no cloud processing
|
||||
- **Minimal permissions**
|
||||
- **Open source**, GPLv3
|
||||
|
||||
---
|
||||
|
||||
## What FairScan is not
|
||||
|
||||
FairScan is **not** intended to:
|
||||
- provide fine-grained manual control over document processing
|
||||
- replicate all features found in other scanning apps
|
||||
- optimize for highly specific use cases at the expense of simplicity
|
||||
|
||||
---
|
||||
|
||||
## Compatibility
|
||||
|
||||
FairScan works on any device that:
|
||||
@@ -62,6 +88,31 @@ 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
|
||||
|
||||
See an example app: [fairscan-intent-sample](https://github.com/pynicolas/fairscan-intent-sample)
|
||||
|
||||
---
|
||||
|
||||
## Technical details
|
||||
|
||||
FairScan uses:
|
||||
@@ -70,6 +121,7 @@ FairScan uses:
|
||||
- [CameraX](https://developer.android.com/media/camera/camerax) for image capture
|
||||
- [LiteRT](https://ai.google.dev/edge/litert) to run the custom segmentation model for automatic document detection
|
||||
- [OpenCV](https://opencv.org/) for perspective correction and image enhancement
|
||||
- [Tesseract](https://github.com/tesseract-ocr/tesseract) for text recognition (OCR)
|
||||
- [PDFBox-Android](https://github.com/TomRoush/PdfBox-Android) for PDF generation
|
||||
|
||||
---
|
||||
|
||||
+17
-26
@@ -2,19 +2,19 @@ plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.aboutLibraries)
|
||||
alias(libs.plugins.protobuf)
|
||||
alias(libs.plugins.aboutLibrariesAndroid)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
val abiCodes = mapOf(
|
||||
"arm64-v8a" to 0,
|
||||
"armeabi-v7a" to -1,
|
||||
"x86_64" to -2,
|
||||
)
|
||||
|
||||
android {
|
||||
namespace = "org.fairscan.app"
|
||||
compileSdk = 35
|
||||
compileSdk = 36
|
||||
sourceSets["main"].assets.srcDir(layout.buildDirectory.dir("generated/assets"))
|
||||
|
||||
defaultConfig {
|
||||
@@ -24,9 +24,9 @@ android {
|
||||
// LiteRT documentation only states that version 1.2.0 requires Android 12:
|
||||
// https://ai.google.dev/edge/litert/android/index
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 34 // increment by 2 because of ABI-specific APKs
|
||||
versionName = "1.7.0"
|
||||
targetSdk = 36
|
||||
versionCode = 93 // increment by 3 because of ABI-specific APKs
|
||||
versionName = "2.2.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -88,8 +88,10 @@ android {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "11"
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
|
||||
}
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
@@ -97,10 +99,14 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
apply(from = "download-tflite.gradle.kts")
|
||||
apply(from = file("download-tflite.gradle.kts"))
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation(project(":imageprocessing")) {
|
||||
exclude(group = "org.openpnp", module = "opencv")
|
||||
}
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
@@ -115,10 +121,8 @@ dependencies {
|
||||
implementation(libs.androidx.camera.camera2)
|
||||
implementation(libs.androidx.camera.lifecycle)
|
||||
implementation(libs.androidx.camera.view)
|
||||
implementation(libs.androidx.datastore)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.androidx.documentfile)
|
||||
implementation(libs.protobuf.javalite)
|
||||
implementation(libs.litert)
|
||||
implementation(libs.litert.support)
|
||||
implementation(libs.litert.metadata)
|
||||
@@ -132,9 +136,11 @@ dependencies {
|
||||
implementation(libs.reorderable)
|
||||
implementation(libs.aboutlibraries.compose.m3)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.tesseract4android)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.assertj)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
@@ -152,21 +158,6 @@ aboutLibraries {
|
||||
}
|
||||
}
|
||||
|
||||
protobuf {
|
||||
protoc {
|
||||
artifact = "com.google.protobuf:protoc:4.32.0"
|
||||
}
|
||||
generateProtoTasks {
|
||||
all().forEach { task ->
|
||||
task.builtins {
|
||||
create("java") {
|
||||
option("lite")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// See https://developer.android.com/build/configure-apk-splits
|
||||
androidComponents {
|
||||
onVariants { variant ->
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import org.gradle.api.tasks.Copy
|
||||
import java.net.URL
|
||||
|
||||
val modelVersion = "v1.1.0"
|
||||
val modelVersion = "v1.2.0"
|
||||
val modelFileName = "fairscan-segmentation-model.tflite"
|
||||
val modelUrl = "https://github.com/pynicolas/fairscan-segmentation-model/releases/download/$modelVersion/$modelFileName"
|
||||
|
||||
|
||||
Vendored
+2
@@ -26,3 +26,5 @@
|
||||
|
||||
-keep class org.fairscan.app.RecentDocument* { *; }
|
||||
-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite { *; }
|
||||
# to have at least org.opencv.core.CvException
|
||||
-keep class org.opencv.core.** { *; }
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
/*
|
||||
* 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.domain
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Log
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.opencv.android.OpenCVLoader
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DocumentDetectionTest {
|
||||
@Test
|
||||
fun extractDocumentFromImage() {
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("org.fairscan.app", appContext.packageName)
|
||||
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val segmentationService = ImageSegmentationService(context) { _, _, _ -> }
|
||||
segmentationService.initialize()
|
||||
OpenCVLoader.initLocal()
|
||||
|
||||
listOf("img01.jpg", "img02.jpg", "img03.jpg").forEach { imageFileName ->
|
||||
val inputStream = context.assets.open("uncropped/$imageFileName")
|
||||
val bitmap = BitmapFactory.decodeStream(inputStream)
|
||||
var outputBitmap: Bitmap? = null
|
||||
|
||||
val segmentationResult = runBlocking {
|
||||
segmentationService.runSegmentationAndReturn(bitmap, 0)
|
||||
}
|
||||
if (segmentationResult != null) {
|
||||
val mask = segmentationResult.segmentation
|
||||
val quad = detectDocumentQuad(mask, false)
|
||||
if (quad != null) {
|
||||
val resizedQuad =
|
||||
quad.scaledTo(mask.width, mask.height, bitmap.width, bitmap.height)
|
||||
outputBitmap = extractDocument(bitmap, resizedQuad, 0)
|
||||
val file = File(context.getExternalFilesDir(null), imageFileName)
|
||||
FileOutputStream(file).use {
|
||||
outputBitmap.compress(Bitmap.CompressFormat.JPEG, 95, it)
|
||||
}
|
||||
Log.i("DocumentDetectionTest", "Image saved to ${file.absolutePath}")
|
||||
}
|
||||
}
|
||||
if (outputBitmap == null) {
|
||||
fail("Failed to extract document from image $imageFileName")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,11 @@
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<!-- Required (on Android 9 and lower) to save files -->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
|
||||
<!-- To download Tesseract language data -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!-- REMOVE ACCESS_NETWORK_STATE -->
|
||||
<!-- cameraX 1.6.1 depends on androidx.media3:media3-common which adds ACCESS_NETWORK_STATE -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" tools:node="remove" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
@@ -27,9 +32,18 @@
|
||||
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>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.GET_CONTENT"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.OPENABLE"/>
|
||||
<data android:mimeType="application/pdf"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
@@ -42,4 +56,4 @@
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
</manifest>
|
||||
|
||||
Binary file not shown.
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -17,21 +17,23 @@ package org.fairscan.app
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewmodel.CreationExtras
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
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.recentDocumentsDataStore
|
||||
import org.fairscan.app.data.LogRepository
|
||||
import org.fairscan.app.data.OcrLanguageRepository
|
||||
import org.fairscan.app.domain.ImageSegmentationService
|
||||
import org.fairscan.app.domain.OcrService
|
||||
import org.fairscan.app.platform.AndroidImageLoader
|
||||
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
|
||||
import java.io.File
|
||||
@@ -42,26 +44,37 @@ class FairScanApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
appContainer = AppContainer(this)
|
||||
appContainer.cleanOrphanSessions()
|
||||
}
|
||||
}
|
||||
|
||||
const val THUMBNAIL_SIZE_DP = 120
|
||||
|
||||
private val Context.dataStore by preferencesDataStore(name = "fairscan_settings")
|
||||
|
||||
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 scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val cacheDir = context.cacheDir
|
||||
private val dataStore = context.dataStore
|
||||
val preparationDir = File(context.cacheDir, "pdfs")
|
||||
val ocrLanguageRepository =
|
||||
OcrLanguageRepository(dataStore, File(context.filesDir, "tesseract/tessdata"))
|
||||
val ocrService = OcrService(ocrLanguageRepository, scope)
|
||||
val fileManager = FileManager(
|
||||
preparationDir,
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
||||
AndroidPdfWriter()
|
||||
AndroidPdfWriter(ocrService, context.assets)
|
||||
)
|
||||
val logRepository = LogRepository(File(context.filesDir, "logs.txt"))
|
||||
val logger = FileLogger(logRepository)
|
||||
val imageSegmentationService = ImageSegmentationService(context, logger)
|
||||
val recentDocumentsDataStore = context.recentDocumentsDataStore
|
||||
val settingsRepository = SettingsRepository(context)
|
||||
val imageLoader = AndroidImageLoader(context.contentResolver)
|
||||
val settingsRepository = SettingsRepository(context, dataStore)
|
||||
|
||||
init {
|
||||
scope.launch { imageSegmentationService.initialize() }
|
||||
scope.launch { ocrService.initialize() }
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
inline fun <reified VM : ViewModel> viewModelFactory(
|
||||
@@ -72,10 +85,40 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
class SessionViewModelFactory(
|
||||
private val application: Application,
|
||||
private val launchMode: LaunchMode,
|
||||
private val appContainer: AppContainer
|
||||
) : ViewModelProvider.Factory {
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return SessionViewModel(application, launchMode, appContainer) as T
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -27,6 +27,7 @@ import android.os.Build.VERSION.SDK_INT
|
||||
import android.os.Build.VERSION_CODES.Q
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.ManagedActivityResultLauncher
|
||||
@@ -43,47 +44,78 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.toClipEntry
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.core.content.ContextCompat.checkSelfPermission
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.net.toFile
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.fairscan.app.data.FileLogger
|
||||
import org.fairscan.app.data.ImageRepository
|
||||
import org.fairscan.app.ui.Navigation
|
||||
import org.fairscan.app.ui.Screen
|
||||
import org.fairscan.app.ui.components.rememberCameraPermissionState
|
||||
import org.fairscan.app.ui.screens.DocumentScreen
|
||||
import org.fairscan.app.ui.screens.LibrariesScreen
|
||||
import org.fairscan.app.ui.screens.ResumeScanScreen
|
||||
import org.fairscan.app.ui.screens.about.AboutEvent
|
||||
import org.fairscan.app.ui.screens.about.AboutScreen
|
||||
import org.fairscan.app.ui.screens.about.AboutViewModel
|
||||
import org.fairscan.app.ui.screens.about.createEmailWithImageIntent
|
||||
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.crop.CropScreen
|
||||
import org.fairscan.app.ui.screens.document.DocumentScreen
|
||||
import org.fairscan.app.ui.screens.export.ExportActions
|
||||
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.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.OcrLanguagesScreen
|
||||
import org.fairscan.app.ui.screens.settings.SettingsScreen
|
||||
import org.fairscan.app.ui.screens.settings.SettingsUiState
|
||||
import org.fairscan.app.ui.screens.settings.SettingsViewModel
|
||||
import org.fairscan.app.ui.theme.FairScanTheme
|
||||
import org.opencv.android.OpenCVLoader
|
||||
import java.io.File
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private lateinit var cameraViewModel: CameraViewModel
|
||||
private lateinit var viewModel: MainViewModel
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
initLibraries()
|
||||
|
||||
val appContainer = (application as FairScanApp).appContainer
|
||||
val viewModel: MainViewModel by viewModels { appContainer.mainViewModelFactory }
|
||||
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 launchMode = resolveLaunchMode(intent)
|
||||
|
||||
val sessionViewModel: SessionViewModel by viewModels {
|
||||
SessionViewModelFactory(
|
||||
application = application,
|
||||
launchMode = launchMode,
|
||||
appContainer = appContainer
|
||||
)
|
||||
}
|
||||
|
||||
val imageRepository = sessionViewModel.imageRepository
|
||||
viewModel = viewModels<MainViewModel> {
|
||||
appContainer.viewModelFactory {
|
||||
MainViewModel(imageRepository, appContainer.logger)
|
||||
}
|
||||
}.value
|
||||
val exportViewModel: ExportViewModel by viewModels {
|
||||
appContainer.viewModelFactory {
|
||||
ExportViewModel(appContainer, imageRepository)
|
||||
}
|
||||
}
|
||||
val aboutViewModel: AboutViewModel by viewModels {
|
||||
appContainer.viewModelFactory {
|
||||
AboutViewModel(appContainer, imageRepository)
|
||||
}
|
||||
}
|
||||
cameraViewModel = viewModels<CameraViewModel> { appContainer.cameraViewModelFactory }.value
|
||||
|
||||
val settingsViewModel: SettingsViewModel
|
||||
by viewModels { appContainer.settingsViewModelFactory }
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
@@ -91,104 +123,212 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
val logger = appContainer.logger
|
||||
val context = LocalContext.current
|
||||
val currentScreen by viewModel.currentScreen.collectAsStateWithLifecycle()
|
||||
val liveAnalysisState by cameraViewModel.liveAnalysisState.collectAsStateWithLifecycle()
|
||||
val importState by cameraViewModel.importState.collectAsStateWithLifecycle()
|
||||
val document by viewModel.documentUiModel.collectAsStateWithLifecycle()
|
||||
val documentUiState by viewModel.documentUiState.collectAsStateWithLifecycle()
|
||||
val cropInitialState by viewModel.cropInitState.collectAsStateWithLifecycle()
|
||||
val exportUiState by exportViewModel.uiState.collectAsStateWithLifecycle()
|
||||
val settingsUiState by settingsViewModel.uiState.collectAsStateWithLifecycle()
|
||||
val cameraPermission = rememberCameraPermissionState()
|
||||
CollectCameraEvents(cameraViewModel, viewModel)
|
||||
CollectExportEvents(context, exportViewModel)
|
||||
CollectAboutEvents(context, aboutViewModel)
|
||||
CollectAboutEvents(context, aboutViewModel, imageRepository)
|
||||
|
||||
FairScanTheme {
|
||||
val navigation = navigation(viewModel)
|
||||
when (val screen = currentScreen) {
|
||||
is Screen.Main.Home -> {
|
||||
val recentDocs by homeViewModel.recentDocuments.collectAsStateWithLifecycle()
|
||||
HomeScreen(
|
||||
cameraPermission = cameraPermission,
|
||||
currentDocument = document,
|
||||
navigation = navigation,
|
||||
onClearScan = { viewModel.startNewDocument() },
|
||||
recentDocuments = recentDocs,
|
||||
onOpenPdf = { fileUri -> openUri(fileUri, ExportFormat.PDF.mimeType) }
|
||||
val navigation = navigation(viewModel, launchMode)
|
||||
val onExportClick = if (launchMode == LaunchMode.EXTERNAL_SCAN_TO_PDF) {
|
||||
{
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val result = exportViewModel.generatePdfForExternalCall()
|
||||
sendActivityResult(result)
|
||||
} catch (e: Exception) {
|
||||
val message = "Export failed"
|
||||
showToast(message)
|
||||
appContainer.logger.e("MainActivity", message, e)
|
||||
return@launch
|
||||
}
|
||||
viewModel.startNewDocument()
|
||||
finish()
|
||||
}
|
||||
Unit
|
||||
}
|
||||
} else {
|
||||
navigation.toExportScreen
|
||||
}
|
||||
|
||||
when (currentScreen) {
|
||||
null -> {
|
||||
// waiting to load pages to get an initial screen
|
||||
}
|
||||
is Screen.Main.ResumeScan -> {
|
||||
ResumeScanScreen(
|
||||
currentDocument = documentUiState,
|
||||
onResumeScan = navigation.toCameraScreen,
|
||||
onStartNewScan = {
|
||||
viewModel.startNewDocument()
|
||||
navigation.toCameraScreen()
|
||||
}
|
||||
)
|
||||
}
|
||||
is Screen.Main.Camera -> {
|
||||
val pickMultiple = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.GetMultipleContents()) {
|
||||
uris -> cameraViewModel.importPhotos(uris)
|
||||
}
|
||||
CameraScreen(
|
||||
viewModel,
|
||||
cameraViewModel,
|
||||
navigation,
|
||||
liveAnalysisState,
|
||||
importState,
|
||||
onImageAnalyzed = { image -> cameraViewModel.liveAnalysis(image) },
|
||||
onFinalizePressed = navigation.toDocumentScreen,
|
||||
cameraPermission = cameraPermission
|
||||
onFinalizePressed = onExportClick,
|
||||
cameraPermission = cameraPermission,
|
||||
onImportClicked = {
|
||||
cameraViewModel.onImportClicked()
|
||||
pickMultiple.launch("image/*")
|
||||
}
|
||||
)
|
||||
}
|
||||
is Screen.Main.EditImage -> {
|
||||
CropScreen(
|
||||
pageId = documentUiState.currentPage?.key?.pageId ?: "",
|
||||
initState = cropInitialState,
|
||||
navigation = navigation,
|
||||
onUpdatePageQuad = { quad -> viewModel.setCurrentPageUserQuad(quad) },
|
||||
)
|
||||
}
|
||||
is Screen.Main.Document -> {
|
||||
DocumentScreen (
|
||||
document = document,
|
||||
initialPage = screen.initialPage,
|
||||
uiState = documentUiState,
|
||||
navigation = navigation,
|
||||
onDeleteImage = { id -> viewModel.deletePage(id) },
|
||||
onRotateImage = { id, clockwise -> viewModel.rotateImage(id, clockwise) },
|
||||
onExportClick = onExportClick,
|
||||
onDeleteImage = { viewModel.deleteCurrentPage() },
|
||||
onRotateImage = { clockwise -> viewModel.rotateCurrentPage(clockwise) },
|
||||
onToggleColorMode = { viewModel.toggleCurrentPageColorMode() },
|
||||
onCropClick = { viewModel.onClickOnCropButton() },
|
||||
onPageReorder = { id, newIndex -> viewModel.movePage(id, newIndex) },
|
||||
onPageSelected = viewModel::onPageSelected
|
||||
)
|
||||
}
|
||||
is Screen.Main.Export -> {
|
||||
ExportScreenWrapper(
|
||||
navigation = navigation,
|
||||
uiState = exportUiState,
|
||||
pdfActions = ExportActions(
|
||||
initializeExportScreen = exportViewModel::initializeExportScreen,
|
||||
currentDocument = document,
|
||||
exportActions = ExportActions(
|
||||
prepareExportIfNeeded = exportViewModel::prepareExportIfNeeded,
|
||||
setFilename = exportViewModel::setFilename,
|
||||
share = { share(exportViewModel.applyRenaming(), exportViewModel) },
|
||||
share = { exportViewModel.onShareClicked() },
|
||||
save = { exportViewModel.onSaveClicked() },
|
||||
open = { item -> openUri(item.uri, item.format.mimeType) }
|
||||
open = { item -> openUri(item.uri, item.format.mimeType, logger) },
|
||||
cancelPreparationJob = exportViewModel::cancelPreparationJob,
|
||||
),
|
||||
onCloseScan = {
|
||||
exportViewModel.resetFilename()
|
||||
viewModel.startNewDocument()
|
||||
viewModel.navigateTo(Screen.Main.Home)
|
||||
},
|
||||
viewModel.navigateTo(Screen.Main.Camera)
|
||||
}
|
||||
)
|
||||
}
|
||||
is Screen.Overlay.About -> {
|
||||
LaunchedEffect(Unit) {
|
||||
aboutViewModel.refreshLastCapturedImageState()
|
||||
}
|
||||
val aboutUiState by aboutViewModel.uiState.collectAsStateWithLifecycle()
|
||||
AboutScreen(
|
||||
aboutUiState = aboutUiState,
|
||||
onBack = navigation.back,
|
||||
onCopyLogs = { aboutViewModel.onCopyLogsClicked() },
|
||||
onContactWithLastImageClicked =
|
||||
{ aboutViewModel.onContactWithLastImageClicked() },
|
||||
onStartActivity = { intent ->
|
||||
try {
|
||||
startActivity(intent)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
showToast(getString(R.string.error_occurred))
|
||||
}
|
||||
},
|
||||
onViewLibraries = navigation.toLibrariesScreen)
|
||||
}
|
||||
is Screen.Overlay.Libraries -> {
|
||||
LibrariesScreen(onBack = navigation.back)
|
||||
}
|
||||
is Screen.Overlay.Settings -> {
|
||||
SettingsScreenWrapper(settingsViewModel, navigation)
|
||||
SettingsScreenWrapper(settingsUiState, settingsViewModel, navigation, logger)
|
||||
}
|
||||
is Screen.Overlay.OcrLanguages -> {
|
||||
OcrLanguagesScreen(
|
||||
uiState = settingsUiState,
|
||||
onBack = navigation.back,
|
||||
onLanguageClick = settingsViewModel::onLanguageClick,
|
||||
onRemoveLanguage = settingsViewModel::onRemoveLanguage,
|
||||
onCancelOcrDownload = settingsViewModel::cancelOcrDownload,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveLaunchMode(intent: Intent?): LaunchMode {
|
||||
return when (intent?.action) {
|
||||
"org.fairscan.app.action.SCAN_TO_PDF" -> LaunchMode.EXTERNAL_SCAN_TO_PDF
|
||||
"android.intent.action.GET_CONTENT" -> LaunchMode.EXTERNAL_SCAN_TO_PDF
|
||||
else -> LaunchMode.NORMAL
|
||||
}
|
||||
}
|
||||
|
||||
private fun showToast(text: String) {
|
||||
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsScreenWrapper(settingsViewModel: SettingsViewModel, nav: Navigation) {
|
||||
private fun SettingsScreenWrapper(
|
||||
settingsUiState: SettingsUiState,
|
||||
settingsViewModel: SettingsViewModel,
|
||||
nav: Navigation,
|
||||
logger: FileLogger,
|
||||
) {
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree()
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
contentResolver.takePersistableUriPermission(uri, flags)
|
||||
settingsViewModel.setExportDirUri(uri.toString())
|
||||
try {
|
||||
contentResolver.takePersistableUriPermission(uri, flags)
|
||||
settingsViewModel.setExportDirUri(uri.toString())
|
||||
} catch (e: Exception) {
|
||||
logger.e("Settings", "Failed to set export dir to $uri", e)
|
||||
showToast(this.getString(R.string.error_file_picker_result))
|
||||
}
|
||||
}
|
||||
}
|
||||
val settingsUiState by settingsViewModel.uiState.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(Unit) {
|
||||
settingsViewModel.refreshExportDirName()
|
||||
}
|
||||
SettingsScreen(
|
||||
settingsUiState,
|
||||
onChooseDirectoryClick = { launcher.launch(null) },
|
||||
onDefaultColorModeChanged = { mode -> settingsViewModel.setDefaultColorMode(mode) },
|
||||
onChooseDirectoryClick = {
|
||||
try {
|
||||
launcher.launch(null)
|
||||
} catch (e: Exception) {
|
||||
val message = getString(R.string.error_file_picker_launch)
|
||||
logger.e("Settings", message, e)
|
||||
showToast(message)
|
||||
}
|
||||
},
|
||||
onResetExportDirClick = { settingsViewModel.setExportDirUri(null) },
|
||||
onExportFormatChanged = { format -> settingsViewModel.setExportFormat(format) },
|
||||
onBack = nav.back,
|
||||
onExportQualityChanged = { quality -> settingsViewModel.setExportQuality(quality) },
|
||||
navigation = nav,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -196,6 +336,7 @@ class MainActivity : ComponentActivity() {
|
||||
private fun CollectAboutEvents(
|
||||
context: Context,
|
||||
aboutViewModel: AboutViewModel,
|
||||
imageRepository: ImageRepository,
|
||||
) {
|
||||
val clipboard = LocalClipboard.current
|
||||
val msgCopiedLogs = stringResource(R.string.copied_logs)
|
||||
@@ -206,7 +347,13 @@ class MainActivity : ComponentActivity() {
|
||||
clipboard.setClipEntry(
|
||||
ClipData.newPlainText("FairScan logs", event.logs).toClipEntry()
|
||||
)
|
||||
Toast.makeText(context, msgCopiedLogs, Toast.LENGTH_SHORT).show()
|
||||
showToast(msgCopiedLogs)
|
||||
}
|
||||
is AboutEvent.PrepareEmailWithLastImage -> {
|
||||
val file = imageRepository.lastAddedSourceFile()
|
||||
if (file != null) {
|
||||
startActivity(createEmailWithImageIntent(context, file))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,8 +371,7 @@ class MainActivity : ComponentActivity() {
|
||||
if (isGranted) {
|
||||
exportViewModel.onSaveClicked()
|
||||
} else {
|
||||
val message = getString(R.string.storage_permission_denied)
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
showToast(this.getString(R.string.storage_permission_denied))
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -236,10 +382,8 @@ class MainActivity : ComponentActivity() {
|
||||
exportViewModel.onRequestSave(context)
|
||||
}
|
||||
}
|
||||
|
||||
is ExportEvent.SaveError -> {
|
||||
val text = getString(R.string.error_save)
|
||||
Toast.makeText(context, text, Toast.LENGTH_SHORT).show()
|
||||
is ExportEvent.Share -> {
|
||||
share(event.result)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,21 +398,16 @@ class MainActivity : ComponentActivity() {
|
||||
LaunchedEffect(Unit) {
|
||||
cameraViewModel.events.collect { event ->
|
||||
when (event) {
|
||||
is CameraEvent.ImageCaptured -> viewModel.handleImageCaptured(event.jpegBytes)
|
||||
is CameraEvent.ImageCaptured -> viewModel.handleImageCaptured(event.page)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun share(result: ExportResult?, viewModel: ExportViewModel) {
|
||||
if (result == null || result.files.isEmpty()) return
|
||||
private fun share(result: ExportResult) {
|
||||
if (result.files.isEmpty()) return
|
||||
|
||||
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 +432,23 @@ 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 {
|
||||
return org.fairscan.app.ui.uriForFile(this, file)
|
||||
}
|
||||
|
||||
private fun checkPermissionThen(
|
||||
requestPermissionLauncher: ManagedActivityResultLauncher<String, Boolean>,
|
||||
action: () -> Unit
|
||||
@@ -306,23 +462,31 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun openUri(fileUri: Uri?, mimeType: String) {
|
||||
private fun openUri(fileUri: Uri?, mimeType: String, logger: FileLogger) {
|
||||
if (fileUri == null) return
|
||||
val uriToOpen: Uri =
|
||||
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)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
try {
|
||||
startActivity(Intent.createChooser(openIntent, getString(R.string.open_file)))
|
||||
val openIntent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uriToOpen, mimeType)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
clipData = ClipData.newUri(contentResolver, "file", uriToOpen)
|
||||
}
|
||||
val chooser = Intent.createChooser(openIntent, getString(R.string.open_file)).apply {
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
startActivity(chooser)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
Toast.makeText(this, getString(R.string.error_no_app), Toast.LENGTH_SHORT).show()
|
||||
showToast(getString(R.string.error_no_app))
|
||||
} catch (e: Exception) {
|
||||
val errorMessage =
|
||||
"Failed to open URI, scheme=${uriToOpen.scheme}, authority=${uriToOpen.authority}"
|
||||
logger.e("OpenUri", errorMessage, e)
|
||||
showToast(getString(R.string.error_occurred))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,15 +499,43 @@ class MainActivity : ComponentActivity() {
|
||||
Log.d("OpenCV", "Initialization successful")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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() }
|
||||
)
|
||||
private fun navigation(viewModel: MainViewModel, launchMode: LaunchMode): Navigation = Navigation(
|
||||
toCameraScreen = { viewModel.navigateTo(Screen.Main.Camera) },
|
||||
toEditImageScreen = { viewModel.navigateTo(Screen.Main.EditImage) },
|
||||
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)
|
||||
}
|
||||
},
|
||||
toOcrLanguagesScreen = { viewModel.navigateTo(Screen.Overlay.OcrLanguages) },
|
||||
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()
|
||||
}
|
||||
},
|
||||
shouldDisplayBackButton = {
|
||||
viewModel.currentScreen.value !is Screen.Main.Camera
|
||||
|| launchMode == LaunchMode.EXTERNAL_SCAN_TO_PDF
|
||||
}
|
||||
)
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
if (keyCode == KeyEvent.KEYCODE_VOLUME_UP ||
|
||||
keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) {
|
||||
if (viewModel.currentScreen.value is Screen.Main.Camera) {
|
||||
cameraViewModel.onVolumeKeyPressed()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return super.onKeyDown(keyCode, event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -16,86 +16,274 @@ package org.fairscan.app
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Matrix
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.fairscan.app.data.ImageRepository
|
||||
import org.fairscan.app.data.Logger
|
||||
import org.fairscan.app.domain.CapturedPage
|
||||
import org.fairscan.app.domain.Rotation
|
||||
import org.fairscan.app.domain.ScanPage
|
||||
import org.fairscan.app.ui.NavigationState
|
||||
import org.fairscan.app.ui.Screen
|
||||
import org.fairscan.app.ui.screens.crop.CropInitState
|
||||
import org.fairscan.app.ui.screens.document.CurrentPageUiState
|
||||
import org.fairscan.app.ui.screens.document.DocumentUiState
|
||||
import org.fairscan.app.ui.state.DocumentUiModel
|
||||
import org.fairscan.app.ui.state.PageThumbnail
|
||||
import org.fairscan.imageprocessing.ColorMode
|
||||
import org.fairscan.imageprocessing.ImageSize
|
||||
import org.fairscan.imageprocessing.Quad
|
||||
import kotlin.math.min
|
||||
|
||||
class MainViewModel(appContainer: AppContainer): ViewModel() {
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MainViewModel(val imageRepository: ImageRepository, logger: Logger): ViewModel() {
|
||||
|
||||
private val imageRepository = appContainer.imageRepository
|
||||
private val _navigationState = MutableStateFlow<NavigationState?>(null)
|
||||
val currentScreen: StateFlow<Screen?> = _navigationState.map { it?.current }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||
|
||||
private val _navigationState = MutableStateFlow(NavigationState.initial())
|
||||
val currentScreen: StateFlow<Screen> = _navigationState.map { it.current }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, _navigationState.value.current)
|
||||
private val _pages = MutableStateFlow<List<ScanPage>>(emptyList())
|
||||
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val pages = imageRepository.pages()
|
||||
|
||||
_pages.value = pages
|
||||
|
||||
_navigationState.value =
|
||||
if (pages.isEmpty()) {
|
||||
NavigationState.initial()
|
||||
} else {
|
||||
NavigationState.initial().navigateTo(Screen.Main.ResumeScan)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val _pageIds = MutableStateFlow(imageRepository.imageIds())
|
||||
val documentUiModel: StateFlow<DocumentUiModel> =
|
||||
_pageIds.map { ids ->
|
||||
DocumentUiModel(
|
||||
pageIds = ids,
|
||||
imageLoader = ::getBitmap,
|
||||
thumbnailLoader = ::getThumbnail,
|
||||
)
|
||||
}.stateIn(
|
||||
_pages.map { pages ->
|
||||
pages.map {
|
||||
val jpeg = imageRepository.getThumbnail(it.key())
|
||||
PageThumbnail(it.key(), jpeg)
|
||||
}.toImmutableList()
|
||||
}
|
||||
.flowOn(Dispatchers.IO)
|
||||
.map { DocumentUiModel(it) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = DocumentUiModel(persistentListOf(), ::getBitmap, ::getThumbnail)
|
||||
initialValue = DocumentUiModel()
|
||||
)
|
||||
|
||||
private val _currentPageIndex = MutableStateFlow(0)
|
||||
private val _loadingPageId = MutableStateFlow<String?>(null)
|
||||
private val currentPageUiState: Flow<CurrentPageUiState?> =
|
||||
combine(_currentPageIndex, _pages, _loadingPageId) { index, pages, loadingId ->
|
||||
val page = pages.getOrNull(index)
|
||||
Pair(page, loadingId)
|
||||
}
|
||||
.mapLatest { (page,loadingId) ->
|
||||
page?.let {
|
||||
val isLoading = (it.id == loadingId)
|
||||
val canBeCropped = page.metadata != null
|
||||
val bitmap = try {
|
||||
imageRepository.jpegBytes(it.key())?.toBitmap()
|
||||
} catch (e: Exception) {
|
||||
logger.e("MainViewModel", "Failed to load image for ${it.id}", e)
|
||||
null
|
||||
}
|
||||
CurrentPageUiState(it.key(), bitmap, it.colorMode, canBeCropped, isLoading)
|
||||
}
|
||||
}
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
val documentUiState: StateFlow<DocumentUiState> =
|
||||
combine(_currentPageIndex, currentPageUiState, documentUiModel) { index, page, document ->
|
||||
DocumentUiState(index, page, document)
|
||||
}
|
||||
.stateIn(
|
||||
viewModelScope, SharingStarted.Eagerly,
|
||||
DocumentUiState(0, null, DocumentUiModel())
|
||||
)
|
||||
|
||||
fun onPageSelected(index: Int) {
|
||||
_currentPageIndex.value = index
|
||||
}
|
||||
|
||||
fun navigateTo(destination: Screen) {
|
||||
_navigationState.update { it.navigateTo(destination) }
|
||||
if (destination is Screen.Main.Document) {
|
||||
require(_pages.value.isNotEmpty()) {
|
||||
"Cannot navigate to DocumentScreen with zero pages"
|
||||
}
|
||||
_currentPageIndex.value = min(_pages.value.size - 1, destination.initialPage)
|
||||
}
|
||||
_navigationState.update { it?.navigateTo(destination) }
|
||||
}
|
||||
|
||||
fun navigateBack() {
|
||||
_navigationState.update { stack -> stack.navigateBack() }
|
||||
_navigationState.update { stack -> stack?.navigateBack() }
|
||||
}
|
||||
|
||||
fun rotateImage(id: String, clockwise: Boolean) {
|
||||
fun rotateCurrentPage(clockwise: Boolean) {
|
||||
viewModelScope.launch {
|
||||
imageRepository.rotate(id, clockwise)
|
||||
_pageIds.value = imageRepository.imageIds()
|
||||
val pages = withContext(Dispatchers.IO) {
|
||||
imageRepository.rotate(currentPage().id, clockwise)
|
||||
imageRepository.pages()
|
||||
}
|
||||
_pages.value = pages
|
||||
}
|
||||
}
|
||||
|
||||
fun movePage(id: String, newIndex: Int) {
|
||||
imageRepository.movePage(id, newIndex)
|
||||
_pageIds.value = imageRepository.imageIds()
|
||||
}
|
||||
|
||||
fun deletePage(id: String) {
|
||||
imageRepository.delete(id)
|
||||
_pageIds.value = imageRepository.imageIds()
|
||||
}
|
||||
|
||||
fun startNewDocument() {
|
||||
_pageIds.value = persistentListOf()
|
||||
viewModelScope.launch {
|
||||
imageRepository.clear()
|
||||
val pages = withContext(Dispatchers.IO) {
|
||||
imageRepository.movePage(id, newIndex)
|
||||
imageRepository.pages()
|
||||
}
|
||||
_pages.value = pages
|
||||
}
|
||||
}
|
||||
|
||||
fun getBitmap(id: String): Bitmap? {
|
||||
val bytes = imageRepository.getContent(id)
|
||||
return bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
|
||||
fun deleteCurrentPage() {
|
||||
viewModelScope.launch {
|
||||
val pages = withContext(Dispatchers.IO) {
|
||||
imageRepository.delete(currentPage().id)
|
||||
imageRepository.pages()
|
||||
}
|
||||
|
||||
if (pages.isEmpty()) {
|
||||
navigateTo(Screen.Main.Camera)
|
||||
_currentPageIndex.value = 0
|
||||
} else if (_currentPageIndex.value >= pages.size) {
|
||||
_currentPageIndex.value = pages.size - 1
|
||||
}
|
||||
_pages.value = pages
|
||||
}
|
||||
}
|
||||
|
||||
fun getThumbnail(id: String): Bitmap? {
|
||||
val bytes = imageRepository.getThumbnail(id)
|
||||
return bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
|
||||
fun toggleCurrentPageColorMode() {
|
||||
viewModelScope.launch {
|
||||
val currentPage = currentPage()
|
||||
currentPage.colorMode?.let {
|
||||
_loadingPageId.value = currentPage.id
|
||||
val newColorMode =
|
||||
if (it == ColorMode.COLOR) ColorMode.GRAYSCALE else ColorMode.COLOR
|
||||
val pages = withContext(Dispatchers.IO) {
|
||||
imageRepository.setColorMode(currentPage.id, newColorMode)
|
||||
imageRepository.pages()
|
||||
}
|
||||
_pages.value = pages
|
||||
_loadingPageId.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleImageCaptured(jpegBytes: ByteArray) {
|
||||
imageRepository.add(jpegBytes)
|
||||
_pageIds.value = imageRepository.imageIds()
|
||||
fun setCurrentPageUserQuad(userQuad: Quad) {
|
||||
viewModelScope.launch {
|
||||
val currentPage = currentPage()
|
||||
val totalRotation = currentPage.totalRotation()
|
||||
val rotateIterations = (4 - totalRotation.degrees / 90) % 4
|
||||
val newQuad = userQuad.rotate90(rotateIterations, ImageSize(1, 1))
|
||||
_loadingPageId.value = currentPage.id
|
||||
val pages = withContext(Dispatchers.IO) {
|
||||
imageRepository.setUserQuad(currentPage.id, newQuad)
|
||||
imageRepository.pages()
|
||||
}
|
||||
_pages.value = pages
|
||||
_loadingPageId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentPage(): ScanPage {
|
||||
val index = _currentPageIndex.value
|
||||
val pages = _pages.value
|
||||
return pages.getOrNull(index) ?: throw IllegalStateException(
|
||||
"No current page for index $index (${pages.size} pages)")
|
||||
}
|
||||
|
||||
fun startNewDocument() {
|
||||
_pages.value = persistentListOf()
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
imageRepository.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleImageCaptured(capturedPage: CapturedPage) {
|
||||
viewModelScope.launch {
|
||||
val pages = withContext(Dispatchers.IO) {
|
||||
val sourceJpeg = capturedPage.sourceJpeg.await()
|
||||
imageRepository.add(
|
||||
capturedPage.pageJpeg,
|
||||
sourceJpeg,
|
||||
capturedPage.metadata,
|
||||
capturedPage.colorMode,
|
||||
)
|
||||
imageRepository.pages()
|
||||
}
|
||||
_pages.value = pages
|
||||
}
|
||||
}
|
||||
|
||||
private val _cropInitState = MutableStateFlow<CropInitState>(CropInitState.Loading)
|
||||
val cropInitState: StateFlow<CropInitState> = _cropInitState
|
||||
|
||||
private var cropInitialStateJob: Job? = null
|
||||
fun onClickOnCropButton() {
|
||||
cropInitialStateJob?.cancel()
|
||||
cropInitialStateJob = viewModelScope.launch {
|
||||
_cropInitState.value = CropInitState.Loading
|
||||
|
||||
val page = currentPage()
|
||||
|
||||
val metadata = page.metadata
|
||||
val rotation = page.totalRotation()
|
||||
|
||||
val bitmap = withContext(Dispatchers.IO) {
|
||||
val source = imageRepository.source(page.id)
|
||||
val bytes = source?.bytes ?: return@withContext null
|
||||
|
||||
val original = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
if (original != null && rotation != Rotation.R0) {
|
||||
val matrix = Matrix().apply { postRotate(rotation.degrees.toFloat()) }
|
||||
Bitmap.createBitmap(
|
||||
original, 0, 0, original.width, original.height, matrix, true
|
||||
)
|
||||
} else {
|
||||
original
|
||||
}
|
||||
}
|
||||
|
||||
val quad = metadata?.normalizedQuad?.rotate90(
|
||||
rotation.degrees / 90,
|
||||
ImageSize(1, 1)
|
||||
)
|
||||
|
||||
_cropInitState.value = if (bitmap == null || quad == null)
|
||||
CropInitState.Error
|
||||
else
|
||||
CropInitState.Ready(page.id, bitmap, quad)
|
||||
navigateTo(Screen.Main.EditImage)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.app.Application
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import org.fairscan.app.data.ImageRepository
|
||||
import org.fairscan.app.data.Logger
|
||||
import org.fairscan.app.platform.ImageProcessor
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
class SessionViewModel(
|
||||
app: Application,
|
||||
val launchMode: LaunchMode,
|
||||
appContainer: AppContainer
|
||||
) : AndroidViewModel(app) {
|
||||
|
||||
val sessionDir: File = when (launchMode) {
|
||||
LaunchMode.NORMAL ->
|
||||
app.filesDir
|
||||
|
||||
LaunchMode.EXTERNAL_SCAN_TO_PDF ->
|
||||
File(appContainer.sessionsRoot(), UUID.randomUUID().toString())
|
||||
.apply { mkdirs() }
|
||||
}
|
||||
|
||||
private val sessionContainer = ScanSessionContainer(
|
||||
context = app,
|
||||
scanRootDir = sessionDir,
|
||||
scope = viewModelScope,
|
||||
logger = appContainer.logger,
|
||||
)
|
||||
|
||||
val imageRepository: ImageRepository = sessionContainer.imageRepository
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
|
||||
if (launchMode == LaunchMode.EXTERNAL_SCAN_TO_PDF) {
|
||||
sessionDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ScanSessionContainer(
|
||||
context: Context,
|
||||
scanRootDir: File,
|
||||
scope: CoroutineScope,
|
||||
logger: Logger,
|
||||
) {
|
||||
private val density = context.resources.displayMetrics.density
|
||||
private val thumbnailSizePx = (THUMBNAIL_SIZE_DP * density).toInt()
|
||||
|
||||
val imageRepository = ImageRepository(
|
||||
scanRootDir,
|
||||
ImageProcessor(thumbnailSizePx),
|
||||
scope,
|
||||
logger,
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -15,14 +15,52 @@
|
||||
package org.fairscan.app.data
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.fairscan.imageprocessing.ColorMode
|
||||
|
||||
@Serializable
|
||||
data class DocumentMetadata(
|
||||
data class DocumentMetadataV1(
|
||||
val version: Int = 1,
|
||||
val pages: List<Page>
|
||||
val pages: List<PageV1>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Page(
|
||||
val file: String,
|
||||
data class PageV1(
|
||||
val file: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DocumentMetadataV2(
|
||||
val version: Int = 2,
|
||||
val pages: List<PageV2>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PageV2(
|
||||
val id: String,
|
||||
val baseRotationDegrees: Int = 0,
|
||||
val manualRotationDegrees: Int = 0,
|
||||
val quad: NormalizedQuad? = null,
|
||||
val quadVersion: Int = 0,
|
||||
val userQuad: NormalizedQuad? = null,
|
||||
val isColored: Boolean? = null,
|
||||
val colorMode: ColorMode? = null,
|
||||
val focalLength: Float? = null,
|
||||
val sensorWidth: Float? = null,
|
||||
val subjectDistance: Float? = null,
|
||||
val sourceWidth: Int? = null,
|
||||
val sourceHeight: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NormalizedQuad(
|
||||
val topLeft: PointD,
|
||||
val topRight: PointD,
|
||||
val bottomRight: PointD,
|
||||
val bottomLeft: PointD
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PointD(
|
||||
val x: Double,
|
||||
val y: Double
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
package org.fairscan.app.data
|
||||
|
||||
import org.fairscan.app.domain.PageToExport
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.OutputStream
|
||||
@@ -25,7 +26,12 @@ data class GeneratedPdf(
|
||||
)
|
||||
|
||||
fun interface PdfWriter {
|
||||
fun writePdfFromJpegs(jpegs: Sequence<ByteArray>, outputStream: OutputStream): Int
|
||||
suspend fun writePdfFromJpegs(
|
||||
pages: List<PageToExport>,
|
||||
outputStream: OutputStream,
|
||||
disableOcr: Boolean,
|
||||
onProgress: (Int) -> Unit,
|
||||
)
|
||||
}
|
||||
|
||||
class FileManager(
|
||||
@@ -42,15 +48,19 @@ class FileManager(
|
||||
}
|
||||
}
|
||||
|
||||
fun generatePdf(jpegs: Sequence<ByteArray>): GeneratedPdf {
|
||||
suspend fun generatePdf(
|
||||
pages: List<PageToExport>,
|
||||
disableOcr: Boolean,
|
||||
onProgress: (Int) -> Unit
|
||||
): GeneratedPdf {
|
||||
pdfDir.mkdirs()
|
||||
require(pdfDir.exists() && pdfDir.isDirectory) { "Invalid pdfDir: $pdfDir" }
|
||||
val file = File(pdfDir, "${System.currentTimeMillis()}.pdf")
|
||||
val pageCount = FileOutputStream(file).use {
|
||||
pdfWriter.writePdfFromJpegs(jpegs, it)
|
||||
FileOutputStream(file).use {
|
||||
pdfWriter.writePdfFromJpegs(pages, it, disableOcr, onProgress)
|
||||
}
|
||||
val sizeBytes = file.length()
|
||||
return GeneratedPdf(file, sizeBytes, pageCount)
|
||||
return GeneratedPdf(file, sizeBytes, pages.size)
|
||||
}
|
||||
|
||||
fun copyToExternalDir(original: File): File {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -14,150 +14,417 @@
|
||||
*/
|
||||
package org.fairscan.app.data
|
||||
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.fairscan.app.domain.Jpeg
|
||||
import org.fairscan.app.domain.PageMetadata
|
||||
import org.fairscan.app.domain.PageViewKey
|
||||
import org.fairscan.app.domain.Rotation
|
||||
import org.fairscan.app.domain.ScanPage
|
||||
import org.fairscan.imageprocessing.ColorMode
|
||||
import org.fairscan.imageprocessing.ImageSize
|
||||
import org.fairscan.imageprocessing.OpticalMeasures
|
||||
import org.fairscan.imageprocessing.Point
|
||||
import org.fairscan.imageprocessing.Quad
|
||||
import org.fairscan.imageprocessing.cameraIntrinsics
|
||||
import java.io.File
|
||||
import java.util.Collections.synchronizedMap
|
||||
|
||||
const val SCAN_DIR_NAME = "scanned_pages"
|
||||
const val SOURCE_DIR_NAME = "sources"
|
||||
const val PROCESSED_DIR_NAME = "scanned_pages"
|
||||
const val THUMBNAIL_DIR_NAME = "thumbnails"
|
||||
|
||||
/**
|
||||
* Repository responsible for:
|
||||
* - page persistence (document.json)
|
||||
* - image files (work, source, thumbnails)
|
||||
* - page-level operations (add, rotate, move, delete)
|
||||
*/
|
||||
class ImageRepository(
|
||||
appFilesDir: File,
|
||||
scanRootDir: File,
|
||||
val transformations: ImageTransformations,
|
||||
private val thumbnailSizePx: Int,
|
||||
private val scope: CoroutineScope,
|
||||
private val logger: Logger,
|
||||
) {
|
||||
private val sourceDir = File(scanRootDir, SOURCE_DIR_NAME).apply { mkdirs() }
|
||||
private val processedDir = File(scanRootDir, PROCESSED_DIR_NAME).apply { mkdirs() }
|
||||
private val thumbnailDir = File(scanRootDir, THUMBNAIL_DIR_NAME)
|
||||
|
||||
private val scanDir: File = File(appFilesDir, SCAN_DIR_NAME).apply {
|
||||
if (!exists()) mkdirs()
|
||||
}
|
||||
private val mutex = Mutex()
|
||||
|
||||
private val thumbnailDir: File = File(appFilesDir, THUMBNAIL_DIR_NAME).apply {
|
||||
if (!exists()) mkdirs()
|
||||
}
|
||||
private val metadataFile = File(processedDir, "document.json")
|
||||
private val json = Json { prettyPrint = false; encodeDefaults = true }
|
||||
private var pages: PageStore = PageStore(loadPages())
|
||||
|
||||
private val metadataFile = File(scanDir, "document.json")
|
||||
private val processingJobs = synchronizedMap(mutableMapOf<PageViewKey, Deferred<Unit>>())
|
||||
private val imageCache = createLruCache<PageViewKey, Deferred<Jpeg?>>(maxEntries = 50)
|
||||
private val thumbnailCache = createLruCache<PageViewKey, Deferred<Jpeg?>>(maxEntries = 1000)
|
||||
|
||||
private var fileNames: MutableList<String> =
|
||||
loadFileNames()
|
||||
private fun <K, V> createLruCache(maxEntries: Int): MutableMap<K, V> =
|
||||
synchronizedMap(object : LinkedHashMap<K, V>(16, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: Map.Entry<K, V>) = size > maxEntries
|
||||
})
|
||||
|
||||
private fun loadFileNames(): MutableList<String> {
|
||||
val filesOnDisk: Set<String> = scanDir.listFiles()
|
||||
// --- Metadata ---
|
||||
|
||||
private fun loadPages(): MutableList<PageV2> {
|
||||
thumbnailDir.deleteRecursively() // clean up dir that was used in older versions
|
||||
normalizeLegacyFiles()
|
||||
val filesOnDisk = processedDir.listFiles()
|
||||
?.filter { it.extension == "jpg" }
|
||||
?.map { it.name }
|
||||
?.toSet()
|
||||
?: emptySet()
|
||||
|
||||
val metadataFiles: List<String>? = loadMetadata()
|
||||
?.pages
|
||||
?.map { it.file }
|
||||
val metadataPages = if (metadataFile.exists()) {
|
||||
runCatching { loadMetadata() }.getOrNull()
|
||||
} else null
|
||||
|
||||
return when {
|
||||
metadataFiles != null -> metadataFiles
|
||||
.filter { it in filesOnDisk }
|
||||
.toMutableList()
|
||||
else -> filesOnDisk
|
||||
.sorted()
|
||||
.toMutableList()
|
||||
metadataPages != null ->
|
||||
metadataPages
|
||||
.filter { processedImageFileName(it.id, it.colorMode, it.quadVersion) in filesOnDisk }
|
||||
.toMutableList()
|
||||
else ->
|
||||
filesOnDisk
|
||||
.sorted()
|
||||
.map { pageFromLegacyFileName(it) }
|
||||
.toMutableList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadMetadata(): DocumentMetadata? =
|
||||
if (metadataFile.exists()) {
|
||||
runCatching {
|
||||
Json.decodeFromString<DocumentMetadata>(metadataFile.readText())
|
||||
}.getOrNull()
|
||||
} else null
|
||||
private fun loadMetadata(): List<PageV2> {
|
||||
val json = metadataFile.readText()
|
||||
|
||||
private fun saveMetadata() {
|
||||
val metadata = DocumentMetadata(version = 1, pages = fileNames.map { id -> Page(id) })
|
||||
metadataFile.writeText(Json.encodeToString(metadata))
|
||||
val jsonElement = Json.parseToJsonElement(json)
|
||||
val version = jsonElement.jsonObject["version"]?.jsonPrimitive?.int ?: 1
|
||||
|
||||
return when (version) {
|
||||
1 -> migrateFromV1(Json.decodeFromJsonElement<DocumentMetadataV1>(jsonElement))
|
||||
2 -> Json.decodeFromJsonElement<DocumentMetadataV2>(jsonElement).pages
|
||||
else -> error("Unsupported metadata version: $version")
|
||||
}
|
||||
}
|
||||
|
||||
fun imageIds(): ImmutableList<String> = fileNames.toImmutableList()
|
||||
private fun migrateFromV1(meta: DocumentMetadataV1): MutableList<PageV2> =
|
||||
meta.pages.map { pageFromLegacyFileName(it.file) }.toMutableList()
|
||||
|
||||
fun add(bytes: ByteArray) {
|
||||
val fileName = "${System.currentTimeMillis()}.jpg"
|
||||
val file = File(scanDir, fileName)
|
||||
file.writeBytes(bytes)
|
||||
writeThumbnail(file)
|
||||
fileNames.add(fileName)
|
||||
private fun pageFromLegacyFileName(fileName: String): PageV2 {
|
||||
val name = fileName.removeSuffix(".jpg")
|
||||
val dashIndex = name.lastIndexOf('-')
|
||||
val id = if (dashIndex >= 0) name.substring(0, dashIndex) else name
|
||||
return PageV2(id)
|
||||
}
|
||||
|
||||
private fun saveMetadata() {
|
||||
val metadata = DocumentMetadataV2(pages = pages.pages())
|
||||
metadataFile.writeText(json.encodeToString(metadata))
|
||||
}
|
||||
|
||||
// --- Main API ---
|
||||
|
||||
suspend fun pages(): List<ScanPage> = mutex.withLock {
|
||||
pages.pages().mapNotNull {
|
||||
runCatching {
|
||||
val manualRotation = Rotation.fromDegrees(it.manualRotationDegrees)
|
||||
ScanPage(it.id, manualRotation, it.colorMode, it.quadVersion, it.toMetadata())
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun add(processed: Jpeg, source: Jpeg, metadata: PageMetadata, colorMode: ColorMode) =
|
||||
mutex.withLock {
|
||||
val id = "${System.currentTimeMillis()}"
|
||||
val key = PageViewKey(id, Rotation.R0, colorMode, 0)
|
||||
processedImageFile(key).writeBytes(processed.bytes)
|
||||
sourceFile(id).writeBytes(source.bytes)
|
||||
pages.addOrReplace(
|
||||
PageV2(
|
||||
id = id,
|
||||
quad = metadata.normalizedQuad.toSerializable(),
|
||||
baseRotationDegrees = metadata.baseRotation.degrees,
|
||||
manualRotationDegrees = Rotation.R0.degrees,
|
||||
isColored = metadata.autoColorMode == ColorMode.COLOR,
|
||||
colorMode = colorMode,
|
||||
focalLength = metadata.opticalMeasures?.cameraIntrinsics?.focalLength,
|
||||
sensorWidth = metadata.opticalMeasures?.cameraIntrinsics?.sensorWidth,
|
||||
subjectDistance = metadata.opticalMeasures?.subjectDistance,
|
||||
sourceWidth = metadata.sourceSize?.width?.toInt(),
|
||||
sourceHeight = metadata.sourceSize?.height?.toInt(),
|
||||
)
|
||||
)
|
||||
saveMetadata()
|
||||
// Pre-populate cache for R0
|
||||
imageCache.put(key, CompletableDeferred(processed))
|
||||
}
|
||||
|
||||
suspend fun setColorMode(id: String, colorMode: ColorMode) {
|
||||
updatePage(id) { page, metadata ->
|
||||
PageUpdate(
|
||||
updatedPage = page.copy(colorMode = colorMode),
|
||||
normalizedQuad = metadata.normalizedQuad,
|
||||
colorMode = colorMode,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setUserQuad(id: String, newQuad: Quad) {
|
||||
updatePage(id) { page, metadata ->
|
||||
PageUpdate(
|
||||
updatedPage = page.copy(
|
||||
quadVersion = page.quadVersion + 1,
|
||||
userQuad = newQuad.toSerializable(),
|
||||
),
|
||||
normalizedQuad = newQuad,
|
||||
colorMode = page.colorMode ?: metadata.autoColorMode,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class PageUpdate(
|
||||
val updatedPage: PageV2,
|
||||
val normalizedQuad: Quad,
|
||||
val colorMode: ColorMode,
|
||||
)
|
||||
|
||||
private suspend fun updatePage(
|
||||
id: String,
|
||||
buildUpdate: (PageV2, PageMetadata) -> PageUpdate
|
||||
) {
|
||||
val page = mutex.withLock { pages.get(id) }
|
||||
val metadata = page?.toMetadata() ?: return
|
||||
val sourceFile = sourceFile(id)
|
||||
if (!sourceFile.exists())
|
||||
return
|
||||
|
||||
val update = buildUpdate(page, metadata)
|
||||
val key = PageViewKey(
|
||||
pageId = id,
|
||||
rotation = Rotation.R0,
|
||||
colorMode = update.colorMode,
|
||||
quadVersion = update.updatedPage.quadVersion
|
||||
)
|
||||
|
||||
val processedFile = processedImageFile(key)
|
||||
val job = processingJobs.computeIfAbsent(key) {
|
||||
scope.async(Dispatchers.IO) {
|
||||
if (!processedFile.exists()) {
|
||||
val sourceJpeg = Jpeg(sourceFile.readBytes())
|
||||
val processedJpeg =
|
||||
transformations.process(
|
||||
sourceJpeg,
|
||||
metadata = metadata.copy(normalizedQuad = update.normalizedQuad),
|
||||
colorMode = update.colorMode
|
||||
)
|
||||
processedFile.writeBytes(processedJpeg.bytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
job.await()
|
||||
} finally {
|
||||
processingJobs.remove(key, job)
|
||||
}
|
||||
|
||||
mutex.withLock {
|
||||
pages.update(id) { update.updatedPage }
|
||||
saveMetadata()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun rotate(id: String, clockwise: Boolean) = mutex.withLock {
|
||||
val page = pages.get(id) ?: return@withLock
|
||||
val delta = if (clockwise) Rotation.R90 else Rotation.R270
|
||||
val newRotation = Rotation.fromDegrees(page.manualRotationDegrees).add(delta)
|
||||
pages.update(id) {
|
||||
it.copy(manualRotationDegrees = newRotation.degrees)
|
||||
}
|
||||
saveMetadata()
|
||||
}
|
||||
|
||||
val idRegex = Regex("([0-9]+)(-(90|180|270))?\\.jpg")
|
||||
suspend fun jpegBytes(key: PageViewKey): Jpeg? =
|
||||
getOrCompute(imageCache, key, ::computeProcessedImage)
|
||||
|
||||
fun rotate(id: String, clockwise: Boolean) {
|
||||
val originalFile = File(scanDir, id)
|
||||
if (!originalFile.exists()) {
|
||||
return
|
||||
|
||||
suspend fun getThumbnail(key: PageViewKey): Jpeg? =
|
||||
getOrCompute(thumbnailCache, key, ::computeThumbnail)
|
||||
|
||||
// --- Cache compute functions ---
|
||||
|
||||
private suspend fun getOrCompute(
|
||||
cache: MutableMap<PageViewKey, Deferred<Jpeg?>>,
|
||||
key: PageViewKey,
|
||||
compute: suspend (PageViewKey) -> Jpeg?
|
||||
): Jpeg? {
|
||||
val deferred = cache.computeIfAbsent(key) { k ->
|
||||
scope.async(Dispatchers.IO) { compute(k) }
|
||||
}
|
||||
idRegex.matchEntire(id)?.let {
|
||||
val baseId = it.groupValues[1]
|
||||
val degrees = it.groupValues[3].ifEmpty { "0" }.toInt()
|
||||
val targetDegrees = (degrees + (if (clockwise) 90 else 270)) % 360
|
||||
val rotatedId = if (targetDegrees == 0) "$baseId.jpg" else "$baseId-$targetDegrees.jpg"
|
||||
val rotatedFile = File(scanDir, rotatedId)
|
||||
transformations.rotate(originalFile, rotatedFile, clockwise)
|
||||
if (rotatedFile.exists()) {
|
||||
val index = fileNames.indexOf(id)
|
||||
if (index >= 0) {
|
||||
fileNames[index] = rotatedId
|
||||
saveMetadata()
|
||||
try {
|
||||
return deferred.await()
|
||||
} catch (e: Exception) {
|
||||
cache.remove(key, deferred)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun computeProcessedImage(key: PageViewKey): Jpeg? =
|
||||
withContext(Dispatchers.IO) {
|
||||
val baseFile = processedImageFile(key)
|
||||
if (!baseFile.exists()) return@withContext null
|
||||
val baseJpeg = Jpeg(baseFile.readBytes())
|
||||
if (key.rotation == Rotation.R0) {
|
||||
baseJpeg
|
||||
} else {
|
||||
transformations.rotate(
|
||||
baseJpeg,
|
||||
key.rotation.degrees)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun computeThumbnail(key: PageViewKey): Jpeg? =
|
||||
withContext(Dispatchers.IO) {
|
||||
val processed = getOrCompute(imageCache, key, ::computeProcessedImage)
|
||||
?: return@withContext null
|
||||
try {
|
||||
transformations.resizeToThumbnail(processed)
|
||||
} catch (e: Exception) {
|
||||
val message = "Failed to compute thumbnail for ${key.pageId}"
|
||||
logger.e("ImageRepository", message, e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// --- Other operations ---
|
||||
|
||||
private fun processedImageFileName(id: String, colorMode: ColorMode?, quadVersion: Int) : String {
|
||||
val sb = StringBuilder(id)
|
||||
if (colorMode != null)
|
||||
sb.append(".").append(colorMode.name.lowercase())
|
||||
if (quadVersion > 0)
|
||||
sb.append(".q").append(quadVersion)
|
||||
sb.append(".jpg")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun processedImageFile(key: PageViewKey) : File =
|
||||
File(processedDir, processedImageFileName(key.pageId, key.colorMode, key.quadVersion))
|
||||
|
||||
private fun sourceFile(id: String): File =
|
||||
File(sourceDir, "$id.jpg")
|
||||
|
||||
fun source(id: String): Jpeg? {
|
||||
val file = sourceFile(id)
|
||||
return if (file.exists()) Jpeg(file.readBytes()) else null
|
||||
}
|
||||
|
||||
suspend fun movePage(id: String, newIndex: Int) = mutex.withLock {
|
||||
pages.move(id, newIndex)
|
||||
saveMetadata()
|
||||
}
|
||||
|
||||
suspend fun delete(id: String) = mutex.withLock {
|
||||
pages.delete(id)
|
||||
saveMetadata()
|
||||
sourceFile(id).delete()
|
||||
processedDir.listFiles()
|
||||
?.filter { it.name.startsWith("$id.") || it.name.startsWith("$id-") }
|
||||
?.forEach { it.delete() }
|
||||
// No need to clean caches: stale entries will be evicted by LRU
|
||||
}
|
||||
|
||||
suspend fun clear() = mutex.withLock {
|
||||
pages.clear()
|
||||
saveMetadata()
|
||||
sourceDir.listFiles()?.forEach { it.delete() }
|
||||
processedDir.listFiles()?.forEach { it.delete() }
|
||||
synchronized(imageCache) { imageCache.clear() }
|
||||
synchronized(thumbnailCache) { thumbnailCache.clear() }
|
||||
}
|
||||
|
||||
// --- Legacy migration ---
|
||||
|
||||
data class DiskPageFiles(
|
||||
val base: File?,
|
||||
val rotated: List<File>
|
||||
)
|
||||
|
||||
// Legacy normalization strategy:
|
||||
// If only rotated files exist, keep ONE arbitrarily as base (id.jpg)
|
||||
// and discard the others. We intentionally sacrifice exact rotation
|
||||
// fidelity to restore a coherent model.
|
||||
private fun normalizeLegacyFiles() {
|
||||
val jpgs = processedDir.listFiles()?.filter { it.extension == "jpg" }.orEmpty()
|
||||
val byId = jpgs.groupBy { file ->
|
||||
val name = file.name.removeSuffix(".jpg")
|
||||
val dash = name.lastIndexOf('-')
|
||||
if (dash >= 0) name.substring(0, dash) else name
|
||||
}
|
||||
val pages = byId.mapValues { (_, files) ->
|
||||
val base = files.find { !it.name.contains('-') }
|
||||
val rotated = files.filter { it.name.contains('-') }
|
||||
DiskPageFiles(base, rotated)
|
||||
}
|
||||
pages.forEach { (id, files) ->
|
||||
if (files.base == null && files.rotated.isNotEmpty()) {
|
||||
val sortedRotatedFiles = files.rotated.sortedBy { it.name }
|
||||
val legacyFile = sortedRotatedFiles.first()
|
||||
val target = File(processedDir, "$id.jpg")
|
||||
if (legacyFile.renameTo(target)) {
|
||||
sortedRotatedFiles.drop(1).forEach { it.delete() }
|
||||
}
|
||||
delete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getContent(id: String): ByteArray? {
|
||||
return getFileFor(id)?.readBytes()
|
||||
}
|
||||
|
||||
fun getFileFor(id: String): File? {
|
||||
val file = File(scanDir, id)
|
||||
return if (file.exists()) file else null
|
||||
}
|
||||
|
||||
fun getThumbnail(id: String): ByteArray? {
|
||||
val thumbFile = getThumbnailFile(id)
|
||||
if (!thumbFile.exists()) {
|
||||
val originalFile = File(scanDir, id)
|
||||
if (!originalFile.exists()) return null
|
||||
writeThumbnail(originalFile)
|
||||
fun lastAddedSourceFile(): File? {
|
||||
val sourceFiles = sourceDir.listFiles()?.filter { it.extension == "jpg" }
|
||||
if (sourceFiles.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
return if (thumbFile.exists()) thumbFile.readBytes() else null
|
||||
}
|
||||
|
||||
private fun writeThumbnail(originalFile: File) {
|
||||
val thumbFile = getThumbnailFile(originalFile.name)
|
||||
transformations.resize(originalFile, thumbFile, thumbnailSizePx)
|
||||
}
|
||||
|
||||
private fun getThumbnailFile(id: String): File = File(thumbnailDir, id)
|
||||
|
||||
fun movePage(id: String, newIndex: Int) {
|
||||
if (!fileNames.remove(id)) return
|
||||
val safeIndex = newIndex.coerceIn(0, fileNames.size)
|
||||
fileNames.add(safeIndex, id)
|
||||
saveMetadata()
|
||||
}
|
||||
|
||||
fun delete(id: String) {
|
||||
File(scanDir, id).delete()
|
||||
getThumbnailFile(id).delete()
|
||||
fileNames.remove(id)
|
||||
saveMetadata()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
fileNames.clear()
|
||||
thumbnailDir.listFiles()?.forEach {
|
||||
file -> file.delete()
|
||||
}
|
||||
scanDir.listFiles()?.forEach {
|
||||
file -> file.delete()
|
||||
}
|
||||
saveMetadata() // "empty" json file
|
||||
return sourceFiles.maxByOrNull { it.lastModified() }
|
||||
}
|
||||
}
|
||||
|
||||
fun Quad.toSerializable(): NormalizedQuad =
|
||||
NormalizedQuad(
|
||||
topLeft = PointD(topLeft.x, topLeft.y),
|
||||
topRight = PointD(topRight.x, topRight.y),
|
||||
bottomRight = PointD(bottomRight.x, bottomRight.y),
|
||||
bottomLeft = PointD(bottomLeft.x, bottomLeft.y)
|
||||
)
|
||||
|
||||
fun NormalizedQuad.toQuad(): Quad =
|
||||
Quad(
|
||||
Point(topLeft.x, topLeft.y),
|
||||
Point(topRight.x, topRight.y),
|
||||
Point(bottomRight.x, bottomRight.y),
|
||||
Point(bottomLeft.x, bottomLeft.y)
|
||||
)
|
||||
|
||||
fun PageV2.toMetadata(): PageMetadata? {
|
||||
if (quad == null || isColored == null) return null
|
||||
val cameraIntrinsics = cameraIntrinsics(focalLength, sensorWidth)
|
||||
val sourceSize =
|
||||
if (sourceWidth != null && sourceHeight != null)
|
||||
ImageSize(sourceWidth, sourceHeight)
|
||||
else
|
||||
null
|
||||
return PageMetadata(
|
||||
(userQuad ?: quad).toQuad(),
|
||||
Rotation.fromDegrees(baseRotationDegrees),
|
||||
if (isColored) ColorMode.COLOR else ColorMode.GRAYSCALE,
|
||||
sourceSize,
|
||||
cameraIntrinsics?.let { OpticalMeasures(it, subjectDistance) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -14,12 +14,20 @@
|
||||
*/
|
||||
package org.fairscan.app.data
|
||||
|
||||
import java.io.File
|
||||
import org.fairscan.app.domain.Jpeg
|
||||
import org.fairscan.app.domain.PageMetadata
|
||||
import org.fairscan.imageprocessing.ColorMode
|
||||
|
||||
interface ImageTransformations {
|
||||
|
||||
fun rotate(inputFile: File, outputFile: File, clockwise: Boolean)
|
||||
fun rotate(input: Jpeg, rotationDegrees: Int): Jpeg
|
||||
|
||||
fun resize(inputFile: File, outputFile: File, maxSize: Int)
|
||||
fun resizeToThumbnail(input: Jpeg): Jpeg
|
||||
|
||||
fun process(
|
||||
source: Jpeg,
|
||||
metadata: PageMetadata,
|
||||
colorMode: ColorMode
|
||||
): Jpeg
|
||||
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.data
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
data class OcrLanguage(
|
||||
val code: String,
|
||||
) {
|
||||
val locale: Locale by lazy {
|
||||
resolveLocale(code)
|
||||
}
|
||||
|
||||
fun displayName(displayLocale: Locale): String =
|
||||
locale.getDisplayName(displayLocale)
|
||||
|
||||
companion object {
|
||||
private fun verticalVariant(locale: Locale) =
|
||||
Locale.Builder().setLocale(locale).setVariant("vertical").build()
|
||||
|
||||
private fun resolveLocale(code: String): Locale {
|
||||
val firstPart = code.substringBefore("_")
|
||||
return LOCALE_OVERRIDES[code] ?: Locale.Builder().setLanguage(firstPart).build()
|
||||
}
|
||||
|
||||
val AVAILABLE_LANGUAGE_CODES = listOf(
|
||||
"afr", "amh", "ara", "asm", "aze_cyrl", "aze", "bel", "ben", "bod", "bos",
|
||||
"bre", "bul", "cat", "ceb", "ces", "chi_sim", "chi_sim_vert", "chi_tra",
|
||||
"chi_tra_vert", "chr", "cos", "cym", "dan", "deu", "div", "dzo",
|
||||
"ell", "eng", "enm", "epo", "est", "eus", "fao", "fas", "fil", "fin", "fra",
|
||||
"frm", "fry", "gla", "gle", "glg", "grc", "guj", "hat", "heb", "hin", "hrv",
|
||||
"hun", "hye", "iku", "ind", "isl", "ita", "jav", "jpn", "jpn_vert",
|
||||
"kan", "kat_old", "kat", "kaz", "khm", "kir", "kmr", "kor", "kor_vert", "lao",
|
||||
"lat", "lav", "lit", "ltz", "mal", "mar", "mkd", "mlt", "mon", "mri", "msa",
|
||||
"mya", "nep", "nld", "nor", "oci", "ori", "pan", "pol", "por", "pus", "que",
|
||||
"ron", "rus", "san", "sin", "slk", "slv", "snd", "spa", "sqi",
|
||||
"srp_latn", "srp", "sun", "swa", "swe", "syr", "tam", "tat", "tel", "tgk",
|
||||
"tha", "tir", "ton", "tur", "uig", "ukr", "urd", "uzb_cyrl", "uzb", "vie",
|
||||
"yid", "yor",
|
||||
)
|
||||
|
||||
private val LOCALE_OVERRIDES = mapOf(
|
||||
// Codes not recognized by Locale.Builder
|
||||
"bod" to Locale.forLanguageTag("bo"), // Tibetan
|
||||
"ces" to Locale.forLanguageTag("cs"), // Czech
|
||||
"cym" to Locale.forLanguageTag("cy"), // Welsh
|
||||
"deu" to Locale.GERMAN,
|
||||
"ell" to Locale.forLanguageTag("el"), // Greek
|
||||
"eus" to Locale.forLanguageTag("eu"), // Basque
|
||||
"fas" to Locale.forLanguageTag("fa"), // Persian
|
||||
"fra" to Locale.FRENCH,
|
||||
"hye" to Locale.forLanguageTag("hy"), // Armenian
|
||||
"isl" to Locale.forLanguageTag("is"), // Icelandic
|
||||
"kat" to Locale.forLanguageTag("ka"), // Georgian
|
||||
"kmr" to Locale.forLanguageTag("ku"), // Kurdish
|
||||
"mkd" to Locale.forLanguageTag("mk"), // Macedonian
|
||||
"mri" to Locale.forLanguageTag("mi"), // Maori
|
||||
"msa" to Locale.forLanguageTag("ms"), // Malay
|
||||
"mya" to Locale.forLanguageTag("my"), // Burmese
|
||||
"nld" to Locale.forLanguageTag("nl"), // Dutch
|
||||
"ron" to Locale.forLanguageTag("ro"), // Romanian
|
||||
"slk" to Locale.forLanguageTag("sk"), // Slovak
|
||||
"sqi" to Locale.forLanguageTag("sq"), // Albanian
|
||||
// Variants pointing to same Locale
|
||||
"chi_sim" to Locale.SIMPLIFIED_CHINESE,
|
||||
"chi_tra" to Locale.TRADITIONAL_CHINESE,
|
||||
"chi_sim_vert" to verticalVariant(Locale.SIMPLIFIED_CHINESE),
|
||||
"chi_tra_vert" to verticalVariant(Locale.TRADITIONAL_CHINESE),
|
||||
"jpn_vert" to verticalVariant(Locale.JAPANESE),
|
||||
"kor_vert" to verticalVariant(Locale.forLanguageTag("ko")),
|
||||
"aze_cyrl" to Locale.forLanguageTag("az-Cyrl"),
|
||||
"uzb_cyrl" to Locale.forLanguageTag("uz-Cyrl"),
|
||||
"srp_latn" to Locale.forLanguageTag("sr-Latn"),
|
||||
"deu_latf" to Locale.forLanguageTag("de-1901"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.data
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.fairscan.app.data.OcrLanguage.Companion.AVAILABLE_LANGUAGE_CODES
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
class OcrLanguageRepository(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
val tessdataDir: File,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private val ENABLED_LANGUAGES = stringSetPreferencesKey("enabled_ocr_languages")
|
||||
}
|
||||
|
||||
val enabledLanguages: Flow<Set<String>> =
|
||||
dataStore.data.map { prefs ->
|
||||
prefs[ENABLED_LANGUAGES] ?: emptySet()
|
||||
}
|
||||
|
||||
suspend fun getInstalledLanguages(): Set<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (!tessdataDir.exists()) {
|
||||
return@withContext emptySet()
|
||||
}
|
||||
return@withContext tessdataDir
|
||||
.listFiles { _, name -> name.endsWith(".traineddata") }
|
||||
?.map { f -> f.nameWithoutExtension }
|
||||
?.filter { it in AVAILABLE_LANGUAGE_CODES }
|
||||
?.toSet()
|
||||
?: emptySet()
|
||||
}
|
||||
|
||||
suspend fun getActiveLanguages(): Set<String> {
|
||||
val enabled = enabledLanguagesValue()
|
||||
val installed = getInstalledLanguages()
|
||||
return enabled.intersect(installed)
|
||||
}
|
||||
|
||||
suspend fun setLanguageEnabled(
|
||||
code: String,
|
||||
enabled: Boolean,
|
||||
) {
|
||||
require(code in AVAILABLE_LANGUAGE_CODES)
|
||||
if (enabled && code !in getInstalledLanguages()) {
|
||||
return
|
||||
}
|
||||
dataStore.edit { prefs ->
|
||||
val current = prefs[ENABLED_LANGUAGES]?.toMutableSet() ?: mutableSetOf()
|
||||
if (enabled) {
|
||||
current.add(code)
|
||||
} else {
|
||||
current.remove(code)
|
||||
}
|
||||
prefs[ENABLED_LANGUAGES] = current
|
||||
}
|
||||
}
|
||||
|
||||
data class DownloadProgress(
|
||||
val downloadedBytes: Long,
|
||||
val totalBytes: Long?,
|
||||
)
|
||||
|
||||
suspend fun downloadLanguage(code: String, onProgress: (DownloadProgress) -> Unit) =
|
||||
withContext(Dispatchers.IO) {
|
||||
require(code in AVAILABLE_LANGUAGE_CODES)
|
||||
tessdataDir.mkdirs()
|
||||
|
||||
val url = URL("https://raw.githubusercontent.com/tesseract-ocr/tessdata_fast/main/$code.traineddata")
|
||||
val targetFile = File(tessdataDir, "$code.traineddata")
|
||||
val tempFile = File(tessdataDir, "$code.download")
|
||||
|
||||
val connection = (url.openConnection() as HttpURLConnection)
|
||||
try {
|
||||
connection.requestMethod = "GET"
|
||||
connection.connectTimeout = 15_000
|
||||
connection.readTimeout = 30_000
|
||||
connection.instanceFollowRedirects = true
|
||||
connection.connect()
|
||||
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
|
||||
throw IOException("HTTP ${connection.responseCode}")
|
||||
}
|
||||
connection.inputStream.use { input ->
|
||||
tempFile.outputStream().use { output ->
|
||||
val totalBytes = connection.contentLengthLong.takeIf { it > 0 }
|
||||
val buffer = ByteArray(8192)
|
||||
var downloadedBytes = 0L
|
||||
input.use { input ->
|
||||
tempFile.outputStream().use { output ->
|
||||
while (true) {
|
||||
coroutineContext.ensureActive()
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
output.write(buffer, 0, read)
|
||||
downloadedBytes += read
|
||||
onProgress(DownloadProgress(downloadedBytes, totalBytes))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tempFile.length() < 300_000) {
|
||||
throw IOException("Downloaded file too small")
|
||||
}
|
||||
if (!tempFile.renameTo(targetFile)) {
|
||||
throw IOException("Failed to move downloaded file")
|
||||
}
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteLanguage(code: String) {
|
||||
val file = File(tessdataDir, "$code.traineddata")
|
||||
file.delete()
|
||||
setLanguageEnabled(code, false)
|
||||
}
|
||||
|
||||
suspend fun buildTesseractLanguageString(): String {
|
||||
return getActiveLanguages()
|
||||
.sorted()
|
||||
.joinToString("+")
|
||||
}
|
||||
|
||||
private suspend fun enabledLanguagesValue(): Set<String> {
|
||||
return enabledLanguages.map { it }.first()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.data
|
||||
|
||||
class PageStore(pages: List<PageV2>) {
|
||||
|
||||
private val pages = LinkedHashMap<String, PageV2>()
|
||||
.also { map -> pages.forEach { map.put(it.id, it) } }
|
||||
|
||||
fun pages(): List<PageV2> =
|
||||
pages.values.toList()
|
||||
|
||||
fun get(id: String): PageV2? =
|
||||
pages[id]
|
||||
|
||||
fun addOrReplace(page: PageV2) =
|
||||
pages.put(page.id, page)
|
||||
|
||||
fun update(id: String, transform: (PageV2) -> PageV2) {
|
||||
val page = pages[id] ?: return
|
||||
pages[id] = transform(page)
|
||||
}
|
||||
|
||||
fun delete(id: String) = pages.remove(id)
|
||||
|
||||
fun clear() = pages.clear()
|
||||
|
||||
fun move(id: String, newIndex: Int) {
|
||||
val page = pages[id] ?: return
|
||||
|
||||
val entries = pages.entries.toList()
|
||||
.filterNot { it.key == id }
|
||||
|
||||
pages.clear()
|
||||
|
||||
val safeIndex = newIndex.coerceIn(0, entries.size)
|
||||
entries.take(safeIndex).forEach { pages[it.key] = it.value }
|
||||
pages.put(id, page)
|
||||
entries.drop(safeIndex).forEach { pages[it.key] = it.value }
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
/*
|
||||
* 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.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.CorruptionException
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.core.Serializer
|
||||
import androidx.datastore.dataStore
|
||||
import com.google.protobuf.InvalidProtocolBufferException
|
||||
import org.fairscan.app.RecentDocuments
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
object RecentDocumentsSerializer : Serializer<RecentDocuments> {
|
||||
override val defaultValue: RecentDocuments = RecentDocuments.getDefaultInstance()
|
||||
|
||||
override suspend fun readFrom(input: InputStream): RecentDocuments {
|
||||
return try {
|
||||
RecentDocuments.parseFrom(input)
|
||||
} catch (e: InvalidProtocolBufferException) {
|
||||
throw CorruptionException("Cannot read proto.", e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun writeTo(
|
||||
t: RecentDocuments,
|
||||
output: OutputStream
|
||||
) = t.writeTo(output)
|
||||
}
|
||||
|
||||
val Context.recentDocumentsDataStore: DataStore<RecentDocuments> by dataStore(
|
||||
fileName = "recent_documents.pb",
|
||||
serializer = RecentDocumentsSerializer
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.domain
|
||||
|
||||
import kotlinx.coroutines.Deferred
|
||||
import org.fairscan.imageprocessing.ColorMode
|
||||
|
||||
data class CapturedPage(
|
||||
val pageJpeg: Jpeg,
|
||||
val sourceJpeg: Deferred<Jpeg>,
|
||||
val metadata: PageMetadata,
|
||||
val colorMode: ColorMode,
|
||||
)
|
||||
@@ -1,189 +0,0 @@
|
||||
/*
|
||||
* 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.domain
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.core.graphics.createBitmap
|
||||
import org.fairscan.app.domain.ImageSegmentationService.Segmentation
|
||||
import org.fairscan.app.domain.quad.detectDocumentQuadFromProbmap
|
||||
import org.fairscan.app.domain.quad.findQuadFromRightAngles
|
||||
import org.fairscan.app.domain.quad.minAreaRect
|
||||
import org.opencv.android.Utils
|
||||
import org.opencv.core.Core
|
||||
import org.opencv.core.CvType
|
||||
import org.opencv.core.Mat
|
||||
import org.opencv.core.MatOfPoint
|
||||
import org.opencv.core.MatOfPoint2f
|
||||
import org.opencv.core.Size
|
||||
import org.opencv.imgproc.Imgproc
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
|
||||
fun detectDocumentQuad(mask: Segmentation, isLiveAnalysis: Boolean, minQuadAreaRatio: Double = 0.02): Quad? {
|
||||
val mat = mask.toMat()
|
||||
val (biggest: MatOfPoint2f?, area) = biggestContour(mat)
|
||||
var vertices: List<Point>?
|
||||
if (biggest != null && biggest.total() == 4L && area > mask.width * mask.height * minQuadAreaRatio) {
|
||||
vertices = biggest.toList()?.map { Point(it.x, it.y) }
|
||||
} else {
|
||||
|
||||
// Fallback 1: adjust threshold
|
||||
val thresholds =
|
||||
if (isLiveAnalysis) listOf(25.0, 50.0, 75.0) else (0..12).map { 0.2 + it * 0.05 }
|
||||
vertices = detectDocumentQuadFromProbmap(mat, thresholds)
|
||||
?.map { Point(it.x, it.y) }
|
||||
if (vertices == null && biggest != null && biggest.total() > 4) {
|
||||
|
||||
// Fallback 2: look for right angles
|
||||
val polygon = biggest.toList().map { Point(it.x, it.y) }
|
||||
vertices = findQuadFromRightAngles(polygon, mask.width, mask.height)
|
||||
if (vertices == null && !isLiveAnalysis) {
|
||||
|
||||
// Fallback 3: bounding rectangle
|
||||
vertices = minAreaRect(polygon, mask.width, mask.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
return if (vertices?.size == 4) createQuad(vertices) else null
|
||||
}
|
||||
|
||||
private fun biggestContour(mat: Mat): Pair<MatOfPoint2f?, Double> {
|
||||
val mat8u = Mat()
|
||||
mat.convertTo(mat8u, CvType.CV_8UC1, 255.0)
|
||||
|
||||
val refinedMask = refineMask(mat8u)
|
||||
|
||||
val blurred = Mat()
|
||||
Imgproc.GaussianBlur(refinedMask, blurred, Size(5.0, 5.0), 0.0)
|
||||
|
||||
val edges = Mat()
|
||||
Imgproc.Canny(blurred, edges, 75.0, 200.0)
|
||||
|
||||
val contours = mutableListOf<MatOfPoint>()
|
||||
val hierarchy = Mat()
|
||||
Imgproc.findContours(edges, contours, hierarchy, Imgproc.RETR_LIST, Imgproc.CHAIN_APPROX_SIMPLE)
|
||||
|
||||
var biggest: MatOfPoint2f? = null
|
||||
var maxArea = 0.0
|
||||
|
||||
for (contour in contours) {
|
||||
val contour2f = MatOfPoint2f(*contour.toArray())
|
||||
val peri = Imgproc.arcLength(contour2f, true)
|
||||
val approx = MatOfPoint2f()
|
||||
Imgproc.approxPolyDP(contour2f, approx, 0.02 * peri, true)
|
||||
|
||||
val area = abs(Imgproc.contourArea(approx))
|
||||
if (area > maxArea) {
|
||||
maxArea = area
|
||||
biggest = approx
|
||||
}
|
||||
}
|
||||
return Pair(biggest, maxArea)
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies morphological operations to improve a document mask.
|
||||
*/
|
||||
fun refineMask(original: Mat): Mat {
|
||||
// Step 0: Ensure the mask is binary (just in case)
|
||||
val binaryMask = Mat()
|
||||
Imgproc.threshold(original, binaryMask, 128.0, 255.0, Imgproc.THRESH_BINARY)
|
||||
|
||||
// Step 1: Closing (fills small holes)
|
||||
val kernelClose = Imgproc.getStructuringElement(Imgproc.MORPH_ELLIPSE, Size(5.0, 5.0))
|
||||
val closed = Mat()
|
||||
Imgproc.morphologyEx(binaryMask, closed, Imgproc.MORPH_CLOSE, kernelClose)
|
||||
|
||||
// Step 2: Gentle opening (removes isolated noise)
|
||||
val kernelOpen = Imgproc.getStructuringElement(Imgproc.MORPH_ELLIPSE, Size(5.0, 5.0))
|
||||
val opened = Mat()
|
||||
Imgproc.morphologyEx(closed, opened, Imgproc.MORPH_OPEN, kernelOpen)
|
||||
|
||||
return opened
|
||||
}
|
||||
|
||||
fun extractDocument(originalBitmap: Bitmap, quad: Quad, rotationDegrees: Int): Bitmap {
|
||||
val widthTop = norm(quad.topLeft, quad.topRight)
|
||||
val widthBottom = norm(quad.bottomLeft, quad.bottomRight)
|
||||
val targetWidth = (widthTop + widthBottom) / 2
|
||||
|
||||
val heightLeft = norm(quad.topLeft, quad.bottomLeft)
|
||||
val heightRight = norm(quad.topRight, quad.bottomRight)
|
||||
val targetHeight = (heightLeft + heightRight) / 2
|
||||
|
||||
val srcPoints = MatOfPoint2f(
|
||||
quad.topLeft.toCv(),
|
||||
quad.topRight.toCv(),
|
||||
quad.bottomRight.toCv(),
|
||||
quad.bottomLeft.toCv(),
|
||||
)
|
||||
val dstPoints = MatOfPoint2f(
|
||||
org.opencv.core.Point(0.0, 0.0),
|
||||
org.opencv.core.Point(targetWidth.toDouble(), 0.0),
|
||||
org.opencv.core.Point(targetWidth.toDouble(), targetHeight.toDouble()),
|
||||
org.opencv.core.Point(0.0, targetHeight.toDouble())
|
||||
)
|
||||
val transform = Imgproc.getPerspectiveTransform(srcPoints, dstPoints)
|
||||
|
||||
val inputMat = Mat()
|
||||
Utils.bitmapToMat(originalBitmap, inputMat)
|
||||
val outputMat = Mat()
|
||||
val outputSize = Size(targetWidth.toDouble(), targetHeight.toDouble())
|
||||
Imgproc.warpPerspective(inputMat, outputMat, transform, outputSize)
|
||||
|
||||
val resized = resize(outputMat, 1500.0)
|
||||
val enhanced = enhanceCapturedImage(resized)
|
||||
val rotated = rotate(enhanced, rotationDegrees)
|
||||
|
||||
return toBitmap(rotated)
|
||||
}
|
||||
|
||||
fun resize(original: Mat, targetMax: Double): Mat {
|
||||
val origSize = original.size()
|
||||
if (max(origSize.width, origSize.height) < targetMax)
|
||||
return original;
|
||||
var targetWidth = targetMax
|
||||
var targetHeight = origSize.height * targetWidth / origSize.width
|
||||
if (origSize.width < origSize.height) {
|
||||
targetHeight = targetMax
|
||||
targetWidth = origSize.width * targetHeight / origSize.height
|
||||
}
|
||||
val result = Mat()
|
||||
Imgproc.resize(original, result, Size(targetWidth, targetHeight), 0.0, 0.0, Imgproc.INTER_AREA)
|
||||
return result
|
||||
}
|
||||
|
||||
fun rotate(input: Mat, degrees: Int): Mat {
|
||||
val output = Mat()
|
||||
when ((degrees % 360 + 360) % 360) {
|
||||
0 -> input.copyTo(output)
|
||||
90 -> Core.rotate(input, output, Core.ROTATE_90_CLOCKWISE)
|
||||
180 -> Core.rotate(input, output, Core.ROTATE_180)
|
||||
270 -> Core.rotate(input, output, Core.ROTATE_90_COUNTERCLOCKWISE)
|
||||
else -> throw IllegalArgumentException("Only 0, 90, 180, 270 degrees are supported")
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private fun toBitmap(mat: Mat): Bitmap {
|
||||
val outputBitmap = createBitmap(mat.cols(), mat.rows())
|
||||
Utils.matToBitmap(mat, outputBitmap)
|
||||
return outputBitmap
|
||||
}
|
||||
|
||||
fun Point.toCv(): org.opencv.core.Point {
|
||||
return org.opencv.core.Point(x, y)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.domain
|
||||
|
||||
import org.fairscan.app.data.ImageRepository
|
||||
import org.fairscan.app.platform.processedImage
|
||||
import org.fairscan.imageprocessing.EstimatedDimensions
|
||||
import org.fairscan.imageprocessing.estimateRealDimensions
|
||||
import org.fairscan.imageprocessing.resizeForMaxPixels
|
||||
import org.fairscan.imageprocessing.scaledTo
|
||||
import org.opencv.core.Mat
|
||||
|
||||
fun interface JpegProvider {
|
||||
suspend fun get(): Jpeg
|
||||
}
|
||||
|
||||
data class PageToExport(
|
||||
val page: ScanPage,
|
||||
val jpeg: JpegProvider,
|
||||
) {
|
||||
fun estimatedDimensions(): EstimatedDimensions? {
|
||||
val metadata = page.metadata
|
||||
if (metadata == null)
|
||||
return null
|
||||
val size = metadata.sourceSize
|
||||
if (size == null)
|
||||
return null
|
||||
val quad = metadata.normalizedQuad.scaledTo(1.0, 1.0, size.width, size.height)
|
||||
val realDimensions = estimateRealDimensions(
|
||||
quad, size.width.toInt(), size.height.toInt(), metadata.opticalMeasures
|
||||
).snapToStandardFormat()
|
||||
return realDimensions.applyRotation(page.totalRotation())
|
||||
}
|
||||
}
|
||||
|
||||
private fun EstimatedDimensions.applyRotation(rotation: Rotation): EstimatedDimensions {
|
||||
if ((rotation == Rotation.R90 || rotation == Rotation.R270)
|
||||
&& this is EstimatedDimensions.Physical) {
|
||||
return EstimatedDimensions.Physical(heightMm, widthMm)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
suspend fun pagesToExport(
|
||||
imageRepository: ImageRepository,
|
||||
exportQuality: ExportQuality
|
||||
): List<PageToExport> {
|
||||
|
||||
val pages = imageRepository.pages()
|
||||
return when (exportQuality) {
|
||||
ExportQuality.BALANCED -> pages.map {
|
||||
PageToExport(it) { jpeg(it, imageRepository) }
|
||||
}
|
||||
|
||||
ExportQuality.LOW -> pages.map { page ->
|
||||
PageToExport(page) {
|
||||
resizeJpegBytesForMaxPixels(
|
||||
jpeg = jpeg(page, imageRepository),
|
||||
maxPixels = exportQuality.maxPixels.toDouble(),
|
||||
jpegQuality = exportQuality.jpegQuality
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ExportQuality.HIGH -> pages.map { page ->
|
||||
PageToExport(page) {
|
||||
val source = imageRepository.source(page.id)
|
||||
val metadata = page.metadata
|
||||
val colorMode = page.colorMode
|
||||
if (source != null && metadata != null && colorMode != null) {
|
||||
val rotation = page.totalRotation()
|
||||
processedImage(source, metadata, rotation, colorMode, exportQuality)
|
||||
}
|
||||
else
|
||||
jpeg(page, imageRepository)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun jpeg(page: ScanPage, imageRepository: ImageRepository): Jpeg {
|
||||
val key = page.key()
|
||||
return imageRepository.jpegBytes(key)
|
||||
?: throw IllegalArgumentException("JPEG not found for $key")
|
||||
}
|
||||
|
||||
private fun resizeJpegBytesForMaxPixels(
|
||||
jpeg: Jpeg,
|
||||
maxPixels: Double,
|
||||
jpegQuality: Int
|
||||
): Jpeg {
|
||||
var decoded: Mat? = null
|
||||
var resized: Mat? = null
|
||||
try {
|
||||
decoded = jpeg.toMat()
|
||||
resized = resizeForMaxPixels(decoded, maxPixels)
|
||||
return Jpeg.fromMat(resized, jpegQuality)
|
||||
} finally {
|
||||
decoded?.release()
|
||||
resized?.release()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.domain
|
||||
|
||||
import org.fairscan.app.R
|
||||
|
||||
enum class ExportQuality(
|
||||
val jpegQuality: Int,
|
||||
val maxPixels: Long,
|
||||
val labelResource: Int
|
||||
) {
|
||||
LOW(
|
||||
jpegQuality = 60,
|
||||
maxPixels = 1_000_000,
|
||||
R.string.export_quality_low,
|
||||
),
|
||||
BALANCED(
|
||||
jpegQuality = 75,
|
||||
maxPixels = 2_000_000,
|
||||
R.string.export_quality_balanced,
|
||||
),
|
||||
HIGH(
|
||||
jpegQuality = 80,
|
||||
maxPixels = 4_000_000,
|
||||
R.string.export_quality_high,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.domain
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import org.fairscan.imageprocessing.decodeJpeg
|
||||
import org.fairscan.imageprocessing.encodeJpeg
|
||||
import org.opencv.core.Mat
|
||||
|
||||
class Jpeg(val bytes: ByteArray) {
|
||||
companion object {
|
||||
fun fromMat(mat: Mat, jpegQuality: Int): Jpeg = Jpeg(encodeJpeg(mat, jpegQuality))
|
||||
}
|
||||
fun toBitmap() : Bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
fun toMat() : Mat = decodeJpeg(bytes)
|
||||
}
|
||||
|
||||
interface ImageLoader {
|
||||
suspend fun load(uri: Uri): Bitmap
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -20,15 +20,11 @@ import android.graphics.Bitmap.createBitmap
|
||||
import android.graphics.Color
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.fairscan.app.data.Logger
|
||||
import org.fairscan.imageprocessing.ImageSize
|
||||
import org.fairscan.imageprocessing.Mask
|
||||
import org.opencv.core.CvType
|
||||
import org.opencv.core.Mat
|
||||
import org.tensorflow.lite.DataType
|
||||
@@ -38,7 +34,6 @@ import org.tensorflow.lite.support.common.ops.NormalizeOp
|
||||
import org.tensorflow.lite.support.image.ImageProcessor
|
||||
import org.tensorflow.lite.support.image.TensorImage
|
||||
import org.tensorflow.lite.support.image.ops.ResizeOp
|
||||
import org.tensorflow.lite.support.image.ops.Rot90Op
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
@@ -48,9 +43,6 @@ class ImageSegmentationService(private val context: Context, private val logger:
|
||||
private const val TAG = "ImageSegmentation"
|
||||
}
|
||||
|
||||
private val _segmentation = MutableStateFlow<SegmentationResult?>(null)
|
||||
val segmentation: StateFlow<SegmentationResult?> = _segmentation.asStateFlow()
|
||||
|
||||
private var interpreter: Interpreter? = null
|
||||
private val inferenceLock = Mutex()
|
||||
|
||||
@@ -69,16 +61,14 @@ class ImageSegmentationService(private val context: Context, private val logger:
|
||||
}
|
||||
}
|
||||
|
||||
private fun runSegmentation(interpreter: Interpreter, bitmap: Bitmap, rotationDegrees: Int): SegmentationResult {
|
||||
private fun runSegmentation(interpreter: Interpreter, bitmap: Bitmap): SegmentationResult {
|
||||
val startTime = SystemClock.uptimeMillis()
|
||||
|
||||
val rotation = -rotationDegrees / 90
|
||||
val (_, h, w, _) = interpreter.getOutputTensor(0).shape()
|
||||
val imageProcessor =
|
||||
ImageProcessor
|
||||
.Builder()
|
||||
.add(ResizeOp(h, w, ResizeOp.ResizeMethod.BILINEAR))
|
||||
.add(Rot90Op(rotation))
|
||||
.add(NormalizeOp(127.5f, 127.5f)) // TODO check if it's correct
|
||||
.build()
|
||||
val tensorImage = TensorImage(DataType.FLOAT32)
|
||||
@@ -90,25 +80,12 @@ class ImageSegmentationService(private val context: Context, private val logger:
|
||||
return SegmentationResult(segmentResult, inferenceTime)
|
||||
}
|
||||
|
||||
suspend fun runSegmentationAndReturn(bitmap: Bitmap, rotationDegrees: Int): SegmentationResult? {
|
||||
suspend fun runSegmentationAndReturn(bitmap: Bitmap): SegmentationResult? {
|
||||
if (interpreter == null) {
|
||||
return null
|
||||
}
|
||||
return inferenceLock.withLock {
|
||||
runSegmentation(interpreter!!, bitmap, rotationDegrees)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun runSegmentationAndEmit(bitmap: Bitmap, rotationDegrees: Int) {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
val segmentationResult = runSegmentationAndReturn(bitmap, rotationDegrees)
|
||||
if (isActive) {
|
||||
_segmentation.value = segmentationResult
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.e(TAG, "Error occurred in image segmentation", e)
|
||||
runSegmentation(interpreter!!, bitmap)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +109,11 @@ class ImageSegmentationService(private val context: Context, private val logger:
|
||||
return maskFloats
|
||||
}
|
||||
|
||||
data class Segmentation(private val probmap: FloatArray, val width: Int, val height: Int) {
|
||||
data class Segmentation(
|
||||
private val probmap: FloatArray,
|
||||
override val width: Int,
|
||||
override val height: Int
|
||||
): Mask {
|
||||
fun get(x: Int, y: Int): Float = probmap[y * width + x]
|
||||
fun toBinaryMask(): Bitmap {
|
||||
val bmp = createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
@@ -144,11 +125,22 @@ class ImageSegmentationService(private val context: Context, private val logger:
|
||||
bmp.setPixels(pixels, 0, width, 0, 0, width, height)
|
||||
return bmp
|
||||
}
|
||||
fun toMat(): Mat {
|
||||
val mat = Mat(height, width, CvType.CV_32FC1)
|
||||
mat.put(0, 0, probmap)
|
||||
return mat
|
||||
|
||||
override fun toMat(): Mat {
|
||||
val threshold = 0.5f
|
||||
|
||||
val mask = Mat(height, width, CvType.CV_8UC1)
|
||||
val data = ByteArray(width * height)
|
||||
|
||||
for (i in probmap.indices) {
|
||||
data[i] = if (probmap[i] >= threshold) 255.toByte() else 0.toByte()
|
||||
}
|
||||
|
||||
mask.put(0, 0, data)
|
||||
return mask
|
||||
}
|
||||
|
||||
fun maskSize() = ImageSize(width, height)
|
||||
}
|
||||
|
||||
data class SegmentationResult(
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.domain
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Rect
|
||||
import com.googlecode.tesseract.android.TessBaseAPI
|
||||
import com.googlecode.tesseract.android.TessBaseAPI.PageIteratorLevel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.fairscan.app.data.OcrLanguageRepository
|
||||
import org.fairscan.imageprocessing.ImageRect
|
||||
import org.fairscan.imageprocessing.OcrTextBox
|
||||
|
||||
class OcrService(
|
||||
private val ocrLanguageRepository: OcrLanguageRepository,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private var tess: TessBaseAPI? = null
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
private var languageString = ""
|
||||
fun languageString() = languageString
|
||||
|
||||
fun initialize() {
|
||||
scope.launch {
|
||||
ocrLanguageRepository.enabledLanguages.collect { _ -> reinitialize() }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reinitialize() {
|
||||
mutex.withLock {
|
||||
tess?.recycle()
|
||||
tess = null
|
||||
|
||||
languageString = ocrLanguageRepository.buildTesseractLanguageString()
|
||||
if (languageString.isEmpty()) return
|
||||
|
||||
val dataPath = ocrLanguageRepository.tessdataDir.parent!!
|
||||
val newTess = TessBaseAPI()
|
||||
if (!newTess.init(dataPath, languageString)) {
|
||||
newTess.recycle()
|
||||
return
|
||||
}
|
||||
tess = newTess
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun runOcr(bitmap: Bitmap): List<OcrTextBox> {
|
||||
mutex.withLock {
|
||||
val tess = this.tess ?: return listOf()
|
||||
val textBoxes = mutableListOf<OcrTextBox>()
|
||||
tess.setImage(bitmap)
|
||||
tess.getUTF8Text() // Trigger text recognition
|
||||
val iterator = tess.resultIterator
|
||||
iterator.begin()
|
||||
do {
|
||||
val word = iterator.getUTF8Text(PageIteratorLevel.RIL_WORD) ?: continue
|
||||
val wordBox = iterator.getBoundingRect(PageIteratorLevel.RIL_WORD)
|
||||
val lineBox = iterator.getBoundingRect(PageIteratorLevel.RIL_TEXTLINE)
|
||||
val confidence = iterator.confidence(PageIteratorLevel.RIL_WORD)
|
||||
if (confidence > 50) {
|
||||
val rect = wordBox.toImageRect()
|
||||
textBoxes.add(OcrTextBox(word, rect, lineBox.height(), lineBox.bottom))
|
||||
}
|
||||
} while (iterator.next(PageIteratorLevel.RIL_WORD))
|
||||
iterator.delete()
|
||||
return textBoxes
|
||||
}
|
||||
}
|
||||
|
||||
private fun Rect.toImageRect(): ImageRect = ImageRect(left, top, right, bottom)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.domain
|
||||
|
||||
import org.fairscan.imageprocessing.ColorMode
|
||||
import org.fairscan.imageprocessing.ImageSize
|
||||
import org.fairscan.imageprocessing.OpticalMeasures
|
||||
import org.fairscan.imageprocessing.Quad
|
||||
|
||||
data class PageMetadata(
|
||||
val normalizedQuad: Quad,
|
||||
val baseRotation: Rotation,
|
||||
val autoColorMode: ColorMode,
|
||||
val sourceSize: ImageSize?,
|
||||
val opticalMeasures: OpticalMeasures?,
|
||||
)
|
||||
|
||||
data class ScanPage(
|
||||
val id: String,
|
||||
val manualRotation: Rotation,
|
||||
val colorMode: ColorMode?,
|
||||
val quadVersion: Int,
|
||||
val metadata: PageMetadata?,
|
||||
) {
|
||||
fun key() = PageViewKey(id, manualRotation, colorMode, quadVersion)
|
||||
fun totalRotation() = manualRotation.add(metadata?.baseRotation ?: Rotation.R0)
|
||||
}
|
||||
|
||||
data class PageViewKey(
|
||||
val pageId: String,
|
||||
val rotation: Rotation,
|
||||
val colorMode: ColorMode?,
|
||||
val quadVersion: Int,
|
||||
)
|
||||
enum class Rotation(val degrees: Int) {
|
||||
R0(0),
|
||||
R90(90),
|
||||
R180(180),
|
||||
R270(270);
|
||||
|
||||
fun add(other: Rotation): Rotation =
|
||||
fromDegrees((degrees + other.degrees) % 360)
|
||||
|
||||
companion object {
|
||||
fun fromDegrees(deg: Int): Rotation =
|
||||
entries.first { it.degrees == ((deg % 360 + 360) % 360) }
|
||||
}
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
/*
|
||||
* 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.domain
|
||||
|
||||
import android.util.Log
|
||||
import org.opencv.core.Core
|
||||
import org.opencv.core.CvType
|
||||
import org.opencv.core.Mat
|
||||
import org.opencv.core.Scalar
|
||||
import org.opencv.core.Size
|
||||
import org.opencv.imgproc.Imgproc
|
||||
import kotlin.math.max
|
||||
|
||||
fun enhanceCapturedImage(img: Mat): Mat {
|
||||
return if (isColoredDocument(img)) {
|
||||
Log.i("PostProcessing", "color document")
|
||||
val result = Mat()
|
||||
Core.convertScaleAbs(img, result, 1.2, 10.0)
|
||||
result
|
||||
} else {
|
||||
Log.i("PostProcessing", "grayscale document")
|
||||
val gray = multiScaleRetinex(img)
|
||||
val contrastedGray = enhanceContrastAuto(gray)
|
||||
val result = Mat()
|
||||
Imgproc.cvtColor(contrastedGray, result, Imgproc.COLOR_GRAY2BGR)
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
fun isColoredDocument(
|
||||
img: Mat,
|
||||
chromaThreshold: Double = 20.0,
|
||||
proportionThreshold: Double = 0.001
|
||||
): Boolean {
|
||||
val lab = Mat()
|
||||
Imgproc.cvtColor(img, lab, Imgproc.COLOR_BGR2Lab)
|
||||
|
||||
val channels = ArrayList<Mat>()
|
||||
Core.split(lab, channels)
|
||||
val a = channels[1]
|
||||
val b = channels[2]
|
||||
|
||||
val aFloat = Mat()
|
||||
val bFloat = Mat()
|
||||
a.convertTo(aFloat, CvType.CV_32F)
|
||||
b.convertTo(bFloat, CvType.CV_32F)
|
||||
|
||||
val aShifted = Mat()
|
||||
val bShifted = Mat()
|
||||
Core.subtract(aFloat, Scalar(128.0), aShifted)
|
||||
Core.subtract(bFloat, Scalar(128.0), bShifted)
|
||||
|
||||
val aSq = Mat()
|
||||
val bSq = Mat()
|
||||
Core.multiply(aShifted, aShifted, aSq)
|
||||
Core.multiply(bShifted, bShifted, bSq)
|
||||
|
||||
val sumSq = Mat()
|
||||
Core.add(aSq, bSq, sumSq)
|
||||
|
||||
val chroma = Mat()
|
||||
Core.sqrt(sumSq, chroma)
|
||||
|
||||
val mask = Mat()
|
||||
Imgproc.threshold(chroma, mask, chromaThreshold, 1.0, Imgproc.THRESH_BINARY)
|
||||
val coloredPixels = Core.countNonZero(mask)
|
||||
|
||||
val totalPixels = chroma.rows() * chroma.cols()
|
||||
val proportion = coloredPixels.toDouble() / totalPixels.toDouble()
|
||||
|
||||
lab.release()
|
||||
channels.forEach { it.release() }
|
||||
aFloat.release()
|
||||
bFloat.release()
|
||||
aShifted.release()
|
||||
bShifted.release()
|
||||
aSq.release()
|
||||
bSq.release()
|
||||
sumSq.release()
|
||||
chroma.release()
|
||||
mask.release()
|
||||
|
||||
return proportion > proportionThreshold
|
||||
}
|
||||
|
||||
|
||||
private fun multiScaleRetinex(img: Mat): Mat {
|
||||
val imageSize = img.size()
|
||||
val maxDim = max(imageSize.width, imageSize.height)
|
||||
val kernelSizes: List<Double> = listOf(maxDim / 50, maxDim / 3)
|
||||
|
||||
// Convert to grayscale (1 channel)
|
||||
val gray = Mat()
|
||||
if (img.channels() == 4) {
|
||||
Imgproc.cvtColor(img, gray, Imgproc.COLOR_BGRA2GRAY)
|
||||
} else if (img.channels() == 3) {
|
||||
Imgproc.cvtColor(img, gray, Imgproc.COLOR_BGR2GRAY)
|
||||
} else {
|
||||
img.copyTo(gray)
|
||||
}
|
||||
|
||||
val imgFloat = Mat()
|
||||
gray.convertTo(imgFloat, CvType.CV_32F)
|
||||
Core.add(imgFloat, Scalar(1.0), imgFloat) // img + 1
|
||||
|
||||
val weight = 1.0 / kernelSizes.size
|
||||
val retinex = Mat.zeros(gray.size(), CvType.CV_32F)
|
||||
|
||||
val logImg = Mat()
|
||||
Core.log(imgFloat, logImg)
|
||||
|
||||
val blur = Mat()
|
||||
val logBlur = Mat()
|
||||
val diff = Mat()
|
||||
|
||||
for (kernelSize in kernelSizes) {
|
||||
Imgproc.boxFilter(imgFloat, blur, -1, Size(kernelSize, kernelSize))
|
||||
Core.add(blur, Scalar(1.0), blur)
|
||||
Core.log(blur, logBlur)
|
||||
|
||||
Core.subtract(logImg, logBlur, diff)
|
||||
val diffGray = Mat()
|
||||
if (diff.channels() > 1) {
|
||||
Imgproc.cvtColor(diff, diffGray, Imgproc.COLOR_BGRA2GRAY)
|
||||
} else {
|
||||
diff.copyTo(diffGray)
|
||||
}
|
||||
Core.addWeighted(retinex, 1.0, diffGray, weight, 0.0, retinex)
|
||||
diffGray.release()
|
||||
}
|
||||
|
||||
// Normalize
|
||||
val minMax = Core.minMaxLoc(retinex)
|
||||
val normalized = Mat()
|
||||
Core.subtract(retinex, Scalar(minMax.minVal), normalized)
|
||||
val scale = if (minMax.maxVal > minMax.minVal) 255.0 / (minMax.maxVal - minMax.minVal) else 1.0
|
||||
Core.multiply(normalized, Scalar(scale), normalized)
|
||||
|
||||
val result = Mat()
|
||||
normalized.convertTo(result, CvType.CV_8U)
|
||||
|
||||
// Cleanup
|
||||
gray.release()
|
||||
imgFloat.release()
|
||||
retinex.release()
|
||||
logImg.release()
|
||||
blur.release()
|
||||
logBlur.release()
|
||||
diff.release()
|
||||
normalized.release()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private fun enhanceContrastAuto(img: Mat): Mat {
|
||||
val gray = if (img.channels() == 1) img else {
|
||||
val tmp = Mat()
|
||||
Imgproc.cvtColor(img, tmp, Imgproc.COLOR_BGR2GRAY)
|
||||
tmp
|
||||
}
|
||||
|
||||
// Flatten and sort pixel values
|
||||
val flat = Mat()
|
||||
gray.reshape(1, 1).convertTo(flat, CvType.CV_32F)
|
||||
val sortedVals = Mat()
|
||||
Core.sort(flat, sortedVals, Core.SORT_ASCENDING)
|
||||
|
||||
val totalPixels = sortedVals.cols()
|
||||
val pLow = sortedVals.get(0, (totalPixels * 0.005).toInt())[0]
|
||||
val pHigh = sortedVals.get(0, (totalPixels * 0.80).toInt())[0]
|
||||
|
||||
flat.release()
|
||||
sortedVals.release()
|
||||
|
||||
val imgF = Mat()
|
||||
img.convertTo(imgF, CvType.CV_32F)
|
||||
val adjusted = Mat()
|
||||
Core.subtract(imgF, Scalar(pLow), adjusted)
|
||||
Core.multiply(adjusted, Scalar(255.0 / max((pHigh - pLow), 1.0)), adjusted)
|
||||
Core.min(adjusted, Scalar(255.0), adjusted)
|
||||
Core.max(adjusted, Scalar(0.0), adjusted)
|
||||
|
||||
val result = Mat()
|
||||
adjusted.convertTo(result, CvType.CV_8U)
|
||||
imgF.release()
|
||||
adjusted.release()
|
||||
|
||||
val final = Mat()
|
||||
Core.convertScaleAbs(result, final, 1.15, -25.0)
|
||||
result.release()
|
||||
|
||||
return final
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
/*
|
||||
* 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.domain.quad
|
||||
|
||||
import org.opencv.core.Mat
|
||||
import org.opencv.core.CvType
|
||||
import org.opencv.core.Size
|
||||
import org.opencv.core.Point
|
||||
import org.opencv.core.Scalar
|
||||
import org.opencv.core.MatOfPoint
|
||||
import org.opencv.core.MatOfPoint2f
|
||||
import org.opencv.core.Core
|
||||
import org.opencv.imgproc.Imgproc
|
||||
import kotlin.math.abs
|
||||
|
||||
// Look for a threshold for which we find a quad in the mask
|
||||
fun detectDocumentQuadFromProbmap(
|
||||
probmap: Mat,
|
||||
thresholds: List<Double>,
|
||||
useOtsu: Boolean = true,
|
||||
minQuadAreaRatio: Double = 0.02
|
||||
): List<Point>? {
|
||||
val probmapU8 = Mat()
|
||||
probmap.convertTo(probmapU8, CvType.CV_8U, 255.0)
|
||||
val probmapSmooth = Mat()
|
||||
Imgproc.GaussianBlur(probmapU8, probmapSmooth, Size(3.0, 3.0), 0.0)
|
||||
|
||||
var bestScore = 0.0
|
||||
var bestQuad: List<Point>? = null
|
||||
|
||||
// 1) Otsu
|
||||
if (useOtsu) {
|
||||
val otsu = Mat()
|
||||
Imgproc.threshold(probmapSmooth, otsu, 0.0, 255.0, Imgproc.THRESH_BINARY + Imgproc.THRESH_OTSU)
|
||||
val quad = findQuadFromBinaryMask(otsu, minQuadAreaRatio)
|
||||
if (quad != null) {
|
||||
val probFloat = Mat()
|
||||
probmap.convertTo(probFloat, CvType.CV_32F)
|
||||
val sc = scoreQuadAgainstProbmap(quad, probFloat)
|
||||
if (sc > bestScore) {
|
||||
bestScore = sc
|
||||
bestQuad = quad
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Threshold sweep
|
||||
for (thr in thresholds) {
|
||||
val bin = Mat()
|
||||
Imgproc.threshold(probmapSmooth, bin, thr * 255.0, 255.0, Imgproc.THRESH_BINARY)
|
||||
val kernel = Imgproc.getStructuringElement(Imgproc.MORPH_ELLIPSE, Size(5.0, 5.0))
|
||||
Imgproc.morphologyEx(bin, bin, Imgproc.MORPH_CLOSE, kernel)
|
||||
val quad = findQuadFromBinaryMask(bin, minQuadAreaRatio)
|
||||
if (quad != null) {
|
||||
val probFloat = Mat()
|
||||
probmap.convertTo(probFloat, CvType.CV_32F)
|
||||
val sc = scoreQuadAgainstProbmap(quad, probFloat)
|
||||
if (sc > bestScore) {
|
||||
bestScore = sc
|
||||
bestQuad = quad
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestQuad
|
||||
}
|
||||
|
||||
// Fill polygon and return binary mask (0/1)
|
||||
fun makePolygonMask(size: Size, polygon: List<Point>): Mat {
|
||||
val mask = Mat.zeros(size, CvType.CV_8U)
|
||||
val pts = MatOfPoint(*polygon.toTypedArray())
|
||||
Imgproc.fillPoly(mask, listOf(pts), Scalar(1.0))
|
||||
return mask
|
||||
}
|
||||
|
||||
// Compute score between quad and probmap
|
||||
fun scoreQuadAgainstProbmap(quad: List<Point>, probmap: Mat): Double {
|
||||
val mask = makePolygonMask(probmap.size(), quad)
|
||||
val maskFloat = Mat()
|
||||
mask.convertTo(maskFloat, CvType.CV_32F)
|
||||
val masked = Mat()
|
||||
Core.multiply(probmap, maskFloat, masked)
|
||||
val meanProb = Core.sumElems(masked).`val`[0] / Core.sumElems(maskFloat).`val`[0]
|
||||
val areaRatio = Core.sumElems(maskFloat).`val`[0] / (probmap.rows() * probmap.cols())
|
||||
return meanProb * (0.7 + 0.3 * areaRatio)
|
||||
}
|
||||
|
||||
// Find largest quadrilateral in a binary mask
|
||||
fun findQuadFromBinaryMask(binMask: Mat, minQuadAreaRatio: Double = 0.02): List<Point>? {
|
||||
val blurred = Mat()
|
||||
Imgproc.GaussianBlur(binMask, blurred, Size(5.0, 5.0), 0.0)
|
||||
val edges = Mat()
|
||||
Imgproc.Canny(blurred, edges, 75.0, 200.0)
|
||||
|
||||
val contours = mutableListOf<MatOfPoint>()
|
||||
Imgproc.findContours(edges, contours, Mat(), Imgproc.RETR_LIST, Imgproc.CHAIN_APPROX_SIMPLE)
|
||||
|
||||
var biggest: MatOfPoint2f? = null
|
||||
var maxArea = 0.0
|
||||
for (cnt in contours) {
|
||||
val cnt2f = MatOfPoint2f(*cnt.toArray())
|
||||
val peri = Imgproc.arcLength(cnt2f, true)
|
||||
val approx = MatOfPoint2f()
|
||||
Imgproc.approxPolyDP(cnt2f, approx, 0.02 * peri, true)
|
||||
if (approx.rows() == 4) {
|
||||
val area = abs(Imgproc.contourArea(approx))
|
||||
if (area > maxArea) {
|
||||
maxArea = area
|
||||
biggest = approx
|
||||
}
|
||||
}
|
||||
}
|
||||
val totalArea = binMask.rows() * binMask.cols().toDouble()
|
||||
return if (maxArea > totalArea * minQuadAreaRatio && biggest != null) {
|
||||
biggest.toList()
|
||||
} else null
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
/*
|
||||
* 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.domain.quad
|
||||
|
||||
import org.fairscan.app.domain.Point
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.acos
|
||||
import kotlin.math.sqrt
|
||||
import kotlin.math.sign
|
||||
|
||||
// Look for 3 consecutive angles that are (almost) right angles
|
||||
fun findQuadFromRightAngles(
|
||||
points: List<Point>,
|
||||
imgWidth: Int,
|
||||
imgHeight: Int,
|
||||
angleMin: Float = 60f,
|
||||
angleMax: Float = 120f
|
||||
): List<Point>? {
|
||||
if (points.size < 4) return null
|
||||
val n = points.size
|
||||
|
||||
val angles = mutableListOf<Double>()
|
||||
for (i in 0 until n) {
|
||||
val a = points[(i + n - 1) % n]
|
||||
val b = points[i]
|
||||
val c = points[(i + 1) % n]
|
||||
angles.add(orientedAngle(a, b, c))
|
||||
}
|
||||
|
||||
var bestQuad: List<Point>? = null
|
||||
var bestScore = Double.POSITIVE_INFINITY
|
||||
|
||||
for (i in 0 until n) {
|
||||
val triplet = listOf(angles[i % n], angles[(i + 1) % n], angles[(i + 2) % n])
|
||||
if (triplet.all { it in angleMin..angleMax }) {
|
||||
val a = points[(i + n - 1) % n]
|
||||
val b = points[i]
|
||||
val c = points[(i + 1) % n]
|
||||
val d = points[(i + 2) % n]
|
||||
val e = points[(i + 3) % n]
|
||||
|
||||
val inter = lineIntersection2(a, b, d, e) ?: continue
|
||||
|
||||
val quad = listOf(b, c, d, inter)
|
||||
|
||||
// ensure inside image bounds
|
||||
if (quad.any { it.x < 0 || it.x >= imgWidth || it.y < 0 || it.y >= imgHeight }) continue
|
||||
|
||||
// ensure convex
|
||||
if (!isConvex(quad)) continue
|
||||
|
||||
val score = quadAngleError(quad)
|
||||
if (score < bestScore) {
|
||||
bestScore = score
|
||||
bestQuad = quad
|
||||
}
|
||||
}
|
||||
}
|
||||
return bestQuad
|
||||
}
|
||||
|
||||
fun angleBetween(v1: Point, v2: Point): Float {
|
||||
val norm1 = sqrt(v1.x * v1.x + v1.y * v1.y) + 1e-9f
|
||||
val norm2 = sqrt(v2.x * v2.x + v2.y * v2.y) + 1e-9f
|
||||
val dot = (v1.x * v2.x + v1.y * v2.y) / (norm1 * norm2)
|
||||
val cosAngle = dot.coerceIn(-1.0, 1.0)
|
||||
return Math.toDegrees(acos(cosAngle).toDouble()).toFloat()
|
||||
}
|
||||
|
||||
fun orientedAngle(a: Point, b: Point, c: Point): Double {
|
||||
val v1 = Point(a.x - b.x, a.y - b.y)
|
||||
val v2 = Point(c.x - b.x, c.y - b.y)
|
||||
val norm1 = sqrt(v1.x * v1.x + v1.y * v1.y) + 1e-9f
|
||||
val norm2 = sqrt(v2.x * v2.x + v2.y * v2.y) + 1e-9f
|
||||
val dot = ((v1.x * v2.x + v1.y * v2.y) / (norm1 * norm2)).coerceIn(-1.0, 1.0)
|
||||
val cross = v1.x * v2.y - v1.y * v2.x
|
||||
var angle = Math.toDegrees(acos(dot))
|
||||
if (cross < 0) angle = 360.0 - angle
|
||||
return angle
|
||||
}
|
||||
|
||||
fun lineIntersection2(p1: Point, p2: Point, p3: Point, p4: Point): Point? {
|
||||
val denom = (p1.x - p2.x) * (p3.y - p4.y) - (p1.y - p2.y) * (p3.x - p4.x)
|
||||
if (abs(denom) < 1e-6f) return null
|
||||
val numX = (p1.x * p2.y - p1.y * p2.x)
|
||||
val numY = (p3.x * p4.y - p3.y * p4.x)
|
||||
val px = (numX * (p3.x - p4.x) - (p1.x - p2.x) * numY) / denom
|
||||
val py = (numX * (p3.y - p4.y) - (p1.y - p2.y) * numY) / denom
|
||||
return Point(px, py)
|
||||
}
|
||||
|
||||
fun quadAngleError(quad: List<Point>): Double {
|
||||
var err = 0.0
|
||||
for (i in 0 until 4) {
|
||||
val a = quad[(i + 3) % 4]
|
||||
val b = quad[i]
|
||||
val c = quad[(i + 1) % 4]
|
||||
val ang = angleBetween(Point(a.x - b.x, a.y - b.y), Point(c.x - b.x, c.y - b.y))
|
||||
err += abs(ang - 90.0)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
fun isConvex(quad: List<Point>): Boolean {
|
||||
if (quad.size != 4) return false
|
||||
var sign = 0
|
||||
for (i in quad.indices) {
|
||||
val a = quad[i]
|
||||
val b = quad[(i + 1) % 4]
|
||||
val c = quad[(i + 2) % 4]
|
||||
val cross = (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x)
|
||||
val currentSign = cross.sign.toInt()
|
||||
if (sign == 0 && currentSign != 0) {
|
||||
sign = currentSign
|
||||
} else if (currentSign != 0 && currentSign != sign) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.platform
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.ImageDecoder
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.util.component1
|
||||
import androidx.core.util.component2
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.fairscan.app.domain.ImageLoader
|
||||
import java.io.IOException
|
||||
|
||||
class AndroidImageLoader(
|
||||
private val contentResolver: ContentResolver
|
||||
) : ImageLoader {
|
||||
|
||||
override suspend fun load(uri: Uri): Bitmap {
|
||||
val bitmap = loadBitmapFromUri(contentResolver, uri)
|
||||
return ensureArgb8888(bitmap)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadBitmapFromUri(
|
||||
contentResolver: ContentResolver,
|
||||
uri: Uri,
|
||||
maxPixels: Int = 12_000_000,
|
||||
): Bitmap = withContext(Dispatchers.IO) {
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
decodeWithImageDecoder(contentResolver, uri, maxPixels)
|
||||
} else {
|
||||
decodeWithBitmapFactory(contentResolver, uri)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(28)
|
||||
private fun decodeWithImageDecoder(
|
||||
contentResolver: ContentResolver,
|
||||
uri: Uri,
|
||||
maxPixels: Int
|
||||
): Bitmap {
|
||||
val source = ImageDecoder.createSource(contentResolver, uri)
|
||||
return ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
|
||||
val (width, height) = info.size
|
||||
val scale = computeScale(width, height, maxPixels)
|
||||
decoder.setTargetSize((width * scale).toInt(), (height * scale).toInt())
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeWithBitmapFactory(contentResolver: ContentResolver, uri: Uri, ): Bitmap {
|
||||
val options = BitmapFactory.Options()
|
||||
val inputStream = contentResolver.openInputStream(uri)
|
||||
?: throw IOException("Cannot open input stream for uri: $uri")
|
||||
return inputStream.use {
|
||||
BitmapFactory.decodeStream(it, null, options)
|
||||
?: throw IOException("Failed to decode bitmap for uri: $uri")
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeScale(width: Int, height: Int, maxPixels: Int): Float {
|
||||
val pixels = width * height
|
||||
return if (pixels > maxPixels) {
|
||||
maxPixels.toFloat() / pixels
|
||||
} else {
|
||||
1f
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureArgb8888(bitmap: Bitmap): Bitmap {
|
||||
return if (bitmap.config != Bitmap.Config.ARGB_8888) {
|
||||
bitmap.copy(Bitmap.Config.ARGB_8888, true)
|
||||
} else {
|
||||
bitmap
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -14,30 +14,283 @@
|
||||
*/
|
||||
package org.fairscan.app.platform
|
||||
|
||||
import android.content.res.AssetManager
|
||||
import android.util.Log
|
||||
import com.tom_roush.pdfbox.cos.COSArray
|
||||
import com.tom_roush.pdfbox.cos.COSDictionary
|
||||
import com.tom_roush.pdfbox.cos.COSName
|
||||
import com.tom_roush.pdfbox.pdmodel.PDDocument
|
||||
import com.tom_roush.pdfbox.pdmodel.PDPage
|
||||
import com.tom_roush.pdfbox.pdmodel.PDPageContentStream
|
||||
import com.tom_roush.pdfbox.pdmodel.PDPageContentStream.AppendMode
|
||||
import com.tom_roush.pdfbox.pdmodel.PDResources
|
||||
import com.tom_roush.pdfbox.pdmodel.common.PDRectangle
|
||||
import com.tom_roush.pdfbox.pdmodel.common.PDStream
|
||||
import com.tom_roush.pdfbox.pdmodel.font.PDFontDescriptor
|
||||
import com.tom_roush.pdfbox.pdmodel.graphics.image.JPEGFactory
|
||||
import org.fairscan.app.BuildConfig
|
||||
import org.fairscan.app.data.PdfWriter
|
||||
import org.fairscan.app.domain.OcrService
|
||||
import org.fairscan.app.domain.PageToExport
|
||||
import org.fairscan.imageprocessing.EstimatedDimensions
|
||||
import org.fairscan.imageprocessing.OcrTextBox
|
||||
import org.fairscan.imageprocessing.PaperFormats
|
||||
import java.io.OutputStream
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
|
||||
class AndroidPdfWriter : PdfWriter {
|
||||
override fun writePdfFromJpegs(jpegs: Sequence<ByteArray>, outputStream: OutputStream): Int {
|
||||
class AndroidPdfWriter(val ocrService: OcrService, val assets: AssetManager) : PdfWriter {
|
||||
|
||||
override suspend fun writePdfFromJpegs(
|
||||
pages: List<PageToExport>,
|
||||
outputStream: OutputStream,
|
||||
disableOcr: Boolean,
|
||||
onProgress: (Int) -> Unit,
|
||||
) {
|
||||
val doc = PDDocument()
|
||||
doc.documentInformation.creationDate = Calendar.getInstance()
|
||||
doc.documentInformation.creator = "FairScan ${BuildConfig.VERSION_NAME}"
|
||||
doc.use { document ->
|
||||
for (jpegBytes in jpegs) {
|
||||
val image = JPEGFactory.createFromByteArray(document, jpegBytes)
|
||||
val page = PDPage(PDRectangle(image.width.toFloat(), image.height.toFloat()))
|
||||
val ocrDocument = OcrDocument(document, assets)
|
||||
for ((index, page) in pages.withIndex()) {
|
||||
val jpeg = page.jpeg.get()
|
||||
val image = JPEGFactory.createFromByteArray(document, jpeg.bytes)
|
||||
|
||||
// PDF has 72 points (units) per inch, 1 inch = 25.4 mm
|
||||
val pointsPerMm = 72f / 25.4f
|
||||
|
||||
val widthPx = image.width.toFloat()
|
||||
val heightPx = image.height.toFloat()
|
||||
|
||||
val dimensions = page.estimatedDimensions()
|
||||
val (widthMm, heightMm) = when (dimensions) {
|
||||
is EstimatedDimensions.Physical ->
|
||||
constrainToMaxFormat(dimensions.widthMm, dimensions.heightMm)
|
||||
else -> {
|
||||
// No physical dimensions available
|
||||
val maxDimMm = PaperFormats.A4.heightMm
|
||||
val scalePxToMm = maxDimMm / maxOf(widthPx, heightPx)
|
||||
constrainToMaxFormat(widthPx * scalePxToMm, heightPx * scalePxToMm)
|
||||
}
|
||||
}
|
||||
val widthPoints = widthMm.toFloat() * pointsPerMm
|
||||
val heightPoints = heightMm.toFloat() * pointsPerMm
|
||||
|
||||
val page = PDPage(PDRectangle(widthPoints, heightPoints))
|
||||
document.addPage(page)
|
||||
|
||||
val contentStream = PDPageContentStream(document, page, AppendMode.OVERWRITE, false)
|
||||
contentStream.drawImage(image, 0f, 0f)
|
||||
contentStream.drawImage(image, 0f, 0f, widthPoints, heightPoints)
|
||||
|
||||
if (!disableOcr) {
|
||||
try {
|
||||
val bitmap = jpeg.toBitmap()
|
||||
val ocrTextBoxes = ocrService.runOcr(bitmap)
|
||||
val pdfPageDimensions = PageDimensions(
|
||||
bitmap.width,
|
||||
bitmap.height,
|
||||
widthPoints,
|
||||
heightPoints
|
||||
)
|
||||
ocrDocument.addPage(page, ocrTextBoxes, pdfPageDimensions)
|
||||
} catch (e: Exception) {
|
||||
Log.e("AndroidPdfWriter", "Failed to run OCR on page $index", e)
|
||||
}
|
||||
}
|
||||
contentStream.close()
|
||||
|
||||
onProgress(index + 1)
|
||||
}
|
||||
// TODO So the whole document is in memory before this line...
|
||||
document.save(outputStream)
|
||||
}
|
||||
return doc.numberOfPages
|
||||
}
|
||||
}
|
||||
|
||||
fun constrainToMaxFormat(widthMm: Double, heightMm: Double): Pair<Double, Double> {
|
||||
val maxDim = 297.0 // A4 height
|
||||
val minDim = 215.9 // Letter width
|
||||
|
||||
val scale = minOf(
|
||||
maxDim / maxOf(widthMm, heightMm),
|
||||
minDim / minOf(widthMm, heightMm),
|
||||
1.0
|
||||
)
|
||||
return widthMm * scale to heightMm * scale
|
||||
}
|
||||
|
||||
data class PageDimensions(
|
||||
val imageWidth: Int,
|
||||
val imageHeight: Int,
|
||||
val pageWidth: Float,
|
||||
val pageHeight: Float,
|
||||
)
|
||||
|
||||
// Adapted from https://github.com/tesseract-ocr/tesseract/blob/main/src/api/pdfrenderer.cpp
|
||||
class OcrDocument(
|
||||
private val document: PDDocument,
|
||||
private val assets: AssetManager,
|
||||
) {
|
||||
private val fontBytes: ByteArray by lazy {
|
||||
assets.open("fonts/TesseractGlyphLessFont.ttf").readBytes()
|
||||
}
|
||||
|
||||
private val cidToGidMap: ByteArray by lazy {
|
||||
ByteArray(65536 * 2) { i -> if (i % 2 == 0) 0x00.toByte() else 0x01.toByte() }
|
||||
}
|
||||
|
||||
private var fontDict: COSDictionary? = null
|
||||
|
||||
fun ensureResourcesAreCreated() {
|
||||
if (fontDict != null)
|
||||
return
|
||||
|
||||
// -- Object 1 : embedded TTF --
|
||||
val fontFileStream = PDStream(document)
|
||||
fontFileStream.createOutputStream().use { it.write(fontBytes) }
|
||||
fontFileStream.cosObject.setInt(COSName.LENGTH1, fontBytes.size)
|
||||
|
||||
// -- Object 2 : CIDToGIDMap stream --
|
||||
val cidToGidStream = PDStream(document)
|
||||
cidToGidStream.createOutputStream(COSName.FLATE_DECODE).use {
|
||||
it.write(cidToGidMap)
|
||||
}
|
||||
|
||||
// -- Object 3 : FontDescriptor --
|
||||
val fontDescriptor = PDFontDescriptor(COSDictionary())
|
||||
fontDescriptor.fontName = "GlyphLessFont"
|
||||
fontDescriptor.flags = 5
|
||||
fontDescriptor.fontBoundingBox = PDRectangle(0f, 0f, 500f, 750f)
|
||||
fontDescriptor.italicAngle = 0f
|
||||
fontDescriptor.ascent = 750f
|
||||
fontDescriptor.descent = 0f
|
||||
fontDescriptor.capHeight = 750f
|
||||
fontDescriptor.stemV = 80f
|
||||
fontDescriptor.cosObject.setItem(COSName.FONT_FILE2, fontFileStream)
|
||||
|
||||
// -- Object 4 : CIDFont descendant --
|
||||
val cidFont = COSDictionary()
|
||||
cidFont.setName(COSName.TYPE, "Font")
|
||||
cidFont.setName(COSName.SUBTYPE, "CIDFontType2")
|
||||
cidFont.setName(COSName.BASE_FONT, "GlyphLessFont")
|
||||
val cidSystemInfo = COSDictionary()
|
||||
cidSystemInfo.setString(COSName.getPDFName("Registry"), "Adobe")
|
||||
cidSystemInfo.setString(COSName.getPDFName("Ordering"), "Identity")
|
||||
cidSystemInfo.setInt(COSName.getPDFName("Supplement"), 0)
|
||||
cidFont.setItem(COSName.getPDFName("CIDSystemInfo"), cidSystemInfo)
|
||||
cidFont.setItem(COSName.FONT_DESC, fontDescriptor)
|
||||
cidFont.setInt(COSName.getPDFName("DW"), 500)
|
||||
cidFont.setItem(COSName.getPDFName("CIDToGIDMap"), cidToGidStream)
|
||||
|
||||
// -- Object 5 : ToUnicode CMap --
|
||||
val toUnicode = buildToUnicodeCMap()
|
||||
val toUnicodeStream = PDStream(document)
|
||||
toUnicodeStream.createOutputStream().use {
|
||||
it.write(toUnicode.toByteArray(Charsets.US_ASCII))
|
||||
}
|
||||
|
||||
// -- Object 6 : Font Type0 --
|
||||
val fontDict = COSDictionary()
|
||||
fontDict.setName(COSName.TYPE, "Font")
|
||||
fontDict.setName(COSName.SUBTYPE, "Type0")
|
||||
fontDict.setName(COSName.BASE_FONT, "GlyphLessFont")
|
||||
fontDict.setName(COSName.ENCODING, "Identity-H")
|
||||
val descendants = COSArray()
|
||||
descendants.add(cidFont)
|
||||
fontDict.setItem(COSName.DESCENDANT_FONTS, descendants)
|
||||
fontDict.setItem(COSName.TO_UNICODE, toUnicodeStream)
|
||||
this.fontDict = fontDict
|
||||
}
|
||||
|
||||
private fun buildToUnicodeCMap(): String = buildString {
|
||||
append("/CIDInit /ProcSet findresource begin\n")
|
||||
append("12 dict begin\n")
|
||||
append("begincmap\n")
|
||||
append("/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n")
|
||||
append("/CMapName /Adobe-Identify-UCS def\n")
|
||||
append("/CMapType 2 def\n")
|
||||
append("1 begincodespacerange\n")
|
||||
append("<0000> <FFFF>\n")
|
||||
append("endcodespacerange\n")
|
||||
append("1 beginbfrange\n")
|
||||
append("<0000> <FFFF> <0000>\n")
|
||||
append("endbfrange\n")
|
||||
append("endcmap\n")
|
||||
append("CMapName currentdict /CMap defineresource pop\n")
|
||||
append("end\nend\n")
|
||||
}
|
||||
|
||||
fun addPage(
|
||||
page: PDPage,
|
||||
ocrTextBoxes: List<OcrTextBox>,
|
||||
dimensions: PageDimensions,
|
||||
) {
|
||||
if (ocrTextBoxes.isEmpty()) return
|
||||
|
||||
ensureResourcesAreCreated()
|
||||
val resources = page.resources ?: PDResources().also { page.resources = it }
|
||||
val fontResources = resources.cosObject
|
||||
.getDictionaryObject(COSName.FONT) as? COSDictionary
|
||||
?: COSDictionary().also {
|
||||
resources.cosObject.setItem(COSName.FONT, it)
|
||||
}
|
||||
fontResources.setItem(COSName.getPDFName("F1"), fontDict)
|
||||
|
||||
val textStream = buildTextStream(ocrTextBoxes, dimensions)
|
||||
val pdTextStream = PDStream(document)
|
||||
pdTextStream.createOutputStream(COSName.FLATE_DECODE).use {
|
||||
it.write(textStream.toByteArray(Charsets.US_ASCII))
|
||||
}
|
||||
|
||||
val contentsArray = COSArray()
|
||||
val iter = page.contentStreams
|
||||
while (iter.hasNext()) contentsArray.add(iter.next().cosObject)
|
||||
contentsArray.add(pdTextStream.cosObject)
|
||||
page.cosObject.setItem(COSName.CONTENTS, contentsArray)
|
||||
}
|
||||
|
||||
private fun buildTextStream(
|
||||
ocrTextBoxes: List<OcrTextBox>,
|
||||
dimensions: PageDimensions,
|
||||
): String {
|
||||
val scaleX = dimensions.pageWidth / dimensions.imageWidth
|
||||
val scaleY = dimensions.pageHeight / dimensions.imageHeight
|
||||
val sb = StringBuilder()
|
||||
|
||||
for (textBox in ocrTextBoxes) {
|
||||
val x = textBox.box.left * scaleX
|
||||
val wordWidth = textBox.box.width * scaleX
|
||||
val fontSize = textBox.lineHeight * scaleY * 0.8f
|
||||
// nominal width: fontSize / kCharWidth (Tesseract convention)
|
||||
val nominalWidth = textBox.text.length * fontSize / 2f
|
||||
val hScale = if (nominalWidth > 0f) (wordWidth / nominalWidth) * 100f else 100f
|
||||
val baselineY = dimensions.pageHeight - (textBox.lineBottom * scaleY) + fontSize * 0.2f
|
||||
|
||||
val utf16hex = buildString {
|
||||
textBox.text.codePoints().forEach { cp ->
|
||||
append(codepointToUtf16beHex(cp))
|
||||
}
|
||||
}
|
||||
|
||||
sb.append("BT\n")
|
||||
sb.append(String.format(Locale.US, "/F1 %.3f Tf\n", fontSize))
|
||||
sb.append(String.format(Locale.US,"%.3f Tz\n", hScale))
|
||||
sb.append("3 Tr\n")
|
||||
sb.append(String.format(Locale.US,"%.3f %.3f Td\n", x, baselineY))
|
||||
sb.append("<${utf16hex}0020> Tj\n")
|
||||
sb.append("ET\n")
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun codepointToUtf16beHex(cp: Int): String {
|
||||
return if (cp < 0x10000) {
|
||||
"%04X".format(cp)
|
||||
} else {
|
||||
val a = cp - 0x10000
|
||||
val high = (a shr 10) + 0xD800
|
||||
val low = (a and 0x3FF) + 0xDC00
|
||||
"%04X%04X".format(high, low)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.platform
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import org.fairscan.app.data.ImageTransformations
|
||||
import org.fairscan.app.domain.CapturedPage
|
||||
import org.fairscan.app.domain.ExportQuality
|
||||
import org.fairscan.app.domain.Jpeg
|
||||
import org.fairscan.app.domain.PageMetadata
|
||||
import org.fairscan.app.domain.Rotation
|
||||
import org.fairscan.app.ui.screens.settings.DefaultColorMode
|
||||
import org.fairscan.imageprocessing.ColorMode
|
||||
import org.fairscan.imageprocessing.ImageSize
|
||||
import org.fairscan.imageprocessing.Mask
|
||||
import org.fairscan.imageprocessing.OpticalMeasures
|
||||
import org.fairscan.imageprocessing.Point
|
||||
import org.fairscan.imageprocessing.Quad
|
||||
import org.fairscan.imageprocessing.autoColorMode
|
||||
import org.fairscan.imageprocessing.createQuad
|
||||
import org.fairscan.imageprocessing.extractDocument
|
||||
import org.fairscan.imageprocessing.resizeForMaxPixels
|
||||
import org.fairscan.imageprocessing.rotate
|
||||
import org.fairscan.imageprocessing.scaledTo
|
||||
import org.opencv.android.Utils
|
||||
import org.opencv.core.CvException
|
||||
import org.opencv.core.Mat
|
||||
import org.opencv.core.Size
|
||||
import org.opencv.imgproc.Imgproc
|
||||
import kotlin.math.min
|
||||
|
||||
class ImageProcessor(private val thumbnailSizePx: Int) : ImageTransformations {
|
||||
|
||||
override fun rotate(input: Jpeg, rotationDegrees: Int): Jpeg {
|
||||
return transform(input, ExportQuality.BALANCED.jpegQuality) {
|
||||
rotate(it, rotationDegrees)
|
||||
}
|
||||
}
|
||||
|
||||
override fun resizeToThumbnail(input: Jpeg): Jpeg {
|
||||
val maxSize = thumbnailSizePx.toFloat()
|
||||
return transform(input, 85) { src ->
|
||||
val ratio = min(maxSize / src.width(), maxSize / src.height())
|
||||
val newW = (src.width() * ratio).toDouble()
|
||||
val newH = (src.height() * ratio).toDouble()
|
||||
val scaled = Mat()
|
||||
try {
|
||||
Imgproc.resize(src, scaled, Size(newW, newH))
|
||||
} catch (e: CvException) {
|
||||
val msg = "Resize failed. src=${src.width()}x${src.height()} dst=${newW}x${newH}"
|
||||
throw IllegalStateException(msg, e)
|
||||
}
|
||||
scaled
|
||||
}
|
||||
}
|
||||
|
||||
private fun transform(
|
||||
inJpeg: Jpeg,
|
||||
jpegQuality: Int,
|
||||
transform: (Mat) -> Mat,
|
||||
): Jpeg {
|
||||
val input = inJpeg.toMat()
|
||||
var output: Mat? = null
|
||||
try {
|
||||
output = transform.invoke(input)
|
||||
return Jpeg.fromMat(output, jpegQuality)
|
||||
} finally {
|
||||
input.release()
|
||||
output?.release()
|
||||
}
|
||||
}
|
||||
|
||||
override fun process(
|
||||
source: Jpeg,
|
||||
metadata: PageMetadata,
|
||||
colorMode: ColorMode
|
||||
): Jpeg {
|
||||
val baseRotation = metadata.baseRotation
|
||||
return processedImage(source, metadata, baseRotation, colorMode, ExportQuality.BALANCED)
|
||||
}
|
||||
}
|
||||
|
||||
fun processedImage(
|
||||
source: Jpeg,
|
||||
metadata: PageMetadata,
|
||||
rotation: Rotation,
|
||||
colorMode: ColorMode,
|
||||
exportQuality: ExportQuality,
|
||||
): Jpeg {
|
||||
val rotationDegrees = rotation.degrees
|
||||
var sourceMat: Mat? = null
|
||||
var page: Mat? = null
|
||||
try {
|
||||
sourceMat = source.toMat()
|
||||
val quad = metadata.normalizedQuad.scaledTo(1, 1, sourceMat.width(), sourceMat.height())
|
||||
page = extractDocument(sourceMat, quad, rotationDegrees, colorMode, exportQuality.maxPixels,
|
||||
metadata.opticalMeasures)
|
||||
return Jpeg.fromMat(page, exportQuality.jpegQuality)
|
||||
} finally {
|
||||
sourceMat?.release()
|
||||
page?.release()
|
||||
}
|
||||
}
|
||||
|
||||
fun extractDocumentFromBitmap(
|
||||
source: Bitmap,
|
||||
quadInMask: Quad?,
|
||||
rotationDegrees: Int,
|
||||
mask: Mask?,
|
||||
viewModelScope: CoroutineScope,
|
||||
defaultColorMode: DefaultColorMode = DefaultColorMode.AUTO,
|
||||
opticalMeasures: OpticalMeasures?,
|
||||
): CapturedPage {
|
||||
val exportQuality = ExportQuality.BALANCED
|
||||
var colorMode = ColorMode.COLOR
|
||||
var autoColorMode = colorMode
|
||||
var normalizedQuad = createQuad(listOf(
|
||||
Point(0.0, 0.0), Point(0.0, 1.0), Point(1.0, 1.0), Point(1.0, 0.0))
|
||||
)
|
||||
var page: Mat
|
||||
|
||||
val rgba = Mat()
|
||||
Utils.bitmapToMat(source, rgba)
|
||||
val bgr = Mat()
|
||||
Imgproc.cvtColor(rgba, bgr, Imgproc.COLOR_RGBA2BGR)
|
||||
rgba.release()
|
||||
|
||||
if (mask == null || quadInMask == null) {
|
||||
// No document detected
|
||||
val resized = resizeForMaxPixels(bgr, exportQuality.maxPixels.toDouble())
|
||||
page = rotate(resized, rotationDegrees)
|
||||
resized.release()
|
||||
} else {
|
||||
val quad = quadInMask.scaledTo(mask.width, mask.height, source.width, source.height)
|
||||
normalizedQuad = quad.scaledTo(source.width, source.height, 1, 1)
|
||||
autoColorMode = autoColorMode(bgr, mask, quad)
|
||||
colorMode = defaultColorMode.colorMode ?: autoColorMode
|
||||
page = extractDocument(bgr, quad, rotationDegrees, colorMode, exportQuality.maxPixels,
|
||||
opticalMeasures)
|
||||
}
|
||||
|
||||
val pageJpeg = Jpeg.fromMat(page, exportQuality.jpegQuality)
|
||||
bgr.release()
|
||||
page.release()
|
||||
|
||||
val baseRotation = Rotation.fromDegrees(rotationDegrees)
|
||||
val sourceSize = ImageSize(source.width, source.height)
|
||||
val metadata =
|
||||
PageMetadata(normalizedQuad, baseRotation, autoColorMode, sourceSize, opticalMeasures)
|
||||
val sourceJpegDeferred = viewModelScope.async(Dispatchers.IO) {
|
||||
compressSource(source)
|
||||
}
|
||||
return CapturedPage(pageJpeg, sourceJpegDeferred, metadata, colorMode)
|
||||
}
|
||||
|
||||
private fun compressSource(source: Bitmap): Jpeg {
|
||||
val rgba = Mat()
|
||||
Utils.bitmapToMat(source, rgba)
|
||||
val bgr = Mat()
|
||||
Imgproc.cvtColor(rgba, bgr, Imgproc.COLOR_RGBA2BGR)
|
||||
rgba.release()
|
||||
return try {
|
||||
Jpeg.fromMat(bgr, 90)
|
||||
} finally {
|
||||
bgr.release()
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
/*
|
||||
* 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.platform
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import org.opencv.core.Core
|
||||
import org.opencv.core.Mat
|
||||
import org.opencv.imgcodecs.Imgcodecs
|
||||
import java.io.File
|
||||
import kotlin.math.min
|
||||
import androidx.core.graphics.scale
|
||||
import org.fairscan.app.data.ImageTransformations
|
||||
|
||||
class OpenCvTransformations : ImageTransformations {
|
||||
override fun rotate(inputFile: File, outputFile: File, clockwise: Boolean) {
|
||||
val src: Mat = Imgcodecs.imread(inputFile.absolutePath)
|
||||
|
||||
require (!src.empty()) { "Could not load image from ${inputFile.absolutePath}" }
|
||||
|
||||
val dst = Mat()
|
||||
Core.rotate(src, dst,
|
||||
if (clockwise) Core.ROTATE_90_CLOCKWISE else Core.ROTATE_90_COUNTERCLOCKWISE
|
||||
)
|
||||
|
||||
if (!Imgcodecs.imwrite(outputFile.absolutePath, dst)) {
|
||||
throw RuntimeException("Could not write image to ${outputFile.absolutePath}")
|
||||
}
|
||||
|
||||
src.release()
|
||||
dst.release()
|
||||
}
|
||||
|
||||
override fun resize(inputFile: File, outputFile: File, maxSize: Int) {
|
||||
val bitmap = BitmapFactory.decodeFile(inputFile.absolutePath)
|
||||
val ratio = min(maxSize.toFloat() / bitmap.width, maxSize.toFloat() / bitmap.height)
|
||||
val newW = (bitmap.width * ratio).toInt()
|
||||
val newH = (bitmap.height * ratio).toInt()
|
||||
val scaled = bitmap.scale(newW, newH)
|
||||
outputFile.outputStream().use {
|
||||
scaled.compress(Bitmap.CompressFormat.JPEG, 85, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.content.FileProvider
|
||||
import java.io.File
|
||||
|
||||
fun uriForFile(context: Context, file: File): Uri {
|
||||
val authority = "${context.packageName}.fileprovider"
|
||||
return FileProvider.getUriForFile(context, authority, file)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -16,34 +16,41 @@ package org.fairscan.app.ui
|
||||
|
||||
sealed class Screen {
|
||||
sealed class Main : Screen() {
|
||||
object Home : Main()
|
||||
object Camera : Main()
|
||||
object EditImage : Main()
|
||||
data class Document(val initialPage: Int = 0) : Main()
|
||||
object Export : Main()
|
||||
object ResumeScan : Main()
|
||||
}
|
||||
sealed class Overlay : Screen() {
|
||||
object About : Overlay()
|
||||
object Libraries : Overlay()
|
||||
object Settings : Overlay()
|
||||
object OcrLanguages : Overlay()
|
||||
}
|
||||
}
|
||||
|
||||
data class Navigation(
|
||||
val toHomeScreen: () -> Unit,
|
||||
val toCameraScreen: () -> Unit,
|
||||
val toEditImageScreen: () -> Unit,
|
||||
val toDocumentScreen: () -> Unit,
|
||||
val toExportScreen: () -> Unit,
|
||||
val toAboutScreen: () -> Unit,
|
||||
val toLibrariesScreen: () -> Unit,
|
||||
val toSettingsScreen: () -> Unit,
|
||||
val toSettingsScreen: (() -> Unit)?,
|
||||
val toOcrLanguagesScreen: () -> Unit,
|
||||
val back: () -> Unit,
|
||||
val shouldDisplayBackButton: () -> Boolean,
|
||||
)
|
||||
|
||||
@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(): NavigationState {
|
||||
val root = Screen.Main.Camera
|
||||
return NavigationState(listOf(root), root)
|
||||
}
|
||||
}
|
||||
|
||||
val current: Screen get() = stack.last()
|
||||
@@ -58,10 +65,12 @@ data class NavigationState private constructor(val stack: List<Screen>) {
|
||||
|
||||
fun navigateBack(): NavigationState {
|
||||
return when (current) {
|
||||
is Screen.Main.Home -> this // Back handled by system
|
||||
is Screen.Main.Camera -> copy(stack = listOf(Screen.Main.Home))
|
||||
root -> this // Back handled by system
|
||||
is Screen.Main.ResumeScan -> this // Back handled by system
|
||||
is Screen.Main.Camera -> this // Back handled by system
|
||||
is Screen.Main.Document -> copy(stack = listOf(Screen.Main.Camera))
|
||||
is Screen.Main.Export -> copy(stack = listOf(Screen.Main.Document()))
|
||||
is Screen.Main.EditImage -> copy(stack = listOf(Screen.Main.Document()))
|
||||
is Screen.Main.Export -> copy(stack = listOf(Screen.Main.Camera))
|
||||
is Screen.Overlay -> copy(stack = stack.dropLast(1))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -15,24 +15,25 @@
|
||||
package org.fairscan.app.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import org.fairscan.app.domain.Jpeg
|
||||
import org.fairscan.app.domain.PageViewKey
|
||||
import org.fairscan.app.domain.Rotation
|
||||
import org.fairscan.app.ui.state.DocumentUiModel
|
||||
import org.fairscan.app.ui.state.PageThumbnail
|
||||
import org.fairscan.imageprocessing.ColorMode
|
||||
|
||||
fun dummyNavigation(): Navigation {
|
||||
return Navigation({}, {}, {}, {}, {}, {}, {}, {})
|
||||
}
|
||||
|
||||
fun fakeDocument(): DocumentUiModel {
|
||||
return DocumentUiModel(persistentListOf(), { _ -> null }, { _ -> null })
|
||||
return Navigation({}, {}, {}, {}, {}, {}, {}, {}, {}, { -> true})
|
||||
}
|
||||
|
||||
fun fakeDocument(pageIds: ImmutableList<String>, context: Context): DocumentUiModel {
|
||||
val loader = { id:String ->
|
||||
context.assets.open(id).use { input ->
|
||||
BitmapFactory.decodeStream(input)
|
||||
}
|
||||
}
|
||||
return DocumentUiModel(pageIds, loader, loader)
|
||||
val pageKeys = pageIds.map {
|
||||
PageThumbnail(PageViewKey(it, Rotation.R0, ColorMode.COLOR, 0), fakeImage(it, context))
|
||||
}.toImmutableList()
|
||||
return DocumentUiModel(pageKeys)
|
||||
}
|
||||
|
||||
fun fakeImage(id: String, context: Context): Jpeg =
|
||||
Jpeg(context.assets.open("${id}.jpg").readBytes())
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
package org.fairscan.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
@@ -28,6 +29,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -56,15 +58,18 @@ fun SecondaryActionButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true
|
||||
) {
|
||||
FilledIconButton (
|
||||
onClick = onClick,
|
||||
colors = IconButtonDefaults.outlinedIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f),
|
||||
contentColor = MaterialTheme.colorScheme.primary
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.primary,
|
||||
disabledContainerColor = if (isSystemInDarkTheme()) Color.DarkGray else Color.LightGray
|
||||
),
|
||||
modifier = modifier.size(40.dp)
|
||||
modifier = modifier.size(40.dp),
|
||||
enabled = enabled
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -16,12 +16,14 @@ package org.fairscan.app.ui.components
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -33,6 +35,8 @@ import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -44,8 +48,10 @@ import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.fairscan.app.THUMBNAIL_SIZE_DP
|
||||
@@ -60,6 +66,7 @@ data class CommonPageListState(
|
||||
val onPageClick: (Int) -> Unit,
|
||||
val onPageReorder: (String, Int) -> Unit,
|
||||
val listState: LazyListState,
|
||||
val showPageNumbers: Boolean,
|
||||
val currentPageIndex: Int? = null,
|
||||
val onLastItemPosition: ((Offset) -> Unit)? = null,
|
||||
)
|
||||
@@ -72,11 +79,12 @@ fun CommonPageList(
|
||||
val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
|
||||
val reorderableLazyListState = rememberReorderableLazyListState(state.listState) { from, to ->
|
||||
state.onPageReorder(from.key as String, to.index)
|
||||
val pageId = state.document.pages[from.index].key.pageId
|
||||
state.onPageReorder(pageId, to.index)
|
||||
}
|
||||
val content: LazyListScope.() -> Unit = {
|
||||
itemsIndexed(state.document.pageIds, key = { _, item -> item}) { index, item ->
|
||||
ReorderableItem(reorderableLazyListState, key = item) { isDragging ->
|
||||
itemsIndexed(state.document.pages.map { it.key }, key = { _, item -> item.pageId}) { index, item ->
|
||||
ReorderableItem(reorderableLazyListState, key = item.pageId) { isDragging ->
|
||||
val borderColor =
|
||||
if (isDragging) MaterialTheme.colorScheme.primary else Color.Transparent
|
||||
val modifier = Modifier
|
||||
@@ -86,7 +94,7 @@ fun CommonPageList(
|
||||
color = borderColor,
|
||||
shape = RoundedCornerShape(6.dp)
|
||||
)
|
||||
val image = state.document.loadThumbnail(index)
|
||||
val image = state.document.thumbnail(index)
|
||||
if (image != null) {
|
||||
PageThumbnail(image, index, state, modifier)
|
||||
}
|
||||
@@ -110,10 +118,12 @@ fun CommonPageList(
|
||||
)
|
||||
}
|
||||
if (state.document.isEmpty()) {
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(THUMBNAIL_SIZE_DP.dp)
|
||||
.addPositionCallback(state.onLastItemPosition, LocalDensity.current, 0.5f)
|
||||
.addPositionCallback(
|
||||
state.onLastItemPosition, LocalDensity.current, 0.5f, layoutDirection)
|
||||
) {}
|
||||
}
|
||||
}
|
||||
@@ -128,7 +138,7 @@ private fun PageThumbnail(
|
||||
val bitmap = image.asImageBitmap()
|
||||
val isSelected = index == state.currentPageIndex
|
||||
val borderColor =
|
||||
if (isSelected) MaterialTheme.colorScheme.secondary else Color.Transparent
|
||||
if (isSelected) MaterialTheme.colorScheme.primary else Color.LightGray
|
||||
val maxImageSize = PAGE_LIST_ELEMENT_SIZE_DP.dp
|
||||
var imageModifier =
|
||||
if (bitmap.height > bitmap.width)
|
||||
@@ -137,45 +147,69 @@ private fun PageThumbnail(
|
||||
Modifier.width(maxImageSize)
|
||||
if (index == state.document.lastIndex()) {
|
||||
val density = LocalDensity.current
|
||||
imageModifier = imageModifier.addPositionCallback(state.onLastItemPosition, density, 1.0f)
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
imageModifier = imageModifier.addPositionCallback(
|
||||
state.onLastItemPosition, density, 1.0f, layoutDirection)
|
||||
}
|
||||
Box (modifier = modifier.height(PAGE_LIST_ELEMENT_SIZE_DP.dp)) {
|
||||
Image(
|
||||
bitmap = bitmap,
|
||||
contentDescription = null,
|
||||
modifier = imageModifier
|
||||
.align(Alignment.Center)
|
||||
.padding(4.dp)
|
||||
.border(2.dp, borderColor)
|
||||
.clickable { state.onPageClick(index) }
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(Color.Black.copy(alpha = 0.5f), shape = RoundedCornerShape(4.dp))
|
||||
.padding(vertical = 0.dp, horizontal = 8.dp)
|
||||
Box(modifier = modifier.height(PAGE_LIST_ELEMENT_SIZE_DP.dp)) {
|
||||
Card(
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
border = BorderStroke(1.dp, borderColor),
|
||||
modifier = Modifier.padding(4.dp).align(Alignment.Center)
|
||||
) {
|
||||
Text(
|
||||
text = "${index + 1}",
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
Image(
|
||||
bitmap = bitmap,
|
||||
contentDescription = "${index + 1}",
|
||||
modifier = imageModifier.clickable { state.onPageClick(index) }
|
||||
)
|
||||
}
|
||||
if (state.showPageNumbers) {
|
||||
PageNumberBadge(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.addPositionCallback(callback: ((Offset) -> Unit)?, density: Density, xFactor: Float): Modifier {
|
||||
@Composable
|
||||
private fun BoxScope.PageNumberBadge(index: Int) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(Color.Black.copy(alpha = 0.5f), shape = RoundedCornerShape(4.dp))
|
||||
.padding(vertical = 0.dp, horizontal = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "${index + 1}",
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.addPositionCallback(
|
||||
callback: ((Offset) -> Unit)?,
|
||||
density: Density,
|
||||
xFactor: Float,
|
||||
layoutDirection: LayoutDirection
|
||||
): Modifier {
|
||||
if (callback == null) {
|
||||
return this
|
||||
}
|
||||
|
||||
return this.onGloballyPositioned { coordinates ->
|
||||
with(density) {
|
||||
callback(coordinates.localToWindow(
|
||||
Offset(
|
||||
x = PAGE_LIST_ELEMENT_SIZE_DP.dp.toPx() * xFactor,
|
||||
y = PAGE_LIST_ELEMENT_SIZE_DP.dp.toPx() / 2)))
|
||||
val width = PAGE_LIST_ELEMENT_SIZE_DP.dp.toPx()
|
||||
|
||||
val x = when (layoutDirection) {
|
||||
LayoutDirection.Ltr -> width * xFactor
|
||||
LayoutDirection.Rtl -> width * (0.5f - xFactor)
|
||||
}
|
||||
|
||||
callback(
|
||||
coordinates.localToWindow(Offset(x = x, y = width / 2))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -54,16 +54,20 @@ import org.fairscan.app.ui.Navigation
|
||||
@Composable
|
||||
fun MyScaffold(
|
||||
navigation: Navigation,
|
||||
pageListState: CommonPageListState,
|
||||
pageListButton: (@Composable () -> Unit)? = null,
|
||||
pageListState: CommonPageListState? = null,
|
||||
bottomBar: @Composable () -> Unit,
|
||||
onBack: (() -> Unit)? = null,
|
||||
content: @Composable (Modifier) -> Unit,
|
||||
) {
|
||||
Box {
|
||||
if (!isLandscape(LocalConfiguration.current)) {
|
||||
Scaffold(
|
||||
bottomBar = { DocumentBar(pageListState, bottomBar, Modifier, pageListButton) }
|
||||
bottomBar = {
|
||||
if (pageListState == null) {
|
||||
bottomBar
|
||||
} else {
|
||||
DocumentBar(pageListState, bottomBar, Modifier)
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
content(Modifier.padding(innerPadding).fillMaxSize())
|
||||
}
|
||||
@@ -73,13 +77,17 @@ fun MyScaffold(
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize()
|
||||
) {
|
||||
content(Modifier.weight(2f))
|
||||
DocumentBar(pageListState, bottomBar, Modifier.weight(1f), pageListButton)
|
||||
if (pageListState == null) {
|
||||
bottomBar
|
||||
} else {
|
||||
DocumentBar(pageListState, bottomBar, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (onBack != null) {
|
||||
if (navigation.shouldDisplayBackButton()) {
|
||||
BackButton(
|
||||
onBack,
|
||||
navigation.back,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
@@ -99,7 +107,6 @@ fun DocumentBar(
|
||||
pageListState: CommonPageListState,
|
||||
buttonBar: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
pageListButton: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val isLandscape = isLandscape(LocalConfiguration.current)
|
||||
Column(
|
||||
@@ -115,17 +122,6 @@ fun DocumentBar(
|
||||
Modifier
|
||||
) {
|
||||
CommonPageList(pageListState, modifier = Modifier.fillMaxWidth())
|
||||
|
||||
if (pageListButton != null) {
|
||||
val alignment = if (isLandscape) Alignment.BottomEnd else Alignment.CenterEnd
|
||||
Box(
|
||||
Modifier
|
||||
.align(alignment)
|
||||
.padding(horizontal = 8.dp, vertical = 16.dp)
|
||||
) {
|
||||
pageListButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val color = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
@@ -171,7 +167,11 @@ fun AppOverflowMenu(
|
||||
modifier
|
||||
) {
|
||||
IconButton(onClick = { expanded = true }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.menu))
|
||||
Icon(
|
||||
Icons.Default.MoreVert,
|
||||
contentDescription = stringResource(R.string.menu),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
@@ -181,14 +181,16 @@ fun AppOverflowMenu(
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
) {
|
||||
|
||||
DropdownMenuItem(
|
||||
leadingIcon = { Icon(Icons.Default.Settings, contentDescription = null) },
|
||||
text = { Text(stringResource(R.string.settings)) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
navigation.toSettingsScreen()
|
||||
}
|
||||
)
|
||||
navigation.toSettingsScreen?.let { toSettings ->
|
||||
DropdownMenuItem(
|
||||
leadingIcon = { Icon(Icons.Default.Settings, contentDescription = null) },
|
||||
text = { Text(stringResource(R.string.settings)) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
toSettings()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenuItem(
|
||||
leadingIcon = { Icon(Icons.Default.Info, contentDescription = null) },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -25,9 +25,3 @@ import java.util.Date
|
||||
fun pageCountText(quantity: Int): String {
|
||||
return LocalResources.current.getQuantityString(R.plurals.page_count, quantity, quantity)
|
||||
}
|
||||
|
||||
fun formatDate(timestamp: Long, context: Context): String {
|
||||
val date = Date(timestamp)
|
||||
return DateFormat.getMediumDateFormat(context).format(date) + " " +
|
||||
DateFormat.getTimeFormat(context).format(date)
|
||||
}
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
/*
|
||||
* 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.ui.screens
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.Description
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableIntState
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
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.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
|
||||
import org.fairscan.app.ui.components.MyScaffold
|
||||
import org.fairscan.app.ui.components.SecondaryActionButton
|
||||
import org.fairscan.app.ui.dummyNavigation
|
||||
import org.fairscan.app.ui.fakeDocument
|
||||
import org.fairscan.app.ui.state.DocumentUiModel
|
||||
import org.fairscan.app.ui.theme.FairScanTheme
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DocumentScreen(
|
||||
document: DocumentUiModel,
|
||||
initialPage: Int,
|
||||
navigation: Navigation,
|
||||
onDeleteImage: (String) -> Unit,
|
||||
onRotateImage: (String, Boolean) -> Unit,
|
||||
onPageReorder: (String, Int) -> Unit,
|
||||
) {
|
||||
// TODO Check how often images are loaded
|
||||
val showDeletePageDialog = rememberSaveable { mutableStateOf(false) }
|
||||
val currentPageIndex = rememberSaveable { mutableIntStateOf(initialPage) }
|
||||
if (currentPageIndex.intValue >= document.pageCount()) {
|
||||
currentPageIndex.intValue = document.pageCount() - 1
|
||||
}
|
||||
if (currentPageIndex.intValue < 0) {
|
||||
navigation.toCameraScreen()
|
||||
return
|
||||
}
|
||||
BackHandler { navigation.back() }
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
LaunchedEffect(initialPage) {
|
||||
listState.scrollToItem(initialPage)
|
||||
}
|
||||
|
||||
MyScaffold(
|
||||
navigation = navigation,
|
||||
pageListState = CommonPageListState(
|
||||
document,
|
||||
onPageClick = { index -> currentPageIndex.intValue = index },
|
||||
onPageReorder = onPageReorder,
|
||||
currentPageIndex = currentPageIndex.intValue,
|
||||
listState = listState,
|
||||
),
|
||||
bottomBar = {
|
||||
BottomBar(navigation)
|
||||
},
|
||||
pageListButton = {
|
||||
SecondaryActionButton(
|
||||
icon = Icons.Default.Add,
|
||||
onClick = navigation.toCameraScreen,
|
||||
contentDescription = stringResource(R.string.add_page),
|
||||
)
|
||||
},
|
||||
) { modifier ->
|
||||
DocumentPreview(
|
||||
document,
|
||||
currentPageIndex,
|
||||
{ showDeletePageDialog.value = true },
|
||||
onRotateImage,
|
||||
modifier
|
||||
)
|
||||
if (showDeletePageDialog.value) {
|
||||
ConfirmationDialog(
|
||||
title = stringResource(R.string.delete_page),
|
||||
message = stringResource(R.string.delete_page_warning),
|
||||
showDialog = showDeletePageDialog
|
||||
) { onDeleteImage(document.pageId(currentPageIndex.intValue)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DocumentPreview(
|
||||
document: DocumentUiModel,
|
||||
currentPageIndex: MutableIntState,
|
||||
onDeleteImage: (String) -> Unit,
|
||||
onRotateImage: (String, Boolean) -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val imageId = document.pageId(currentPageIndex.intValue)
|
||||
Column (
|
||||
modifier = modifier
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerLow)
|
||||
) {
|
||||
Box (
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
val bitmap = document.load(currentPageIndex.intValue)
|
||||
if (bitmap != null) {
|
||||
val imageBitmap = bitmap.asImageBitmap()
|
||||
val zoomState = remember(imageId) {
|
||||
ZoomState(
|
||||
contentSize = Size(bitmap.width.toFloat(), bitmap.height.toFloat())
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier
|
||||
.fillMaxSize(0.92f)
|
||||
.align(Alignment.Center)) {
|
||||
Image(
|
||||
bitmap = imageBitmap,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.align(Alignment.Center)
|
||||
.zoomable(zoomState)
|
||||
)
|
||||
}
|
||||
}
|
||||
RotationButtons(imageId, onRotateImage, Modifier.align(Alignment.BottomCenter))
|
||||
SecondaryActionButton(
|
||||
Icons.Outlined.Delete,
|
||||
contentDescription = stringResource(R.string.delete_page),
|
||||
onClick = { onDeleteImage(imageId) },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(8.dp)
|
||||
)
|
||||
Text("${currentPageIndex.intValue + 1} / ${document.pageCount()}",
|
||||
color = MaterialTheme.colorScheme.inverseOnSurface,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(all = 16.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.5f),
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RotationButtons(
|
||||
imageId: String,
|
||||
onRotateImage: (String, Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(modifier = modifier.padding(4.dp)) {
|
||||
SecondaryActionButton(
|
||||
icon = Icons.AutoMirrored.Default.RotateLeft,
|
||||
contentDescription = "Rotate left",
|
||||
onClick = { onRotateImage(imageId, false) }
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
SecondaryActionButton(
|
||||
icon = Icons.AutoMirrored.Default.RotateRight,
|
||||
contentDescription = "Rotate right",
|
||||
onClick = { onRotateImage(imageId, true) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomBar(
|
||||
navigation: Navigation,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
MainActionButton(
|
||||
onClick = navigation.toExportScreen,
|
||||
icon = Icons.Default.Description,
|
||||
text = stringResource(R.string.export),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun DocumentScreenPreview() {
|
||||
FairScanTheme {
|
||||
DocumentScreen(
|
||||
fakeDocument(
|
||||
listOf(1, 2).map { "gallica.bnf.fr-bpt6k5530456s-$it.jpg" }.toImmutableList(),
|
||||
LocalContext.current
|
||||
),
|
||||
initialPage = 1,
|
||||
navigation = dummyNavigation(),
|
||||
onDeleteImage = { _ -> },
|
||||
onRotateImage = { _,_ -> },
|
||||
onPageReorder = { _,_ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, widthDp = 640, heightDp = 320)
|
||||
@Composable
|
||||
fun DocumentScreenPreviewInLandscapeMode() {
|
||||
DocumentScreenPreview()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.ui.screens
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import org.fairscan.app.R
|
||||
import org.fairscan.app.domain.PageViewKey
|
||||
import org.fairscan.app.domain.Rotation
|
||||
import org.fairscan.app.ui.components.isLandscape
|
||||
import org.fairscan.app.ui.components.pageCountText
|
||||
import org.fairscan.app.ui.fakeDocument
|
||||
import org.fairscan.app.ui.fakeImage
|
||||
import org.fairscan.app.ui.screens.document.CurrentPageUiState
|
||||
import org.fairscan.app.ui.screens.document.DocumentUiState
|
||||
import org.fairscan.app.ui.theme.FairScanTheme
|
||||
import org.fairscan.imageprocessing.ColorMode
|
||||
|
||||
|
||||
@Composable
|
||||
fun ResumeScanScreen(
|
||||
currentDocument: DocumentUiState,
|
||||
onResumeScan: () -> Unit,
|
||||
onStartNewScan: () -> Unit,
|
||||
) {
|
||||
val pageCount = currentDocument.document.pageCount()
|
||||
val firstPageThumbnail = currentDocument.currentPage?.bitmap
|
||||
|
||||
val resumeScanModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onResumeScan)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
|
||||
.padding(horizontal = 16.dp, vertical = 24.dp)
|
||||
|
||||
Scaffold { innerPadding ->
|
||||
if (!isLandscape(LocalConfiguration.current)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
Column(
|
||||
modifier = resumeScanModifier.weight(2f),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
FirstPageThumbnail(firstPageThumbnail, Modifier.weight(1f))
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
ResumeScanActions(pageCount, onResumeScan)
|
||||
}
|
||||
HorizontalDivider()
|
||||
NewScanArea(onStartNewScan, Modifier.weight(1f))
|
||||
}
|
||||
} else {
|
||||
Row {
|
||||
Row(
|
||||
modifier = resumeScanModifier.weight(1.8f),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
FirstPageThumbnail(firstPageThumbnail, Modifier.weight(1f))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
) {
|
||||
ResumeScanActions(pageCount, onResumeScan)
|
||||
}
|
||||
}
|
||||
VerticalDivider()
|
||||
NewScanArea(onStartNewScan, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
private fun FirstPageThumbnail(firstPageThumbnail: Bitmap?, modifier: Modifier) {
|
||||
firstPageThumbnail?.let { bmp ->
|
||||
Box(
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
bitmap = bmp.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.border(1.dp, Color.Gray, RoundedCornerShape(8.dp)),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResumeScanActions(pageCount: Int, onResumeScan: () -> Unit) {
|
||||
|
||||
FlowRow {
|
||||
val style = MaterialTheme.typography.bodyMedium
|
||||
Text(stringResource(R.string.scan_current), style = style)
|
||||
Text(" • ", style = style)
|
||||
Text(pageCountText(pageCount), style = style)
|
||||
}
|
||||
BigButton(onClick = onResumeScan, text = stringResource(R.string.scan_resume))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NewScanArea(onStartNewScan: () -> Unit, modifier: Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.clickable(onClick = onStartNewScan)
|
||||
.padding(horizontal = 16.dp, vertical = 32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.scan_discard_current),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
BigButton(onClick = onStartNewScan, text = stringResource(R.string.scan_new))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BigButton(onClick: () -> Unit, text: String) {
|
||||
Button(onClick = onClick, modifier = Modifier.padding(vertical = 16.dp)) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, showSystemUi = true)
|
||||
@Preview(showBackground = true, widthDp = 320, heightDp = 640)
|
||||
@Preview(showBackground = true, widthDp = 640, heightDp = 320)
|
||||
@Composable
|
||||
fun ResumeScanScreenPreview() {
|
||||
val image = fakeImage("gallica.bnf.fr-bpt6k5530456s-1", LocalContext.current).toBitmap()
|
||||
val document = fakeDocument(
|
||||
listOf(1, 2).map { "gallica.bnf.fr-bpt6k5530456s-$it" }.toImmutableList(),
|
||||
LocalContext.current
|
||||
)
|
||||
val key = PageViewKey("123", Rotation.R0, null, 0)
|
||||
FairScanTheme {
|
||||
ResumeScanScreen(
|
||||
DocumentUiState(1, CurrentPageUiState(key,image, ColorMode.COLOR, true), document),
|
||||
{}, {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -57,6 +57,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -76,8 +77,11 @@ import org.fairscan.app.ui.theme.FairScanTheme
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AboutScreen(
|
||||
aboutUiState: AboutUiState,
|
||||
onBack: () -> Unit,
|
||||
onCopyLogs: () -> Unit,
|
||||
onContactWithLastImageClicked: () -> Unit,
|
||||
onStartActivity: (Intent) -> Unit,
|
||||
onViewLibraries: () -> Unit,
|
||||
) {
|
||||
val showLicenseDialog = rememberSaveable { mutableStateOf(false) }
|
||||
@@ -93,7 +97,10 @@ fun AboutScreen(
|
||||
) { paddingValues ->
|
||||
AboutContent(
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
aboutUiState,
|
||||
onCopyLogs,
|
||||
onContactWithLastImageClicked,
|
||||
onStartActivity,
|
||||
showLicenseDialog,
|
||||
onViewLibraries)
|
||||
}
|
||||
@@ -105,7 +112,10 @@ fun AboutScreen(
|
||||
@Composable
|
||||
fun AboutContent(
|
||||
modifier: Modifier = Modifier,
|
||||
aboutUiState: AboutUiState,
|
||||
onCopyLogs: () -> Unit,
|
||||
onContactWithLastImageClicked: () -> Unit,
|
||||
onStartActivity: (Intent) -> Unit,
|
||||
showLicenseDialog: MutableState<Boolean>,
|
||||
onViewLibraries: () -> Unit,
|
||||
) {
|
||||
@@ -145,16 +155,10 @@ fun AboutContent(
|
||||
}
|
||||
|
||||
Section(title = stringResource(R.string.contact)) {
|
||||
val emailAddress = "contact@fairscan.org"
|
||||
ContactLink(
|
||||
icon = Icons.Default.Email,
|
||||
text = emailAddress,
|
||||
onClick = {
|
||||
val intent = Intent(Intent.ACTION_SENDTO).apply {
|
||||
data = "mailto:$emailAddress".toUri()
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
text = EMAIL_ADDRESS,
|
||||
onClick = { onStartActivity(createContactEmailIntent()) }
|
||||
)
|
||||
val websiteUrl = "https://fairscan.org"
|
||||
ContactLink(
|
||||
@@ -162,9 +166,13 @@ fun AboutContent(
|
||||
text = websiteUrl,
|
||||
onClick = {
|
||||
val intent = Intent(Intent.ACTION_VIEW, websiteUrl.toUri())
|
||||
context.startActivity(intent)
|
||||
onStartActivity(intent)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Section(title = stringResource(R.string.support)) {
|
||||
EmailImageButton(aboutUiState, onContactWithLastImageClicked)
|
||||
CopyLogsButton (onClick = onCopyLogs)
|
||||
}
|
||||
|
||||
@@ -295,11 +303,33 @@ fun CopyLogsButton(onClick: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmailImageButton(
|
||||
aboutUiState: AboutUiState,
|
||||
onContactWithLastImageClicked: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
enabled = aboutUiState.hasLastCapturedImage,
|
||||
onClick = onContactWithLastImageClicked
|
||||
)
|
||||
.alpha(if (aboutUiState.hasLastCapturedImage) 1f else 0.5f)
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Default.Email, contentDescription = null)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Text(stringResource(R.string.support_last_image))
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AboutScreenPreview() {
|
||||
FairScanTheme {
|
||||
AboutScreen(onBack = {}, onCopyLogs = {}, onViewLibraries = {})
|
||||
val state = AboutUiState(true)
|
||||
AboutScreen(state, {}, {}, {}, {}, {})
|
||||
}
|
||||
}
|
||||
+6
-8
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -12,13 +12,11 @@
|
||||
* 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.ui.screens.home
|
||||
package org.fairscan.app.ui.screens.about
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
data class RecentDocumentUiState(
|
||||
val fileUri: Uri,
|
||||
val fileName: String,
|
||||
val saveTimestamp: Long,
|
||||
val pageCount: Int,
|
||||
@Immutable
|
||||
data class AboutUiState(
|
||||
val hasLastCapturedImage: Boolean = false,
|
||||
)
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -18,23 +18,31 @@ import android.os.Build
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.fairscan.app.AppContainer
|
||||
import org.fairscan.app.BuildConfig
|
||||
import org.fairscan.app.data.ImageRepository
|
||||
import java.time.LocalDateTime
|
||||
|
||||
sealed interface AboutEvent {
|
||||
data class CopyLogs(val logs: String) : AboutEvent
|
||||
object PrepareEmailWithLastImage : AboutEvent
|
||||
}
|
||||
|
||||
class AboutViewModel(container: AppContainer): ViewModel() {
|
||||
class AboutViewModel(container: AppContainer, val imageRepository: ImageRepository): ViewModel() {
|
||||
|
||||
private val logRepository = container.logRepository
|
||||
|
||||
private val _events = MutableSharedFlow<AboutEvent>()
|
||||
val events = _events.asSharedFlow()
|
||||
|
||||
private val _uiState = MutableStateFlow(AboutUiState())
|
||||
val uiState = _uiState.asStateFlow()
|
||||
|
||||
fun onCopyLogsClicked() {
|
||||
viewModelScope.launch {
|
||||
val logs = buildFullLogs()
|
||||
@@ -55,4 +63,15 @@ class AboutViewModel(container: AppContainer): ViewModel() {
|
||||
return header + logRepository.getLogs()
|
||||
}
|
||||
|
||||
fun refreshLastCapturedImageState() {
|
||||
_uiState.update {
|
||||
it.copy(hasLastCapturedImage = imageRepository.lastAddedSourceFile() != null)
|
||||
}
|
||||
}
|
||||
|
||||
fun onContactWithLastImageClicked() {
|
||||
viewModelScope.launch {
|
||||
_events.emit(AboutEvent.PrepareEmailWithLastImage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.ui.screens.about
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.net.toUri
|
||||
import org.fairscan.app.BuildConfig
|
||||
import org.fairscan.app.ui.uriForFile
|
||||
import java.io.File
|
||||
|
||||
const val EMAIL_ADDRESS = "contact@fairscan.org"
|
||||
|
||||
fun createContactEmailIntent(): Intent =
|
||||
Intent(Intent.ACTION_SENDTO).apply {
|
||||
data = "mailto:$EMAIL_ADDRESS".toUri()
|
||||
}
|
||||
|
||||
fun createEmailWithImageIntent(context: Context, imageFile: File?): Intent {
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "image/jpeg"
|
||||
putExtra(Intent.EXTRA_EMAIL, arrayOf(EMAIL_ADDRESS))
|
||||
putExtra(
|
||||
Intent.EXTRA_SUBJECT,
|
||||
"FairScan ${BuildConfig.VERSION_NAME}"
|
||||
)
|
||||
if (imageFile != null) {
|
||||
val uri = uriForFile(context, imageFile)
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
}
|
||||
return Intent.createChooser(intent, null)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -15,11 +15,22 @@
|
||||
package org.fairscan.app.ui.screens.camera
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.hardware.camera2.CameraCaptureSession
|
||||
import android.hardware.camera2.CameraMetadata
|
||||
import android.hardware.camera2.CaptureRequest
|
||||
import android.hardware.camera2.CaptureResult
|
||||
import android.hardware.camera2.TotalCaptureResult
|
||||
import android.util.Log
|
||||
import android.util.Size
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import android.widget.LinearLayout
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.camera.camera2.interop.Camera2CameraInfo
|
||||
import androidx.camera.camera2.interop.Camera2Interop
|
||||
import androidx.camera.camera2.interop.ExperimentalCamera2Interop
|
||||
import androidx.camera.core.CameraControl
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.FocusMeteringAction
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageCapture
|
||||
import androidx.camera.core.ImageCaptureException
|
||||
@@ -27,17 +38,27 @@ import androidx.camera.core.ImageProxy
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.core.resolutionselector.AspectRatioStrategy
|
||||
import androidx.camera.core.resolutionselector.ResolutionSelector
|
||||
import androidx.camera.core.resolutionselector.ResolutionStrategy
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
@@ -47,17 +68,22 @@ import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.scale
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import org.fairscan.app.domain.Point
|
||||
import org.fairscan.app.domain.scaledTo
|
||||
import org.fairscan.app.ui.components.CameraPermissionState
|
||||
import org.fairscan.imageprocessing.CameraIntrinsics
|
||||
import org.fairscan.imageprocessing.OpticalMeasures
|
||||
import org.fairscan.imageprocessing.Point
|
||||
import org.fairscan.imageprocessing.Quad
|
||||
import org.fairscan.imageprocessing.cameraIntrinsics
|
||||
import org.fairscan.imageprocessing.scaledTo
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.max
|
||||
|
||||
@Composable
|
||||
fun CameraPreview(
|
||||
@@ -66,6 +92,7 @@ fun CameraPreview(
|
||||
captureController: CameraCaptureController,
|
||||
onPreviewViewReady: (PreviewView) -> Unit,
|
||||
cameraPermission: CameraPermissionState,
|
||||
onError: (String, Throwable) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -77,44 +104,88 @@ fun CameraPreview(
|
||||
val cameraProviderFuture by remember {
|
||||
mutableStateOf(ProcessCameraProvider.getInstance(context))
|
||||
}
|
||||
|
||||
val analysisExecutor = remember { Executors.newSingleThreadExecutor() }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
onDispose {
|
||||
cameraProviderFuture.get().unbindAll()
|
||||
analysisExecutor.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(modifier = modifier, factory = {
|
||||
val previewView = PreviewView(it).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
|
||||
scaleType = PreviewView.ScaleType.FIT_CENTER
|
||||
onPreviewViewReady(this)
|
||||
}
|
||||
val executor = Executors.newSingleThreadExecutor()
|
||||
cameraProviderFuture.addListener({
|
||||
bindCameraUseCases(
|
||||
lifecycleOwner = lifecycleOwner,
|
||||
cameraProviderFuture = cameraProviderFuture,
|
||||
executor = executor,
|
||||
previewView = previewView,
|
||||
onImageAnalyzed = onImageAnalyzed,
|
||||
captureController = captureController
|
||||
)
|
||||
}, ContextCompat.getMainExecutor(context))
|
||||
var bindState by remember { mutableStateOf<CameraBindState>(CameraBindState.Idle) }
|
||||
var retryKey by remember { mutableStateOf(0) }
|
||||
var previewView: PreviewView? by remember { mutableStateOf(null) }
|
||||
|
||||
when (bindState) {
|
||||
is CameraBindState.Error -> {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text("Camera unavailable")
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(onClick = { retryKey++ }) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
AndroidView(
|
||||
modifier = modifier,
|
||||
factory = {
|
||||
PreviewView(it).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
|
||||
scaleType = PreviewView.ScaleType.FIT_CENTER
|
||||
onPreviewViewReady(this)
|
||||
previewView = this
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
LaunchedEffect(previewView, retryKey) {
|
||||
val view = previewView ?: return@LaunchedEffect
|
||||
|
||||
val provider = cameraProviderFuture.get()
|
||||
|
||||
val result = runCatching {
|
||||
bindCameraUseCases(
|
||||
lifecycleOwner,
|
||||
provider,
|
||||
analysisExecutor,
|
||||
view,
|
||||
onImageAnalyzed,
|
||||
captureController
|
||||
)
|
||||
}
|
||||
|
||||
bindState = result.fold(
|
||||
onSuccess = { CameraBindState.Bound },
|
||||
onFailure = {
|
||||
onError("Camera unavailable", it)
|
||||
CameraBindState.Error(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
previewView
|
||||
})
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCamera2Interop::class)
|
||||
fun bindCameraUseCases(
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
cameraProviderFuture: ListenableFuture<ProcessCameraProvider>,
|
||||
cameraProvider: ProcessCameraProvider,
|
||||
executor: ExecutorService,
|
||||
previewView: PreviewView,
|
||||
onImageAnalyzed: (ImageProxy) -> Unit,
|
||||
captureController: CameraCaptureController,
|
||||
) {
|
||||
cameraProvider.unbindAll()
|
||||
|
||||
val ratio_4_3 = ResolutionSelector.Builder()
|
||||
.setAspectRatioStrategy(AspectRatioStrategy.RATIO_4_3_FALLBACK_AUTO_STRATEGY)
|
||||
.build()
|
||||
@@ -129,35 +200,77 @@ fun bindCameraUseCases(
|
||||
.setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888).build()
|
||||
imageAnalysis.setAnalyzer(executor, onImageAnalyzed)
|
||||
|
||||
val imageCapture = ImageCapture.Builder()
|
||||
.setResolutionSelector(ratio_4_3)
|
||||
val imageCaptureBuilder = ImageCapture.Builder()
|
||||
.setResolutionSelector(
|
||||
ResolutionSelector.Builder()
|
||||
.setResolutionStrategy(
|
||||
ResolutionStrategy(
|
||||
Size(4400, 3300),
|
||||
ResolutionStrategy.FALLBACK_RULE_CLOSEST_LOWER_THEN_HIGHER
|
||||
)
|
||||
)
|
||||
.setAspectRatioStrategy(
|
||||
AspectRatioStrategy.RATIO_4_3_FALLBACK_AUTO_STRATEGY
|
||||
)
|
||||
.build()
|
||||
)
|
||||
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
|
||||
.build()
|
||||
|
||||
Camera2Interop.Extender(imageCaptureBuilder)
|
||||
.setSessionCaptureCallback(object : CameraCaptureSession.CaptureCallback() {
|
||||
override fun onCaptureCompleted(
|
||||
session: CameraCaptureSession,
|
||||
request: CaptureRequest,
|
||||
result: TotalCaptureResult
|
||||
) {
|
||||
result.get(CaptureResult.LENS_FOCUS_DISTANCE)?.let {
|
||||
captureController.lastFocusDistanceDiopters = it
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
val imageCapture = imageCaptureBuilder.build()
|
||||
captureController.imageCapture = imageCapture
|
||||
|
||||
val cameraProvider = cameraProviderFuture.get()
|
||||
val camera = cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector,
|
||||
imageAnalysis, preview, imageCapture)
|
||||
captureController.cameraControl = camera.cameraControl
|
||||
captureController.setCameraCharacteristics(Camera2CameraInfo.from(camera.cameraInfo))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AnalysisOverlay(liveAnalysisState: LiveAnalysisState, debugMode: Boolean) {
|
||||
val binaryMask = liveAnalysisState.binaryMask
|
||||
if (binaryMask == null) {
|
||||
return
|
||||
}
|
||||
val maskSize = liveAnalysisState.maskSize ?: return
|
||||
val targetQuad = liveAnalysisState.stableQuad
|
||||
var displayedQuad by remember { mutableStateOf<Quad?>(null) }
|
||||
val quadColor = MaterialTheme.colorScheme.primary
|
||||
|
||||
LaunchedEffect(targetQuad) {
|
||||
if (targetQuad == null) {
|
||||
displayedQuad = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
while (true) {
|
||||
displayedQuad = displayedQuad?.let { current ->
|
||||
lerpQuad(current, targetQuad, 0.15f)
|
||||
} ?: targetQuad
|
||||
|
||||
withFrameNanos { }
|
||||
}
|
||||
}
|
||||
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
if (debugMode) {
|
||||
drawMask(this, binaryMask)
|
||||
val binaryMask = liveAnalysisState.binaryMaskProvider.invoke()
|
||||
binaryMask?.let { drawMask(this, it) }
|
||||
}
|
||||
if (liveAnalysisState.documentQuad != null) {
|
||||
val scaledQuad = liveAnalysisState.documentQuad.scaledTo(
|
||||
fromWidth = binaryMask.width,
|
||||
fromHeight = binaryMask.height,
|
||||
toWidth = size.width.toInt(),
|
||||
toHeight = size.height.toInt()
|
||||
displayedQuad?.let { quad ->
|
||||
val scaledQuad = quad.scaledTo(
|
||||
fromWidth = maskSize.width,
|
||||
fromHeight = maskSize.height,
|
||||
toWidth = size.width.toDouble(),
|
||||
toHeight = size.height.toDouble()
|
||||
)
|
||||
scaledQuad.edges().forEach {
|
||||
drawLine(quadColor, it.from.toOffset(), it.to.toOffset(), 10.0f)
|
||||
@@ -202,23 +315,80 @@ class CameraCaptureController {
|
||||
var cameraControl: CameraControl? = null
|
||||
var imageCapture: ImageCapture? = null
|
||||
private val executor = Executors.newSingleThreadExecutor()
|
||||
var previewView: PreviewView? = null
|
||||
var cameraIntrinsics: CameraIntrinsics? = null
|
||||
var canUseFocusDistance = false
|
||||
|
||||
@Volatile
|
||||
var lastFocusDistanceDiopters: Float? = null
|
||||
|
||||
fun shutdown() {
|
||||
executor.shutdown()
|
||||
}
|
||||
|
||||
fun takePicture(onImageCaptured: (ImageProxy?) -> Unit) {
|
||||
fun takePicture(onImageCaptured: (ImageProxy?, OpticalMeasures?) -> Unit) {
|
||||
imageCapture?.takePicture(
|
||||
executor,
|
||||
object : ImageCapture.OnImageCapturedCallback() {
|
||||
override fun onCaptureSuccess(imageProxy: ImageProxy) {
|
||||
onImageCaptured(imageProxy)
|
||||
val diopters = lastFocusDistanceDiopters
|
||||
val subjectDistanceInMm =
|
||||
if (canUseFocusDistance && diopters != null && diopters != 0.0f) {
|
||||
1000 / diopters
|
||||
} else {
|
||||
null
|
||||
}
|
||||
onImageCaptured(
|
||||
imageProxy,
|
||||
cameraIntrinsics?.let { OpticalMeasures(it, subjectDistanceInMm) })
|
||||
}
|
||||
override fun onError(exception: ImageCaptureException) {
|
||||
Log.e("CameraCapture", "Image capture failed: ${exception.message}", exception)
|
||||
onImageCaptured(null)
|
||||
onImageCaptured(null, null)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun tapToFocus(tapOffset: Offset) {
|
||||
val view = previewView ?: return
|
||||
val control = cameraControl ?: return
|
||||
|
||||
val factory = view.meteringPointFactory
|
||||
val point = factory.createPoint(tapOffset.x, tapOffset.y)
|
||||
|
||||
val action = FocusMeteringAction.Builder(point)
|
||||
.setAutoCancelDuration(5, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
control.startFocusAndMetering(action)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCamera2Interop::class)
|
||||
fun setCameraCharacteristics(cameraInfo: Camera2CameraInfo) {
|
||||
val focalLengths = cameraInfo.getCameraCharacteristic(
|
||||
android.hardware.camera2.CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS
|
||||
)
|
||||
val sensorSize = cameraInfo.getCameraCharacteristic(
|
||||
android.hardware.camera2.CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE
|
||||
)
|
||||
cameraIntrinsics =
|
||||
if (focalLengths == null || focalLengths.size != 1 || sensorSize == null) {
|
||||
null
|
||||
} else {
|
||||
cameraIntrinsics(focalLengths[0], max(sensorSize.width, sensorSize.height))
|
||||
}
|
||||
val calibration = cameraInfo.getCameraCharacteristic(
|
||||
android.hardware.camera2.CameraCharacteristics.LENS_INFO_FOCUS_DISTANCE_CALIBRATION
|
||||
)
|
||||
canUseFocusDistance =
|
||||
calibration == CameraMetadata.LENS_INFO_FOCUS_DISTANCE_CALIBRATION_CALIBRATED
|
||||
|| calibration == CameraMetadata.LENS_INFO_FOCUS_DISTANCE_CALIBRATION_APPROXIMATE
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface CameraBindState {
|
||||
object Idle : CameraBindState
|
||||
object Bound : CameraBindState
|
||||
data class Error(val throwable: Throwable) : CameraBindState
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -15,41 +15,50 @@
|
||||
package org.fairscan.app.ui.screens.camera
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.camera.core.ImageProxy
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.updateTransition
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AddPhotoAlternate
|
||||
import androidx.compose.material.icons.filled.Done
|
||||
import androidx.compose.material.icons.filled.FlashOff
|
||||
import androidx.compose.material.icons.filled.FlashOn
|
||||
import androidx.compose.material.icons.filled.Highlight
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -66,24 +75,35 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Devices
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.delay
|
||||
import org.fairscan.app.MainViewModel
|
||||
import org.fairscan.app.R
|
||||
import org.fairscan.app.domain.CapturedPage
|
||||
import org.fairscan.app.domain.Jpeg
|
||||
import org.fairscan.app.domain.PageMetadata
|
||||
import org.fairscan.app.domain.Rotation.R0
|
||||
import org.fairscan.app.ui.Navigation
|
||||
import org.fairscan.app.ui.Screen
|
||||
import org.fairscan.app.ui.components.CameraPermissionState
|
||||
@@ -94,6 +114,9 @@ import org.fairscan.app.ui.components.pageCountText
|
||||
import org.fairscan.app.ui.dummyNavigation
|
||||
import org.fairscan.app.ui.fakeDocument
|
||||
import org.fairscan.app.ui.theme.FairScanTheme
|
||||
import org.fairscan.imageprocessing.ColorMode
|
||||
import org.fairscan.imageprocessing.Point
|
||||
import org.fairscan.imageprocessing.Quad
|
||||
|
||||
const val CAPTURED_IMAGE_DISPLAY_DURATION = 1500L
|
||||
const val ANIMATION_DURATION = 200
|
||||
@@ -104,21 +127,28 @@ fun CameraScreen(
|
||||
cameraViewModel: CameraViewModel,
|
||||
navigation: Navigation,
|
||||
liveAnalysisState: LiveAnalysisState,
|
||||
importState: ImportState,
|
||||
onImageAnalyzed: (ImageProxy) -> Unit,
|
||||
onFinalizePressed: () -> Unit,
|
||||
cameraPermission: CameraPermissionState,
|
||||
onImportClicked: () -> Unit,
|
||||
) {
|
||||
var previewView by remember { mutableStateOf<PreviewView?>(null) }
|
||||
val document by viewModel.documentUiModel.collectAsStateWithLifecycle()
|
||||
val thumbnailCoords = remember { mutableStateOf(Offset.Zero) }
|
||||
var isDebugMode by remember { mutableStateOf(false) }
|
||||
var isTorchEnabled by remember { mutableStateOf(false) }
|
||||
|
||||
BackHandler { navigation.back() }
|
||||
val isTorchEnabled by cameraViewModel.isTorchEnabled.collectAsStateWithLifecycle()
|
||||
var torchReapplied by remember { mutableStateOf(false) }
|
||||
|
||||
val captureController = remember { CameraCaptureController() }
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { captureController.shutdown() }
|
||||
onDispose {
|
||||
captureController.shutdown()
|
||||
torchReapplied = false
|
||||
}
|
||||
}
|
||||
LaunchedEffect(captureController.cameraControl, isTorchEnabled) {
|
||||
captureController.cameraControl?.enableTorch(isTorchEnabled)
|
||||
}
|
||||
|
||||
val captureState by cameraViewModel.captureState.collectAsStateWithLifecycle()
|
||||
@@ -139,20 +169,59 @@ fun CameraScreen(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
cameraViewModel.resetLiveAnalysis()
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
cameraViewModel.cancelImport()
|
||||
}
|
||||
}
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
LaunchedEffect(document.pageCount()) {
|
||||
if (!document.isEmpty()) {
|
||||
listState.animateScrollToItem(document.lastIndex())
|
||||
}
|
||||
}
|
||||
|
||||
val onCapture = {
|
||||
previewView?.bitmap?.let {
|
||||
Log.i("FairScan", "Pressed <Capture>")
|
||||
cameraViewModel.onCapturePressed(it)
|
||||
captureController.takePicture(
|
||||
onImageCaptured = { imageProxy, opticalMeasures ->
|
||||
cameraViewModel.onImageCaptured(imageProxy, opticalMeasures)
|
||||
}
|
||||
)
|
||||
}?: Unit
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
cameraViewModel.volumeKeyEvent.collect {
|
||||
onCapture()
|
||||
}
|
||||
}
|
||||
|
||||
val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
CameraScreenScaffold(
|
||||
cameraPreview = {
|
||||
CameraPreview(
|
||||
onImageAnalyzed = onImageAnalyzed,
|
||||
onImageAnalyzed = {
|
||||
onImageAnalyzed(it)
|
||||
// Re-apply torch once the camera session is actually active (first analysis)
|
||||
if (!torchReapplied) {
|
||||
captureController.cameraControl?.enableTorch(isTorchEnabled)
|
||||
torchReapplied = true
|
||||
}
|
||||
},
|
||||
captureController = captureController,
|
||||
onPreviewViewReady = { view -> previewView = view },
|
||||
onPreviewViewReady = { view ->
|
||||
previewView = view
|
||||
captureController.previewView = view
|
||||
},
|
||||
cameraPermission = cameraPermission,
|
||||
onError = { message, throwable -> cameraViewModel.logError(message, throwable) }
|
||||
)
|
||||
},
|
||||
pageListState =
|
||||
@@ -161,32 +230,30 @@ fun CameraScreen(
|
||||
onPageClick = { index -> viewModel.navigateTo(Screen.Main.Document(index)) },
|
||||
onPageReorder = { id, index -> viewModel.movePage(id, index) },
|
||||
listState = listState,
|
||||
showPageNumbers = false,
|
||||
onLastItemPosition = { offset -> thumbnailCoords.value = offset },
|
||||
),
|
||||
cameraUiState = CameraUiState(
|
||||
document.pageCount(),
|
||||
liveAnalysisState,
|
||||
captureState,
|
||||
importState,
|
||||
showDetectionError,
|
||||
isLandscape = isLandscape,
|
||||
isDebugMode,
|
||||
isTorchEnabled),
|
||||
onCapture = {
|
||||
previewView?.bitmap?.let {
|
||||
Log.i("FairScan", "Pressed <Capture>")
|
||||
cameraViewModel.onCapturePressed(it)
|
||||
captureController.takePicture(
|
||||
onImageCaptured = { imageProxy -> cameraViewModel.onImageCaptured(imageProxy) }
|
||||
)
|
||||
}
|
||||
},
|
||||
onCapture = onCapture,
|
||||
onFinalizePressed = onFinalizePressed,
|
||||
onDebugModeSwitched = { isDebugMode = !isDebugMode },
|
||||
onTorchSwitched = {
|
||||
isTorchEnabled = !isTorchEnabled
|
||||
captureController.cameraControl?.enableTorch(isTorchEnabled) },
|
||||
cameraViewModel.setTorchEnabled(!isTorchEnabled)
|
||||
},
|
||||
thumbnailCoords = thumbnailCoords,
|
||||
navigation = navigation
|
||||
navigation = navigation,
|
||||
captureController,
|
||||
isCameraPermissionGranted = cameraPermission.isGranted,
|
||||
onRequestCameraPermission = { cameraPermission.request() },
|
||||
onImportClicked = onImportClicked,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -201,7 +268,19 @@ private fun CameraScreenScaffold(
|
||||
onTorchSwitched: () -> Unit,
|
||||
thumbnailCoords: MutableState<Offset>,
|
||||
navigation: Navigation,
|
||||
captureController: CameraCaptureController,
|
||||
isCameraPermissionGranted: Boolean,
|
||||
onRequestCameraPermission: () -> Unit,
|
||||
onImportClicked: () -> Unit,
|
||||
) {
|
||||
var focusPoint by remember { mutableStateOf<Offset?>(null) }
|
||||
LaunchedEffect(focusPoint) {
|
||||
if (focusPoint != null) {
|
||||
delay(1000)
|
||||
focusPoint = null
|
||||
}
|
||||
}
|
||||
|
||||
var tapCount by remember { mutableLongStateOf(0) }
|
||||
var lastTapTime by remember { mutableLongStateOf(0L) }
|
||||
val tapThreshold = 500L
|
||||
@@ -223,18 +302,71 @@ private fun CameraScreenScaffold(
|
||||
MyScaffold(
|
||||
navigation = navigation,
|
||||
pageListState = pageListState,
|
||||
bottomBar = { Bar(cameraUiState.pageCount, onFinalizePressed) }
|
||||
bottomBar = { Bar(cameraUiState.pageCount, onFinalizePressed, onImportClicked) }
|
||||
) { modifier ->
|
||||
CameraPreviewBox(
|
||||
cameraPreview,
|
||||
cameraUiState,
|
||||
onCapture,
|
||||
onTorchSwitched,
|
||||
modifier.clickable(onClick = onPageCountClick)
|
||||
)
|
||||
if (cameraUiState.importState is ImportState.Selecting) {
|
||||
// display nothing: photo picker is active
|
||||
} else if (cameraUiState.importState is ImportState.Importing) {
|
||||
ImportInProgress(cameraUiState.importState, modifier)
|
||||
} else if (!isCameraPermissionGranted) {
|
||||
CameraPermissionRationale(onRequestCameraPermission, modifier)
|
||||
} else {
|
||||
CameraPreviewBox(
|
||||
cameraPreview,
|
||||
cameraUiState,
|
||||
focusPoint,
|
||||
onCapture,
|
||||
onTorchSwitched,
|
||||
modifier.pointerInput(Unit) {
|
||||
detectTapGestures { offset ->
|
||||
focusPoint = offset
|
||||
captureController.tapToFocus(offset)
|
||||
onPageCountClick()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
if (cameraUiState.captureState is CaptureState.CapturePreview) {
|
||||
CapturedImage(cameraUiState.captureState.processed.asImageBitmap(), thumbnailCoords)
|
||||
val page = cameraUiState.captureState.capturedPage.pageJpeg.toBitmap()
|
||||
CapturedImage(page.asImageBitmap(), thumbnailCoords)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ImportInProgress(state: ImportState.Importing, modifier: Modifier) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.6f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = LocalResources.current.getQuantityString(
|
||||
R.plurals.importing_photos,
|
||||
state.total,
|
||||
state.total
|
||||
),
|
||||
color = Color.White
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
if (state.total > 1) {
|
||||
LinearProgressIndicator(
|
||||
progress = { state.processed.toFloat() / state.total },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
} else {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -243,6 +375,7 @@ private fun CameraScreenScaffold(
|
||||
private fun CameraPreviewBox(
|
||||
cameraPreview: @Composable (() -> Unit),
|
||||
cameraUiState: CameraUiState,
|
||||
focusPoint: Offset?,
|
||||
onCapture: () -> Unit,
|
||||
onTorchSwitched: () -> Unit,
|
||||
modifier: Modifier,
|
||||
@@ -258,6 +391,7 @@ private fun CameraPreviewBox(
|
||||
if (cameraUiState.isDebugMode) {
|
||||
MessageBox(cameraUiState.liveAnalysisState.inferenceTime)
|
||||
}
|
||||
FocusOverlay(focusPoint)
|
||||
CaptureButton(
|
||||
onClick = onCapture,
|
||||
modifier = Modifier
|
||||
@@ -269,13 +403,12 @@ private fun CameraPreviewBox(
|
||||
modifier = Modifier.align(Alignment.BottomStart)
|
||||
) {
|
||||
val torchEnabled = cameraUiState.isTorchEnabled
|
||||
val icon = if (torchEnabled) Icons.Default.FlashOn else Icons.Default.FlashOff
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
imageVector = Icons.Default.Highlight,
|
||||
contentDescription =
|
||||
stringResource(
|
||||
if (torchEnabled) R.string.turn_off_torch else R.string.turn_on_torch),
|
||||
tint = Color.White
|
||||
tint = if (torchEnabled) Color.White else Color.White.copy(alpha = 0.5f)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -402,7 +535,7 @@ private fun CameraPreviewWithOverlay(
|
||||
.background(Color.Black.copy(alpha = 0.6f))
|
||||
)
|
||||
}
|
||||
if (cameraUiState.showDetectionError) {
|
||||
if (cameraUiState.showCaptureError) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
@@ -410,7 +543,7 @@ private fun CameraPreviewWithOverlay(
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.error_no_document),
|
||||
text = stringResource(R.string.error_occurred),
|
||||
color = Color.White,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
@@ -419,6 +552,23 @@ private fun CameraPreviewWithOverlay(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FocusOverlay(focusPoint: Offset?) {
|
||||
if (focusPoint == null) return
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val size = 80f
|
||||
drawRect(
|
||||
color = Color.White,
|
||||
topLeft = Offset(
|
||||
focusPoint.x - size / 2,
|
||||
focusPoint.y - size / 2
|
||||
),
|
||||
size = Size(size, size),
|
||||
style = Stroke(width = 3f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageBox(inferenceTime: Long) {
|
||||
Text(
|
||||
@@ -434,12 +584,27 @@ fun MessageBox(inferenceTime: Long) {
|
||||
private fun Bar(
|
||||
pageCount: Int,
|
||||
onFinalizePressed: () -> Unit,
|
||||
onImportClicked: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.End
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onImportClicked,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = MaterialTheme.colorScheme.primary
|
||||
),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary)
|
||||
) {
|
||||
Icon(Icons.Default.AddPhotoAlternate, contentDescription = null)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(stringResource(R.string.import_photos),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
MainActionButton(
|
||||
onClick = onFinalizePressed,
|
||||
enabled = pageCount > 0,
|
||||
@@ -449,7 +614,34 @@ private fun Bar(
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun CameraPermissionRationale(onRequestCameraPermission: () -> Unit, modifier:Modifier) {
|
||||
Box (modifier = modifier, contentAlignment = Alignment.Center) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.camera_permission_rationale),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = onRequestCameraPermission,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
) {
|
||||
Text(stringResource(R.string.grant_permission))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name="Pixel 1", showSystemUi = true, device = Devices.PIXEL)
|
||||
@Preview(name="Pixel 4", showSystemUi = true, device = Devices.PIXEL_4)
|
||||
@Preview(name="Pixel 9 pro XL", showSystemUi = true, device = Devices.PIXEL_9_PRO_XL)
|
||||
@Composable
|
||||
fun CameraScreenPreview() {
|
||||
ScreenPreview(CaptureState.Idle)
|
||||
@@ -458,9 +650,15 @@ fun CameraScreenPreview() {
|
||||
@Preview(showBackground = true, showSystemUi = true)
|
||||
@Composable
|
||||
fun CameraScreenPreviewWithProcessedImage() {
|
||||
val p = Point(0 , 0)
|
||||
val quad = Quad(p, p, p, p)
|
||||
ScreenPreview(CaptureState.CapturePreview(
|
||||
debugImage("uncropped/img01.jpg"),
|
||||
debugImage("gallica.bnf.fr-bpt6k5530456s-1.jpg")))
|
||||
debugImage("uncropped/img01.jpg").toBitmap(),
|
||||
CapturedPage(
|
||||
debugImage("gallica.bnf.fr-bpt6k5530456s-1.jpg"),
|
||||
CompletableDeferred(Jpeg(ByteArray(0))),
|
||||
PageMetadata(quad, R0, ColorMode.COLOR, null, null),
|
||||
ColorMode.COLOR)))
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, widthDp = 640, heightDp = 320)
|
||||
@@ -469,8 +667,25 @@ fun CameraScreenPreviewInLandscapeMode() {
|
||||
ScreenPreview(CaptureState.Idle, rotationDegrees = 90f)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ScreenPreview(captureState: CaptureState, rotationDegrees: Float = 0f) {
|
||||
fun CameraScreenPreviewWithNoPermission() {
|
||||
ScreenPreview(CaptureState.Idle, isCameraPermissionGranted = false)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun CameraScreenPreviewImporting() {
|
||||
ScreenPreview(CaptureState.Idle, ImportState.Importing(2, 3))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScreenPreview(
|
||||
captureState: CaptureState,
|
||||
importState: ImportState = ImportState.Idle,
|
||||
rotationDegrees: Float = 0f,
|
||||
isCameraPermissionGranted: Boolean = true,
|
||||
) {
|
||||
FairScanTheme {
|
||||
val thumbnailCoords = remember { mutableStateOf(Offset.Zero) }
|
||||
CameraScreenScaffold(
|
||||
@@ -482,7 +697,7 @@ private fun ScreenPreview(captureState: CaptureState, rotationDegrees: Float = 0
|
||||
contentAlignment = Alignment.TopCenter
|
||||
) {
|
||||
Image(
|
||||
debugImage("uncropped/img01.jpg").asImageBitmap(),
|
||||
debugImage("uncropped/img01.jpg").toBitmap().asImageBitmap(),
|
||||
modifier=Modifier.rotate(rotationDegrees),
|
||||
contentDescription = null
|
||||
)
|
||||
@@ -492,29 +707,32 @@ private fun ScreenPreview(captureState: CaptureState, rotationDegrees: Float = 0
|
||||
CommonPageListState(
|
||||
document = fakeDocument(
|
||||
listOf(1, 2)
|
||||
.map { "gallica.bnf.fr-bpt6k5530456s-$it.jpg" }
|
||||
.map { "gallica.bnf.fr-bpt6k5530456s-$it" }
|
||||
.toImmutableList(),
|
||||
LocalContext.current),
|
||||
onPageClick = {},
|
||||
onPageReorder = { _, _ -> },
|
||||
listState = LazyListState(),
|
||||
showPageNumbers = false,
|
||||
),
|
||||
cameraUiState = CameraUiState(pageCount = 4, LiveAnalysisState(), captureState,
|
||||
false, rotationDegrees > 0, false, false),
|
||||
importState, false, rotationDegrees > 0, false, false),
|
||||
onCapture = {},
|
||||
onFinalizePressed = {},
|
||||
onDebugModeSwitched = {},
|
||||
onTorchSwitched = {},
|
||||
thumbnailCoords = thumbnailCoords,
|
||||
navigation = dummyNavigation()
|
||||
navigation = dummyNavigation(),
|
||||
captureController = CameraCaptureController(),
|
||||
isCameraPermissionGranted = isCameraPermissionGranted,
|
||||
onRequestCameraPermission = {},
|
||||
onImportClicked = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun debugImage(imgName: String): Bitmap {
|
||||
private fun debugImage(imgName: String): Jpeg {
|
||||
val context = LocalContext.current
|
||||
return context.assets.open(imgName).use { input ->
|
||||
BitmapFactory.decodeStream(input)
|
||||
}
|
||||
return Jpeg(context.assets.open(imgName).readBytes())
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -16,21 +16,29 @@ package org.fairscan.app.ui.screens.camera
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.runtime.Immutable
|
||||
import org.fairscan.app.domain.Quad
|
||||
import org.fairscan.imageprocessing.ImageSize
|
||||
import org.fairscan.imageprocessing.Quad
|
||||
|
||||
@Immutable
|
||||
data class LiveAnalysisState(
|
||||
val inferenceTime: Long = 0L,
|
||||
val binaryMask: Bitmap? = null,
|
||||
val documentQuad: Quad? = null,
|
||||
val timestamp: Long = System.currentTimeMillis(),
|
||||
val maskSize: ImageSize? = null,
|
||||
val binaryMaskProvider: () -> Bitmap? = { -> null },
|
||||
val stableQuad: Quad? = null,
|
||||
)
|
||||
|
||||
sealed class ImportState {
|
||||
object Idle : ImportState()
|
||||
object Selecting : ImportState()
|
||||
data class Importing(val processed: Int, val total: Int) : ImportState()
|
||||
}
|
||||
|
||||
data class CameraUiState(
|
||||
val pageCount: Int,
|
||||
val liveAnalysisState: LiveAnalysisState,
|
||||
val captureState: CaptureState,
|
||||
val showDetectionError: Boolean,
|
||||
val importState: ImportState,
|
||||
val showCaptureError: Boolean,
|
||||
val isLandscape: Boolean,
|
||||
val isDebugMode: Boolean,
|
||||
val isTorchEnabled: Boolean,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -15,73 +15,77 @@
|
||||
package org.fairscan.app.ui.screens.camera
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.util.Log
|
||||
import android.graphics.Matrix
|
||||
import android.net.Uri
|
||||
import androidx.camera.core.ImageProxy
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.fairscan.app.AppContainer
|
||||
import org.fairscan.app.domain.detectDocumentQuad
|
||||
import org.fairscan.app.domain.extractDocument
|
||||
import org.fairscan.app.domain.scaledTo
|
||||
import java.io.ByteArrayOutputStream
|
||||
import org.fairscan.app.domain.CapturedPage
|
||||
import org.fairscan.app.platform.extractDocumentFromBitmap
|
||||
import org.fairscan.imageprocessing.ImageSize
|
||||
import org.fairscan.imageprocessing.Mode
|
||||
import org.fairscan.imageprocessing.OpticalMeasures
|
||||
import org.fairscan.imageprocessing.detectDocumentQuad
|
||||
import java.util.concurrent.CancellationException
|
||||
|
||||
sealed interface CameraEvent {
|
||||
data class ImageCaptured(val jpegBytes: ByteArray) : CameraEvent
|
||||
data class ImageCaptured(val page: CapturedPage) : CameraEvent
|
||||
}
|
||||
|
||||
class CameraViewModel(appContainer: AppContainer): ViewModel() {
|
||||
|
||||
private val imageSegmentationService = appContainer.imageSegmentationService
|
||||
private val settingsRepository = appContainer.settingsRepository
|
||||
private val imageLoader = appContainer.imageLoader
|
||||
private val logger = appContainer.logger
|
||||
|
||||
private val _events = MutableSharedFlow<CameraEvent>()
|
||||
val events = _events.asSharedFlow()
|
||||
|
||||
private var _liveAnalysisState = MutableStateFlow(LiveAnalysisState())
|
||||
val liveAnalysisState: StateFlow<LiveAnalysisState> = _liveAnalysisState.asStateFlow()
|
||||
private var lastSuccessfulLiveAnalysisState: LiveAnalysisState? = null
|
||||
private var quadStabilizer = QuadStabilizer()
|
||||
|
||||
private val _captureState = MutableStateFlow<CaptureState>(CaptureState.Idle)
|
||||
val captureState: StateFlow<CaptureState> = _captureState
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
imageSegmentationService.initialize()
|
||||
imageSegmentationService.segmentation
|
||||
.filterNotNull()
|
||||
.map {
|
||||
// TODO Should we really call toBinaryMask if it's used only in debug mode?
|
||||
val binaryMask = it.segmentation.toBinaryMask()
|
||||
LiveAnalysisState(
|
||||
inferenceTime = it.inferenceTime,
|
||||
binaryMask = binaryMask,
|
||||
documentQuad = detectDocumentQuad(it.segmentation, isLiveAnalysis = true),
|
||||
timestamp = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
.collect {
|
||||
_liveAnalysisState.value = it
|
||||
if (it.documentQuad != null) {
|
||||
lastSuccessfulLiveAnalysisState = it
|
||||
}
|
||||
}
|
||||
}
|
||||
private val _importState = MutableStateFlow<ImportState>(ImportState.Idle)
|
||||
val importState: StateFlow<ImportState> = _importState
|
||||
private var importJob: Job? = null
|
||||
|
||||
private val _isTorchEnabled = MutableStateFlow(false)
|
||||
val isTorchEnabled: StateFlow<Boolean> = _isTorchEnabled
|
||||
|
||||
private val _volumeKeyEvent = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
val volumeKeyEvent = _volumeKeyEvent.asSharedFlow()
|
||||
|
||||
fun onVolumeKeyPressed() {
|
||||
_volumeKeyEvent.tryEmit(Unit)
|
||||
}
|
||||
|
||||
fun resetLiveAnalysis() {
|
||||
quadStabilizer = QuadStabilizer()
|
||||
_liveAnalysisState.value = LiveAnalysisState()
|
||||
}
|
||||
|
||||
fun onCapturePressed(frozenImage: Bitmap) {
|
||||
_captureState.value = CaptureState.Capturing(frozenImage)
|
||||
resetLiveAnalysis()
|
||||
}
|
||||
|
||||
private fun onCaptureProcessed(captured: Bitmap?) {
|
||||
private fun onCaptureProcessed(captured: CapturedPage?) {
|
||||
val current = _captureState.value
|
||||
_captureState.value = when {
|
||||
current is CaptureState.Capturing && captured != null ->
|
||||
@@ -93,70 +97,86 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
|
||||
}
|
||||
|
||||
fun liveAnalysis(imageProxy: ImageProxy) {
|
||||
if (_captureState.value !is CaptureState.Idle) {
|
||||
if (_captureState.value !is CaptureState.Idle || _importState.value !is ImportState.Idle) {
|
||||
imageProxy.close()
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
imageSegmentationService.runSegmentationAndEmit(
|
||||
imageProxy.toBitmap(),
|
||||
imageProxy.imageInfo.rotationDegrees,
|
||||
)
|
||||
val rotationDegrees = imageProxy.imageInfo.rotationDegrees
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
imageSegmentationService.runSegmentationAndReturn(imageProxy.toBitmap())
|
||||
}
|
||||
|
||||
result?.let {
|
||||
val segmentation = result.segmentation
|
||||
val maskSize = segmentation.maskSize()
|
||||
val originalSize = ImageSize(imageProxy.width, imageProxy.height)
|
||||
val rawQuad = withContext(Dispatchers.Default) {
|
||||
detectDocumentQuad(segmentation, originalSize, Mode.LIVE_ANALYSIS)
|
||||
?.rotate90(rotationDegrees / 90, maskSize)
|
||||
}
|
||||
val binaryMaskProvider = { ->
|
||||
var binaryMask: Bitmap = segmentation.toBinaryMask()
|
||||
if (rotationDegrees != 0) {
|
||||
binaryMask = rotateBitmap(binaryMask, rotationDegrees.toFloat())
|
||||
}
|
||||
binaryMask
|
||||
}
|
||||
val stableQuad = quadStabilizer.update(rawQuad)
|
||||
_liveAnalysisState.value = LiveAnalysisState(
|
||||
inferenceTime = result.inferenceTime,
|
||||
binaryMaskProvider = binaryMaskProvider,
|
||||
maskSize = maskSize,
|
||||
stableQuad = stableQuad,
|
||||
)
|
||||
}
|
||||
|
||||
imageProxy.close()
|
||||
}
|
||||
}
|
||||
|
||||
fun onImageCaptured(imageProxy: ImageProxy?) {
|
||||
fun onImageCaptured(imageProxy: ImageProxy?, opticalMeasures: OpticalMeasures?) {
|
||||
if (imageProxy != null) {
|
||||
viewModelScope.launch {
|
||||
val image = processCapturedImage(imageProxy)
|
||||
imageProxy.close()
|
||||
onCaptureProcessed(image)
|
||||
try {
|
||||
val source = imageProxy.toBitmap()
|
||||
val rotationDegrees = imageProxy.imageInfo.rotationDegrees
|
||||
val page =
|
||||
processCapturedImage(source, rotationDegrees, opticalMeasures, Mode.CAPTURE)
|
||||
imageProxy.close()
|
||||
onCaptureProcessed(page)
|
||||
} catch (e: RuntimeException) {
|
||||
logger.e("Camera", "Failed to process captured image", e)
|
||||
onCaptureProcessed(null)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
onCaptureProcessed(null)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun processCapturedImage(imageProxy: ImageProxy): Bitmap? = withContext(Dispatchers.IO) {
|
||||
var corrected: Bitmap? = null
|
||||
val bitmap = imageProxy.toBitmap()
|
||||
val segmentation = imageSegmentationService.runSegmentationAndReturn(bitmap, 0)
|
||||
if (segmentation != null) {
|
||||
val mask = segmentation.segmentation
|
||||
var quad = detectDocumentQuad(mask, isLiveAnalysis = false)
|
||||
if (quad == null) {
|
||||
val now = System.currentTimeMillis()
|
||||
lastSuccessfulLiveAnalysisState?.timestamp?.let {
|
||||
val offset = now - it
|
||||
Log.i("Quad", "Last successful live analysis was $offset ms ago")
|
||||
}
|
||||
val recentLive = lastSuccessfulLiveAnalysisState?.takeIf {
|
||||
now - it.timestamp <= 1500
|
||||
}
|
||||
val rotations = (-imageProxy.imageInfo.rotationDegrees / 90) + 4
|
||||
quad = recentLive?.documentQuad?.rotate90(rotations, mask.width, mask.height)
|
||||
if (quad != null) {
|
||||
Log.i("Quad", "Using quad taken in live analysis; rotations=$rotations")
|
||||
}
|
||||
}
|
||||
if (quad != null) {
|
||||
val resizedQuad = quad.scaledTo(mask.width, mask.height, bitmap.width, bitmap.height)
|
||||
corrected = extractDocument(bitmap, resizedQuad, imageProxy.imageInfo.rotationDegrees)
|
||||
}
|
||||
}
|
||||
return@withContext corrected
|
||||
private suspend fun processCapturedImage(
|
||||
source: Bitmap,
|
||||
rotationDegrees: Int,
|
||||
opticalMeasures: OpticalMeasures?,
|
||||
mode: Mode,
|
||||
): CapturedPage = withContext(Dispatchers.IO) {
|
||||
val segmentation = imageSegmentationService.runSegmentationAndReturn(source)
|
||||
val mask = segmentation?.segmentation
|
||||
val originalSize = ImageSize(source.width, source.height)
|
||||
val quad = mask?.let { detectDocumentQuad(mask, originalSize, mode) }
|
||||
val defaultColorMode = settingsRepository.defaultColorMode.first()
|
||||
val result = extractDocumentFromBitmap(
|
||||
source, quad, rotationDegrees, mask, viewModelScope, defaultColorMode, opticalMeasures)
|
||||
return@withContext result
|
||||
}
|
||||
|
||||
fun addProcessedImage(quality: Int = 75) {
|
||||
fun addProcessedImage() {
|
||||
val current = _captureState.value
|
||||
if (current is CaptureState.CapturePreview) {
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
current.processed.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
|
||||
val jpegBytes = outputStream.toByteArray()
|
||||
viewModelScope.launch {
|
||||
_events.emit(CameraEvent.ImageCaptured(jpegBytes))
|
||||
_events.emit(CameraEvent.ImageCaptured(current.capturedPage))
|
||||
}
|
||||
}
|
||||
_captureState.value = CaptureState.Idle
|
||||
@@ -166,6 +186,53 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
|
||||
_captureState.value = CaptureState.Idle
|
||||
}
|
||||
|
||||
fun logError(message:String, throwable: Throwable) {
|
||||
viewModelScope.launch {
|
||||
logger.e("Camera", message, throwable)
|
||||
}
|
||||
}
|
||||
|
||||
fun setTorchEnabled(enabled: Boolean) {
|
||||
_isTorchEnabled.value = enabled
|
||||
}
|
||||
|
||||
fun importPhotos(uris: List<Uri>) {
|
||||
importJob?.cancel()
|
||||
if (uris.isEmpty()) {
|
||||
_importState.value = ImportState.Idle
|
||||
return
|
||||
}
|
||||
importJob = viewModelScope.launch {
|
||||
_importState.value = ImportState.Importing(0, uris.size)
|
||||
uris.forEachIndexed { index, uri ->
|
||||
ensureActive()
|
||||
try {
|
||||
val photoToImport = imageLoader.load(uri)
|
||||
ensureActive()
|
||||
val page = processCapturedImage(photoToImport, 0, null, Mode.IMPORT)
|
||||
ensureActive()
|
||||
_events.emit(CameraEvent.ImageCaptured(page))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
logger.e("Import", "Failed to import image: $uri", e)
|
||||
}
|
||||
_importState.value = ImportState.Importing(index + 1, uris.size)
|
||||
}
|
||||
_importState.value = ImportState.Idle
|
||||
}
|
||||
}
|
||||
|
||||
fun onImportClicked() {
|
||||
_importState.value = ImportState.Selecting
|
||||
resetLiveAnalysis()
|
||||
}
|
||||
|
||||
fun cancelImport() {
|
||||
importJob?.cancel()
|
||||
importJob = null
|
||||
_importState.value = ImportState.Idle
|
||||
}
|
||||
}
|
||||
|
||||
sealed class CaptureState {
|
||||
@@ -176,6 +243,12 @@ sealed class CaptureState {
|
||||
data class CaptureError(override val frozenImage: Bitmap) : CaptureState()
|
||||
data class CapturePreview(
|
||||
override val frozenImage: Bitmap,
|
||||
val processed: Bitmap
|
||||
val capturedPage: CapturedPage,
|
||||
) : CaptureState()
|
||||
}
|
||||
|
||||
fun rotateBitmap(source: Bitmap, angle: Float): Bitmap {
|
||||
val matrix = Matrix()
|
||||
matrix.postRotate(angle)
|
||||
return Bitmap.createBitmap(source, 0, 0, source.getWidth(), source.getHeight(), matrix, true)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.ui.screens.camera
|
||||
|
||||
import org.fairscan.imageprocessing.Point
|
||||
import org.fairscan.imageprocessing.Quad
|
||||
import org.fairscan.imageprocessing.norm
|
||||
|
||||
class QuadStabilizer {
|
||||
|
||||
private var stableCount = 0
|
||||
private var lastRawQuad: Quad? = null
|
||||
|
||||
fun update(rawQuad: Quad?): Quad? {
|
||||
val previousQuad = lastRawQuad
|
||||
lastRawQuad = rawQuad
|
||||
|
||||
if (rawQuad == null) {
|
||||
stableCount = 0
|
||||
return null
|
||||
}
|
||||
|
||||
if (previousQuad == null) {
|
||||
stableCount = 1
|
||||
return null
|
||||
}
|
||||
|
||||
val dist = previousQuad.maxCornerDistanceTo(rawQuad)
|
||||
// 20f is based on the assumption that the preview has a size of 640×480
|
||||
if (dist < 20f) {
|
||||
stableCount++
|
||||
} else {
|
||||
stableCount = 1
|
||||
}
|
||||
|
||||
return if (stableCount >= 3) rawQuad else null
|
||||
}
|
||||
}
|
||||
|
||||
private fun Quad.maxCornerDistanceTo(other: Quad): Float {
|
||||
return listOf(
|
||||
norm(topLeft, other.topLeft),
|
||||
norm(topRight, other.topRight),
|
||||
norm(bottomRight, other.bottomRight),
|
||||
norm(bottomLeft, other.bottomLeft),
|
||||
).max().toFloat()
|
||||
}
|
||||
|
||||
fun lerp(a: Point, b: Point, alpha: Float): Point {
|
||||
return Point(
|
||||
x = a.x + alpha * (b.x - a.x),
|
||||
y = a.y + alpha * (b.y - a.y)
|
||||
)
|
||||
}
|
||||
|
||||
fun lerpQuad(a: Quad, b: Quad, alpha: Float): Quad {
|
||||
return Quad(
|
||||
topLeft = lerp(a.topLeft, b.topLeft, alpha),
|
||||
topRight = lerp(a.topRight, b.topRight, alpha),
|
||||
bottomRight = lerp(a.bottomRight, b.bottomRight, alpha),
|
||||
bottomLeft = lerp(a.bottomLeft, b.bottomLeft, alpha),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.ui.screens.crop
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.mandatorySystemGestures
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.listSaver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
import org.fairscan.app.R
|
||||
import org.fairscan.app.ui.Navigation
|
||||
import org.fairscan.app.ui.components.MainActionButton
|
||||
import org.fairscan.app.ui.components.MyScaffold
|
||||
import org.fairscan.app.ui.components.isLandscape
|
||||
import org.fairscan.app.ui.dummyNavigation
|
||||
import org.fairscan.app.ui.theme.FairScanTheme
|
||||
import org.fairscan.imageprocessing.Point
|
||||
import org.fairscan.imageprocessing.Quad
|
||||
|
||||
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CropScreen(
|
||||
pageId: String,
|
||||
initState: CropInitState,
|
||||
navigation: Navigation,
|
||||
onUpdatePageQuad: (Quad) -> Unit,
|
||||
) {
|
||||
val quadHandler = remember { QuadEditingHandler() }
|
||||
|
||||
val (bitmap, initialQuad) = if (initState is CropInitState.Ready && initState.pageId == pageId) {
|
||||
Pair(initState.bitmap, initState.quad)
|
||||
} else {
|
||||
Pair(null, null)
|
||||
}
|
||||
|
||||
val editableQuad = rememberSaveable(pageId, saver = QuadSaver) {
|
||||
mutableStateOf(initialQuad)
|
||||
}
|
||||
val state = remember(pageId) {
|
||||
CropScreenState(editableQuad)
|
||||
}
|
||||
|
||||
BackHandler { navigation.back() }
|
||||
|
||||
val isLandscape = isLandscape(LocalConfiguration.current)
|
||||
val density = LocalDensity.current
|
||||
val bottomInsetForSystemGestures = with(density) {
|
||||
WindowInsets.mandatorySystemGestures.getBottom(density).toDp()
|
||||
}
|
||||
|
||||
MyScaffold(
|
||||
navigation = navigation,
|
||||
bottomBar = {},
|
||||
) { modifier ->
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
bitmap?.let { bmp ->
|
||||
val imageBitmap = remember(bmp) { bmp.asImageBitmap() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.align(Alignment.Center),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Image(
|
||||
bitmap = imageBitmap,
|
||||
contentDescription = "Image to edit",
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = bottomInsetForSystemGestures)
|
||||
.onGloballyPositioned { coordinates ->
|
||||
state.containerSize = coordinates.size
|
||||
},
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
|
||||
DragQuadOverlay(state, quadHandler, bmp)
|
||||
}
|
||||
}
|
||||
|
||||
DragMagnifyingGlass(state, bitmap)
|
||||
|
||||
ActionButtons(
|
||||
modifier = Modifier
|
||||
.align(if (isLandscape) Alignment.CenterEnd else Alignment.BottomCenter)
|
||||
.padding(16.dp)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||
onConfirm = {
|
||||
state.editableQuad?.let { onUpdatePageQuad(it) }
|
||||
navigation.back()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionButtons(
|
||||
modifier: Modifier,
|
||||
onConfirm: () -> Unit
|
||||
) {
|
||||
MainActionButton(
|
||||
onClick = onConfirm,
|
||||
text = stringResource(R.string.apply),
|
||||
icon = Icons.Filled.Check,
|
||||
iconDescription = stringResource(R.string.apply),
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun DragQuadOverlay(
|
||||
state: CropScreenState,
|
||||
quadHandler: QuadEditingHandler,
|
||||
bmp: Bitmap
|
||||
) {
|
||||
if (state.editableQuad == null || state.containerSize == null) return
|
||||
|
||||
val containerSize = state.containerSize!!
|
||||
val displaySize = QuadCoordinateUtils.calculateDisplaySize(bmp.width, bmp.height, containerSize)
|
||||
val liftWiggleThresholdPx = with(LocalDensity.current) {
|
||||
CropScreenState.LIFT_WIGGLE_MAX_DISTANCE.toPx()
|
||||
}
|
||||
|
||||
QuadOverlay(
|
||||
quad = state.editableQuad!!,
|
||||
containerSize = containerSize,
|
||||
displaySize = displaySize,
|
||||
modifier = Modifier.pointerInput(Unit) {
|
||||
detectDragGestures(
|
||||
onDragStart = { startPos ->
|
||||
val quad = state.editableQuad ?: return@detectDragGestures
|
||||
state.dragPosition = startPos
|
||||
|
||||
// Prefer the index stored at raw touch-down (exact touch position,
|
||||
// before slop). Fall back to re-detecting at the slop position only
|
||||
// when the raw-touch handler missed the down event.
|
||||
val cornerIndex = if (state.touchDownCornerIndex >= 0) {
|
||||
state.touchDownCornerIndex
|
||||
} else {
|
||||
quadHandler.findTouchedCorner(startPos, quad, containerSize, displaySize)
|
||||
}
|
||||
|
||||
if (cornerIndex >= 0) {
|
||||
state.startCornerDrag(cornerIndex)
|
||||
}
|
||||
},
|
||||
onDragEnd = {
|
||||
state.rollbackLastDragStepIfLikelyLiftWiggle(liftWiggleThresholdPx)
|
||||
state.endDrag()
|
||||
state.onTouchUp()
|
||||
},
|
||||
onDragCancel = {
|
||||
state.rollbackLastDragStepIfLikelyLiftWiggle(liftWiggleThresholdPx)
|
||||
state.endDrag()
|
||||
state.onTouchUp()
|
||||
},
|
||||
onDrag = { change, dragAmount ->
|
||||
// change.consume() is intentionally omitted: detectDragGestures
|
||||
// already calls it.consume() internally after this callback returns.
|
||||
state.dragPosition = change.position
|
||||
val quad = state.editableQuad ?: return@detectDragGestures
|
||||
state.recordDragStep(quad, dragAmount)
|
||||
val normalizedDelta = QuadCoordinateUtils.screenDeltaToNormalized(
|
||||
dragAmount, displaySize
|
||||
)
|
||||
|
||||
when {
|
||||
state.draggedCornerIndex >= 0 -> {
|
||||
state.updateQuad(
|
||||
quadHandler.updateQuadCorner(
|
||||
quad, state.draggedCornerIndex, normalizedDelta
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
// Second pointer-input: fires immediately on press (before touch slop)
|
||||
// so the loupe appears as soon as the finger touches a handle.
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
val quad = state.editableQuad
|
||||
if (quad != null) {
|
||||
val cIdx = quadHandler.findTouchedCorner(down.position, quad, containerSize, displaySize)
|
||||
if (cIdx >= 0) {
|
||||
state.onTouchDown(down.position, cIdx)
|
||||
}
|
||||
}
|
||||
// For a tap (no drag): waitForUpOrCancellation() sees the UP event and
|
||||
// returns it, so we call onTouchUp() here.
|
||||
// For a drag: detectDragGestures consumes move events, causing
|
||||
// waitForUpOrCancellation() to return null. We do NOT call onTouchUp()
|
||||
// here; onDragEnd / onDragCancel above handle that instead.
|
||||
if (waitForUpOrCancellation() != null) {
|
||||
state.onTouchUp()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DragMagnifyingGlass(state: CropScreenState, bitmap: Bitmap?) {
|
||||
// showLoupe becomes true immediately on touch-down and stays true for
|
||||
// one additional second after the finger is lifted.
|
||||
val showLoupe = remember { mutableStateOf(false) }
|
||||
// Remember the last valid focus position so the loupe keeps rendering
|
||||
// correctly during the 1-second fade-out (when dragged indices are reset).
|
||||
val lastKnownFocusPosition = remember { mutableStateOf<Offset?>(null) }
|
||||
|
||||
LaunchedEffect(state.isTouching) {
|
||||
if (state.isTouching) {
|
||||
showLoupe.value = true
|
||||
} else {
|
||||
delay(1_000)
|
||||
showLoupe.value = false
|
||||
}
|
||||
}
|
||||
|
||||
if (!showLoupe.value || state.dragPosition == null || state.containerSize == null) return
|
||||
|
||||
val bmp = bitmap ?: return
|
||||
val containerSize = state.containerSize!!
|
||||
val displaySize = QuadCoordinateUtils.calculateDisplaySize(
|
||||
bmp.width, bmp.height, containerSize
|
||||
)
|
||||
val quad = state.editableQuad
|
||||
|
||||
// Resolve which corner index to focus on.
|
||||
// Priority: active drag > pre-drag touch-down > nothing (fade-out phase).
|
||||
val activeCornerIndex = state.draggedCornerIndex.takeIf { it >= 0 }
|
||||
?: state.touchDownCornerIndex.takeIf { it >= 0 }
|
||||
|
||||
val focusPosition = if (quad != null) {
|
||||
when {
|
||||
activeCornerIndex != null -> {
|
||||
val corner = when (activeCornerIndex) {
|
||||
0 -> quad.topLeft
|
||||
1 -> quad.topRight
|
||||
2 -> quad.bottomRight
|
||||
3 -> quad.bottomLeft
|
||||
else -> null
|
||||
}
|
||||
corner?.let {
|
||||
QuadCoordinateUtils.normalizedToScreen(it, containerSize, displaySize)
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
} else null
|
||||
|
||||
// Keep the last known focus position so it's still valid after endDrag() resets the indices.
|
||||
if (focusPosition != null) lastKnownFocusPosition.value = focusPosition
|
||||
// On the very first touch the drag indices are not set yet and lastKnownFocusPosition
|
||||
// has never been populated, so fall back to dragPosition (the finger is on the handle).
|
||||
val effectiveFocusPosition = focusPosition ?: lastKnownFocusPosition.value ?: state.dragPosition ?: return
|
||||
|
||||
MagnifyingGlass(
|
||||
bitmap = bmp,
|
||||
fingerPosition = state.dragPosition!!,
|
||||
focusPosition = effectiveFocusPosition,
|
||||
containerSize = containerSize,
|
||||
displaySize = displaySize,
|
||||
quad = state.editableQuad,
|
||||
)
|
||||
}
|
||||
|
||||
val QuadSaver: Saver<MutableState<Quad?>, *> = listSaver(
|
||||
save = { state ->
|
||||
state.value?.let {
|
||||
listOf(
|
||||
it.topLeft.x, it.topLeft.y,
|
||||
it.topRight.x, it.topRight.y,
|
||||
it.bottomRight.x, it.bottomRight.y,
|
||||
it.bottomLeft.x, it.bottomLeft.y,
|
||||
)
|
||||
} ?: listOf()
|
||||
},
|
||||
restore = { list ->
|
||||
val quad = if (list.size == 8) {
|
||||
Quad(
|
||||
topLeft = Point(list[0], list[1]),
|
||||
topRight = Point(list[2], list[3]),
|
||||
bottomRight = Point(list[4], list[5]),
|
||||
bottomLeft = Point(list[6], list[7]),
|
||||
)
|
||||
} else null
|
||||
mutableStateOf(quad)
|
||||
}
|
||||
)
|
||||
|
||||
@Composable
|
||||
@Preview(showSystemUi = true)
|
||||
@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, showSystemUi = true)
|
||||
@Preview(name = "Landscape", showBackground = true, widthDp = 640, heightDp = 320)
|
||||
@Preview(name = "RTL", locale = "ar", showSystemUi = true)
|
||||
fun EditPageScreenPreview() {
|
||||
FairScanTheme {
|
||||
val dummyImage = LocalContext.current.assets.open("gallica.bnf.fr-bpt6k5530456s-1.jpg").use { input ->
|
||||
BitmapFactory.decodeStream(input)
|
||||
}
|
||||
val quad = Quad(Point(.1, .1), Point(.9, .1), Point(.9, .9), Point(.1, .9))
|
||||
CropScreen(
|
||||
pageId = "123",
|
||||
initState = CropInitState.Ready("123",dummyImage, quad),
|
||||
navigation = dummyNavigation(),
|
||||
onUpdatePageQuad = { _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.ui.screens.crop
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import org.fairscan.imageprocessing.Quad
|
||||
|
||||
sealed interface CropInitState {
|
||||
object Loading : CropInitState
|
||||
object Error : CropInitState
|
||||
data class Ready(
|
||||
val pageId: String,
|
||||
val bitmap: Bitmap,
|
||||
val quad: Quad
|
||||
) : CropInitState
|
||||
}
|
||||
|
||||
class CropScreenState(editableQuad: MutableState<Quad?> = mutableStateOf(null)) {
|
||||
companion object {
|
||||
val LIFT_WIGGLE_MAX_DISTANCE = 8.dp
|
||||
const val LIFT_WIGGLE_WINDOW_MS = 70L
|
||||
}
|
||||
|
||||
var containerSize by mutableStateOf<IntSize?>(null)
|
||||
var editableQuad: Quad? by editableQuad
|
||||
private set
|
||||
var draggedCornerIndex by mutableIntStateOf(-1)
|
||||
var dragPosition by mutableStateOf<Offset?>(null)
|
||||
/** True from the moment the finger touches a drag handle until it is lifted. */
|
||||
var isTouching by mutableStateOf(false)
|
||||
/**
|
||||
* Corner / edge index detected at the raw touch-down (before touch-slop).
|
||||
* Carried into [startCornerDrag] so that the slop-adjusted
|
||||
* position in onDragStart cannot miss the handle.
|
||||
*/
|
||||
var touchDownCornerIndex by mutableIntStateOf(-1)
|
||||
|
||||
private var quadBeforeDrag: Quad? = null
|
||||
private var quadBeforeLastDragStep: Quad? = null
|
||||
private var lastDragStepDistancePx: Float = Float.MAX_VALUE
|
||||
private var lastDragStepAtMs: Long = 0L
|
||||
|
||||
fun updateQuad(newQuad: Quad) {
|
||||
editableQuad = newQuad
|
||||
}
|
||||
|
||||
fun startCornerDrag(cornerIndex: Int) {
|
||||
quadBeforeDrag = editableQuad
|
||||
draggedCornerIndex = cornerIndex
|
||||
clearLastDragStep()
|
||||
}
|
||||
|
||||
fun recordDragStep(previousQuad: Quad, dragAmount: Offset, eventTimeMs: Long = System.currentTimeMillis()) {
|
||||
quadBeforeLastDragStep = previousQuad
|
||||
lastDragStepDistancePx = dragAmount.getDistance()
|
||||
lastDragStepAtMs = eventTimeMs
|
||||
}
|
||||
|
||||
fun rollbackLastDragStepIfLikelyLiftWiggle(
|
||||
maxDistancePx: Float,
|
||||
nowMs: Long = System.currentTimeMillis()
|
||||
) {
|
||||
if (quadBeforeLastDragStep == null) return
|
||||
val isRecent = nowMs - lastDragStepAtMs <= LIFT_WIGGLE_WINDOW_MS
|
||||
val isSmall = lastDragStepDistancePx <= maxDistancePx
|
||||
if (isRecent && isSmall) {
|
||||
editableQuad = quadBeforeLastDragStep
|
||||
}
|
||||
}
|
||||
|
||||
fun endDrag() {
|
||||
quadBeforeDrag = null
|
||||
clearLastDragStep()
|
||||
draggedCornerIndex = -1
|
||||
// dragPosition is intentionally kept so the loupe can still render
|
||||
// during its 1-second fade-out after the finger is lifted.
|
||||
}
|
||||
|
||||
private fun clearLastDragStep() {
|
||||
quadBeforeLastDragStep = null
|
||||
lastDragStepDistancePx = Float.MAX_VALUE
|
||||
lastDragStepAtMs = 0L
|
||||
}
|
||||
|
||||
/**
|
||||
* Called as soon as the finger touches a drag handle (before touch-slop),
|
||||
* so the loupe is shown immediately.
|
||||
* [cornerIndex] is the handle index found at the exact
|
||||
* touch position; it is stored so that drag start handling can use it even
|
||||
* if the slop-adjusted position drifts outside the hit-test radius.
|
||||
*/
|
||||
fun onTouchDown(position: Offset, cornerIndex: Int = -1) {
|
||||
isTouching = true
|
||||
dragPosition = position
|
||||
touchDownCornerIndex = cornerIndex
|
||||
}
|
||||
|
||||
/** Called when the finger is lifted; triggers the loupe fade-out delay. */
|
||||
fun onTouchUp() {
|
||||
isTouching = false
|
||||
touchDownCornerIndex = -1
|
||||
}
|
||||
|
||||
fun isDragging(): Boolean = draggedCornerIndex >= 0
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.ui.screens.crop
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.ClipOp
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.clipPath
|
||||
import androidx.compose.ui.graphics.drawscope.clipRect
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.fairscan.imageprocessing.Quad
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val LOUPE_BORDER_WIDTH = 3.dp
|
||||
private const val ZOOM_FACTOR = 3f
|
||||
|
||||
/**
|
||||
* Layout parameters for the magnifying-glass loupe.
|
||||
*
|
||||
* Use `LoupeLayoutConfig<Dp>` in Compose UI code and [toPx] to convert to
|
||||
* `LoupeLayoutConfig<Float>` for pixel-level calculations.
|
||||
*/
|
||||
data class LoupeLayoutConfig<T>(
|
||||
/** Radius of the loupe circle */
|
||||
val loupeRadius: T,
|
||||
/** Gap between the finger and the nearest edge of the loupe */
|
||||
val verticalOffset: T,
|
||||
/** Minimum space between the loupe and the screen edge */
|
||||
val screenMargin: T,
|
||||
) {
|
||||
companion object {
|
||||
val Default = LoupeLayoutConfig(
|
||||
loupeRadius = 60.dp,
|
||||
verticalOffset = 40.dp,
|
||||
screenMargin = 8.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert a dp-valued config to pixels using the current [LocalDensity]. */
|
||||
@Composable
|
||||
internal fun LoupeLayoutConfig<Dp>.toPx(): LoupeLayoutConfig<Float> {
|
||||
val density = LocalDensity.current
|
||||
return with(density) {
|
||||
LoupeLayoutConfig(
|
||||
loupeRadius = loupeRadius.toPx(),
|
||||
verticalOffset = verticalOffset.toPx(),
|
||||
screenMargin = screenMargin.toPx(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a magnifying glass / loupe showing a zoomed-in patch of [bitmap]
|
||||
* centred around [focusPosition].
|
||||
*
|
||||
* Positioning rules:
|
||||
* 1. By default, the loupe is placed **above** the finger.
|
||||
* 2. If there is not enough room above, it moves to the **left** of the finger.
|
||||
* 3. If there is not enough room on the left either, it moves to the **right**.
|
||||
*
|
||||
* @param bitmap The full source bitmap (original image).
|
||||
* @param fingerPosition Current finger position in screen (container) coordinates, used for loupe placement.
|
||||
* @param focusPosition The exact point to zoom into (e.g. corner or edge midpoint) in screen coordinates.
|
||||
* @param containerSize Size of the full-screen container.
|
||||
* @param displaySize Size of the image as rendered (letterboxed inside the container).
|
||||
*/
|
||||
@Composable
|
||||
fun MagnifyingGlass(
|
||||
bitmap: Bitmap,
|
||||
fingerPosition: Offset,
|
||||
focusPosition: Offset,
|
||||
containerSize: IntSize,
|
||||
displaySize: IntSize,
|
||||
quad: Quad? = null,
|
||||
configDp: LoupeLayoutConfig<Dp> = LoupeLayoutConfig.Default,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val configPx = configDp.toPx()
|
||||
val borderWidth = with(density) { LOUPE_BORDER_WIDTH.toPx() }
|
||||
|
||||
// compute loupe centre position
|
||||
val loupeCenter = computeLoupeCenter(
|
||||
dragPosition = fingerPosition,
|
||||
configPx = configPx,
|
||||
containerWidth = containerSize.width.toFloat(),
|
||||
)
|
||||
|
||||
// compute the bitmap region to sample
|
||||
val imageOffset = QuadCoordinateUtils.getImageOffset(containerSize, displaySize)
|
||||
|
||||
// Focus position mapped to bitmap pixel coordinates
|
||||
val bitmapX = ((focusPosition.x - imageOffset.width) / displaySize.width * bitmap.width)
|
||||
.coerceIn(0f, (bitmap.width - 1).toFloat())
|
||||
val bitmapY = ((focusPosition.y - imageOffset.height) / displaySize.height * bitmap.height)
|
||||
.coerceIn(0f, (bitmap.height - 1).toFloat())
|
||||
|
||||
// How many bitmap pixels the loupe shows in each direction
|
||||
val bitmapRegionHalf = (bitmap.width / displaySize.width.toFloat()) * configPx.loupeRadius / ZOOM_FACTOR
|
||||
|
||||
val imageBitmap = remember(bitmap) { bitmap.asImageBitmap() }
|
||||
|
||||
val borderColor = MaterialTheme.colorScheme.primary
|
||||
val quadLineColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f)
|
||||
val backgroundColor = MaterialTheme.colorScheme.background
|
||||
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.size(configDp.loupeRadius * 2)
|
||||
.layout { measurable, constraints ->
|
||||
val placeable = measurable.measure(constraints)
|
||||
layout(placeable.width, placeable.height) {
|
||||
placeable.placeRelative(
|
||||
IntOffset(
|
||||
(loupeCenter.x - configPx.loupeRadius).roundToInt(),
|
||||
(loupeCenter.y - configPx.loupeRadius).roundToInt()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
val loupeDiameter = configPx.loupeRadius * 2
|
||||
val circlePath = Path().apply {
|
||||
addOval(
|
||||
androidx.compose.ui.geometry.Rect(0f, 0f, loupeDiameter, loupeDiameter)
|
||||
)
|
||||
}
|
||||
|
||||
clipPath(circlePath) {
|
||||
// Fill background so areas outside the image are opaque
|
||||
drawRect(color = backgroundColor)
|
||||
|
||||
// Source rect in bitmap coordinates
|
||||
val srcLeft = (bitmapX - bitmapRegionHalf).toInt().coerceAtLeast(0)
|
||||
val srcTop = (bitmapY - bitmapRegionHalf).toInt().coerceAtLeast(0)
|
||||
val srcRight = (bitmapX + bitmapRegionHalf).toInt().coerceAtMost(bitmap.width)
|
||||
val srcBottom = (bitmapY + bitmapRegionHalf).toInt().coerceAtMost(bitmap.height)
|
||||
|
||||
if (srcRight > srcLeft && srcBottom > srcTop) {
|
||||
|
||||
// Destination offset – compensate when the source rect was clamped
|
||||
val dstOffsetX = ((srcLeft - (bitmapX - bitmapRegionHalf)) / (2 * bitmapRegionHalf) * loupeDiameter)
|
||||
val dstOffsetY = ((srcTop - (bitmapY - bitmapRegionHalf)) / (2 * bitmapRegionHalf) * loupeDiameter)
|
||||
|
||||
val dstWidth = ((srcRight - srcLeft) / (2 * bitmapRegionHalf) * loupeDiameter).toInt()
|
||||
val dstHeight = ((srcBottom - srcTop) / (2 * bitmapRegionHalf) * loupeDiameter).toInt()
|
||||
|
||||
drawImage(
|
||||
image = imageBitmap,
|
||||
srcOffset = IntOffset(srcLeft, srcTop),
|
||||
srcSize = IntSize(srcRight - srcLeft, srcBottom - srcTop),
|
||||
dstOffset = IntOffset(dstOffsetX.toInt(), dstOffsetY.toInt()),
|
||||
dstSize = IntSize(dstWidth, dstHeight),
|
||||
)
|
||||
}
|
||||
|
||||
// Draw quad overlay and edges inside the loupe
|
||||
// Convert each normalized quad corner to bitmap-pixel coords,
|
||||
// then to loupe-local coords using the same mapping as the bitmap sampling.
|
||||
if (quad != null) {
|
||||
val bitmapOriginX = bitmapX - bitmapRegionHalf
|
||||
val bitmapOriginY = bitmapY - bitmapRegionHalf
|
||||
val scale = loupeDiameter / (2 * bitmapRegionHalf)
|
||||
|
||||
fun normalizedToLoupe(nx: Double, ny: Double): Offset {
|
||||
val bx = (nx * bitmap.width).toFloat()
|
||||
val by = (ny * bitmap.height).toFloat()
|
||||
return Offset(
|
||||
(bx - bitmapOriginX) * scale,
|
||||
(by - bitmapOriginY) * scale
|
||||
)
|
||||
}
|
||||
|
||||
val loupeCorners = listOf(
|
||||
normalizedToLoupe(quad.topLeft.x, quad.topLeft.y),
|
||||
normalizedToLoupe(quad.topRight.x, quad.topRight.y),
|
||||
normalizedToLoupe(quad.bottomRight.x, quad.bottomRight.y),
|
||||
normalizedToLoupe(quad.bottomLeft.x, quad.bottomLeft.y),
|
||||
)
|
||||
|
||||
// Draw quad edge lines on top
|
||||
for (i in 0 until 4) {
|
||||
drawLine(
|
||||
color = quadLineColor,
|
||||
start = loupeCorners[i],
|
||||
end = loupeCorners[(i + 1) % 4],
|
||||
strokeWidth = 3f
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Border
|
||||
drawCircle(
|
||||
color = borderColor,
|
||||
radius = configPx.loupeRadius - borderWidth / 2,
|
||||
center = Offset(configPx.loupeRadius, configPx.loupeRadius),
|
||||
style = Stroke(width = borderWidth)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides where the loupe centre should be.
|
||||
*
|
||||
* Priority:
|
||||
* 1. Above the finger (centred horizontally, clamped to screen edges).
|
||||
* 2. If no vertical room -> to the left.
|
||||
* 3. If no room on the left -> to the right.
|
||||
*/
|
||||
internal fun computeLoupeCenter(
|
||||
dragPosition: Offset,
|
||||
configPx: LoupeLayoutConfig<Float>,
|
||||
containerWidth: Float,
|
||||
): Offset {
|
||||
val loupeRadius = configPx.loupeRadius
|
||||
val verticalOffset = configPx.verticalOffset
|
||||
val screenMargin = configPx.screenMargin
|
||||
|
||||
// Try above
|
||||
val aboveCenterY = dragPosition.y - verticalOffset - loupeRadius
|
||||
if (aboveCenterY - loupeRadius >= screenMargin) {
|
||||
// Enough room above -> place centred horizontally on the finger, clamped to screen edges
|
||||
val cx = dragPosition.x.coerceIn(screenMargin + loupeRadius, containerWidth - screenMargin - loupeRadius)
|
||||
return Offset(cx, aboveCenterY)
|
||||
}
|
||||
|
||||
// Not enough room above -> try left
|
||||
val leftCenterX = dragPosition.x - verticalOffset - loupeRadius
|
||||
if (leftCenterX - loupeRadius >= screenMargin) {
|
||||
val cy = dragPosition.y.coerceIn(screenMargin + loupeRadius, Float.MAX_VALUE)
|
||||
return Offset(leftCenterX, cy)
|
||||
}
|
||||
|
||||
// Not enough room on the left -> place right
|
||||
val rightCenterX = dragPosition.x + verticalOffset + loupeRadius
|
||||
val cx = rightCenterX.coerceAtMost(containerWidth - screenMargin - loupeRadius)
|
||||
val cy = dragPosition.y.coerceIn(screenMargin + loupeRadius, Float.MAX_VALUE)
|
||||
return Offset(cx, cy)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.ui.screens.crop
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import org.fairscan.imageprocessing.Point
|
||||
|
||||
object QuadCoordinateUtils {
|
||||
|
||||
fun calculateDisplaySize(
|
||||
bitmapWidth: Int,
|
||||
bitmapHeight: Int,
|
||||
containerSize: IntSize
|
||||
): IntSize {
|
||||
val imageAspectRatio = bitmapWidth.toFloat() / bitmapHeight.toFloat()
|
||||
val containerAspectRatio = containerSize.width / containerSize.height.toFloat()
|
||||
|
||||
return if (imageAspectRatio > containerAspectRatio) {
|
||||
IntSize(containerSize.width, (containerSize.width / imageAspectRatio).toInt())
|
||||
} else {
|
||||
IntSize((containerSize.height * imageAspectRatio).toInt(), containerSize.height)
|
||||
}
|
||||
}
|
||||
|
||||
fun normalizedToScreen(point: Point, containerSize: IntSize, displaySize: IntSize): Offset {
|
||||
val offsetX = (containerSize.width - displaySize.width) / 2
|
||||
val offsetY = (containerSize.height - displaySize.height) / 2
|
||||
return Offset(
|
||||
x = (point.x * displaySize.width).toFloat() + offsetX,
|
||||
y = (point.y * displaySize.height).toFloat() + offsetY
|
||||
)
|
||||
}
|
||||
|
||||
fun screenDeltaToNormalized(delta: Offset, displaySize: IntSize): Offset {
|
||||
return Offset(
|
||||
x = delta.x / displaySize.width,
|
||||
y = delta.y / displaySize.height
|
||||
)
|
||||
}
|
||||
|
||||
fun getImageOffset(containerSize: IntSize, displaySize: IntSize): IntSize {
|
||||
return IntSize(
|
||||
(containerSize.width - displaySize.width) / 2,
|
||||
(containerSize.height - displaySize.height) / 2
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.ui.screens.crop
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import org.fairscan.imageprocessing.Point
|
||||
import org.fairscan.imageprocessing.Quad
|
||||
|
||||
class QuadEditingHandler {
|
||||
|
||||
companion object {
|
||||
const val CORNER_RADIUS = 40f
|
||||
const val CORNER_TOUCH_RADIUS = 90f
|
||||
}
|
||||
|
||||
fun findTouchedCorner(
|
||||
touchPos: Offset,
|
||||
quad: Quad,
|
||||
containerSize: IntSize,
|
||||
displaySize: IntSize
|
||||
): Int {
|
||||
return findTouchedCornerCandidates(touchPos, quad, containerSize, displaySize)
|
||||
.firstOrNull() ?: -1
|
||||
}
|
||||
|
||||
fun findTouchedCornerCandidates(
|
||||
touchPos: Offset,
|
||||
quad: Quad,
|
||||
containerSize: IntSize,
|
||||
displaySize: IntSize
|
||||
): List<Int> {
|
||||
val corners = getCornerPositions(quad, containerSize, displaySize)
|
||||
return corners
|
||||
.mapIndexed { index, corner -> index to (touchPos - corner).getDistance() }
|
||||
.filter { (_, distance) -> distance < CORNER_TOUCH_RADIUS }
|
||||
.sortedBy { (_, distance) -> distance }
|
||||
.map { (index, _) -> index }
|
||||
}
|
||||
|
||||
fun updateQuadCorner(quad: Quad, cornerIndex: Int, delta: Offset): Quad {
|
||||
val normalizedDelta = Point(delta.x.toDouble(), delta.y.toDouble())
|
||||
val candidate = when (cornerIndex) {
|
||||
0 -> quad.copy(topLeft = clampPoint(quad.topLeft + normalizedDelta))
|
||||
1 -> quad.copy(topRight = clampPoint(quad.topRight + normalizedDelta))
|
||||
2 -> quad.copy(bottomRight = clampPoint(quad.bottomRight + normalizedDelta))
|
||||
3 -> quad.copy(bottomLeft = clampPoint(quad.bottomLeft + normalizedDelta))
|
||||
else -> quad
|
||||
}
|
||||
return if (candidate.isConvex()) candidate else quad
|
||||
}
|
||||
|
||||
private fun getCornerPositions(quad: Quad, containerSize: IntSize, displaySize: IntSize): List<Offset> {
|
||||
return listOf(
|
||||
QuadCoordinateUtils.normalizedToScreen(quad.topLeft, containerSize, displaySize),
|
||||
QuadCoordinateUtils.normalizedToScreen(quad.topRight, containerSize, displaySize),
|
||||
QuadCoordinateUtils.normalizedToScreen(quad.bottomRight, containerSize, displaySize),
|
||||
QuadCoordinateUtils.normalizedToScreen(quad.bottomLeft, containerSize, displaySize)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private fun clampPoint(point: Point): Point {
|
||||
return Point(
|
||||
point.x.coerceIn(0.0, 1.0),
|
||||
point.y.coerceIn(0.0, 1.0)
|
||||
)
|
||||
}
|
||||
|
||||
private operator fun Point.plus(other: Point): Point {
|
||||
return Point(this.x + other.x, this.y + other.y)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.ui.screens.crop
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.systemGestureExclusion
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import org.fairscan.imageprocessing.Point
|
||||
import org.fairscan.imageprocessing.Quad
|
||||
import org.fairscan.imageprocessing.scaledTo
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
@Composable
|
||||
fun QuadOverlay(
|
||||
quad: Quad,
|
||||
containerSize: IntSize,
|
||||
displaySize: IntSize,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val quadColor = MaterialTheme.colorScheme.primary
|
||||
val handleColor = quadColor.copy(alpha = 0.5f)
|
||||
|
||||
Canvas(modifier = modifier
|
||||
.fillMaxSize()
|
||||
// Android limits the exclusion areas, so we cannot simply exclude the whole quad/image.
|
||||
// Instead, the corners must get excluded individually.
|
||||
.systemGestureExclusion { cornerExclusionRect(quad.topLeft, containerSize, displaySize) }
|
||||
.systemGestureExclusion { cornerExclusionRect(quad.topRight, containerSize, displaySize) }
|
||||
.systemGestureExclusion { cornerExclusionRect(quad.bottomRight, containerSize, displaySize) }
|
||||
.systemGestureExclusion { cornerExclusionRect(quad.bottomLeft, containerSize, displaySize) }
|
||||
) {
|
||||
val scaledQuad = quad.scaledTo(
|
||||
fromWidth = 1,
|
||||
fromHeight = 1,
|
||||
toWidth = displaySize.width,
|
||||
toHeight = displaySize.height
|
||||
)
|
||||
|
||||
val offset = QuadCoordinateUtils.getImageOffset(containerSize, displaySize)
|
||||
val corners = listOf(
|
||||
scaledQuad.topLeft.toOffset(),
|
||||
scaledQuad.topRight.toOffset(),
|
||||
scaledQuad.bottomRight.toOffset(),
|
||||
scaledQuad.bottomLeft.toOffset()
|
||||
).map { it.copy(x = it.x + offset.width, y = it.y + offset.height) }
|
||||
|
||||
// Draw edges
|
||||
for (i in 0 until 4) {
|
||||
drawLine(
|
||||
color = quadColor,
|
||||
start = corners[i],
|
||||
end = corners[(i + 1) % 4],
|
||||
strokeWidth = 10.0f
|
||||
)
|
||||
}
|
||||
|
||||
// Draw corner handles
|
||||
corners.forEach { corner ->
|
||||
drawCircle(
|
||||
color = handleColor,
|
||||
radius = QuadEditingHandler.CORNER_RADIUS,
|
||||
center = corner
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
fun Point.toOffset() = Offset(x.toFloat(), y.toFloat())
|
||||
|
||||
private fun cornerExclusionRect(
|
||||
corner: Point,
|
||||
containerSize: IntSize,
|
||||
displaySize: IntSize
|
||||
): Rect {
|
||||
val pos = QuadCoordinateUtils.normalizedToScreen(corner, containerSize, displaySize)
|
||||
val r = QuadEditingHandler.CORNER_TOUCH_RADIUS
|
||||
return Rect(max(.0f, pos.x - r),
|
||||
max(.0f, pos.y - r),
|
||||
min(containerSize.width.toFloat(), pos.x + r),
|
||||
min(containerSize.height.toFloat(), pos.y + r)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.ui.screens.document
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AutoFixHigh
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Contrast
|
||||
import androidx.compose.material.icons.filled.Crop
|
||||
import androidx.compose.material.icons.filled.Done
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.RotateLeft
|
||||
import androidx.compose.material.icons.filled.RotateRight
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
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.R
|
||||
import org.fairscan.app.domain.PageViewKey
|
||||
import org.fairscan.app.domain.Rotation
|
||||
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
|
||||
import org.fairscan.app.ui.components.MyScaffold
|
||||
import org.fairscan.app.ui.components.SecondaryActionButton
|
||||
import org.fairscan.app.ui.dummyNavigation
|
||||
import org.fairscan.app.ui.fakeDocument
|
||||
import org.fairscan.app.ui.fakeImage
|
||||
import org.fairscan.app.ui.theme.FairScanTheme
|
||||
import org.fairscan.imageprocessing.ColorMode
|
||||
import org.fairscan.imageprocessing.ColorMode.COLOR
|
||||
import org.fairscan.imageprocessing.ColorMode.GRAYSCALE
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DocumentScreen(
|
||||
uiState: DocumentUiState,
|
||||
navigation: Navigation,
|
||||
onExportClick: () -> Unit,
|
||||
onDeleteImage: () -> Unit,
|
||||
onRotateImage: (Boolean) -> Unit,
|
||||
onToggleColorMode: () -> Unit,
|
||||
onCropClick: () -> Unit,
|
||||
onPageReorder: (String, Int) -> Unit,
|
||||
onPageSelected: (Int) -> Unit,
|
||||
) {
|
||||
val showDeletePageDialog = rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val document = uiState.document
|
||||
val currentPageIndex = uiState.currentPageIndex
|
||||
BackHandler { navigation.back() }
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
LaunchedEffect(currentPageIndex) {
|
||||
listState.scrollToItem(currentPageIndex)
|
||||
}
|
||||
|
||||
MyScaffold(
|
||||
navigation = navigation,
|
||||
pageListState = CommonPageListState(
|
||||
document,
|
||||
onPageClick = { index -> onPageSelected(index) },
|
||||
onPageReorder = onPageReorder,
|
||||
currentPageIndex = currentPageIndex,
|
||||
listState = listState,
|
||||
showPageNumbers = true,
|
||||
),
|
||||
bottomBar = {
|
||||
BottomBar(onExportClick, navigation.toCameraScreen)
|
||||
},
|
||||
) { modifier ->
|
||||
DocumentPreview(
|
||||
uiState,
|
||||
{ showDeletePageDialog.value = true },
|
||||
onRotateImage,
|
||||
onToggleColorMode,
|
||||
onCropClick,
|
||||
modifier
|
||||
)
|
||||
if (showDeletePageDialog.value) {
|
||||
ConfirmationDialog(
|
||||
title = stringResource(R.string.delete_page),
|
||||
message = stringResource(R.string.delete_page_warning),
|
||||
showDialog = showDeletePageDialog
|
||||
) { onDeleteImage() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DocumentPreview(
|
||||
uiState: DocumentUiState,
|
||||
onDeleteImage: () -> Unit,
|
||||
onRotateImage: (Boolean) -> Unit,
|
||||
onToggleColorMode: () -> Unit,
|
||||
onCropClick: () -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val currentPageIndex = uiState.currentPageIndex
|
||||
val document = uiState.document
|
||||
Column (
|
||||
modifier = modifier
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerLow)
|
||||
) {
|
||||
Box (
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
val bitmap = uiState.currentPage?.bitmap
|
||||
val pageKey = uiState.currentPage?.key
|
||||
if (bitmap != null && pageKey != null) {
|
||||
val imageBitmap = bitmap.asImageBitmap()
|
||||
val zoomState = remember(pageKey) {
|
||||
ZoomState(
|
||||
contentSize = Size(bitmap.width.toFloat(), bitmap.height.toFloat())
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier
|
||||
.fillMaxSize(0.92f)
|
||||
.align(Alignment.Center)) {
|
||||
Image(
|
||||
bitmap = imageBitmap,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.align(Alignment.Center)
|
||||
.zoomable(zoomState)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (uiState.currentPage?.isLoading ?: false) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.4f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
EditButtons(
|
||||
uiState,
|
||||
onToggleColorMode,
|
||||
onCropClick,
|
||||
modifier = Modifier.align(Alignment.BottomStart)
|
||||
)
|
||||
RotationButtons(onRotateImage, Modifier.align(Alignment.BottomCenter))
|
||||
SecondaryActionButton(
|
||||
Icons.Outlined.Delete,
|
||||
contentDescription = stringResource(R.string.delete_page),
|
||||
onClick = { onDeleteImage() },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(8.dp)
|
||||
)
|
||||
Text("${currentPageIndex + 1} / ${document.pageCount()}",
|
||||
color = MaterialTheme.colorScheme.inverseOnSurface,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(all = 16.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.7f),
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RotationButtons(
|
||||
onRotateImage: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
// RotateLeft on the left, RotateRight on the right: for both LTR and RTL languages
|
||||
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
|
||||
Row(modifier = modifier.padding(8.dp)) {
|
||||
// Using AutoMirrored icons would lead to an opposite rotation in RTL languages
|
||||
@Suppress("DEPRECATION")
|
||||
SecondaryActionButton(
|
||||
icon = Icons.Default.RotateLeft,
|
||||
contentDescription = stringResource(R.string.rotate_left),
|
||||
onClick = { onRotateImage(false) }
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
@Suppress("DEPRECATION")
|
||||
SecondaryActionButton(
|
||||
icon = Icons.Default.RotateRight,
|
||||
contentDescription = stringResource(R.string.rotate_right),
|
||||
onClick = { onRotateImage(true) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditButtons(
|
||||
uiState: DocumentUiState,
|
||||
onToggleColorMode: () -> Unit,
|
||||
onCropClick: () -> Unit,
|
||||
modifier: Modifier
|
||||
) {
|
||||
Row(modifier = modifier.padding(8.dp)) {
|
||||
uiState.currentPage?.colorMode?.let {
|
||||
ColorModeButton(
|
||||
currentColorMode = it,
|
||||
onToggle = { onToggleColorMode() },
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
if (uiState.currentPage?.canBeCropped ?: false) {
|
||||
SecondaryActionButton(
|
||||
icon = Icons.Default.Crop,
|
||||
contentDescription = stringResource(R.string.crop),
|
||||
onClick = onCropClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ColorModeButton(
|
||||
currentColorMode: ColorMode,
|
||||
onToggle: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Box(modifier = modifier) {
|
||||
SecondaryActionButton(
|
||||
icon = Icons.Default.AutoFixHigh,
|
||||
contentDescription = stringResource(R.string.color_mode),
|
||||
onClick = { expanded = true },
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.color_mode_color)) },
|
||||
leadingIcon = { Icon(Icons.Default.Palette, contentDescription = null) },
|
||||
onClick = {
|
||||
if (currentColorMode != COLOR) onToggle()
|
||||
expanded = false
|
||||
},
|
||||
trailingIcon = {
|
||||
if (currentColorMode == COLOR) {
|
||||
Icon(Icons.Default.Check, contentDescription = null)
|
||||
}
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.color_mode_grayscale)) },
|
||||
leadingIcon = { Icon(Icons.Default.Contrast, contentDescription = null) },
|
||||
onClick = {
|
||||
if (currentColorMode != GRAYSCALE) onToggle()
|
||||
expanded = false
|
||||
},
|
||||
trailingIcon = {
|
||||
if (currentColorMode == GRAYSCALE) {
|
||||
Icon(Icons.Default.Check, contentDescription = null)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomBar(
|
||||
onExportClick: () -> Unit,
|
||||
onAddPageClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onAddPageClick,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = MaterialTheme.colorScheme.primary
|
||||
),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary),
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
) {
|
||||
Icon(Icons.Outlined.Add, contentDescription = null)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(stringResource(R.string.add_page),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
MainActionButton(
|
||||
onClick = onExportClick,
|
||||
icon = Icons.Default.Done,
|
||||
text = stringResource(R.string.export),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
@Preview(locale = "ar")
|
||||
@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true)
|
||||
@Preview(name = "Landscape", showBackground = true, widthDp = 640, heightDp = 320)
|
||||
fun DocumentScreenPreview() {
|
||||
FairScanTheme {
|
||||
val image = fakeImage("gallica.bnf.fr-bpt6k5530456s-1", LocalContext.current).toBitmap()
|
||||
val document = fakeDocument(
|
||||
listOf(1, 2).map { "gallica.bnf.fr-bpt6k5530456s-$it" }.toImmutableList(),
|
||||
LocalContext.current
|
||||
)
|
||||
val key = PageViewKey("123", Rotation.R0, null, 0)
|
||||
DocumentScreen(
|
||||
uiState = DocumentUiState(1, CurrentPageUiState(key,image, COLOR, true), document),
|
||||
navigation = dummyNavigation(),
|
||||
onExportClick = {},
|
||||
onDeleteImage = { },
|
||||
onRotateImage = { _ -> },
|
||||
onToggleColorMode = { },
|
||||
onCropClick = { },
|
||||
onPageReorder = { _,_ -> },
|
||||
onPageSelected = { _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.ui.screens.document
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import org.fairscan.app.domain.PageViewKey
|
||||
import org.fairscan.app.ui.state.DocumentUiModel
|
||||
import org.fairscan.imageprocessing.ColorMode
|
||||
|
||||
data class DocumentUiState(
|
||||
val currentPageIndex: Int,
|
||||
val currentPage: CurrentPageUiState?,
|
||||
val document: DocumentUiModel,
|
||||
)
|
||||
|
||||
data class CurrentPageUiState(
|
||||
val key: PageViewKey,
|
||||
val bitmap: Bitmap?,
|
||||
val colorMode: ColorMode?,
|
||||
val canBeCropped: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
)
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -14,49 +14,69 @@
|
||||
*/
|
||||
package org.fairscan.app.ui.screens.export
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.text.format.Formatter
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Done
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -66,7 +86,9 @@ 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 kotlinx.collections.immutable.persistentListOf
|
||||
import org.fairscan.app.R
|
||||
import org.fairscan.app.THUMBNAIL_SIZE_DP
|
||||
import org.fairscan.app.ui.Navigation
|
||||
import org.fairscan.app.ui.components.AppOverflowMenu
|
||||
import org.fairscan.app.ui.components.BackButton
|
||||
@@ -75,74 +97,75 @@ 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.fakeDocument
|
||||
import org.fairscan.app.ui.screens.settings.ExportFormat.PDF
|
||||
import org.fairscan.app.ui.state.DocumentUiModel
|
||||
import org.fairscan.app.ui.theme.FairScanTheme
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.io.IOException
|
||||
|
||||
@Composable
|
||||
fun ExportScreenWrapper(
|
||||
navigation: Navigation,
|
||||
uiState: ExportUiState,
|
||||
pdfActions: ExportActions,
|
||||
currentDocument: DocumentUiModel,
|
||||
exportActions: ExportActions,
|
||||
onCloseScan: () -> Unit,
|
||||
) {
|
||||
BackHandler { navigation.back() }
|
||||
|
||||
val showConfirmationDialog = rememberSaveable { mutableStateOf(false) }
|
||||
val filename = remember { mutableStateOf(defaultFilename()) }
|
||||
LaunchedEffect(Unit) {
|
||||
pdfActions.setFilename(filename.value)
|
||||
pdfActions.initializeExportScreen()
|
||||
}
|
||||
|
||||
val onFilenameChange = { newName:String ->
|
||||
filename.value = newName
|
||||
pdfActions.setFilename(newName)
|
||||
LaunchedEffect(Unit) {
|
||||
exportActions.prepareExportIfNeeded()
|
||||
}
|
||||
val ensureCorrectFileName = {
|
||||
val value = filename.value.trim().ifEmpty { defaultFilename() }
|
||||
if (value != filename.value) {
|
||||
onFilenameChange(value)
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
exportActions.cancelPreparationJob()
|
||||
}
|
||||
}
|
||||
|
||||
val onFilenameChange = { newName:String ->
|
||||
exportActions.setFilename(newName)
|
||||
}
|
||||
|
||||
ExportScreen(
|
||||
filename = filename,
|
||||
onFilenameChange = onFilenameChange,
|
||||
uiState = uiState,
|
||||
currentDocument = currentDocument,
|
||||
navigation = navigation,
|
||||
onShare = {
|
||||
ensureCorrectFileName()
|
||||
pdfActions.share()
|
||||
if (!uiState.isSaving) {
|
||||
exportActions.share()
|
||||
}
|
||||
},
|
||||
onSave = {
|
||||
ensureCorrectFileName()
|
||||
pdfActions.save()
|
||||
if (!uiState.isSaving) {
|
||||
exportActions.save()
|
||||
}
|
||||
},
|
||||
onOpen = pdfActions.open,
|
||||
onOpen = exportActions.open,
|
||||
onCloseScan = {
|
||||
if (uiState.hasSavedOrShared)
|
||||
onCloseScan()
|
||||
else
|
||||
showConfirmationDialog.value = true
|
||||
if (!uiState.isSaving) {
|
||||
if (uiState.hasSavedOrShared)
|
||||
onCloseScan()
|
||||
else
|
||||
showConfirmationDialog.value = true
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (showConfirmationDialog.value) {
|
||||
NewDocumentDialog(onCloseScan, showConfirmationDialog, stringResource(R.string.end_scan))
|
||||
NewDocumentDialog(onCloseScan, showConfirmationDialog, stringResource(R.string.scan_new))
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ExportScreen(
|
||||
filename: MutableState<String>,
|
||||
onFilenameChange: (String) -> Unit,
|
||||
uiState: ExportUiState,
|
||||
currentDocument: DocumentUiModel,
|
||||
navigation: Navigation,
|
||||
onShare: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
@@ -162,15 +185,16 @@ fun ExportScreen(
|
||||
) { innerPadding ->
|
||||
val containerModifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.padding(16.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
val onThumbnailClick = navigation.toDocumentScreen
|
||||
if (!isLandscape(LocalConfiguration.current)) {
|
||||
Column(
|
||||
modifier = containerModifier.fillMaxSize(),
|
||||
modifier = containerModifier.fillMaxSize().padding(vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
TextFieldAndPdfInfos(filename, onFilenameChange, uiState, onOpen)
|
||||
PdfInfosAndResultBar(uiState, currentDocument, onOpen, onThumbnailClick)
|
||||
Spacer(Modifier.weight(1f)) // push buttons down
|
||||
MainActions(uiState, onShare, onSave, onCloseScan)
|
||||
MainActions(onFilenameChange, uiState, onShare, onSave, onCloseScan)
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
@@ -178,13 +202,14 @@ fun ExportScreen(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier.weight(1f)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
TextFieldAndPdfInfos(filename, onFilenameChange, uiState, onOpen)
|
||||
PdfInfosAndResultBar(uiState, currentDocument, onOpen, onThumbnailClick)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
MainActions(uiState, onShare, onSave, onCloseScan)
|
||||
MainActions(onFilenameChange, uiState, onShare, onSave, onCloseScan)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,52 +218,143 @@ fun ExportScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TextFieldAndPdfInfos(
|
||||
filename: MutableState<String>,
|
||||
onFilenameChange: (String) -> Unit,
|
||||
private fun PdfInfosAndResultBar(
|
||||
uiState: ExportUiState,
|
||||
currentDocument: DocumentUiModel,
|
||||
onOpen: (SavedItem) -> Unit,
|
||||
onThumbnailClick: () -> Unit,
|
||||
) {
|
||||
FilenameTextField(filename, onFilenameChange)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
|
||||
val result = uiState.result
|
||||
PdfInfoCard {
|
||||
PdfInfos(uiState, currentDocument, onThumbnailClick)
|
||||
SaveStatusBar(uiState, onOpen)
|
||||
}
|
||||
|
||||
// PDF infos
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
|
||||
if (uiState.isGenerating) {
|
||||
Text(stringResource(R.string.creating_export), fontStyle = FontStyle.Italic)
|
||||
} else if (result != null) {
|
||||
val context = LocalContext.current
|
||||
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(sizeMessageKey, formattedFileSize),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
uiState.error?.let {
|
||||
ErrorBar(it)
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.savedBundle != null) {
|
||||
SaveInfoBar(uiState.savedBundle, onOpen)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PdfInfoCard(
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 1.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(12.dp)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
if (uiState.errorMessage != null) {
|
||||
ErrorBar(uiState.errorMessage)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PdfInfos(
|
||||
uiState: ExportUiState,
|
||||
currentDocument: DocumentUiModel,
|
||||
onThumbnailClick: () -> Unit,
|
||||
) {
|
||||
val result = uiState.result
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
val thumbnail = currentDocument.thumbnail(0)
|
||||
thumbnail?.let {
|
||||
Card(
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.heightIn(max = THUMBNAIL_SIZE_DP.dp)
|
||||
.widthIn(max = THUMBNAIL_SIZE_DP.dp)
|
||||
) {
|
||||
Image(
|
||||
bitmap = thumbnail.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.clickable { onThumbnailClick() }
|
||||
)
|
||||
}
|
||||
}
|
||||
// PDF infos
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
val pageCount = result?.pageCount ?: uiState.progress?.totalPages
|
||||
pageCount?.let { count -> Text(text = pageCountText(count)) }
|
||||
|
||||
uiState.ocrActivation?.let { activated ->
|
||||
Text(
|
||||
if (activated)
|
||||
stringResource(R.string.text_recognition_enabled)
|
||||
else
|
||||
stringResource(R.string.text_recognition_disabled),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
|
||||
if (uiState.isGenerating) {
|
||||
Text(
|
||||
text = stringResource(R.string.creating_export),
|
||||
fontStyle = FontStyle.Italic,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
uiState.progress?.let{ p ->
|
||||
if (p.totalPages == 1) {
|
||||
LinearProgressIndicator()
|
||||
} else {
|
||||
LinearProgressIndicator({ p.progress })
|
||||
}
|
||||
}
|
||||
} else if (result != null) {
|
||||
val context = LocalContext.current
|
||||
val formattedFileSize = formatFileSize(result.sizeInBytes, context)
|
||||
val sizeMessageKey =
|
||||
if (result.files.size == 1) R.string.file_size else R.string.file_size_total
|
||||
Text(
|
||||
text = stringResource(sizeMessageKey, formattedFileSize),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SaveStatusBar(
|
||||
uiState: ExportUiState,
|
||||
onOpen: (SavedItem) -> Unit,
|
||||
) {
|
||||
when {
|
||||
uiState.isSaving -> {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
uiState.savedBundle != null -> {
|
||||
SaveInfoBar(uiState.savedBundle, onOpen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilenameTextField(
|
||||
filename: MutableState<String>,
|
||||
filename: String,
|
||||
onFilenameChange: (String) -> Unit,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
OutlinedTextField(
|
||||
value = filename.value,
|
||||
value = filename,
|
||||
onValueChange = onFilenameChange,
|
||||
label = { Text(stringResource(R.string.filename)) },
|
||||
singleLine = true,
|
||||
@@ -246,9 +362,9 @@ private fun FilenameTextField(
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester),
|
||||
trailingIcon = {
|
||||
if (filename.value.isNotEmpty()) {
|
||||
if (filename.isNotEmpty()) {
|
||||
IconButton(onClick = {
|
||||
filename.value = ""
|
||||
onFilenameChange("")
|
||||
focusRequester.requestFocus()
|
||||
}) {
|
||||
Icon(Icons.Default.Clear, stringResource(R.string.clear_text))
|
||||
@@ -260,6 +376,7 @@ private fun FilenameTextField(
|
||||
|
||||
@Composable
|
||||
private fun MainActions(
|
||||
onFilenameChange: (String) -> Unit,
|
||||
uiState: ExportUiState,
|
||||
onShare: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
@@ -268,30 +385,35 @@ private fun MainActions(
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
ExportButton(
|
||||
onClick = onShare,
|
||||
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.result != null,
|
||||
isPrimary = !uiState.hasSavedOrShared,
|
||||
icon = Icons.Default.Download,
|
||||
text = stringResource(R.string.save),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
ActionSurface {
|
||||
FilenameTextField(uiState.filename, onFilenameChange)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
ExportButton(
|
||||
onClick = onShare,
|
||||
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.result != null,
|
||||
isPrimary = !uiState.hasSavedOrShared,
|
||||
icon = Icons.Default.Download,
|
||||
text = stringResource(R.string.save),
|
||||
modifier = Modifier.weight(1f)
|
||||
.alpha(if (uiState.isSaving) 0.6f else 1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
ExportButton(
|
||||
icon = Icons.Default.Done,
|
||||
text = stringResource(R.string.end_scan),
|
||||
text = stringResource(R.string.scan_new),
|
||||
onClick = onCloseScan,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isPrimary = uiState.hasSavedOrShared,
|
||||
@@ -299,6 +421,27 @@ private fun MainActions(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionSurface(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
|
||||
tonalElevation = 1.dp,
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.padding(12.dp)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun ExportButton(
|
||||
icon: ImageVector,
|
||||
@@ -332,63 +475,189 @@ fun ExportButton(
|
||||
modifier = modifier
|
||||
) {
|
||||
Icon(icon, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(text)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
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,
|
||||
private fun SaveInfoBar(
|
||||
savedBundle: SavedBundle,
|
||||
onOpen: (SavedItem) -> Unit,
|
||||
) {
|
||||
val dirName = savedBundle.saveDir?.name ?: stringResource(R.string.download_dirname)
|
||||
val items = savedBundle.items
|
||||
val nbFiles = items.size
|
||||
val firstFileName = items[0].fileName
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
tonalElevation = 0.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(12.dp)
|
||||
) {
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = LocalResources.current.getQuantityString(
|
||||
R.plurals.files_saved_to,
|
||||
nbFiles,
|
||||
nbFiles, firstFileName, dirName
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
if (nbFiles == 1) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
MainActionButton(
|
||||
onClick = { onOpen(items[0]) },
|
||||
text = stringResource(R.string.open),
|
||||
icon = Icons.AutoMirrored.Filled.OpenInNew,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorBar(error: ExportError) {
|
||||
val (summary, details) = error.toDisplayText()
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(vertical = 8.dp, horizontal = 16.dp),
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.error,
|
||||
RoundedCornerShape(12.dp)
|
||||
)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
val items = savedBundle.items
|
||||
val nbFiles = items.size
|
||||
val firstFileName = items[0].fileName
|
||||
Text(
|
||||
text = LocalResources.current.getQuantityString(
|
||||
R.plurals.files_saved_to,
|
||||
nbFiles,
|
||||
nbFiles, firstFileName, dirName
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (nbFiles == 1) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
MainActionButton(
|
||||
onClick = { onOpen(items[0]) },
|
||||
text = stringResource(R.string.open),
|
||||
icon = Icons.AutoMirrored.Filled.OpenInNew,
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = summary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
if (details != null) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
val clipboard =
|
||||
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val text = buildString {
|
||||
append(summary)
|
||||
append("\n\n")
|
||||
append(details)
|
||||
}
|
||||
clipboard.setPrimaryClip(
|
||||
ClipData.newPlainText("Export error", text)
|
||||
)
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ContentCopy,
|
||||
contentDescription = stringResource(R.string.copy_logs),
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (details != null) {
|
||||
Text(
|
||||
text = details,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorBar(errorMessage: String) {
|
||||
Text(
|
||||
text = stringResource(R.string.error, errorMessage),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.errorContainer)
|
||||
.padding(16.dp),
|
||||
)
|
||||
private fun ExportError.toDisplayText(): Pair<String, String?> {
|
||||
return when (this) {
|
||||
is ExportError.OnPrepareOrShare -> {
|
||||
val summary = message
|
||||
val details = throwable.message
|
||||
summary to details
|
||||
}
|
||||
|
||||
is ExportError.OnSave -> {
|
||||
val summary = stringResource(messageRes)
|
||||
val contextLines = buildErrorContextLines(saveDir)
|
||||
val details = buildString {
|
||||
if (contextLines.isNotEmpty()) {
|
||||
append(contextLines.joinToString("\n"))
|
||||
}
|
||||
throwable?.message?.let {
|
||||
if (isNotEmpty()) append("\n\n")
|
||||
append(it)
|
||||
}
|
||||
}.ifEmpty { null }
|
||||
|
||||
summary to details
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun defaultFilename(): String {
|
||||
val timestamp = SimpleDateFormat("yyyy-MM-dd HH.mm.ss", Locale.getDefault()).format(Date())
|
||||
return "Scan $timestamp"
|
||||
@Composable
|
||||
private fun buildErrorContextLines(
|
||||
saveDir: SaveDir?,
|
||||
): List<String> {
|
||||
val defaultDirName = stringResource(R.string.download_dirname)
|
||||
|
||||
val folderLine = when {
|
||||
saveDir == null ->
|
||||
stringResource(R.string.error_context_folder, defaultDirName)
|
||||
|
||||
saveDir.name != null ->
|
||||
stringResource(R.string.error_context_folder, saveDir.name)
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
val providerLine = saveDir?.uri?.authority
|
||||
?.let(::providerLabel)
|
||||
?.let { stringResource(R.string.error_context_provider, it) }
|
||||
|
||||
return listOfNotNull(folderLine, providerLine)
|
||||
}
|
||||
|
||||
fun providerLabel(authority: String): String =
|
||||
when {
|
||||
authority.contains("nextcloud", ignoreCase = true) ->
|
||||
"Nextcloud"
|
||||
authority == "com.android.externalstorage.documents" ->
|
||||
"Local storage"
|
||||
else ->
|
||||
authority
|
||||
}
|
||||
|
||||
fun formatFileSize(sizeInBytes: Long?, context: Context): String {
|
||||
return if (sizeInBytes == null) context.getString(R.string.unknown_size)
|
||||
else Formatter.formatShortFileSize(context, sizeInBytes)
|
||||
@@ -398,7 +667,11 @@ fun formatFileSize(sizeInBytes: Long?, context: Context): String {
|
||||
@Composable
|
||||
fun PreviewExportScreenDuringGeneration() {
|
||||
ExportPreviewToCustomize(
|
||||
uiState = ExportUiState(isGenerating = true)
|
||||
uiState = ExportUiState(
|
||||
isGenerating = true,
|
||||
progress = ExportProgress(1, 3),
|
||||
ocrActivation = true,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -409,6 +682,7 @@ fun PreviewExportScreenAfterGeneration() {
|
||||
ExportPreviewToCustomize(
|
||||
uiState = ExportUiState(
|
||||
result = ExportResult.Pdf(file, 442897L, 3),
|
||||
ocrActivation = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -421,7 +695,7 @@ fun PreviewExportScreenAfterSave() {
|
||||
uiState = ExportUiState(
|
||||
result = ExportResult.Pdf(file, 442897L, 3),
|
||||
savedBundle = SavedBundle(
|
||||
listOf(SavedItem(file.toUri(), defaultFilename() + ".pdf", PDF))
|
||||
listOf(SavedItem(file.toUri(), "12345.pdf", PDF))
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -431,7 +705,8 @@ fun PreviewExportScreenAfterSave() {
|
||||
@Composable
|
||||
fun ExportScreenPreviewWithError() {
|
||||
ExportPreviewToCustomize(
|
||||
ExportUiState(errorMessage = "PDF generation failed")
|
||||
ExportUiState(error =
|
||||
ExportError.OnPrepareOrShare("PDF generation failed", IOException("Boom")))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -444,7 +719,7 @@ fun PreviewExportScreenAfterSaveHorizontal() {
|
||||
result = ExportResult.Pdf(file, 442897L, 3),
|
||||
savedBundle = SavedBundle(
|
||||
listOf(SavedItem(file.toUri(), "my_file.pdf", PDF)),
|
||||
exportDirName="MyVeryVeryLongDirectoryName"),
|
||||
SaveDir("fil:///root/dir".toUri() ,"MyVeryVeryLongDirectoryName")),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -453,10 +728,13 @@ fun PreviewExportScreenAfterSaveHorizontal() {
|
||||
fun ExportPreviewToCustomize(uiState: ExportUiState) {
|
||||
FairScanTheme {
|
||||
ExportScreen(
|
||||
filename = remember { mutableStateOf("Scan 2025-07-02 17.40.42") },
|
||||
onFilenameChange = {_->},
|
||||
navigation = dummyNavigation(),
|
||||
uiState = uiState,
|
||||
currentDocument = fakeDocument(
|
||||
persistentListOf("gallica.bnf.fr-bpt6k5530456s-1"),
|
||||
LocalContext.current
|
||||
),
|
||||
navigation = dummyNavigation(),
|
||||
onShare = {},
|
||||
onSave = {},
|
||||
onOpen = {},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -19,16 +19,26 @@ import org.fairscan.app.ui.screens.settings.ExportFormat
|
||||
|
||||
data class ExportUiState(
|
||||
val format: ExportFormat = ExportFormat.PDF,
|
||||
val filename: String = "",
|
||||
val isGenerating: Boolean = false,
|
||||
val progress: ExportProgress? = null,
|
||||
val ocrActivation: Boolean? = null,
|
||||
val isSaving: Boolean = false,
|
||||
val result: ExportResult? = null,
|
||||
val desiredFilename: String = "",
|
||||
val savedBundle: SavedBundle? = null,
|
||||
val hasShared: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val error: ExportError? = null,
|
||||
) {
|
||||
val hasSavedOrShared get() = savedBundle != null || hasShared
|
||||
}
|
||||
|
||||
data class ExportProgress(
|
||||
val completedPages: Int,
|
||||
val totalPages: Int,
|
||||
) {
|
||||
val progress = completedPages.toFloat() / totalPages
|
||||
}
|
||||
|
||||
data class SavedItem(
|
||||
val uri: Uri,
|
||||
val fileName: String,
|
||||
@@ -37,6 +47,24 @@ data class SavedItem(
|
||||
|
||||
data class SavedBundle(
|
||||
val items: List<SavedItem>,
|
||||
val exportDir: Uri? = null,
|
||||
val exportDirName: String? = null,
|
||||
val saveDir: SaveDir? = null,
|
||||
)
|
||||
|
||||
data class SaveDir(
|
||||
val uri: Uri,
|
||||
val name: String?,
|
||||
)
|
||||
|
||||
sealed class ExportError {
|
||||
|
||||
data class OnPrepareOrShare(
|
||||
val message: String,
|
||||
val throwable: Throwable,
|
||||
) : ExportError()
|
||||
|
||||
data class OnSave(
|
||||
val messageRes: Int,
|
||||
val saveDir: SaveDir?,
|
||||
val throwable: Throwable? = null,
|
||||
): ExportError()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -14,13 +14,21 @@
|
||||
*/
|
||||
package org.fairscan.app.ui.screens.export
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.media.MediaScannerConnection
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
@@ -33,126 +41,232 @@ import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.fairscan.app.AppContainer
|
||||
import org.fairscan.app.RecentDocument
|
||||
import org.fairscan.app.R
|
||||
import org.fairscan.app.data.FileManager
|
||||
import org.fairscan.app.data.ImageRepository
|
||||
import org.fairscan.app.domain.ExportQuality
|
||||
import org.fairscan.app.domain.PageViewKey
|
||||
import org.fairscan.app.domain.pagesToExport
|
||||
import org.fairscan.app.ui.screens.settings.ExportFormat
|
||||
import org.fairscan.app.ui.screens.settings.ExportFormat.PDF
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.IOException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.CancellationException
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
sealed interface ExportEvent {
|
||||
data object RequestSave : ExportEvent
|
||||
data object SaveError : ExportEvent
|
||||
data class Share(val result: ExportResult) : 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 ocrService = container.ocrService
|
||||
private val logger = container.logger
|
||||
|
||||
private val _events = MutableSharedFlow<ExportEvent>()
|
||||
val events = _events.asSharedFlow()
|
||||
|
||||
private suspend fun generatePdf(): ExportResult.Pdf = withContext(Dispatchers.IO) {
|
||||
val imageIds = imageRepository.imageIds()
|
||||
val jpegs = imageIds.asSequence()
|
||||
.mapNotNull { id -> imageRepository.getContent(id) }
|
||||
val pdf = fileManager.generatePdf(jpegs)
|
||||
private suspend fun generatePdf(
|
||||
exportQuality: ExportQuality,
|
||||
disableOcr: Boolean,
|
||||
onProgress: (Int) -> Unit,
|
||||
): ExportResult.Pdf = withContext(Dispatchers.IO) {
|
||||
val pageToExports = pagesToExport(imageRepository, exportQuality)
|
||||
val pdf = fileManager.generatePdf(pageToExports, disableOcr, onProgress)
|
||||
return@withContext ExportResult.Pdf(pdf.file, pdf.sizeInBytes, pdf.pageCount)
|
||||
}
|
||||
|
||||
suspend fun generatePdfForExternalCall(): ExportResult.Pdf {
|
||||
val pdf = generatePdf(ExportQuality.BALANCED, true) {}
|
||||
val sourceFile = pdf.file
|
||||
val targetFile = File(sourceFile.parentFile, defaultFilename() + ".pdf")
|
||||
if (sourceFile.absolutePath == targetFile.absolutePath) return pdf
|
||||
if (targetFile.exists() || !sourceFile.renameTo(targetFile)) return pdf
|
||||
return pdf.copy(file = targetFile)
|
||||
}
|
||||
|
||||
private val _uiState = MutableStateFlow(ExportUiState())
|
||||
val uiState: StateFlow<ExportUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var lastPreparationKey: ExportPreparationKey? = null
|
||||
private var preparationJob: Job? = null
|
||||
private var desiredFilename: String = ""
|
||||
private var exportFormat = ExportFormat.PDF
|
||||
|
||||
fun setFilename(name: String) {
|
||||
desiredFilename = name
|
||||
_uiState.update {
|
||||
it.copy(filename = name)
|
||||
}
|
||||
}
|
||||
|
||||
fun initializeExportScreen() {
|
||||
cancelPreparation()
|
||||
fun resetFilename() {
|
||||
_uiState.update {
|
||||
it.copy(filename = "")
|
||||
}
|
||||
}
|
||||
|
||||
preparationJob = viewModelScope.launch {
|
||||
exportFormat = settingsRepository.exportFormat.first()
|
||||
_uiState.update { it.copy(format = exportFormat) }
|
||||
try {
|
||||
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()
|
||||
}
|
||||
private fun defaultFilename(): String {
|
||||
val timestamp = SimpleDateFormat("yyyy-MM-dd HH.mm.ss", Locale.getDefault()).format(Date())
|
||||
return "Scan $timestamp"
|
||||
}
|
||||
|
||||
private fun ensureValidFilename() {
|
||||
_uiState.update {
|
||||
val normalized = it.filename.trim().ifEmpty { defaultFilename() }
|
||||
if (normalized != it.filename) {
|
||||
it.copy(filename = normalized)
|
||||
} else it
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun currentPageKeys(): ImmutableList<PageViewKey> =
|
||||
imageRepository.pages().map { it.key() }.toImmutableList()
|
||||
|
||||
fun cancelPreparationJob() {
|
||||
preparationJob?.let {
|
||||
// keep result if job is not active
|
||||
if (it.isActive) {
|
||||
_uiState.update { ExportUiState() }
|
||||
}
|
||||
it.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
fun prepareExportIfNeeded() {
|
||||
ensureValidFilename()
|
||||
|
||||
viewModelScope.launch {
|
||||
val exportQuality = settingsRepository.exportQuality.first()
|
||||
val exportFormat = settingsRepository.exportFormat.first()
|
||||
val ocrLanguageString = ocrService.languageString()
|
||||
|
||||
val currentPageKeys = currentPageKeys()
|
||||
val key = ExportPreparationKey(
|
||||
currentPageKeys, exportFormat, exportQuality, ocrLanguageString)
|
||||
if (key == lastPreparationKey && _uiState.value.result != null) {
|
||||
return@launch
|
||||
}
|
||||
val pageCount = currentPageKeys.size
|
||||
|
||||
lastPreparationKey = key
|
||||
preparationJob?.cancel()
|
||||
|
||||
preparationJob = launch {
|
||||
val ocrActivation = if (exportFormat == PDF) ocrLanguageString.isNotEmpty() else null
|
||||
_uiState.update {
|
||||
it.copy(isGenerating = false, result = result)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val message = "Failed to prepare $exportFormat export"
|
||||
logger.e("FairScan", message, e)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isGenerating = false,
|
||||
errorMessage = message
|
||||
ExportUiState(
|
||||
filename = it.filename,
|
||||
format = exportFormat,
|
||||
isGenerating = true,
|
||||
progress = ExportProgress(0, pageCount),
|
||||
ocrActivation = ocrActivation,
|
||||
)
|
||||
}
|
||||
val onProgress: (Int) -> Unit = { completedPages ->
|
||||
_uiState.update {
|
||||
it.copy(progress = ExportProgress(completedPages, pageCount))
|
||||
}
|
||||
}
|
||||
try {
|
||||
val t1 = System.currentTimeMillis()
|
||||
val result = if (exportFormat == ExportFormat.JPEG) {
|
||||
generateJpegs(exportQuality, onProgress)
|
||||
} else {
|
||||
generatePdf(exportQuality, false, onProgress)
|
||||
}
|
||||
_uiState.update { it.copy(result = result) }
|
||||
val t2 = System.currentTimeMillis()
|
||||
Log.i("Export", "Generation: $pageCount pages, $exportQuality, ${t2 - t1} ms")
|
||||
} catch (e: CancellationException) {
|
||||
// Preparation cancelled: do nothing
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
val message = "Failed to prepare $exportFormat export"
|
||||
logger.e("FairScan", message, e)
|
||||
_uiState.update {
|
||||
it.copy(error = ExportError.OnPrepareOrShare(message, e))
|
||||
}
|
||||
} finally {
|
||||
_uiState.update { it.copy(isGenerating = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelPreparation() {
|
||||
preparationJob?.cancel()
|
||||
_uiState.value = ExportUiState()
|
||||
private suspend fun generateJpegs(
|
||||
exportQuality: ExportQuality,
|
||||
onProgress: (Int) -> Unit,
|
||||
): ExportResult.Jpeg = withContext(Dispatchers.IO) {
|
||||
val pageToExports = pagesToExport(imageRepository, exportQuality)
|
||||
val timestamp = System.currentTimeMillis()
|
||||
preparationDir.mkdirs()
|
||||
val files = pageToExports.mapIndexed { index, page ->
|
||||
val file = File(preparationDir, "$timestamp-${index + 1}.jpg")
|
||||
file.writeBytes(page.jpeg.get().bytes)
|
||||
onProgress(index + 1)
|
||||
file
|
||||
}.toList()
|
||||
val sizeInBytes = files.sumOf { it.length() }
|
||||
ExportResult.Jpeg(files, sizeInBytes)
|
||||
}
|
||||
|
||||
fun setAsShared() {
|
||||
_uiState.update { it.copy(hasShared = true) }
|
||||
private fun renameFile(source: File, target: File) {
|
||||
if (source.absolutePath == target.absolutePath) return
|
||||
if (target.exists() && !target.delete()) {
|
||||
throw IOException("Cannot delete existing file ${target.absolutePath}")
|
||||
}
|
||||
if (!source.renameTo(target)) {
|
||||
throw IOException("Failed to rename ${source.name} to ${target.name}")
|
||||
}
|
||||
}
|
||||
|
||||
fun applyRenaming(): ExportResult? {
|
||||
val result = _uiState.value.result ?: return null
|
||||
when (result) {
|
||||
private fun applyRenaming(): ExportResult {
|
||||
val result = _uiState.value.result
|
||||
?: throw IllegalStateException("Export result missing")
|
||||
ensureValidFilename()
|
||||
val filename = _uiState.value.filename
|
||||
val updated = when (result) {
|
||||
is ExportResult.Pdf -> {
|
||||
val fileName = FileManager.addPdfExtensionIfMissing(desiredFilename)
|
||||
val fileName = FileManager.addPdfExtensionIfMissing(filename)
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
renameFile(result.file, newFile)
|
||||
ExportResult.Pdf(newFile, result.sizeInBytes, result.pageCount)
|
||||
}
|
||||
is ExportResult.Jpeg -> {
|
||||
val base = desiredFilename.removeSuffix(".jpg")
|
||||
val base = filename.removeSuffix(".jpg")
|
||||
val files = result.files
|
||||
val renamedFiles = files.mapIndexed { index, file ->
|
||||
val indexSuffix = if (files.size == 1) "" else "_${index + 1}"
|
||||
val newFile = File(file.parentFile, "${base}${indexSuffix}.jpg")
|
||||
if (file.absolutePath != newFile.absolutePath) {
|
||||
if (newFile.exists()) newFile.delete()
|
||||
file.renameTo(newFile)
|
||||
}
|
||||
renameFile(file, newFile)
|
||||
newFile
|
||||
}
|
||||
val updated = result.copy(jpegFiles = renamedFiles)
|
||||
_uiState.update { it.copy(result = updated) }
|
||||
result.copy(jpegFiles = renamedFiles)
|
||||
}
|
||||
}
|
||||
_uiState.update { it.copy(result = updated) }
|
||||
return updated
|
||||
}
|
||||
|
||||
fun onShareClicked() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val result = applyRenaming()
|
||||
_events.emit(ExportEvent.Share(result))
|
||||
_uiState.update { it.copy(hasShared = true) }
|
||||
} catch (e: Exception) {
|
||||
val message = "Failed to prepare share"
|
||||
logger.e("FairScan", message, e)
|
||||
_uiState.update { it.copy(error = ExportError.OnPrepareOrShare(message, e)) }
|
||||
}
|
||||
}
|
||||
return _uiState.value.result
|
||||
}
|
||||
|
||||
fun onSaveClicked() {
|
||||
@@ -163,43 +277,73 @@ class ExportViewModel(container: AppContainer): ViewModel() {
|
||||
|
||||
fun onRequestSave(context: Context) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update {it.copy(isSaving = true, error = null, savedBundle = null) }
|
||||
val exportFormat = uiState.value.format
|
||||
val saveDir = saveDir(context)
|
||||
try {
|
||||
save(context)
|
||||
// Must not run on the main thread: some SAF providers (e.g. Nextcloud)
|
||||
// may perform network I/O
|
||||
withContext(Dispatchers.IO) {
|
||||
save(context, saveDir, exportFormat)
|
||||
}
|
||||
} catch (e: MissingExportDirPermissionException) {
|
||||
logger.e("FairScan", "Missing export dir permission", e)
|
||||
_uiState.update {
|
||||
it.copy(error =
|
||||
ExportError.OnSave(R.string.error_export_dir_permission_lost, saveDir))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.e("FairScan", "Failed to save PDF", e)
|
||||
_events.emit(ExportEvent.SaveError)
|
||||
_uiState.update {
|
||||
it.copy(error = ExportError.OnSave(R.string.error_save, saveDir, e))
|
||||
}
|
||||
} finally {
|
||||
_uiState.update { it.copy(isSaving = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun save(context:Context) {
|
||||
val result = applyRenaming() ?: return
|
||||
val exportDir = settingsRepository.exportDirUri.first()?.toUri()
|
||||
private suspend fun saveDir(context:Context): SaveDir? {
|
||||
val uri = settingsRepository.exportDirUri.first()?.toUri() ?: return null
|
||||
val name = resolveExportDirName(context, uri)
|
||||
return SaveDir(uri, name)
|
||||
}
|
||||
|
||||
private suspend fun save(context: Context, saveDir: SaveDir?, exportFormat: ExportFormat) {
|
||||
val result = applyRenaming()
|
||||
val savedItems = mutableListOf<SavedItem>()
|
||||
val filesForMediaScan = mutableListOf<File>()
|
||||
|
||||
for (file in result.files) {
|
||||
val saved = if (exportDir == null) {
|
||||
val out = fileManager.copyToExternalDir(file)
|
||||
filesForMediaScan.add(out)
|
||||
SavedItem(out.toUri(), out.name, exportFormat)
|
||||
val saved = if (saveDir == null) {
|
||||
// No export dir defined -> save to Downloads
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
// Android 10+: use MediaStore API
|
||||
val uri = saveViaMediaStore(context, file, exportFormat)
|
||||
SavedItem(uri, file.name, exportFormat)
|
||||
} else {
|
||||
// Android 8 and 9: use File API
|
||||
// (MediaStore doesn't allow to choose Downloads for Android<10)
|
||||
val out = fileManager.copyToExternalDir(file)
|
||||
filesForMediaScan.add(out)
|
||||
SavedItem(out.toUri(), out.name, exportFormat)
|
||||
}
|
||||
} else {
|
||||
val safFile = copyViaSaf(context, file, exportDir, exportFormat)
|
||||
// Use Storage Access Framework to save to the chosen directory
|
||||
if (!context.contentResolver.persistedUriPermissions.any { perm ->
|
||||
perm.uri == saveDir.uri && perm.isWritePermission
|
||||
}) {
|
||||
throw MissingExportDirPermissionException(saveDir.uri)
|
||||
}
|
||||
val safFile = saveViaSaf(context, file, saveDir.uri, exportFormat)
|
||||
SavedItem(safFile.uri, safFile.name ?: file.name, exportFormat)
|
||||
}
|
||||
savedItems += saved
|
||||
}
|
||||
|
||||
val exportDirName = resolveExportDirName(context, exportDir)
|
||||
val bundle = SavedBundle(savedItems, exportDir, exportDirName)
|
||||
val bundle = SavedBundle(savedItems, saveDir)
|
||||
_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) }
|
||||
}
|
||||
|
||||
@@ -217,7 +361,34 @@ class ExportViewModel(container: AppContainer): ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyViaSaf(
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
private fun saveViaMediaStore(
|
||||
context: Context,
|
||||
source: File,
|
||||
format: ExportFormat
|
||||
): Uri {
|
||||
val resolver = context.contentResolver
|
||||
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, source.name)
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, format.mimeType)
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
|
||||
}
|
||||
|
||||
val collection = MediaStore.Downloads.EXTERNAL_CONTENT_URI
|
||||
val uri = resolver.insert(collection, values)
|
||||
?: throw IOException("Failed to create MediaStore entry")
|
||||
|
||||
resolver.openOutputStream(uri)?.use { out ->
|
||||
source.inputStream().use { input ->
|
||||
input.copyTo(out)
|
||||
}
|
||||
} ?: throw IOException("Failed to open output stream")
|
||||
|
||||
return uri
|
||||
}
|
||||
|
||||
private fun saveViaSaf(
|
||||
context: Context,
|
||||
source: File,
|
||||
exportDirUri: Uri,
|
||||
@@ -252,29 +423,15 @@ class ExportViewModel(container: AppContainer): ViewModel() {
|
||||
DocumentFile.fromTreeUri(context, exportDirUri)?.name
|
||||
}
|
||||
}
|
||||
|
||||
fun addRecentDocument(fileUri: Uri, fileName: String, pageCount: Int) {
|
||||
viewModelScope.launch {
|
||||
recentDocumentsDataStore.updateData { current ->
|
||||
val newDoc = RecentDocument.newBuilder()
|
||||
.setFileUri(fileUri.toString())
|
||||
.setFileName(fileName)
|
||||
.setPageCount(pageCount)
|
||||
.setCreatedAt(System.currentTimeMillis())
|
||||
.build()
|
||||
current.toBuilder()
|
||||
.addDocuments(0, newDoc)
|
||||
.also { builder ->
|
||||
while (builder.documentsCount > 3) {
|
||||
builder.removeDocuments(builder.documentsCount - 1)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ExportPreparationKey(
|
||||
val pages: ImmutableList<PageViewKey>,
|
||||
val format: ExportFormat,
|
||||
val quality: ExportQuality,
|
||||
val ocrLanguageString: String,
|
||||
)
|
||||
|
||||
sealed class ExportResult {
|
||||
abstract val files: List<File>
|
||||
abstract val sizeInBytes: Long
|
||||
@@ -287,7 +444,7 @@ sealed class ExportResult {
|
||||
override val pageCount: Int,
|
||||
) : ExportResult() {
|
||||
override val files get() = listOf(file)
|
||||
override val format: ExportFormat = ExportFormat.PDF
|
||||
override val format: ExportFormat = PDF
|
||||
}
|
||||
|
||||
data class Jpeg(
|
||||
@@ -301,9 +458,16 @@ sealed class ExportResult {
|
||||
}
|
||||
|
||||
data class ExportActions(
|
||||
val initializeExportScreen: () -> Unit,
|
||||
val prepareExportIfNeeded: () -> Unit,
|
||||
val setFilename: (String) -> Unit,
|
||||
val share: () -> Unit,
|
||||
val save: () -> Unit,
|
||||
val open: (SavedItem) -> Unit,
|
||||
val cancelPreparationJob: () -> Unit,
|
||||
)
|
||||
|
||||
class MissingExportDirPermissionException(
|
||||
val uri: Uri
|
||||
) : IllegalStateException(
|
||||
"Missing persisted write permission for export dir: $uri"
|
||||
)
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
/*
|
||||
* 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.ui.screens.home
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DeleteOutline
|
||||
import androidx.compose.material.icons.filled.PhotoCamera
|
||||
import androidx.compose.material.icons.filled.PictureAsPdf
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.fairscan.app.R
|
||||
import org.fairscan.app.ui.Navigation
|
||||
import org.fairscan.app.ui.components.AppOverflowMenu
|
||||
import org.fairscan.app.ui.components.CameraPermissionState
|
||||
import org.fairscan.app.ui.components.formatDate
|
||||
import org.fairscan.app.ui.components.pageCountText
|
||||
import org.fairscan.app.ui.components.rememberCameraPermissionState
|
||||
import org.fairscan.app.ui.dummyNavigation
|
||||
import org.fairscan.app.ui.fakeDocument
|
||||
import org.fairscan.app.ui.state.DocumentUiModel
|
||||
import org.fairscan.app.ui.theme.FairScanTheme
|
||||
import java.io.File
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
cameraPermission: CameraPermissionState,
|
||||
currentDocument: DocumentUiModel,
|
||||
navigation: Navigation,
|
||||
onClearScan: () -> Unit,
|
||||
recentDocuments: List<RecentDocumentUiState>,
|
||||
onOpenPdf: (Uri) -> Unit,
|
||||
) {
|
||||
Scaffold (
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.app_name)) },
|
||||
actions = { AppOverflowMenu(navigation) }
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
if (!cameraPermission.isGranted) {
|
||||
CameraPermissionRationale(cameraPermission)
|
||||
} else {
|
||||
ScanButton(
|
||||
onClick = {
|
||||
onClearScan()
|
||||
navigation.toCameraScreen()
|
||||
},
|
||||
Modifier.align(Alignment.CenterHorizontally)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
if (!currentDocument.isEmpty()) {
|
||||
OngoingScanBanner(
|
||||
currentDocument,
|
||||
onResumeScan = navigation.toDocumentScreen,
|
||||
onClearScan = onClearScan,
|
||||
)
|
||||
} else if (recentDocuments.isNotEmpty()) {
|
||||
RecentDocumentList(recentDocuments, onOpenPdf)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CameraPermissionRationale(cameraPermission: CameraPermissionState) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.camera_permission_rationale),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = { cameraPermission.request() },
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
) {
|
||||
Text(stringResource(R.string.grant_permission))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ScanButton(onClick: () -> Unit, modifier: Modifier) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
modifier = modifier.padding(32.dp),
|
||||
elevation = ButtonDefaults.buttonElevation(defaultElevation = 6.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PhotoCamera,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.scan_button),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OngoingScanBanner(
|
||||
currentDocument: DocumentUiModel,
|
||||
onResumeScan: () -> Unit,
|
||||
onClearScan: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
tonalElevation = 2.dp,
|
||||
shadowElevation = 4.dp,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
currentDocument.load(0)?.let {
|
||||
Image(
|
||||
bitmap = it.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(60.dp)
|
||||
.clip(RoundedCornerShape(4.dp)),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(12.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.scan_in_progress),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = pageCountText(currentDocument.pageCount()),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = onClearScan,
|
||||
modifier = Modifier.size(24.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.DeleteOutline,
|
||||
contentDescription = stringResource(R.string.discard_scan),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Button(onClick = onResumeScan) {
|
||||
Text(stringResource(R.string.resume))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecentDocumentList(
|
||||
recentDocuments: List<RecentDocumentUiState>,
|
||||
onOpenPdf: (Uri) -> Unit
|
||||
) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
stringResource(R.string.last_saved_pdf_files),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 12.dp, bottom = 4.dp)
|
||||
)
|
||||
Column {
|
||||
recentDocuments.forEach { doc ->
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
doc.fileName,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = pageCountText(doc.pageCount) + " • " +
|
||||
formatDate(doc.saveTimestamp, LocalContext.current),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.Default.PictureAsPdf,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { onOpenPdf(doc.fileUri) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun HomeScreenPreviewOnFirstLaunch() {
|
||||
FairScanTheme {
|
||||
HomeScreen(
|
||||
cameraPermission = rememberCameraPermissionState(),
|
||||
currentDocument = fakeDocument(),
|
||||
navigation = dummyNavigation(),
|
||||
onClearScan = {},
|
||||
recentDocuments = listOf(),
|
||||
onOpenPdf = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun HomeScreenPreviewWithCurrentDocument() {
|
||||
FairScanTheme {
|
||||
HomeScreen(
|
||||
cameraPermission = rememberCameraPermissionState(),
|
||||
currentDocument = fakeDocument(
|
||||
persistentListOf("gallica.bnf.fr-bpt6k5530456s-1.jpg"),
|
||||
LocalContext.current
|
||||
),
|
||||
navigation = dummyNavigation(),
|
||||
onClearScan = {},
|
||||
recentDocuments = listOf(),
|
||||
onOpenPdf = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun HomeScreenPreviewWithLastSavedFiles() {
|
||||
FairScanTheme {
|
||||
HomeScreen(
|
||||
cameraPermission = rememberCameraPermissionState(),
|
||||
currentDocument = fakeDocument(),
|
||||
navigation = dummyNavigation(),
|
||||
onClearScan = {},
|
||||
recentDocuments = listOf(
|
||||
RecentDocumentUiState(
|
||||
File("/path/my_file.pdf").toUri(), "my_file.pdf", 1755971180000, 3),
|
||||
RecentDocumentUiState(
|
||||
"content:///path/scan2.pdf".toUri(), "scan2.pdf",1755000500000, 1)
|
||||
),
|
||||
onOpenPdf = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
/*
|
||||
* 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.ui.screens.home
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import org.fairscan.app.AppContainer
|
||||
import java.io.File
|
||||
|
||||
class HomeViewModel(appContainer: AppContainer, appContext: Context): ViewModel() {
|
||||
|
||||
private val recentDocumentsDataStore = appContainer.recentDocumentsDataStore
|
||||
|
||||
val recentDocuments: StateFlow<List<RecentDocumentUiState>> =
|
||||
recentDocumentsDataStore.data.map {
|
||||
it.documentsList.mapNotNull { doc ->
|
||||
var fileName = doc.fileName
|
||||
var uri: Uri? = null
|
||||
if (doc.fileUri.isNullOrEmpty()) {
|
||||
if (!doc.filePath.isNullOrEmpty()) {
|
||||
val file = File(doc.filePath)
|
||||
uri = file.toUri()
|
||||
fileName = file.name
|
||||
}
|
||||
} else {
|
||||
uri = doc.fileUri.toUri()
|
||||
}
|
||||
if (uri != null) {
|
||||
RecentDocumentUiState(
|
||||
fileUri = uri,
|
||||
fileName = fileName,
|
||||
saveTimestamp = doc.createdAt,
|
||||
pageCount = doc.pageCount,
|
||||
)
|
||||
} else null
|
||||
}.filter { item -> uriExists(appContext, item.fileUri) }
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = emptyList(),
|
||||
)
|
||||
|
||||
private fun uriExists(context: Context, uri: Uri): Boolean {
|
||||
return if (uri.scheme == "file") {
|
||||
File(uri.path.orEmpty()).exists()
|
||||
} else {
|
||||
try {
|
||||
DocumentFile.fromSingleUri(context, uri)?.exists() == true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.ui.screens.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.fairscan.app.R
|
||||
import org.fairscan.app.data.OcrLanguage
|
||||
import org.fairscan.app.ui.components.BackButton
|
||||
import org.fairscan.app.ui.theme.FairScanTheme
|
||||
import android.text.format.Formatter
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
enum class LanguageState {
|
||||
ACTIVE,
|
||||
INACTIVE,
|
||||
NOT_INSTALLED,
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun OcrLanguagesScreen(
|
||||
uiState: SettingsUiState,
|
||||
onBack: () -> Unit,
|
||||
onLanguageClick: (String) -> Unit,
|
||||
onRemoveLanguage: (String) -> Unit,
|
||||
onCancelOcrDownload: () -> Unit,
|
||||
) {
|
||||
BackHandler { onBack() }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.settings_ocr_languages)) },
|
||||
navigationIcon = { BackButton(onBack) },
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
OcrLanguagesContent(
|
||||
uiState = uiState,
|
||||
onLanguageClick = onLanguageClick,
|
||||
onCancelOcrDownload = onCancelOcrDownload,
|
||||
onRemoveLanguage = onRemoveLanguage,
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OcrLanguagesContent(
|
||||
uiState: SettingsUiState,
|
||||
onLanguageClick: (String) -> Unit,
|
||||
onRemoveLanguage: (String) -> Unit,
|
||||
onCancelOcrDownload: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val locale = Locale.current.platformLocale
|
||||
|
||||
val installed = remember(
|
||||
uiState.installedOcrLanguages,
|
||||
uiState.enabledOcrLanguages,
|
||||
locale,
|
||||
) {
|
||||
uiState.installedOcrLanguages
|
||||
.map { OcrLanguage(it) }
|
||||
.sortedWith(compareBy { it.displayName(locale) })
|
||||
}
|
||||
|
||||
val available = remember(
|
||||
uiState.installedOcrLanguages,
|
||||
locale,
|
||||
) {
|
||||
OcrLanguage.AVAILABLE_LANGUAGE_CODES
|
||||
.filterNot { it in uiState.installedOcrLanguages }
|
||||
.map { OcrLanguage(it) }
|
||||
.sortedBy { it.displayName(locale) }
|
||||
}
|
||||
|
||||
val suggested = available.firstOrNull {
|
||||
it.locale.displayLanguage == locale.displayLanguage
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize()
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_ocr_download_intro),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (installed.isNotEmpty()) {
|
||||
item {
|
||||
SectionTitle(
|
||||
stringResource(R.string.settings_ocr_languages_installed)
|
||||
)
|
||||
}
|
||||
|
||||
items(
|
||||
items = installed,
|
||||
key = { it.code }
|
||||
) { lang ->
|
||||
LanguageItem(
|
||||
language = lang,
|
||||
state = if (lang.code in uiState.enabledOcrLanguages) {
|
||||
LanguageState.ACTIVE
|
||||
} else {
|
||||
LanguageState.INACTIVE
|
||||
},
|
||||
onClick = { onLanguageClick(lang.code) },
|
||||
onRemove = { onRemoveLanguage(lang.code) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (suggested != null) {
|
||||
item {
|
||||
SectionTitle(
|
||||
stringResource(R.string.settings_ocr_suggested)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
LanguageItem(
|
||||
language = suggested,
|
||||
state = LanguageState.NOT_INSTALLED,
|
||||
onClick = {
|
||||
onLanguageClick(suggested.code)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
SectionTitle(
|
||||
stringResource(R.string.settings_ocr_languages_available)
|
||||
)
|
||||
}
|
||||
|
||||
items(
|
||||
available.filter { it != suggested },
|
||||
key = { it.code }
|
||||
) { lang ->
|
||||
LanguageItem(
|
||||
language = lang,
|
||||
state = LanguageState.NOT_INSTALLED,
|
||||
onClick = {
|
||||
onLanguageClick(lang.code)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
uiState.currentDownload?.let { download ->
|
||||
OcrDownloadDialog(
|
||||
state = download,
|
||||
onCancel = onCancelOcrDownload,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LanguageItem(
|
||||
language: OcrLanguage,
|
||||
state: LanguageState,
|
||||
onClick: () -> Unit,
|
||||
onRemove: (() -> Unit)? = null,
|
||||
) {
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
val locale = Locale.current.platformLocale
|
||||
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(language.displayName(locale))
|
||||
},
|
||||
leadingContent = {
|
||||
when (state) {
|
||||
LanguageState.ACTIVE ->
|
||||
Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = null
|
||||
)
|
||||
|
||||
LanguageState.INACTIVE ->
|
||||
Icon(
|
||||
Icons.Default.RadioButtonUnchecked,
|
||||
contentDescription = null
|
||||
)
|
||||
|
||||
LanguageState.NOT_INSTALLED ->
|
||||
Icon(
|
||||
Icons.Default.Download,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
if (onRemove != null) {
|
||||
Box {
|
||||
IconButton(onClick = { menuExpanded = true }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = null)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
onDismissRequest = { menuExpanded = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.delete)) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onRemove()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.clickable(onClick = onClick)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionTitle(
|
||||
text: String,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(
|
||||
horizontal = 16.dp,
|
||||
vertical = 16.dp,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OcrDownloadDialog(
|
||||
state: OcrDownloadUiState,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {}, // tapping outside the dialog should not cancel
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.settings_ocr_downloading),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
},
|
||||
text = {
|
||||
if (state.failed) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_ocr_download_error),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
} else {
|
||||
Column {
|
||||
Text(state.language.displayName(Locale.current.platformLocale))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
val progress =
|
||||
state.totalBytes?.let { total ->
|
||||
state.downloadedBytes.toFloat() / total
|
||||
}
|
||||
|
||||
if (progress != null) {
|
||||
LinearProgressIndicator(progress = { progress }, Modifier.fillMaxWidth())
|
||||
} else {
|
||||
LinearProgressIndicator(Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
buildProgressText(
|
||||
state.downloadedBytes,
|
||||
state.totalBytes,
|
||||
LocalContext.current
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onCancel) {
|
||||
Text(stringResource(if (state.failed) R.string.close else R.string.cancel))
|
||||
}
|
||||
},
|
||||
confirmButton = {}
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildProgressText(
|
||||
downloadedBytes: Long,
|
||||
totalBytes: Long?,
|
||||
context: Context,
|
||||
): String {
|
||||
return if (totalBytes != null) {
|
||||
listOf(
|
||||
Formatter.formatShortFileSize(context, downloadedBytes),
|
||||
Formatter.formatShortFileSize(context, totalBytes),
|
||||
).joinToString(" / ")
|
||||
} else {
|
||||
Formatter.formatShortFileSize(context, downloadedBytes)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun OcrLanguagesScreenPreviewBasic() {
|
||||
OcrLanguagesScreenPreview(
|
||||
SettingsUiState(
|
||||
installedOcrLanguages = setOf("fra", "deu"),
|
||||
enabledOcrLanguages = setOf("fra")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun OcrLanguagesScreenPreviewWithDownloadDialog() {
|
||||
OcrLanguagesScreenPreview(
|
||||
SettingsUiState(currentDownload =
|
||||
OcrDownloadUiState(OcrLanguage("eng"), 500_000, 1_200_000)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun OcrLanguagesScreenPreviewWithDownloadError() {
|
||||
OcrLanguagesScreenPreview(
|
||||
SettingsUiState(currentDownload =
|
||||
OcrDownloadUiState(OcrLanguage("eng"), failed = true)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OcrLanguagesScreenPreview(uiState: SettingsUiState) {
|
||||
FairScanTheme {
|
||||
OcrLanguagesScreen(uiState, {}, {}, {}, {})
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -15,26 +15,49 @@
|
||||
package org.fairscan.app.ui.screens.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.net.toUri
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.fairscan.app.R
|
||||
import org.fairscan.app.domain.ExportQuality
|
||||
import org.fairscan.imageprocessing.ColorMode
|
||||
|
||||
private val Context.dataStore by preferencesDataStore(name = "fairscan_settings")
|
||||
|
||||
class SettingsRepository(private val context: Context) {
|
||||
class SettingsRepository(
|
||||
private val context: Context,
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
|
||||
private val DEFAULT_COLOR_MODE = stringPreferencesKey("default_color_mode")
|
||||
private val EXPORT_DIR_URI = stringPreferencesKey("export_dir_uri")
|
||||
private val EXPORT_FORMAT = stringPreferencesKey("export_format")
|
||||
private val EXPORT_QUALITY = stringPreferencesKey("export_quality")
|
||||
|
||||
val defaultColorMode: Flow<DefaultColorMode> =
|
||||
dataStore.data.map { prefs ->
|
||||
when (prefs[DEFAULT_COLOR_MODE]) {
|
||||
"AUTO" -> DefaultColorMode.AUTO
|
||||
"COLOR" -> DefaultColorMode.COLOR
|
||||
"GRAYSCALE" -> DefaultColorMode.GRAYSCALE
|
||||
else -> DefaultColorMode.AUTO
|
||||
}
|
||||
}
|
||||
|
||||
val exportDirUri: Flow<String?> =
|
||||
context.dataStore.data.map { prefs ->
|
||||
dataStore.data.map { prefs ->
|
||||
prefs[EXPORT_DIR_URI]
|
||||
}
|
||||
|
||||
fun resolveExportDirName(uri: String): String? {
|
||||
return DocumentFile.fromTreeUri(context, uri.toUri())?.name
|
||||
}
|
||||
|
||||
val exportFormat: Flow<ExportFormat> =
|
||||
context.dataStore.data.map { prefs ->
|
||||
dataStore.data.map { prefs ->
|
||||
when (prefs[EXPORT_FORMAT]) {
|
||||
"JPEG" -> ExportFormat.JPEG
|
||||
"PDF", null -> ExportFormat.PDF
|
||||
@@ -42,8 +65,24 @@ class SettingsRepository(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
val exportQuality: Flow<ExportQuality> =
|
||||
dataStore.data.map { prefs ->
|
||||
when (prefs[EXPORT_QUALITY]) {
|
||||
"LOW" -> ExportQuality.LOW
|
||||
"HIGH" -> ExportQuality.HIGH
|
||||
"BALANCED", null -> ExportQuality.BALANCED
|
||||
else -> ExportQuality.BALANCED
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setDefaultColorMode(mode: DefaultColorMode) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[DEFAULT_COLOR_MODE] = mode.name
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setExportDirUri(uri: String?) {
|
||||
context.dataStore.edit { prefs ->
|
||||
dataStore.edit { prefs ->
|
||||
if (uri == null) {
|
||||
prefs.remove(EXPORT_DIR_URI)
|
||||
} else {
|
||||
@@ -53,10 +92,22 @@ class SettingsRepository(private val context: Context) {
|
||||
}
|
||||
|
||||
suspend fun setExportFormat(format: ExportFormat) {
|
||||
context.dataStore.edit { prefs ->
|
||||
dataStore.edit { prefs ->
|
||||
prefs[EXPORT_FORMAT] = format.name
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setExportQuality(quality: ExportQuality) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[EXPORT_QUALITY] = quality.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class DefaultColorMode(val colorMode: ColorMode?, val labelResource: Int) {
|
||||
AUTO(null, R.string.color_mode_auto),
|
||||
COLOR(ColorMode.COLOR, R.string.color_mode_color),
|
||||
GRAYSCALE(ColorMode.GRAYSCALE, R.string.color_mode_grayscale),
|
||||
}
|
||||
|
||||
enum class ExportFormat(val mimeType: String) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -14,8 +14,6 @@
|
||||
*/
|
||||
package org.fairscan.app.ui.screens.settings
|
||||
|
||||
|
||||
import android.content.Context
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -30,124 +28,276 @@ 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.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
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.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
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.OcrLanguage
|
||||
import org.fairscan.app.domain.ExportQuality
|
||||
import org.fairscan.app.ui.Navigation
|
||||
import org.fairscan.app.ui.components.BackButton
|
||||
import org.fairscan.app.ui.dummyNavigation
|
||||
import org.fairscan.app.ui.theme.FairScanTheme
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
uiState: SettingsUiState,
|
||||
onDefaultColorModeChanged: (DefaultColorMode) -> Unit,
|
||||
onChooseDirectoryClick: () -> Unit,
|
||||
onResetExportDirClick: () -> Unit,
|
||||
onExportFormatChanged: (ExportFormat) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onExportQualityChanged: (ExportQuality) -> Unit,
|
||||
navigation: Navigation,
|
||||
) {
|
||||
BackHandler { onBack() }
|
||||
BackHandler { navigation.back() }
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.settings)) },
|
||||
navigationIcon = { BackButton(onBack) },
|
||||
navigationIcon = { BackButton(navigation.back) },
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
SettingsContent(
|
||||
uiState,
|
||||
onDefaultColorModeChanged,
|
||||
onChooseDirectoryClick,
|
||||
onResetExportDirClick,
|
||||
onExportFormatChanged,
|
||||
modifier = Modifier.padding(paddingValues))
|
||||
onExportQualityChanged,
|
||||
navigation,
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SettingsContent(
|
||||
uiState: SettingsUiState,
|
||||
onDefaultColorModeChanged: (DefaultColorMode) -> Unit,
|
||||
onChooseDirectoryClick: () -> Unit,
|
||||
onResetExportDirClick: () -> Unit,
|
||||
onExportFormatChanged: (ExportFormat) -> Unit,
|
||||
onExportQualityChanged: (ExportQuality) -> Unit,
|
||||
navigation: Navigation,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val folderName = remember(uiState.exportDirUri) {
|
||||
extractFolderName(uiState.exportDirUri, context)
|
||||
val displayLocale = Locale.current.platformLocale
|
||||
val export = uiState.export
|
||||
val (folderLabel, folderLabelColor) = when {
|
||||
export.dirUri == null ->
|
||||
stringResource(R.string.download_dirname) to
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
|
||||
export.dirName != null ->
|
||||
export.dirName to
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
|
||||
else ->
|
||||
stringResource(R.string.export_folder_permission_lost) to
|
||||
MaterialTheme.colorScheme.error
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.padding(20.dp)
|
||||
.padding(vertical = 8.dp, horizontal = 24.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
DirectorySettingItem(
|
||||
label = stringResource(R.string.export_directory),
|
||||
folderName = folderName,
|
||||
onClick = onChooseDirectoryClick
|
||||
val context = LocalResources.current
|
||||
|
||||
Text(stringResource(R.string.settings_section_scan), style = MaterialTheme.typography.titleLarge)
|
||||
|
||||
SingleChoiceSetting(
|
||||
title = stringResource(R.string.color_mode_default),
|
||||
entries = DefaultColorMode.entries,
|
||||
onValueChanged = onDefaultColorModeChanged,
|
||||
label = { t -> context.getString(t.labelResource) },
|
||||
selectedValue = uiState.defaultColorMode,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
if (uiState.exportDirUri != null) {
|
||||
OutlinedButton(
|
||||
Text(stringResource(R.string.settings_section_export), style = MaterialTheme.typography.titleLarge)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
DirectorySettingItem(
|
||||
label = stringResource(R.string.export_directory),
|
||||
folderLabel,
|
||||
folderLabelColor,
|
||||
onClick = onChooseDirectoryClick,
|
||||
)
|
||||
|
||||
if (export.dirUri != null) {
|
||||
TextButton(
|
||||
onClick = onResetExportDirClick,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary),
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.reset_to_default))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Text("Export format", style = MaterialTheme.typography.titleLarge)
|
||||
SingleChoiceSetting(
|
||||
title = stringResource(R.string.export_quality),
|
||||
entries = ExportQuality.entries.reversed(),
|
||||
selectedValue = export.quality,
|
||||
onValueChanged = onExportQualityChanged,
|
||||
label = { t -> context.getString(t.labelResource) },
|
||||
)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(
|
||||
selected = uiState.exportFormat == ExportFormat.PDF,
|
||||
onClick = { onExportFormatChanged(ExportFormat.PDF) },
|
||||
SingleChoiceSetting(
|
||||
title = stringResource(R.string.export_format),
|
||||
entries = ExportFormat.entries,
|
||||
selectedValue = export.format,
|
||||
onValueChanged = onExportFormatChanged,
|
||||
label = { it.name },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
stringResource(R.string.settings_section_ocr),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.settings_ocr_languages)) },
|
||||
supportingContent = {
|
||||
Text(uiState.enabledOcrLanguages
|
||||
.map { OcrLanguage(it).displayName(displayLocale) }
|
||||
.sorted()
|
||||
.joinToString(" • ")
|
||||
.ifEmpty { stringResource(R.string.settings_ocr_languages_disabled) }
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { navigation.toOcrLanguagesScreen() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> SingleChoiceSetting(
|
||||
title: String,
|
||||
entries: List<T>,
|
||||
selectedValue: T,
|
||||
onValueChanged: (T) -> Unit,
|
||||
label: (T) -> String,
|
||||
) {
|
||||
var showDialog by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(title)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(label(selectedValue))
|
||||
},
|
||||
trailingContent = {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null
|
||||
)
|
||||
Text("PDF")
|
||||
},
|
||||
modifier = Modifier.clickable {
|
||||
showDialog = true
|
||||
}
|
||||
)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(
|
||||
selected = uiState.exportFormat == ExportFormat.JPEG,
|
||||
onClick = { onExportFormatChanged(ExportFormat.JPEG) },
|
||||
)
|
||||
Text("JPEG")
|
||||
}
|
||||
if (showDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
showDialog = false
|
||||
},
|
||||
title = {
|
||||
Text(title)
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
entries.forEach { entry ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
onValueChanged(entry)
|
||||
showDialog = false
|
||||
}
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = selectedValue == entry,
|
||||
onClick = {
|
||||
onValueChanged(entry)
|
||||
showDialog = false
|
||||
}
|
||||
)
|
||||
|
||||
Text(
|
||||
text = label(entry),
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DirectorySettingItem(
|
||||
label: String,
|
||||
folderName: String,
|
||||
folderLabel: String,
|
||||
folderLabelColor: Color,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Column {
|
||||
|
||||
) {
|
||||
Column (
|
||||
modifier = Modifier.padding(vertical = 0.dp, horizontal = 12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
@@ -156,7 +306,9 @@ fun DirectorySettingItem(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
shape = MaterialTheme.shapes.medium
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
colors = CardDefaults.cardColors(containerColor = Color.Transparent),
|
||||
border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.onSurfaceVariant),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -166,8 +318,9 @@ fun DirectorySettingItem(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = folderName,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
text = folderLabel,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = folderLabelColor,
|
||||
)
|
||||
|
||||
Icon(
|
||||
@@ -179,24 +332,22 @@ fun DirectorySettingItem(
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractFolderName(uriString: String?, context: Context): String {
|
||||
if (uriString == null) return context.getString(R.string.download_dirname)
|
||||
return runCatching {
|
||||
val uri = uriString.toUri()
|
||||
uri.lastPathSegment?.substringAfter(':')?.substringAfter('/') ?: uriString
|
||||
}.getOrElse { uriString }
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(heightDp = 780)
|
||||
@Composable
|
||||
fun SettingsScreenPreviewWithoutDir() {
|
||||
SettingsScreenPreview(SettingsUiState(null))
|
||||
SettingsScreenPreview(SettingsUiState(
|
||||
installedOcrLanguages = setOf("fra", "eng", "deu"),
|
||||
enabledOcrLanguages = setOf("fra", "eng")
|
||||
))
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun SettingsScreenPreviewWithDir() {
|
||||
SettingsScreenPreview(SettingsUiState("content://root/dir"))
|
||||
SettingsScreenPreview(
|
||||
SettingsUiState(export= ExportSettingsUiState(dirUri = "content://root/dir"))
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -204,10 +355,12 @@ fun SettingsScreenPreview(uiState: SettingsUiState) {
|
||||
FairScanTheme {
|
||||
SettingsScreen(
|
||||
uiState,
|
||||
onDefaultColorModeChanged = {},
|
||||
onChooseDirectoryClick = {},
|
||||
onResetExportDirClick = {},
|
||||
onExportFormatChanged = {},
|
||||
onBack = {}
|
||||
onExportQualityChanged = {},
|
||||
navigation = dummyNavigation(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -16,26 +16,77 @@ package org.fairscan.app.ui.screens.settings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.fairscan.app.AppContainer
|
||||
import org.fairscan.app.data.OcrLanguage
|
||||
import org.fairscan.app.domain.ExportQuality
|
||||
|
||||
data class SettingsUiState(
|
||||
val exportDirUri: String? = null,
|
||||
val exportFormat: ExportFormat = ExportFormat.PDF,
|
||||
val defaultColorMode: DefaultColorMode = DefaultColorMode.AUTO,
|
||||
val export: ExportSettingsUiState = ExportSettingsUiState(),
|
||||
val installedOcrLanguages: Set<String> = emptySet(),
|
||||
val enabledOcrLanguages: Set<String> = emptySet(),
|
||||
val currentDownload: OcrDownloadUiState? = null
|
||||
)
|
||||
|
||||
data class ExportSettingsUiState(
|
||||
val dirUri: String? = null,
|
||||
val dirName: String? = null,
|
||||
val format: ExportFormat = ExportFormat.PDF,
|
||||
val quality: ExportQuality = ExportQuality.BALANCED,
|
||||
)
|
||||
|
||||
data class OcrDownloadUiState(
|
||||
val language: OcrLanguage,
|
||||
val downloadedBytes: Long = 0,
|
||||
val totalBytes: Long? = null,
|
||||
val failed: Boolean = false,
|
||||
)
|
||||
|
||||
class SettingsViewModel(container: AppContainer) : ViewModel() {
|
||||
|
||||
private val repo = container.settingsRepository
|
||||
private val ocrLanguageRepo = container.ocrLanguageRepository
|
||||
private val logger = container.logger
|
||||
|
||||
private val _installedLanguages = MutableStateFlow<Set<String>>(emptySet())
|
||||
private val _ocrDownload = MutableStateFlow<OcrDownloadUiState?>(null)
|
||||
private var downloadJob: Job? = null
|
||||
|
||||
private val _dirName = MutableStateFlow<String?>(null)
|
||||
val dirName: StateFlow<String?> = _dirName
|
||||
|
||||
private val exportSettingsState =
|
||||
combine(
|
||||
repo.exportDirUri,
|
||||
dirName,
|
||||
repo.exportFormat,
|
||||
repo.exportQuality,
|
||||
) {
|
||||
dirUri, dirName, format, quality ->
|
||||
ExportSettingsUiState(dirUri, dirName, format, quality)
|
||||
}
|
||||
val uiState = combine(
|
||||
repo.exportDirUri,
|
||||
repo.exportFormat,
|
||||
) { dir, format ->
|
||||
repo.defaultColorMode,
|
||||
exportSettingsState,
|
||||
_installedLanguages,
|
||||
ocrLanguageRepo.enabledLanguages,
|
||||
_ocrDownload,
|
||||
) { colorMode, exportSettings, installed, enabled, download ->
|
||||
SettingsUiState(
|
||||
exportDirUri = dir,
|
||||
exportFormat = format,
|
||||
defaultColorMode = colorMode,
|
||||
export = exportSettings,
|
||||
installedOcrLanguages = installed,
|
||||
enabledOcrLanguages = enabled,
|
||||
currentDownload = download,
|
||||
)
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
@@ -43,9 +94,27 @@ class SettingsViewModel(container: AppContainer) : ViewModel() {
|
||||
SettingsUiState()
|
||||
)
|
||||
|
||||
private suspend fun refreshInstalledLanguages() {
|
||||
_installedLanguages.value =
|
||||
ocrLanguageRepo.getInstalledLanguages()
|
||||
}
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
refreshInstalledLanguages()
|
||||
}
|
||||
}
|
||||
|
||||
fun setDefaultColorMode(pref: DefaultColorMode) {
|
||||
viewModelScope.launch {
|
||||
repo.setDefaultColorMode(pref)
|
||||
}
|
||||
}
|
||||
|
||||
fun setExportDirUri(uri: String?) {
|
||||
viewModelScope.launch {
|
||||
repo.setExportDirUri(uri)
|
||||
refreshExportDirName()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,4 +123,67 @@ class SettingsViewModel(container: AppContainer) : ViewModel() {
|
||||
repo.setExportFormat(format)
|
||||
}
|
||||
}
|
||||
|
||||
fun setExportQuality(quality: ExportQuality) {
|
||||
viewModelScope.launch {
|
||||
repo.setExportQuality(quality)
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshExportDirName() {
|
||||
viewModelScope.launch {
|
||||
val uri = repo.exportDirUri.first()
|
||||
_dirName.value = uri?.let { repo.resolveExportDirName(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun onLanguageClick(code: String) {
|
||||
if (uiState.value.installedOcrLanguages.contains(code)) {
|
||||
setOcrLanguageEnabled(code, !uiState.value.enabledOcrLanguages.contains(code))
|
||||
} else {
|
||||
installLanguage(code)
|
||||
}
|
||||
}
|
||||
|
||||
fun installLanguage(code: String) {
|
||||
downloadJob?.cancel()
|
||||
downloadJob = viewModelScope.launch {
|
||||
_ocrDownload.value = OcrDownloadUiState(OcrLanguage(code))
|
||||
try {
|
||||
ocrLanguageRepo.downloadLanguage(code) { progress ->
|
||||
_ocrDownload.value =
|
||||
_ocrDownload.value?.copy(
|
||||
downloadedBytes = progress.downloadedBytes,
|
||||
totalBytes = progress.totalBytes,
|
||||
)
|
||||
}
|
||||
ocrLanguageRepo.setLanguageEnabled(code, true)
|
||||
refreshInstalledLanguages()
|
||||
_ocrDownload.value = null
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
logger.e("OcrDownload", "Download failed for $code", e)
|
||||
_ocrDownload.value = _ocrDownload.value?.copy(failed = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelOcrDownload() {
|
||||
downloadJob?.cancel()
|
||||
_ocrDownload.value = null
|
||||
}
|
||||
|
||||
fun setOcrLanguageEnabled(code: String, enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
ocrLanguageRepo.setLanguageEnabled(code, enabled)
|
||||
}
|
||||
}
|
||||
|
||||
fun onRemoveLanguage(code: String) {
|
||||
viewModelScope.launch {
|
||||
ocrLanguageRepo.deleteLanguage(code)
|
||||
refreshInstalledLanguages()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -16,28 +16,28 @@ package org.fairscan.app.ui.state
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.fairscan.app.domain.Jpeg
|
||||
import org.fairscan.app.domain.PageViewKey
|
||||
|
||||
data class DocumentUiModel(
|
||||
val pageIds: ImmutableList<String>,
|
||||
private val imageLoader: (String) -> Bitmap?,
|
||||
private val thumbnailLoader: (String) -> Bitmap?
|
||||
val pages: ImmutableList<PageThumbnail> = persistentListOf(),
|
||||
) {
|
||||
fun pageCount(): Int {
|
||||
return pageIds.size
|
||||
}
|
||||
fun pageId(index: Int): String {
|
||||
return pageIds[index]
|
||||
return pages.size
|
||||
}
|
||||
fun isEmpty(): Boolean {
|
||||
return pageIds.isEmpty()
|
||||
return pages.isEmpty()
|
||||
}
|
||||
fun lastIndex(): Int {
|
||||
return pageIds.lastIndex
|
||||
return pages.lastIndex
|
||||
}
|
||||
fun load(index: Int): Bitmap? {
|
||||
return imageLoader(pageIds[index])
|
||||
fun thumbnail(index: Int): Bitmap? {
|
||||
return pages.getOrNull(index)?.thumbnail?.toBitmap()
|
||||
}
|
||||
fun loadThumbnail(index: Int): Bitmap? {
|
||||
return thumbnailLoader(pageIds[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class PageThumbnail(
|
||||
val key: PageViewKey,
|
||||
val thumbnail: Jpeg?,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
syntax = "proto3";
|
||||
|
||||
option java_package = "org.fairscan.app";
|
||||
option java_multiple_files = true;
|
||||
|
||||
message RecentDocument {
|
||||
string file_path = 1;
|
||||
int64 created_at = 2; // timestamp in ms
|
||||
int32 page_count = 3;
|
||||
string file_uri = 4;
|
||||
string file_name = 5;
|
||||
}
|
||||
|
||||
message RecentDocuments {
|
||||
repeated RecentDocument documents = 1;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<resources>
|
||||
<string name="about">عن</string>
|
||||
<string name="add_page">أضف صفحة</string>
|
||||
<string name="app_tagline">تطبيق بسيط ومحترم لمسح مستنداتك ضوئيًا.</string>
|
||||
<string name="apply">تطبيق</string>
|
||||
<string name="back">ارجع</string>
|
||||
<string name="camera_permission_denied">رُفض إذن الوصول إلى الكاميرا</string>
|
||||
<string name="camera_permission_rationale">يتطلب التطبيق الوصول إلى الكاميرا لمسح المستندات ضوئيًا. تُخزن الصور الملتقطة على هذا الجهاز فقط، وسيتم حذفها عند إغلاق المسح الحالي.</string>
|
||||
<string name="cancel">ألغِ</string>
|
||||
<string name="change_directory">غيّر المجلد</string>
|
||||
<string name="clear_text">امحُ النص</string>
|
||||
<string name="close">إغلاق</string>
|
||||
<string name="color_mode">فلتر</string>
|
||||
<string name="color_mode_auto">تلقائي</string>
|
||||
<string name="color_mode_color">ألوان</string>
|
||||
<string name="color_mode_default">الفلتر الافتراضي</string>
|
||||
<string name="color_mode_grayscale">تدرج الرمادي</string>
|
||||
<string name="contact">تواصل</string>
|
||||
<string name="copied_logs">نُسخت السجلات إلى الحافظة</string>
|
||||
<string name="copy_logs">انسخ السجلات</string>
|
||||
<string name="creating_export">يجهز التصدير…</string>
|
||||
<string name="crop">اقتصاص</string>
|
||||
<string name="delete">حذف</string>
|
||||
<string name="delete_page">احذف الصفحة</string>
|
||||
<string name="delete_page_warning">هل تريد حذف هذه الصفحة؟</string>
|
||||
<string name="developer">مطوّر</string>
|
||||
<string name="download_dirname">التنزيلات</string>
|
||||
<string name="error_context_folder">مجلد التصدير: %1$s</string>
|
||||
<string name="error_context_provider">المزود: %1$s</string>
|
||||
<string name="error_export_dir_permission_lost">لم يعد من الممكن الوصول إلى مجلد التصدير المحدّد. يُرجى اختيار مجلد آخر.</string>
|
||||
<string name="error_no_app">لم يُعثر على تطبيق لفتح هذا الملف</string>
|
||||
<string name="error_occurred">حدث خطأ</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_folder_permission_lost">فُقدت صلاحية الوصول إلى المجلد</string>
|
||||
<string name="export_format">صيغة التصدير</string>
|
||||
<string name="export_quality">جودة تصدير</string>
|
||||
<string name="export_quality_low">منخفضة</string>
|
||||
<string name="export_quality_balanced">متوازنة</string>
|
||||
<string name="export_quality_high">عالية</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="import_photos">استيراد</string>
|
||||
<string name="libraries">المكتبات</string>
|
||||
<string name="libraries_open_source">مكتبات مفتوحة المصدر</string>
|
||||
<string name="license">الترخيص</string>
|
||||
<string name="licensed_under">هذا التطبيق مرخص بموجب رخصة جنو العمومية العامة، الإصدار 3.0.</string>
|
||||
<string name="menu">قائمة</string>
|
||||
<string name="new_document_warning">سيتم فقدان عملية المسح الحالية. أتريد الاستمرار؟</string>
|
||||
<string name="open">افتح</string>
|
||||
<string name="open_file">افتح الملف</string>
|
||||
<string name="reset_to_default">صفّر للإعدادات المبدئية</string>
|
||||
<string name="rotate_left">تدوير إلى اليسار</string>
|
||||
<string name="rotate_right">تدوير إلى اليمين</string>
|
||||
<string name="save">احفظ</string>
|
||||
<string name="scan_new">مسح جديد</string>
|
||||
<string name="scan_current">المسح الحالي</string>
|
||||
<string name="scan_discard_current">تجاهل المسح الحالي</string>
|
||||
<string name="scan_resume">متابعة المسح</string>
|
||||
<string name="settings">الإعدادات</string>
|
||||
<string name="settings_ocr_download_error">فشل التنزيل. تحقق من اتصال الإنترنت وتأكد من السماح لهذا التطبيق بالوصول إلى الشبكة.</string>
|
||||
<string name="settings_ocr_download_intro">يتم تنزيل ملفات اللغة من مشروع Tesseract الرسمي. تبقى عملية التعرّف على النصوص دون اتصال بالإنترنت بالكامل.</string>
|
||||
<string name="settings_ocr_downloading">جارٍ تنزيل ملف اللغة</string>
|
||||
<string name="settings_ocr_languages">اللغات</string>
|
||||
<string name="settings_ocr_languages_available">اللغات المتاحة</string>
|
||||
<string name="settings_ocr_languages_disabled">معطّل</string>
|
||||
<string name="settings_ocr_languages_installed">مثبّتة</string>
|
||||
<string name="settings_ocr_suggested">مقترح</string>
|
||||
<string name="settings_section_ocr">التعرّف على النصوص</string>
|
||||
<string name="settings_section_scan">المسح</string>
|
||||
<string name="settings_section_export">التصدير</string>
|
||||
<string name="share">شارك</string>
|
||||
<string name="share_document">شارك المستند</string>
|
||||
<string name="storage_permission_denied">تعذر حفظ الملف: رُفض الإذن</string>
|
||||
<string name="support">المساعدة</string>
|
||||
<string name="support_last_image">أبلِغ عن مشكلة في آخر صورة مُلتقطَة </string>
|
||||
<string name="text_recognition_disabled">التعرّف على النصوص: معطّل</string>
|
||||
<string name="text_recognition_enabled">التعرّف على النصوص: مفعّل</string>
|
||||
<string name="turn_off_torch">أطفئ الكشاف</string>
|
||||
<string name="turn_on_torch">شغّل الكشاف</string>
|
||||
<string name="unknown_size">حجم مجهول</string>
|
||||
<string name="version">الإصدار</string>
|
||||
<string name="view_full_list">اعرض القائمة الكاملة</string>
|
||||
<string name="view_the_full_license">اعرض الترخيص الكامل</string>
|
||||
<string name="yes">نعم</string>
|
||||
<plurals name="files_saved_to">
|
||||
<item quantity="zero">لم يُحفظ أي ملفات في %3$s</item>
|
||||
<item quantity="one">حُفظ %2$s في %3$s</item>
|
||||
<item quantity="two">حُفظ ملفين في %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="importing_photos">
|
||||
<item quantity="zero">جارٍ استيراد 0 صورة</item>
|
||||
<item quantity="one">جارٍ استيراد صورة واحدة</item>
|
||||
<item quantity="two">جارٍ استيراد صورتين</item>
|
||||
<item quantity="few">جارٍ استيراد %d صور</item>
|
||||
<item quantity="many">جارٍ استيراد %d صورة</item>
|
||||
<item quantity="other">جارٍ استيراد %d صورة</item>
|
||||
</plurals>
|
||||
<plurals name="page_count">
|
||||
<item quantity="zero">لا صفحات</item>
|
||||
<item quantity="one">%d صفحة</item>
|
||||
<item quantity="two">%d صفحتان</item>
|
||||
<item quantity="few">%d صفحات</item>
|
||||
<item quantity="many">%d صفحة</item>
|
||||
<item quantity="other">%d صفحة</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
@@ -2,34 +2,49 @@
|
||||
<string name="about">O aplikaci</string>
|
||||
<string name="add_page">Přidat stránku</string>
|
||||
<string name="app_tagline">Jednoduchá a respektující aplikace pro skenování vašich dokumentů</string>
|
||||
<string name="apply">Použít</string>
|
||||
<string name="back">Zpět</string>
|
||||
<string name="camera_permission_denied">Byl odepřen přístup k fotoaparátu</string>
|
||||
<string name="camera_permission_rationale">Aby bylo možné skenovat dokumenty, tato aplikace potřebuje přístup k fotoaparátu. Nasnímané obrázky jsou ukládány pouze do tohoto zařízení a budou smazány, když zavřete stávající dokument.</string>
|
||||
<string name="camera_permission_rationale">Aby bylo možné skenovat dokumenty, tato aplikace potřebuje přístup k fotoaparátu. Nasnímané obrázky jsou ukládány pouze do tohoto zařízení a budou smazány, když ukončíte aktuální sken.</string>
|
||||
<string name="cancel">Zrušit</string>
|
||||
<string name="change_directory">Změnit složku</string>
|
||||
<string name="clear_text">Smazat text</string>
|
||||
<string name="close">Zavřít</string>
|
||||
<string name="color_mode">Filtr</string>
|
||||
<string name="color_mode_auto">Automaticky</string>
|
||||
<string name="color_mode_color">Barva</string>
|
||||
<string name="color_mode_default">Výchozí filtr</string>
|
||||
<string name="color_mode_grayscale">Odstíny šedi</string>
|
||||
<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_export">Příprava exportu…</string>
|
||||
<string name="crop">Oříznout</string>
|
||||
<string name="delete">Smazat</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>
|
||||
<string name="discard_scan">Zrušit skenování</string>
|
||||
<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_context_folder">Složka exportu: %1$s</string>
|
||||
<string name="error_context_provider">Poskytovatel: %1$s</string>
|
||||
<string name="error_export_dir_permission_lost">Vybraná složka pro export již není dostupná. Vyberte prosím jinou složku.</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_occurred">Došlo k chybě</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_folder_permission_lost">Ztracený přístup ke složce</string>
|
||||
<string name="export_format">Formát exportu</string>
|
||||
<string name="export_quality">Kvalita exportu</string>
|
||||
<string name="export_quality_low">Nízká</string>
|
||||
<string name="export_quality_balanced">Vyvážená</string>
|
||||
<string name="export_quality_high">Vysoká</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>
|
||||
<string name="import_photos">Import</string>
|
||||
<string name="libraries">Kníhovny</string>
|
||||
<string name="libraries_open_source">Open-source knihovny</string>
|
||||
<string name="license">Licence</string>
|
||||
@@ -39,14 +54,32 @@
|
||||
<string name="open">Otevřít</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="rotate_left">Otočit doleva</string>
|
||||
<string name="rotate_right">Otočit doprava</string>
|
||||
<string name="save">Uložit</string>
|
||||
<string name="scan_button">Nové skenování</string>
|
||||
<string name="scan_in_progress">Probíhá skenování</string>
|
||||
<string name="scan_new">Nové skenování</string>
|
||||
<string name="scan_current">Aktuální sken</string>
|
||||
<string name="scan_discard_current">Zahodit aktuální sken</string>
|
||||
<string name="scan_resume">Pokračovat ve skenování</string>
|
||||
<string name="settings">Nastavení</string>
|
||||
<string name="settings_ocr_download_error">Stahování se nezdařilo. Zkontrolujte připojení k internetu a ujistěte se, že má tato aplikace povolený přístup k síti.</string>
|
||||
<string name="settings_ocr_download_intro">Jazykové soubory jsou stahovány z oficiálního projektu Tesseract. Rozpoznávání textu zůstává zcela offline.</string>
|
||||
<string name="settings_ocr_downloading">Stahování jazykového souboru</string>
|
||||
<string name="settings_ocr_languages">Jazyky</string>
|
||||
<string name="settings_ocr_languages_available">Dostupné jazyky</string>
|
||||
<string name="settings_ocr_languages_disabled">Vypnuto</string>
|
||||
<string name="settings_ocr_languages_installed">Nainstalované</string>
|
||||
<string name="settings_ocr_suggested">Doporučeno</string>
|
||||
<string name="settings_section_ocr">Rozpoznávání textu</string>
|
||||
<string name="settings_section_scan">Skenování</string>
|
||||
<string name="settings_section_export">Export</string>
|
||||
<string name="share">Sdílet</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="support">Podpora</string>
|
||||
<string name="support_last_image">Nahlásit problém s posledním zachyceným obrázkem</string>
|
||||
<string name="text_recognition_disabled">Rozpoznávání textu: vypnuto</string>
|
||||
<string name="text_recognition_enabled">Rozpoznávání textu: zapnuto</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>
|
||||
@@ -60,6 +93,12 @@
|
||||
<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="importing_photos">
|
||||
<item quantity="one">Importování 1 fotografie</item>
|
||||
<item quantity="few">Importování %d fotografií</item>
|
||||
<item quantity="many">Importování %d fotografií</item>
|
||||
<item quantity="other">Importování %d fotografií</item>
|
||||
</plurals>
|
||||
<plurals name="page_count">
|
||||
<item quantity="one">%d stránka</item> <!-- 1 -->
|
||||
<item quantity="few">%d stránky</item> <!-- 2–4 -->
|
||||
|
||||
@@ -2,34 +2,49 @@
|
||||
<string name="about">Über</string>
|
||||
<string name="add_page">Seite hinzufügen</string>
|
||||
<string name="app_tagline">Eine einfache und respektvolle App zum Scannen Ihrer Dokumente.</string>
|
||||
<string name="apply">Anwenden</string>
|
||||
<string name="back">Zurück</string>
|
||||
<string name="camera_permission_denied">Kamerazugriff wurde verweigert</string>
|
||||
<string name="camera_permission_rationale">Die App benötigt Zugriff auf die Kamera, um Dokumente zu scannen. Aufgenommene Bilder werden nur auf diesem Gerät gespeichert und beim Schließen des aktuellen Dokuments gelöscht.</string>
|
||||
<string name="camera_permission_rationale">Die App benötigt Zugriff auf die Kamera, um Dokumente zu scannen. Aufgenommene Bilder werden nur auf diesem Gerät gespeichert und beim Schließen des aktuellen Scans gelöscht.</string>
|
||||
<string name="cancel">Abbrechen</string>
|
||||
<string name="change_directory">Ordner ändern</string>
|
||||
<string name="clear_text">Text löschen</string>
|
||||
<string name="close">Schließen</string>
|
||||
<string name="color_mode">Filter</string>
|
||||
<string name="color_mode_auto">Automatisch</string>
|
||||
<string name="color_mode_color">Farbe</string>
|
||||
<string name="color_mode_default">Standardfilter</string>
|
||||
<string name="color_mode_grayscale">Graustufen</string>
|
||||
<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_export">Export wird vorbereitet…</string>
|
||||
<string name="crop">Zuschneiden</string>
|
||||
<string name="delete">Löschen</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>
|
||||
<string name="discard_scan">Löschen</string>
|
||||
<string name="download_dirname">Downloads</string>
|
||||
<string name="end_scan">Scan beenden</string>
|
||||
<string name="error">Fehler: %1$s</string>
|
||||
<string name="error_context_folder">Exportordner: %1$s</string>
|
||||
<string name="error_context_provider">Anbieter: %1$s</string>
|
||||
<string name="error_export_dir_permission_lost">Der ausgewählte Exportordner ist nicht mehr zugänglich. Bitte wählen Sie einen anderen Ordner.</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_occurred">Ein Fehler ist aufgetreten</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_folder_permission_lost">Zugriff auf Ordner verloren</string>
|
||||
<string name="export_format">Exportformat</string>
|
||||
<string name="export_quality">Exportqualität</string>
|
||||
<string name="export_quality_low">Niedrig</string>
|
||||
<string name="export_quality_balanced">Ausgewogen</string>
|
||||
<string name="export_quality_high">Hoch</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>
|
||||
<string name="import_photos">Importieren</string>
|
||||
<string name="libraries">Bibliotheken</string>
|
||||
<string name="libraries_open_source">Open-Source-Bibliotheken</string>
|
||||
<string name="license">Lizenz</string>
|
||||
@@ -39,14 +54,32 @@
|
||||
<string name="open">Öffnen</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="rotate_left">Nach links drehen</string>
|
||||
<string name="rotate_right">Nach rechts drehen</string>
|
||||
<string name="save">Speichern</string>
|
||||
<string name="scan_button">Neuer Scan</string>
|
||||
<string name="scan_in_progress">Scan läuft</string>
|
||||
<string name="scan_new">Neuer Scan</string>
|
||||
<string name="scan_current">Aktueller Scan</string>
|
||||
<string name="scan_discard_current">Aktuellen Scan verwerfen</string>
|
||||
<string name="scan_resume">Scan fortsetzen</string>
|
||||
<string name="settings">Einstellungen</string>
|
||||
<string name="settings_ocr_download_error">Download fehlgeschlagen. Überprüfen Sie Ihre Internetverbindung und stellen Sie sicher, dass der Netzwerkzugriff für diese App erlaubt ist.</string>
|
||||
<string name="settings_ocr_download_intro">Sprachdateien werden vom offiziellen Tesseract-Projekt heruntergeladen. Die Texterkennung bleibt vollständig offline.</string>
|
||||
<string name="settings_ocr_downloading">Sprachdatei wird heruntergeladen</string>
|
||||
<string name="settings_ocr_languages">Sprachen</string>
|
||||
<string name="settings_ocr_languages_available">Verfügbare Sprachen</string>
|
||||
<string name="settings_ocr_languages_disabled">Deaktiviert</string>
|
||||
<string name="settings_ocr_languages_installed">Installiert</string>
|
||||
<string name="settings_ocr_suggested">Empfohlen</string>
|
||||
<string name="settings_section_ocr">Texterkennung</string>
|
||||
<string name="settings_section_scan">Scan</string>
|
||||
<string name="settings_section_export">Export</string>
|
||||
<string name="share">Teilen</string>
|
||||
<string name="share_document">Dokument teilen</string>
|
||||
<string name="storage_permission_denied">Datei kann nicht gespeichert werden: Berechtigung verweigert</string>
|
||||
<string name="support">Support</string>
|
||||
<string name="support_last_image">Problem mit dem zuletzt aufgenommenen Bild melden</string>
|
||||
<string name="text_recognition_disabled">Texterkennung: deaktiviert</string>
|
||||
<string name="text_recognition_enabled">Texterkennung: aktiviert</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>
|
||||
@@ -58,6 +91,10 @@
|
||||
<item quantity="one">%2$s gespeichert in %3$s</item>
|
||||
<item quantity="other">%1$d Dateien gespeichert in %3$s</item>
|
||||
</plurals>
|
||||
<plurals name="importing_photos">
|
||||
<item quantity="one">1 Foto wird importiert</item>
|
||||
<item quantity="other">%d Fotos werden importiert</item>
|
||||
</plurals>
|
||||
<plurals name="page_count">
|
||||
<item quantity="one">%d Seite</item>
|
||||
<item quantity="other">%d Seiten</item>
|
||||
|
||||
@@ -2,34 +2,49 @@
|
||||
<string name="about">Acerca de</string>
|
||||
<string name="add_page">Agregar página</string>
|
||||
<string name="app_tagline">Una aplicación sencilla y respetuosa para escanear tus documentos.</string>
|
||||
<string name="apply">Aplicar</string>
|
||||
<string name="back">Atrás</string>
|
||||
<string name="camera_permission_denied">Permiso de cámara denegado</string>
|
||||
<string name="camera_permission_rationale">La aplicación necesita acceso a la cámara para escanear documentos. Las imágenes capturadas se guardan solo en este dispositivo y se eliminarán cuando cierres el documento actual.</string>
|
||||
<string name="camera_permission_rationale">La aplicación necesita acceso a la cámara para escanear documentos. Las imágenes capturadas se guardan solo en este dispositivo y se eliminarán cuando cierres el escaneo actual.</string>
|
||||
<string name="cancel">Cancelar</string>
|
||||
<string name="change_directory">Cambiar carpeta</string>
|
||||
<string name="clear_text">Borrar texto</string>
|
||||
<string name="close">Cerrar</string>
|
||||
<string name="color_mode">Filtro</string>
|
||||
<string name="color_mode_auto">Automático</string>
|
||||
<string name="color_mode_color">Color</string>
|
||||
<string name="color_mode_default">Filtro predeterminado</string>
|
||||
<string name="color_mode_grayscale">Escala de grises</string>
|
||||
<string name="contact">Contacto</string>
|
||||
<string name="copied_logs">Registros copiados al portapapeles</string>
|
||||
<string name="copy_logs">Copiar registros</string>
|
||||
<string name="creating_export">Preparando la exportación…</string>
|
||||
<string name="crop">Recortar</string>
|
||||
<string name="delete">Eliminar</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>
|
||||
<string name="discard_scan">Descartar escaneo</string>
|
||||
<string name="download_dirname">Descargas</string>
|
||||
<string name="end_scan">Finalizar escaneo</string>
|
||||
<string name="error">Error: %1$s</string>
|
||||
<string name="error_context_folder">Carpeta de exportación: %1$s</string>
|
||||
<string name="error_context_provider">Proveedor: %1$s</string>
|
||||
<string name="error_export_dir_permission_lost">La carpeta de exportación seleccionada ya no es accesible. Por favor, elija otra carpeta.</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_occurred">Se produjo un error</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_folder_permission_lost">Acceso a la carpeta perdido</string>
|
||||
<string name="export_format">Formato de exportación</string>
|
||||
<string name="export_quality">Calidad de exportación</string>
|
||||
<string name="export_quality_low">Baja</string>
|
||||
<string name="export_quality_balanced">Equilibrada</string>
|
||||
<string name="export_quality_high">Alta</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>
|
||||
<string name="import_photos">Importar</string>
|
||||
<string name="libraries">Bibliotecas</string>
|
||||
<string name="libraries_open_source">Bibliotecas de código abierto</string>
|
||||
<string name="license">Licencia</string>
|
||||
@@ -39,14 +54,32 @@
|
||||
<string name="open">Abrir</string>
|
||||
<string name="open_file">Abrir archivo</string>
|
||||
<string name="reset_to_default">Restablecer valores predeterminados</string>
|
||||
<string name="resume">Reanudar</string>
|
||||
<string name="rotate_left">Girar a la izquierda</string>
|
||||
<string name="rotate_right">Girar a la derecha</string>
|
||||
<string name="save">Guardar</string>
|
||||
<string name="scan_button">Nuevo escaneo</string>
|
||||
<string name="scan_in_progress">Escaneo en curso</string>
|
||||
<string name="scan_new">Nuevo escaneo</string>
|
||||
<string name="scan_current">Escaneo actual</string>
|
||||
<string name="scan_discard_current">Descartar el escaneo actual</string>
|
||||
<string name="scan_resume">Reanudar escaneo</string>
|
||||
<string name="settings">Ajustes</string>
|
||||
<string name="settings_ocr_download_error">Descarga fallida. Comprueba tu conexión a Internet y asegúrate de que esta aplicación tenga permitido el acceso a la red.</string>
|
||||
<string name="settings_ocr_download_intro">Los archivos de idioma se descargan desde el proyecto oficial Tesseract. El reconocimiento de texto sigue siendo completamente local.</string>
|
||||
<string name="settings_ocr_downloading">Descargando archivo de idioma</string>
|
||||
<string name="settings_ocr_languages">Idiomas</string>
|
||||
<string name="settings_ocr_languages_available">Idiomas disponibles</string>
|
||||
<string name="settings_ocr_languages_disabled">Desactivado</string>
|
||||
<string name="settings_ocr_languages_installed">Instalados</string>
|
||||
<string name="settings_ocr_suggested">Sugerido</string>
|
||||
<string name="settings_section_ocr">Reconocimiento de texto</string>
|
||||
<string name="settings_section_scan">Escaneo</string>
|
||||
<string name="settings_section_export">Exportación</string>
|
||||
<string name="share">Compartir</string>
|
||||
<string name="share_document">Compartir documento</string>
|
||||
<string name="storage_permission_denied">No se puede guardar el archivo: permiso denegado</string>
|
||||
<string name="support">Soporte</string>
|
||||
<string name="support_last_image">Informar de un problema con la última imagen capturada</string>
|
||||
<string name="text_recognition_disabled">Reconocimiento de texto: desactivado</string>
|
||||
<string name="text_recognition_enabled">Reconocimiento de texto: activado</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>
|
||||
@@ -58,6 +91,10 @@
|
||||
<item quantity="one">%2$s guardado en %3$s</item>
|
||||
<item quantity="other">%1$d archivos guardados en %3$s</item>
|
||||
</plurals>
|
||||
<plurals name="importing_photos" tools:ignore="MissingQuantity">
|
||||
<item quantity="one">Importando 1 foto</item>
|
||||
<item quantity="other">Importando %d fotos</item>
|
||||
</plurals>
|
||||
<plurals name="page_count" tools:ignore="MissingQuantity">
|
||||
<item quantity="one">%d página</item>
|
||||
<item quantity="other">%d páginas</item>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="about">Rakenduse teave</string>
|
||||
<string name="add_page">Lisa leht</string>
|
||||
<string name="app_tagline">Lihtsaltkasutatav ja viisakas rakendus dokumentide skaneerimiseks.</string>
|
||||
<string name="apply">Rakenda</string>
|
||||
<string name="back">Tagasi</string>
|
||||
<string name="camera_permission_denied">Kaamera kasutamise õigused on puudu</string>
|
||||
<string name="camera_permission_rationale">Et dokumentide skaneerimine toimiks, vajab see rakendus õigust kasutada kaamerat. Pildihõive käigus jäädvustatud pildid salvestuvad vaid siin selles seadmes ja kustutatakse töösoleva skaneerimise lõppedes.</string>
|
||||
<string name="cancel">Katkesta</string>
|
||||
<string name="change_directory">Muuda kausta</string>
|
||||
<string name="clear_text">Eemalda tekst</string>
|
||||
<string name="close">Sulge</string>
|
||||
<string name="color_mode">Filtreeri</string>
|
||||
<string name="color_mode_auto">Automaatne</string>
|
||||
<string name="color_mode_color">Värvid</string>
|
||||
<string name="color_mode_default">Vaikimisi filter</string>
|
||||
<string name="color_mode_grayscale">Halltoonides</string>
|
||||
<string name="contact">Kontakt</string>
|
||||
<string name="copied_logs">Logid on kopeeritud lõikelauale</string>
|
||||
<string name="copy_logs">Kopeeri logid</string>
|
||||
<string name="creating_export">Valmistun eksportima…</string>
|
||||
<string name="crop">Kadreeri</string>
|
||||
<string name="delete">Kustuta</string>
|
||||
<string name="delete_page">Lehe kustutamine</string>
|
||||
<string name="delete_page_warning">Kas kindlasti soovid selle lehe kustutada?</string>
|
||||
<string name="developer">Arendaja</string>
|
||||
<string name="download_dirname">Allalaadimised</string>
|
||||
<string name="error_context_folder">Ekspordikaust: %1$s</string>
|
||||
<string name="error_context_provider">Teenusepakkuja: %1$s</string>
|
||||
<string name="error_export_dir_permission_lost">Valitud ekspordikaust pole enam kättesaadav. Palun vali mõnu muu kaust.</string>
|
||||
<string name="error_no_app">Salle faili avamiseks ei leidu rakendust</string>
|
||||
<string name="error_occurred">Tekkis viga</string>
|
||||
<string name="error_save">Faili salvestamine ei õnnestunud</string>
|
||||
<string name="export">Ekspordi</string>
|
||||
<string name="export_as">Eksportimise vorming: %1$s</string>
|
||||
<string name="export_directory">Ekspordikaust</string>
|
||||
<string name="export_folder_permission_lost">Ligipääs kaustale on kadunud</string>
|
||||
<string name="export_format">Ekspordivorming</string>
|
||||
<string name="export_quality">Ekspordi kvaliteet</string>
|
||||
<string name="export_quality_low">Madal</string>
|
||||
<string name="export_quality_balanced">Tasakaalustatud</string>
|
||||
<string name="export_quality_high">Kõrge</string>
|
||||
<string name="file_size">Faili suurus: %1$s</string>
|
||||
<string name="file_size_total">Maht kokku: %1$s</string>
|
||||
<string name="filename">Failinimi</string>
|
||||
<string name="grant_permission">Anna vastavad õigused</string>
|
||||
<string name="import_photos">Impordi</string>
|
||||
<string name="libraries">Teegid</string>
|
||||
<string name="libraries_open_source">Avatud lähtekoodil põhinevad teegid</string>
|
||||
<string name="license">Litsents</string>
|
||||
<string name="menu">Menüü</string>
|
||||
<string name="new_document_warning">Hetkel skaneeritud sisu läheb kaotsi. Kas kindlasti tahad jätkata?</string>
|
||||
<string name="open">Ava</string>
|
||||
<string name="open_file">Ava fail</string>
|
||||
<string name="reset_to_default">Taasta algväärtused</string>
|
||||
<string name="rotate_left">Pööra vasakule</string>
|
||||
<string name="rotate_right">Pööra paremale</string>
|
||||
<string name="save">Salvesta</string>
|
||||
<string name="scan_new">Uus skaneerimine</string>
|
||||
<string name="scan_current">Praegune skaneerimine</string>
|
||||
<string name="scan_discard_current">Loobu hetkel skaneeritust</string>
|
||||
<string name="licensed_under">Käesolev rakendus on litsentseeritud GNU Üldise Avaliku Litsentsi versiooni 3.0 alusel.</string>
|
||||
<string name="scan_resume">Jätka skaneerimisega</string>
|
||||
<string name="settings">Seadistused</string>
|
||||
<string name="settings_ocr_download_error">Allalaadimine ei õnnestunud. Palun vaata, et sinu nutiseadme internetiühendus toimiks ja ligipääs võrgule on selle rakenduse jaoks lubatud.</string>
|
||||
<string name="settings_ocr_download_intro">Keelefailid on laaditud alla Tesseracti projekti ametlikust veebisaidist. Tekstituvastus toimib täies mahus ilma võrguühendust kasutamata.</string>
|
||||
<string name="settings_ocr_downloading">Keelefaili allalaadimine</string>
|
||||
<string name="settings_ocr_languages">Keeled</string>
|
||||
<string name="settings_ocr_languages_available">Saadaval keeled</string>
|
||||
<string name="settings_ocr_languages_disabled">Pole kasutusel</string>
|
||||
<string name="settings_ocr_languages_installed">Paigaldatud</string>
|
||||
<string name="settings_ocr_suggested">Meie soovitus</string>
|
||||
<string name="settings_section_ocr">Tekstituvastus</string>
|
||||
<string name="settings_section_scan">Skaneerimine</string>
|
||||
<string name="settings_section_export">Eksport</string>
|
||||
<string name="share">Jaga</string>
|
||||
<string name="share_document">Jaga dokumenti</string>
|
||||
<string name="storage_permission_denied">Faili salvestamine ei õnnestu: õigused on puudu</string>
|
||||
<string name="support">Kasutajatugi</string>
|
||||
<string name="support_last_image">Teavita viimase pildihõivega seotud veast või probleemist</string>
|
||||
<string name="text_recognition_disabled">Tekstituvastus: pole kasutusel</string>
|
||||
<string name="text_recognition_enabled">Tekstituvastus: on kasutusel</string>
|
||||
<string name="turn_off_torch">Lülita taskulamp välja</string>
|
||||
<string name="turn_on_torch">Lülita taskulamp sisse</string>
|
||||
<string name="unknown_size">Tundmatu suurus</string>
|
||||
<string name="version">Versioon</string>
|
||||
<string name="view_full_list">Vaata täismahus loendit</string>
|
||||
<string name="view_the_full_license">Vaata täismahus litsentsi</string>
|
||||
<string name="yes">Jah</string>
|
||||
<plurals name="files_saved_to">
|
||||
<item quantity="one">%2$s fail on salvestatud kausta %3$s</item>
|
||||
<item quantity="other">%1$d faili on salvestatud kausta %3$s</item>
|
||||
</plurals>
|
||||
<plurals name="importing_photos">
|
||||
<item quantity="one">Importimisel on 1 foto</item>
|
||||
<item quantity="other">Importimisel on %d fotot</item>
|
||||
</plurals>
|
||||
<plurals name="page_count">
|
||||
<item quantity="one">%d leht</item>
|
||||
<item quantity="other">%d lehte</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
@@ -1,35 +1,51 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<string name="about">À propos</string>
|
||||
<string name="add_page">Ajouter une page</string>
|
||||
<string name="app_tagline">Une application simple et respectueuse pour scanner vos documents.</string>
|
||||
<string name="apply">Appliquer</string>
|
||||
<string name="back">Retour</string>
|
||||
<string name="camera_permission_denied">L\'autorisation d\'accès à la caméra a été refusée</string>
|
||||
<string name="camera_permission_rationale">L’application a besoin d’accéder à l’appareil photo pour scanner des documents. Les images capturées sont enregistrées uniquement sur cet appareil et seront supprimées lorsque vous fermerez le document en cours.</string>
|
||||
<string name="camera_permission_rationale">L’application a besoin d’accéder à l’appareil photo pour scanner des documents. Les images capturées sont enregistrées uniquement sur cet appareil et seront supprimées lorsque vous fermerez le scan en cours.</string>
|
||||
<string name="cancel">Annuler</string>
|
||||
<string name="change_directory">Changer de dossier</string>
|
||||
<string name="clear_text">Effacer le text</string>
|
||||
<string name="clear_text">Effacer le texte</string>
|
||||
<string name="close">Fermer</string>
|
||||
<string name="color_mode">Filtre</string>
|
||||
<string name="color_mode_auto">Automatique</string>
|
||||
<string name="color_mode_color">Couleur</string>
|
||||
<string name="color_mode_default">Filtre par défaut</string>
|
||||
<string name="color_mode_grayscale">Niveaux de gris</string>
|
||||
<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_export">Préparation de l’export…</string>
|
||||
<string name="crop">Recadrer</string>
|
||||
<string name="delete">Supprimer</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>
|
||||
<string name="discard_scan">Supprimer le scan</string>
|
||||
<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_context_folder">Dossier d’export : %1$s</string>
|
||||
<string name="error_context_provider">Fournisseur : %1$s</string>
|
||||
<string name="error_export_dir_permission_lost">Le dossier d’export sélectionné n’est plus accessible. Veuillez choisir un autre dossier.</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_occurred">Une erreur s’est produite</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 d’export</string>
|
||||
<string name="export_folder_permission_lost">Accès au dossier perdu</string>
|
||||
<string name="export_format">Format d’export</string>
|
||||
<string name="export_quality">Qualité d’export</string>
|
||||
<string name="export_quality_low">Basse</string>
|
||||
<string name="export_quality_balanced">Équilibrée</string>
|
||||
<string name="export_quality_high">Élevée</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 l’appareil :</string>
|
||||
<string name="import_photos">Importer</string>
|
||||
<string name="libraries">Bibliothèques</string>
|
||||
<string name="libraries_open_source">Bibliothèques open source</string>
|
||||
<string name="license">Licence</string>
|
||||
@@ -39,14 +55,32 @@
|
||||
<string name="open">Ouvrir</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="rotate_left">Pivoter vers la gauche</string>
|
||||
<string name="rotate_right">Pivoter vers la droite</string>
|
||||
<string name="save">Enregistrer</string>
|
||||
<string name="scan_button">Nouveau scan</string>
|
||||
<string name="scan_in_progress">Scan en cours</string>
|
||||
<string name="scan_new">Nouveau scan</string>
|
||||
<string name="scan_current">Scan en cours</string>
|
||||
<string name="scan_discard_current">Abandonner le scan en cours</string>
|
||||
<string name="scan_resume">Reprendre le scan</string>
|
||||
<string name="settings">Paramètres</string>
|
||||
<string name="settings_ocr_download_error">Échec du téléchargement. Vérifiez votre connexion Internet et assurez-vous que l\'accès au réseau est autorisé pour cette application.</string>
|
||||
<string name="settings_ocr_download_intro">Les fichiers de langue sont téléchargés depuis le projet officiel Tesseract. La reconnaissance de texte reste entièrement hors ligne.</string>
|
||||
<string name="settings_ocr_downloading">Téléchargement du fichier de langue</string>
|
||||
<string name="settings_ocr_languages">Langues</string>
|
||||
<string name="settings_ocr_languages_available">Langues disponibles</string>
|
||||
<string name="settings_ocr_languages_disabled">Désactivé</string>
|
||||
<string name="settings_ocr_languages_installed">Installées</string>
|
||||
<string name="settings_ocr_suggested">Suggéré</string>
|
||||
<string name="settings_section_ocr">Reconnaissance de texte</string>
|
||||
<string name="settings_section_scan">Scan</string>
|
||||
<string name="settings_section_export">Export</string>
|
||||
<string name="share">Partager</string>
|
||||
<string name="share_document">Partager le document</string>
|
||||
<string name="storage_permission_denied">Impossible d’enregistrer le fichier : permission refusée</string>
|
||||
<string name="support">Support</string>
|
||||
<string name="support_last_image">Signaler un problème avec la dernière image capturée</string>
|
||||
<string name="text_recognition_disabled">Reconnaissance de texte : désactivée</string>
|
||||
<string name="text_recognition_enabled">Reconnaissance de texte : activé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>
|
||||
@@ -58,6 +92,10 @@
|
||||
<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="importing_photos" tools:ignore="MissingQuantity">
|
||||
<item quantity="one">Import de %d photo</item>
|
||||
<item quantity="other">Import de %d photos</item>
|
||||
</plurals>
|
||||
<plurals name="page_count" tools:ignore="MissingQuantity">
|
||||
<item quantity="one">%d page</item>
|
||||
<item quantity="other">%d pages</item>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<resources>
|
||||
<string name="about">Sobre</string>
|
||||
<string name="add_page">Engadir páxina</string>
|
||||
<string name="app_tagline">Unha aplicación sinxela e respectuosa para escanear os teus documentos.</string>
|
||||
<string name="apply">Aplicar</string>
|
||||
<string name="back">Atrás</string>
|
||||
<string name="camera_permission_denied">Permiso da cámara denegado</string>
|
||||
<string name="camera_permission_rationale">A aplicación precisa acceso á cámara para escanear documentos. As imaxes capturadas gárdanse só neste dispositivo e eliminaranse ao pechar o escaneo actual.</string>
|
||||
<string name="cancel">Cancelar</string>
|
||||
<string name="change_directory">Trocar cartafol</string>
|
||||
<string name="clear_text">Borrar texto</string>
|
||||
<string name="close">Pechar</string>
|
||||
<string name="color_mode">Filtro</string>
|
||||
<string name="color_mode_auto">Automático</string>
|
||||
<string name="color_mode_color">Cor</string>
|
||||
<string name="color_mode_default">Filtro predeterminado</string>
|
||||
<string name="color_mode_grayscale">Escala de grises</string>
|
||||
<string name="contact">Contacto</string>
|
||||
<string name="copied_logs">Rexistros copiados ao portapapeis</string>
|
||||
<string name="copy_logs">Copiar rexistros</string>
|
||||
<string name="creating_export">Preparando exportación…</string>
|
||||
<string name="crop">Recortar</string>
|
||||
<string name="delete">Eliminar</string>
|
||||
<string name="delete_page">Eliminar páxina</string>
|
||||
<string name="delete_page_warning">Queres eliminar esta páxina?</string>
|
||||
<string name="developer">Desenvolvedor</string>
|
||||
<string name="download_dirname">Descargas</string>
|
||||
<string name="error_context_folder">Cartafol de exportación: %1$s</string>
|
||||
<string name="error_context_provider">Provedor: %1$s</string>
|
||||
<string name="error_export_dir_permission_lost">O cartafol de exportación seleccionado xa non é accesible. Escolle outro cartafol.</string>
|
||||
<string name="error_no_app">Non se atopou ningunha aplicación para abrir este ficheiro</string>
|
||||
<string name="error_occurred">Produciuse un erro</string>
|
||||
<string name="error_save">Produciuse un erro ao gardar o ficheiro</string>
|
||||
<string name="export">Exportar</string>
|
||||
<string name="export_as">Exportar como %1$s</string>
|
||||
<string name="export_directory">Cartafol de exportación</string>
|
||||
<string name="export_folder_permission_lost">Perdeuse o acceso ao cartafol</string>
|
||||
<string name="export_format">Formato de exportación</string>
|
||||
<string name="export_quality">Calidade de exportación</string>
|
||||
<string name="export_quality_low">Baixa</string>
|
||||
<string name="export_quality_balanced">Equilibrada</string>
|
||||
<string name="export_quality_high">Alta</string>
|
||||
<string name="file_size">Tamaño do ficheiro: %1$s</string>
|
||||
<string name="file_size_total">Tamaño total: %1$s</string>
|
||||
<string name="filename">Nome do ficheiro</string>
|
||||
<string name="grant_permission">Conceder permiso</string>
|
||||
<string name="import_photos">Importar</string>
|
||||
<string name="libraries">Bibliotecas</string>
|
||||
<string name="libraries_open_source">Bibliotecas de código aberto</string>
|
||||
<string name="license">Licenza</string>
|
||||
<string name="licensed_under">Esta aplicación está licenciada baixo a GNU General Public License v3.0.</string>
|
||||
<string name="menu">Menú</string>
|
||||
<string name="new_document_warning">O escaneo actual perderase. Queres continuar?</string>
|
||||
<string name="open">Abrir</string>
|
||||
<string name="open_file">Abrir ficheiro</string>
|
||||
<string name="reset_to_default">Restablecer valores predeterminados</string>
|
||||
<string name="rotate_left">Xirar á esquerda</string>
|
||||
<string name="rotate_right">Xirar á dereita</string>
|
||||
<string name="save">Gardar</string>
|
||||
<string name="scan_new">Novo escaneo</string>
|
||||
<string name="scan_current">Escaneo actual</string>
|
||||
<string name="scan_discard_current">Descartar o escaneo actual</string>
|
||||
<string name="scan_resume">Retomar escaneo</string>
|
||||
<string name="settings">Configuración</string>
|
||||
<string name="settings_ocr_download_error">Produciuse un erro na descarga. Comproba a túa conexión á Internet e asegúrate de que esta aplicación ten permitido o acceso á rede.</string>
|
||||
<string name="settings_ocr_download_intro">Os ficheiros de idioma descárganse desde o proxecto oficial Tesseract. O recoñecemento de texto segue sendo completamente sen conexión.</string>
|
||||
<string name="settings_ocr_downloading">Descargando ficheiro de idioma</string>
|
||||
<string name="settings_ocr_languages">Idiomas</string>
|
||||
<string name="settings_ocr_languages_available">Idiomas dispoñibles</string>
|
||||
<string name="settings_ocr_languages_disabled">Desactivado</string>
|
||||
<string name="settings_ocr_languages_installed">Instalados</string>
|
||||
<string name="settings_ocr_suggested">Suxerido</string>
|
||||
<string name="settings_section_ocr">Recoñecemento de texto</string>
|
||||
<string name="settings_section_scan">Escaneo</string>
|
||||
<string name="settings_section_export">Exportación</string>
|
||||
<string name="share">Compartir</string>
|
||||
<string name="share_document">Compartir documento</string>
|
||||
<string name="storage_permission_denied">Non se pode gardar o ficheiro: permiso denegado</string>
|
||||
<string name="support">Soporte</string>
|
||||
<string name="support_last_image">Informar dun problema coa derradeira imaxe capturada</string>
|
||||
<string name="text_recognition_disabled">Recoñecemento de texto: desactivado</string>
|
||||
<string name="text_recognition_enabled">Recoñecemento de texto: activado</string>
|
||||
<string name="turn_off_torch">Apagar lanterna</string>
|
||||
<string name="turn_on_torch">Acender lanterna</string>
|
||||
<string name="unknown_size">Tamaño descoñecido</string>
|
||||
<string name="version">Versión</string>
|
||||
<string name="view_full_list">Ver lista completa</string>
|
||||
<string name="view_the_full_license">Ver a licenza completa</string>
|
||||
<string name="yes">Si</string>
|
||||
<plurals name="files_saved_to">
|
||||
<item quantity="one">%2$s gardado en %3$s</item>
|
||||
<item quantity="other">%1$d ficheiros gardados en %3$s</item>
|
||||
</plurals>
|
||||
<plurals name="importing_photos">
|
||||
<item quantity="one">Importando 1 foto</item>
|
||||
<item quantity="other">Importando %d fotos</item>
|
||||
</plurals>
|
||||
<plurals name="page_count">
|
||||
<item quantity="one">%d páxina</item>
|
||||
<item quantity="other">%d páxinas</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
@@ -0,0 +1,102 @@
|
||||
<resources>
|
||||
<string name="about">Névjegy</string>
|
||||
<string name="add_page">Új oldal</string>
|
||||
<string name="app_tagline">Egy egyszerű, a felhasználót tiszteletben tartó dokumentum szkenner alkalmazás.</string>
|
||||
<string name="apply">Alkalmaz</string>
|
||||
<string name="back">Vissza</string>
|
||||
<string name="camera_permission_denied">Kamerahozzáférés megtagadva</string>
|
||||
<string name="camera_permission_rationale">Az alkalmazásnak a dokumentumok beolvasásához kamerahozzáférésre van szüksége. A rögzített képek csak ezen az eszközön kerülnek tárolásra, és törlődnek, amikor bezárod a folyamatban lévő szkennelést.</string>
|
||||
<string name="cancel">Mégse</string>
|
||||
<string name="change_directory">Mappaválasztás</string>
|
||||
<string name="clear_text">Szöveg törlése</string>
|
||||
<string name="close">Bezárás</string>
|
||||
<string name="color_mode">Színszűrő</string>
|
||||
<string name="color_mode_auto">Automatikus</string>
|
||||
<string name="color_mode_color">Színes</string>
|
||||
<string name="color_mode_default">Alapértelmezett színszűrő</string>
|
||||
<string name="color_mode_grayscale">Szürkeárnyalatos</string>
|
||||
<string name="contact">Kapcsolat</string>
|
||||
<string name="copied_logs">Napló vágólapra másolva</string>
|
||||
<string name="copy_logs">Napló másolása</string>
|
||||
<string name="creating_export">Dokumentum előkészítése…</string>
|
||||
<string name="crop">Levágás</string>
|
||||
<string name="delete">Törlés</string>
|
||||
<string name="delete_page">Oldal törlése</string>
|
||||
<string name="delete_page_warning">Törölni szeretnéd ezt az oldalt?</string>
|
||||
<string name="developer">Fejlesztő</string>
|
||||
<string name="download_dirname">Letöltések</string>
|
||||
<string name="error_context_folder">Exportálási mappa: %1$s</string>
|
||||
<string name="error_context_provider">Szolgáltató: %1$s</string>
|
||||
<string name="error_export_dir_permission_lost">A kijelölt exportálási mappa már nem elérhető. Kérlek válassz másik mappát.</string>
|
||||
<string name="error_no_app">Nem található alkalmazás a fájl megnyitásához</string>
|
||||
<string name="error_occurred">Hiba történt</string>
|
||||
<string name="error_save">Fájl mentése sikertelen</string>
|
||||
<string name="export">Exportálás</string>
|
||||
<string name="export_as">%1$s exportálása</string>
|
||||
<string name="export_directory">Exportálás helye</string>
|
||||
<string name="export_folder_permission_lost">Mappahozzáférés elveszett</string>
|
||||
<string name="export_format">Formátum</string>
|
||||
<string name="export_quality">Minőség</string>
|
||||
<string name="export_quality_low">Alacsony</string>
|
||||
<string name="export_quality_balanced">Kiegyensúlyozott</string>
|
||||
<string name="export_quality_high">Magas</string>
|
||||
<string name="file_size">Fájlméret: %1$s</string>
|
||||
<string name="file_size_total">Teljes méret: %1$s</string>
|
||||
<string name="filename">Fájlnév</string>
|
||||
<string name="grant_permission">Engedélyezés</string>
|
||||
<string name="import_photos">Importálás</string>
|
||||
<string name="libraries">Programkönyvtárak</string>
|
||||
<string name="libraries_open_source">Nyílt forráskódú programkönyvtárak</string>
|
||||
<string name="license">Licenc</string>
|
||||
<string name="licensed_under">Ez az alkalmazás a GNU General Public License v3.0 (GPLv3) licenc alatt érhető el.</string>
|
||||
<string name="menu">Menü</string>
|
||||
<string name="new_document_warning">A jelenlegi szkennelés el fog veszni. Biztosan folytatod?</string>
|
||||
<string name="open">Megnyitás</string>
|
||||
<string name="open_file">Fájl megnyitása</string>
|
||||
<string name="reset_to_default">Alapértelmezés visszaállítása</string>
|
||||
<string name="rotate_left">Forgatás balra</string>
|
||||
<string name="rotate_right">Forgatás jobbra</string>
|
||||
<string name="save">Mentés</string>
|
||||
<string name="scan_new">Új szkennelés</string>
|
||||
<string name="scan_current">Folyamatban lévő szkennelés</string>
|
||||
<string name="scan_discard_current">A folyamatban lévő szkennelés elvetése</string>
|
||||
<string name="scan_resume">Szkennelés folytatása</string>
|
||||
<string name="settings">Beállítások</string>
|
||||
<string name="settings_ocr_download_error">A letöltés sikertelen. Ellenőrizd az internetkapcsolatodat, valamint hogy a hálózati hozzáférés engedélyezett-e az alkalmazás számára.</string>
|
||||
<string name="settings_ocr_download_intro">"A nyelvi fájlok a hivatalos Tesseract projektből kerülnek letöltésre. A szövegfelismerés ezt követően teljes mértékben offline történik."</string>
|
||||
<string name="settings_ocr_downloading">Nyelvi fájl letöltése</string>
|
||||
<string name="settings_ocr_languages">Nyelvek</string>
|
||||
<string name="settings_ocr_languages_available">Elérhető nyelvek</string>
|
||||
<string name="settings_ocr_languages_disabled">Kikapcsolva</string>
|
||||
<string name="settings_ocr_languages_installed">Telepítve</string>
|
||||
<string name="settings_ocr_suggested">Ajánlott</string>
|
||||
<string name="settings_section_ocr">Szövegfelismerés</string>
|
||||
<string name="settings_section_scan">Szkennelés</string>
|
||||
<string name="settings_section_export">Exportálás</string>
|
||||
<string name="share">Megosztás</string>
|
||||
<string name="share_document">Dokumentum megosztása</string>
|
||||
<string name="storage_permission_denied">Fájl mentése sikertelen: engedély megtagadva</string>
|
||||
<string name="support">Támogatás</string>
|
||||
<string name="support_last_image">A legutóbb rögzített képpel kapcsolatos probléma jelentése</string>
|
||||
<string name="text_recognition_disabled">Szövegfelismerés: kikapcsolva</string>
|
||||
<string name="text_recognition_enabled">Szövegfelismerés: bekapcsolva</string>
|
||||
<string name="turn_off_torch">Zseblámpa kikapcsolása</string>
|
||||
<string name="turn_on_torch">Zseblámpa bekapcsolása</string>
|
||||
<string name="unknown_size">Ismeretlen méret</string>
|
||||
<string name="version">Verzió</string>
|
||||
<string name="view_full_list">Teljes lista megtekintése</string>
|
||||
<string name="view_the_full_license">Teljes licenc megtekintése</string>
|
||||
<string name="yes">Igen</string>
|
||||
<plurals name="files_saved_to">
|
||||
<item quantity="one">%2$s mentve ide: %3$s</item>
|
||||
<item quantity="other">%1$d fájl mentve ide: %3$s</item>
|
||||
</plurals>
|
||||
<plurals name="importing_photos">
|
||||
<item quantity="one">%d fénykép importálása</item>
|
||||
<item quantity="other">%d fénykép importálása</item>
|
||||
</plurals>
|
||||
<plurals name="page_count">
|
||||
<item quantity="one">%d oldal</item>
|
||||
<item quantity="other">%d oldal</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
@@ -0,0 +1,102 @@
|
||||
<resources>
|
||||
<string name="about">Tentang</string>
|
||||
<string name="add_page">Tambahkan halaman</string>
|
||||
<string name="app_tagline">Aplikasi sederhana dan aman untuk memindai dokumen Anda</string>
|
||||
<string name="apply">Terapkan</string>
|
||||
<string name="back">Kembali</string>
|
||||
<string name="camera_permission_denied">Izin penggunaan kamera ditolak</string>
|
||||
<string name="camera_permission_rationale">Aplikasi ini memerlukan akses kamera untuk memindai dokumen. Gambar yang diambil hanya disimpan di perangkat ini dan akan dihapus saat Anda menutup pemindaian saat ini.</string>
|
||||
<string name="cancel">Batalkan</string>
|
||||
<string name="change_directory">Ganti folder</string>
|
||||
<string name="clear_text">Bersihkan teks</string>
|
||||
<string name="close">Tutup</string>
|
||||
<string name="color_mode">Filter</string>
|
||||
<string name="color_mode_auto">Otomatis</string>
|
||||
<string name="color_mode_color">Warna</string>
|
||||
<string name="color_mode_default">Filter bawaan</string>
|
||||
<string name="color_mode_grayscale">Abu-abu</string>
|
||||
<string name="contact">Kontak</string>
|
||||
<string name="copied_logs">Log disalin ke papan klip</string>
|
||||
<string name="copy_logs">Salin log</string>
|
||||
<string name="creating_export">Mempersiapkan ekspor…</string>
|
||||
<string name="crop">Potong</string>
|
||||
<string name="delete">Hapus</string>
|
||||
<string name="delete_page">Hapus halaman</string>
|
||||
<string name="delete_page_warning">Apakah Anda ingin menghapus halaman ini?</string>
|
||||
<string name="developer">Pengembang</string>
|
||||
<string name="download_dirname">Undugan</string>
|
||||
<string name="error_context_folder">Folder ekspor %1$s</string>
|
||||
<string name="error_context_provider">Penyedia: %1$s</string>
|
||||
<string name="error_export_dir_permission_lost">Folder ekspor yang dipilih tidak lagi dapat diakses. Silakan pilih folder lain.</string>
|
||||
<string name="error_no_app">Tidak ditemukan aplikasi untuk membuka file ini.</string>
|
||||
<string name="error_occurred">Terjadi kesalahan</string>
|
||||
<string name="error_save">Gagal menyimpan file</string>
|
||||
<string name="export">Ekspor</string>
|
||||
<string name="export_as">Ekspor sebagai %1$s</string>
|
||||
<string name="export_directory">Folder ekspor</string>
|
||||
<string name="export_folder_permission_lost">Akses folder hilang</string>
|
||||
<string name="export_format">Format ekspor</string>
|
||||
<string name="export_quality">Kualitas ekspor</string>
|
||||
<string name="export_quality_low">Rendah</string>
|
||||
<string name="export_quality_balanced">Sedang</string>
|
||||
<string name="export_quality_high">Tinggi</string>
|
||||
<string name="file_size">Ukuran file: %1$s</string>
|
||||
<string name="file_size_total">Ukuran total: %1$s</string>
|
||||
<string name="filename">Nama file</string>
|
||||
<string name="grant_permission">Berikan izin</string>
|
||||
<string name="import_photos">Impor</string>
|
||||
<string name="libraries">Perpustakaan</string>
|
||||
<string name="libraries_open_source">Pustaka sumber terbuka</string>
|
||||
<string name="license">Lisensi</string>
|
||||
<string name="licensed_under">Aplikasi ini dilisensikan berdasarkan Lisensi Publik Umum GNU v3.0.</string>
|
||||
<string name="menu">Menu</string>
|
||||
<string name="new_document_warning">Pemindaian saat ini akan hilang. Apakah Anda ingin melanjutkan?</string>
|
||||
<string name="open">Buka</string>
|
||||
<string name="open_file">Buka file</string>
|
||||
<string name="reset_to_default">Kembalikan ke bawaan</string>
|
||||
<string name="rotate_left">Putar ke kiri</string>
|
||||
<string name="rotate_right">Putar ke kanan</string>
|
||||
<string name="save">Simpan</string>
|
||||
<string name="scan_new">Pindai baru</string>
|
||||
<string name="scan_current">Pindaian terkini</string>
|
||||
<string name="scan_discard_current">Abaikan pemindaian saat ini.</string>
|
||||
<string name="scan_resume">Ulangi pemindaian</string>
|
||||
<string name="settings">Pengaturan</string>
|
||||
<string name="settings_ocr_download_error">Pengunduhan gagal. Periksa koneksi internet Anda dan pastikan akses jaringan diizinkan untuk aplikasi ini.</string>
|
||||
<string name="settings_ocr_download_intro">"Berkas bahasa diunduh dari proyek resmi Tesseract. Pengenalan teks tetap sepenuhnya offline."</string>
|
||||
<string name="settings_ocr_downloading">Mengunduh berkas bahasa</string>
|
||||
<string name="settings_ocr_languages">Bahasa</string>
|
||||
<string name="settings_ocr_languages_available">Bahasa yang tersedia</string>
|
||||
<string name="settings_ocr_languages_disabled">Matikan</string>
|
||||
<string name="settings_ocr_languages_installed">Terpasang</string>
|
||||
<string name="settings_ocr_suggested">Disarankan</string>
|
||||
<string name="settings_section_ocr">Pengenalan teks</string>
|
||||
<string name="settings_section_scan">Pindah</string>
|
||||
<string name="settings_section_export">Ekspor</string>
|
||||
<string name="share">Bagikan</string>
|
||||
<string name="share_document">Bagikan dokumen</string>
|
||||
<string name="storage_permission_denied">Tidak dapat menyimpan file: izin ditolak.</string>
|
||||
<string name="support">Dukung</string>
|
||||
<string name="support_last_image">Laporkan masalah pada gambar yang terakhir diambil</string>
|
||||
<string name="text_recognition_disabled">Pengenalan teks: matikan</string>
|
||||
<string name="text_recognition_enabled">Pengenalan teks: diaktifkan</string>
|
||||
<string name="turn_off_torch">Matikan senter</string>
|
||||
<string name="turn_on_torch">Hidupkan senter</string>
|
||||
<string name="unknown_size">Ukuran tidak diketahui</string>
|
||||
<string name="version">Versi</string>
|
||||
<string name="view_full_list">Lihat daftar lengkap</string>
|
||||
<string name="view_the_full_license">Lihat lisensi lengkapnya</string>
|
||||
<string name="yes">Yes</string>
|
||||
<plurals name="files_saved_to">
|
||||
<item quantity="one">%2$s disimpan ke %3$s</item>
|
||||
<item quantity="other">%1$d berkas yang disimpan ke %3$s</item>
|
||||
</plurals>
|
||||
<plurals name="importing_photos">
|
||||
<item quantity="one">Impor 1 foto</item>
|
||||
<item quantity="other">Mengimpor %d foto</item>
|
||||
</plurals>
|
||||
<plurals name="page_count">
|
||||
<item quantity="one">%d halaman</item>
|
||||
<item quantity="other">%d halaman</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
@@ -2,34 +2,49 @@
|
||||
<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>
|
||||
<string name="apply">Applica</string>
|
||||
<string name="back">Indietro</string>
|
||||
<string name="camera_permission_denied">Autorizzazione fotocamera negata</string>
|
||||
<string name="camera_permission_rationale">L\'app richiede l\'accesso alla fotocamera per scansionare documenti. Le immagini catturate sono salvate solo in questo dispositivo e verranno eliminate quando chiudi il documento attuale.</string>
|
||||
<string name="camera_permission_rationale">L\'app richiede l\'accesso alla fotocamera per scansionare documenti. Le immagini catturate sono salvate solo in questo dispositivo e verranno eliminate quando chiudi la scansione corrente.</string>
|
||||
<string name="cancel">Annulla</string>
|
||||
<string name="change_directory">Cambia cartella</string>
|
||||
<string name="clear_text">Svuota testo</string>
|
||||
<string name="close">Chiudi</string>
|
||||
<string name="color_mode">Filtro</string>
|
||||
<string name="color_mode_auto">Automatico</string>
|
||||
<string name="color_mode_color">Colore</string>
|
||||
<string name="color_mode_default">Filtro predefinito</string>
|
||||
<string name="color_mode_grayscale">Scala di grigi</string>
|
||||
<string name="contact">Contatti</string>
|
||||
<string name="copied_logs">Log copiati negli appunti</string>
|
||||
<string name="copy_logs">Copia log</string>
|
||||
<string name="creating_export">Preparazione dell’esportazione…</string>
|
||||
<string name="crop">Ritaglia</string>
|
||||
<string name="delete">Elimina</string>
|
||||
<string name="delete_page">Elimina pagina</string>
|
||||
<string name="delete_page_warning">Vuoi eliminare questa pagina?</string>
|
||||
<string name="developer">Sviluppatore</string>
|
||||
<string name="discard_scan">Scarta scansione</string>
|
||||
<string name="download_dirname">Download</string>
|
||||
<string name="end_scan">Termina scansione</string>
|
||||
<string name="error">Errore: %1$s</string>
|
||||
<string name="error_context_folder">Cartella di esportazione: %1$s</string>
|
||||
<string name="error_context_provider">Provider: %1$s</string>
|
||||
<string name="error_export_dir_permission_lost">La cartella di esportazione selezionata non è più accessibile. Scegli un’altra cartella.</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_occurred">Si è verificato un errore</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_folder_permission_lost">Accesso alla cartella perso</string>
|
||||
<string name="export_format">Formato di esportazione</string>
|
||||
<string name="export_quality">Qualità di esportazione</string>
|
||||
<string name="export_quality_low">Bassa</string>
|
||||
<string name="export_quality_balanced">Bilanciata</string>
|
||||
<string name="export_quality_high">Alta</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>
|
||||
<string name="import_photos">Importa</string>
|
||||
<string name="libraries">Librerie</string>
|
||||
<string name="libraries_open_source">Librerie open source</string>
|
||||
<string name="license">Licenza</string>
|
||||
@@ -39,14 +54,32 @@
|
||||
<string name="open">Apri</string>
|
||||
<string name="open_file">Apri file</string>
|
||||
<string name="reset_to_default">Ripristina impostazioni predefinite</string>
|
||||
<string name="resume">Riprendi</string>
|
||||
<string name="rotate_left">Ruota a sinistra</string>
|
||||
<string name="rotate_right">Ruota a destra</string>
|
||||
<string name="save">Salva</string>
|
||||
<string name="scan_button">Nuova scansione</string>
|
||||
<string name="scan_in_progress">Scansione in corso</string>
|
||||
<string name="scan_new">Nuova scansione</string>
|
||||
<string name="scan_current">Scansione corrente</string>
|
||||
<string name="scan_discard_current">Elimina la scansione corrente</string>
|
||||
<string name="scan_resume">Riprendi scansione</string>
|
||||
<string name="settings">Impostazioni</string>
|
||||
<string name="settings_ocr_download_error">Download non riuscito. Controlla la connessione a Internet e assicurati che l\'accesso alla rete sia consentito per questa app.</string>
|
||||
<string name="settings_ocr_download_intro">I file di lingua vengono scaricati dal progetto ufficiale Tesseract. Il riconoscimento del testo rimane completamente offline.</string>
|
||||
<string name="settings_ocr_downloading">Download del file di lingua</string>
|
||||
<string name="settings_ocr_languages">Lingue</string>
|
||||
<string name="settings_ocr_languages_available">Lingue disponibili</string>
|
||||
<string name="settings_ocr_languages_disabled">Disattivato</string>
|
||||
<string name="settings_ocr_languages_installed">Installate</string>
|
||||
<string name="settings_ocr_suggested">Suggerito</string>
|
||||
<string name="settings_section_ocr">Riconoscimento del testo</string>
|
||||
<string name="settings_section_scan">Scansione</string>
|
||||
<string name="settings_section_export">Esportazione</string>
|
||||
<string name="share">Condividi</string>
|
||||
<string name="share_document">Condividi documento</string>
|
||||
<string name="storage_permission_denied">Impossibile salvare il file: permesso negato</string>
|
||||
<string name="support">Supporto</string>
|
||||
<string name="support_last_image">Segnala un problema con l’ultima immagine acquisita</string>
|
||||
<string name="text_recognition_disabled">Riconoscimento del testo: disattivato</string>
|
||||
<string name="text_recognition_enabled">Riconoscimento del testo: attivato</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>
|
||||
@@ -58,6 +91,10 @@
|
||||
<item quantity="one">%2$s salvato in %3$s</item>
|
||||
<item quantity="other">%1$d file salvati in %3$s</item>
|
||||
</plurals>
|
||||
<plurals name="importing_photos" tools:ignore="MissingQuantity">
|
||||
<item quantity="one">Importazione di 1 foto</item>
|
||||
<item quantity="other">Importazione di %d foto</item>
|
||||
</plurals>
|
||||
<plurals name="page_count" tools:ignore="MissingQuantity">
|
||||
<item quantity="one">%d pagina</item>
|
||||
<item quantity="other">%d pagine</item>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<string name="about">このアプリについて</string>
|
||||
<string name="add_page">ページを追加</string>
|
||||
<string name="app_tagline">シンプルで安心なドキュメントスキャナーです。</string>
|
||||
<string name="apply">適用</string>
|
||||
<string name="back">戻る</string>
|
||||
<string name="camera_permission_denied">カメラの権限が拒否されました</string>
|
||||
<string name="camera_permission_rationale">ドキュメントをスキャンするためにカメラへのアクセスが必要です。撮影した画像はこのデバイスにのみ保存され、現在のスキャンを閉じると削除されます。</string>
|
||||
<string name="cancel">キャンセル</string>
|
||||
<string name="change_directory">フォルダを変更</string>
|
||||
<string name="clear_text">テキストを消去</string>
|
||||
<string name="close">閉じる</string>
|
||||
<string name="color_mode">フィルター</string>
|
||||
<string name="color_mode_auto">自動</string>
|
||||
<string name="color_mode_color">カラー</string>
|
||||
<string name="color_mode_default">デフォルトフィルター</string>
|
||||
<string name="color_mode_grayscale">グレースケール</string>
|
||||
<string name="contact">連絡先</string>
|
||||
<string name="copied_logs">ログをクリップボードにコピーしました</string>
|
||||
<string name="copy_logs">ログをコピー</string>
|
||||
<string name="creating_export">エクスポートを準備中…</string>
|
||||
<string name="crop">トリミング</string>
|
||||
<string name="delete">削除</string>
|
||||
<string name="delete_page">ページを削除</string>
|
||||
<string name="delete_page_warning">このページを削除しますか?</string>
|
||||
<string name="developer">開発者</string>
|
||||
<string name="download_dirname">ダウンロード</string>
|
||||
<string name="error_context_folder">エクスポートフォルダ: %1$s</string>
|
||||
<string name="error_context_provider">プロバイダー: %1$s</string>
|
||||
<string name="error_export_dir_permission_lost">選択したエクスポートフォルダにアクセスできなくなりました。別のフォルダを選択してください。</string>
|
||||
<string name="error_no_app">このファイルを開けるアプリが見つかりません</string>
|
||||
<string name="error_occurred">エラーが発生しました</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_folder_permission_lost">フォルダへのアクセスが失われました</string>
|
||||
<string name="export_format">エクスポート形式</string>
|
||||
<string name="export_quality">エクスポート品質</string>
|
||||
<string name="export_quality_low">低</string>
|
||||
<string name="export_quality_balanced">バランス</string>
|
||||
<string name="export_quality_high">高</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="import_photos">インポート</string>
|
||||
<string name="libraries">ライブラリ</string>
|
||||
<string name="libraries_open_source">オープンソースライブラリ</string>
|
||||
<string name="license">ライセンス</string>
|
||||
<string name="licensed_under">このアプリは GNU General Public License v3.0 の下でライセンスされています。</string>
|
||||
<string name="menu">メニュー</string>
|
||||
<string name="new_document_warning">現在のスキャンは失われます。続けますか?</string>
|
||||
<string name="open">開く</string>
|
||||
<string name="open_file">ファイルを開く</string>
|
||||
<string name="reset_to_default">デフォルトに戻す</string>
|
||||
<string name="rotate_left">左に回転</string>
|
||||
<string name="rotate_right">右に回転</string>
|
||||
<string name="save">保存</string>
|
||||
<string name="scan_new">新規スキャン</string>
|
||||
<string name="scan_current">現在のスキャン</string>
|
||||
<string name="scan_discard_current">現在のスキャンを破棄</string>
|
||||
<string name="scan_resume">スキャンを再開</string>
|
||||
<string name="settings">設定</string>
|
||||
<string name="settings_ocr_download_error">ダウンロードに失敗しました。インターネット接続を確認し、このアプリのネットワークアクセスが許可されていることを確認してください。</string>
|
||||
<string name="settings_ocr_download_intro">言語ファイルは公式の Tesseract プロジェクトからダウンロードされます。テキスト認識は完全にオフラインで実行されます。</string>
|
||||
<string name="settings_ocr_downloading">言語ファイルをダウンロード中</string>
|
||||
<string name="settings_ocr_languages">言語</string>
|
||||
<string name="settings_ocr_languages_available">利用可能な言語</string>
|
||||
<string name="settings_ocr_languages_disabled">無効</string>
|
||||
<string name="settings_ocr_languages_installed">インストール済み</string>
|
||||
<string name="settings_ocr_suggested">おすすめ</string>
|
||||
<string name="settings_section_ocr">テキスト認識</string>
|
||||
<string name="settings_section_scan">スキャン</string>
|
||||
<string name="settings_section_export">エクスポート</string>
|
||||
<string name="share">共有</string>
|
||||
<string name="share_document">ドキュメントを共有</string>
|
||||
<string name="storage_permission_denied">権限が拒否されたため、ファイルを保存できません</string>
|
||||
<string name="support">サポート</string>
|
||||
<string name="support_last_image">最後に撮影した画像の問題を報告</string>
|
||||
<string name="text_recognition_disabled">テキスト認識: 無効</string>
|
||||
<string name="text_recognition_enabled">テキスト認識: 有効</string>
|
||||
<string name="turn_off_torch">ライトをオフ</string>
|
||||
<string name="turn_on_torch">ライトをオン</string>
|
||||
<string name="unknown_size">サイズ不明</string>
|
||||
<string name="version">バージョン</string>
|
||||
<string name="view_full_list">全リストを表示</string>
|
||||
<string name="view_the_full_license">ライセンス全文を表示</string>
|
||||
<string name="yes">はい</string>
|
||||
<plurals name="files_saved_to" tools:ignore="MissingQuantity">
|
||||
<item quantity="other">%3$s に %1$d 件のファイルを保存しました</item>
|
||||
</plurals>
|
||||
<plurals name="importing_photos" tools:ignore="MissingQuantity">
|
||||
<item quantity="other">%d 枚の写真をインポート中</item>
|
||||
</plurals>
|
||||
<plurals name="page_count" tools:ignore="MissingQuantity">
|
||||
<item quantity="other">%d ページ</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
@@ -0,0 +1,106 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="export_quality_balanced">Sabalansēta</string>
|
||||
<string name="close">Aizvērt</string>
|
||||
<string name="save">Saglabāt</string>
|
||||
<string name="add_page">Pievienot lapu</string>
|
||||
<string name="error_occurred">Radās kļūda</string>
|
||||
<string name="view_full_list">Skatīt visu sarakstu</string>
|
||||
<string name="developer">Izstrādātājs</string>
|
||||
<string name="rotate_left">Pagriezt pa kreisi</string>
|
||||
<string name="settings_ocr_languages_installed">Uzstādīta</string>
|
||||
<plurals name="page_count">
|
||||
<item quantity="zero">%d lapas</item>
|
||||
<item quantity="one">%d lapa</item>
|
||||
<item quantity="other">%d lapas</item>
|
||||
</plurals>
|
||||
<string name="delete_page">Dzēst lapu</string>
|
||||
<string name="settings_ocr_downloading">Lejupielādē valodas datni</string>
|
||||
<string name="text_recognition_enabled">Teksta atpazīšana: iespējota</string>
|
||||
<string name="yes">Jā</string>
|
||||
<string name="filename">Datnes nosaukums</string>
|
||||
<string name="camera_permission_denied">Piekļuve kamerai liegta</string>
|
||||
<plurals name="files_saved_to">
|
||||
<item quantity="zero">%1$d datnes saglabātas %3$s mapē</item>
|
||||
<item quantity="one">%2$s saglabāts %3$s mapē</item>
|
||||
<item quantity="other">%1$d datnes saglabātas %3$s mapē</item>
|
||||
</plurals>
|
||||
<string name="text_recognition_disabled">Teksta atpazīšana: atspējota</string>
|
||||
<string name="back">Atpakaļ</string>
|
||||
<string name="delete">Dzēst</string>
|
||||
<string name="support">Atbalsts</string>
|
||||
<string name="version">Versija</string>
|
||||
<string name="export_quality_low">Zema</string>
|
||||
<string name="export_quality_high">Augsta</string>
|
||||
<string name="contact">Kontakti</string>
|
||||
<string name="libraries_open_source">Atvērtā pirmkoda bibliotēkas</string>
|
||||
<string name="error_save">Neizdevās saglabāt datni</string>
|
||||
<string name="settings_ocr_languages_disabled">Atspējotas</string>
|
||||
<string name="license">Licence</string>
|
||||
<string name="settings_ocr_languages">Valodas</string>
|
||||
<string name="about">Par lietotni</string>
|
||||
<string name="turn_on_torch">Ieslēgt zibspuldzi/lukturīti</string>
|
||||
<string name="cancel">Atcelt</string>
|
||||
<string name="delete_page_warning">Vai tiešām vēlaties dzēst šo lapu?</string>
|
||||
<string name="open">Atvērt</string>
|
||||
<string name="view_the_full_license">Skatīt pašu licenci</string>
|
||||
<string name="file_size">Datnes izmērs: %1$s</string>
|
||||
<string name="open_file">Atvērt datni</string>
|
||||
<string name="settings">Iestatījumi</string>
|
||||
<string name="libraries">Bibliotēkas</string>
|
||||
<string name="settings_section_ocr">Teksta atpazīšana</string>
|
||||
<string name="unknown_size">Nezināms izmērs</string>
|
||||
<string name="turn_off_torch">Izslēgt zibspuldzi/lukturīti</string>
|
||||
<string name="rotate_right">Pagriezt pa labi</string>
|
||||
<string name="share">Kopīgot</string>
|
||||
<string name="file_size_total">Kopējais izmērs: %1$s</string>
|
||||
<string name="crop">Apgriezt</string>
|
||||
<string name="share_document">Kopīgot dokumentu</string>
|
||||
<string name="settings_ocr_languages_available">Pieejamās valodas</string>
|
||||
<string name="settings_ocr_download_intro">Valodu datnes lejupielādē no oficiālās Tesseract projekta vietnes. Teksta atpazīšana pilnībā notiek bezsaistē.</string>
|
||||
<string name="download_dirname">Lejupielādes</string>
|
||||
<string name="menu">Izvēlne</string>
|
||||
<string name="settings_section_export">Veidošana</string>
|
||||
<string name="export">Veidot</string>
|
||||
<string name="settings_section_scan">Skenējums</string>
|
||||
<string name="change_directory">Mainīt mapi</string>
|
||||
<string name="clear_text">Notīrīt tekstu</string>
|
||||
<string name="error_context_provider">Pakalpojuma sniedzējs: %1$s</string>
|
||||
<string name="error_export_dir_permission_lost">Atlasītā veidošanas mape vairs nav pieejama. Lūdzu, atlasiet citu mapi.</string>
|
||||
<string name="export_as">Veidot kā %1$s</string>
|
||||
<string name="export_format">Veidošanas formāts</string>
|
||||
<string name="export_directory">Mape, kur saglabāt</string>
|
||||
<string name="export_quality">Veidošanas kvalitāte</string>
|
||||
<string name="camera_permission_rationale">Lai varētu veikt skenējumus, lietotnei ir nepieciešama piekļuve kamerai. Uzņemtie attēli tiek saglabāti tikai šajā ierīcē un tiks dzēsti, kad aizvērsiet pašreizējo skenējumu.</string>
|
||||
<string name="color_mode_auto">Automātiski</string>
|
||||
<string name="copied_logs">Žurnāla ieraksti kopēti starpliktuvē</string>
|
||||
<string name="copy_logs">Kopēt žurnāla ierakstus</string>
|
||||
<string name="error_no_app">Nav atrasta neviena lietotne, kas varētu atvērt šo datni</string>
|
||||
<string name="export_folder_permission_lost">Zaudēta piekļuve mapei</string>
|
||||
<string name="grant_permission">Dot atļauju</string>
|
||||
<string name="licensed_under">Šī lietotne ir licencēta saskaņā ar GNU General Public License v3.0 noteikumiem.</string>
|
||||
<string name="new_document_warning">Pašreizējais skenējums tiks zaudēts. Vai tiešām vēlaties turpināt?</string>
|
||||
<string name="reset_to_default">Atiestatīt uz noklusējuma iestatījumu</string>
|
||||
<string name="scan_new">Jauns skenējums</string>
|
||||
<string name="scan_current">Pašreizējais skenējums</string>
|
||||
<string name="scan_discard_current">Atmestīs pašreizējo skenējumu</string>
|
||||
<string name="scan_resume">Turpināt skenējumu</string>
|
||||
<string name="settings_ocr_download_error">Neizdevās lejupielādēt. Pārbaudiet savu interneta savienojumu un pārliecinieties, ka šai lietotnei ir tīkla piekļuves atļauja.</string>
|
||||
<string name="settings_ocr_suggested">Iesaka</string>
|
||||
<string name="app_tagline">Vienkārša un cieņpilna lietotne, kas veic jūsu dokumentu skenējumus.</string>
|
||||
<string name="color_mode">Krāsu filtrs</string>
|
||||
<string name="color_mode_default">Noklusējuma krāsu filtrs</string>
|
||||
<string name="color_mode_grayscale">Pelēktoņu</string>
|
||||
<string name="import_photos">Pievienot</string>
|
||||
<string name="storage_permission_denied">Nevar saglabāt datni: piekļuve liegta</string>
|
||||
<string name="support_last_image">Ziņot par problēmu saistībā ar pēdējo uzņemto attēlu</string>
|
||||
<plurals name="importing_photos">
|
||||
<item quantity="zero">Pievieno %d fotoattēlus</item>
|
||||
<item quantity="one">Pievieno %d fotoattēlu</item>
|
||||
<item quantity="other">Pievieno %d fotoattēlus</item>
|
||||
</plurals>
|
||||
<string name="color_mode_color">Pilnkrāsu</string>
|
||||
<string name="creating_export">Sagatavo…</string>
|
||||
<string name="apply">Apstiprināt</string>
|
||||
<string name="error_context_folder">Mape, kur saglabā: %1$s</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<color name="splash_background">#FF313332</color>
|
||||
</resources>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user