add document scanner

This commit is contained in:
m
2026-09-13 21:45:28 +02:00
parent 5578e4a354
commit 3ef6ac7dcb
9 changed files with 136 additions and 5 deletions
+15 -1
View File
@@ -15,9 +15,18 @@ android {
defaultConfig {
applicationId = "com.vaultdrop.mobile"
minSdk = 26
targetSdk = 35
// targetSdk 35 exige des .so alignés en 16K ; les libs précompilées
// OpenCV/CameraX sont en 4K (warning de compatibilité sur devices
// 16K-pages). On reste en 34 tant qu'elles ne sont pas mises à jour.
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
// OpenCV natif : on conserve uniquement les ABIs ciblés
// (arm64 = devices, x86_64 = émulateur) pour garder l'APK raisonnable.
ndk {
abiFilters += listOf("arm64-v8a", "x86_64")
}
}
buildTypes {
@@ -73,6 +82,11 @@ dependencies {
ksp(libs.androidx.hilt.compiler)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view)
implementation(libs.opencv)
implementation(libs.retrofit)
implementation(libs.retrofit.converter.moshi)
implementation(libs.okhttp)
@@ -3,6 +3,11 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<application
android:name=".VaultDropApplication"
@@ -5,24 +5,27 @@ import androidx.room.RoomDatabase
import com.vaultdrop.mobile.data.local.dao.FileDao
import com.vaultdrop.mobile.data.local.dao.FolderDao
import com.vaultdrop.mobile.data.local.dao.PendingOperationDao
import com.vaultdrop.mobile.data.local.dao.ScanDao
import com.vaultdrop.mobile.data.local.dao.UserPreferenceDao
import com.vaultdrop.mobile.data.local.entity.FileEntity
import com.vaultdrop.mobile.data.local.entity.FolderEntity
import com.vaultdrop.mobile.data.local.entity.PendingOperationEntity
import com.vaultdrop.mobile.data.local.entity.ScanPageEntity
import com.vaultdrop.mobile.data.local.entity.ScanSessionEntity
import com.vaultdrop.mobile.data.local.entity.UserPreferenceEntity
/*
* DB SQLite locale, `dot.db` (même nom que la version Expo).
* v1: folders ; v2: user_preferences ; v3: files ; v4: category sur files ;
* v5: created_in_app sur folders ; v6: processed sur files (mode review) ;
* v7: pending_operations (outbox).
* v7: pending_operations (outbox) ; v8: scan_sessions + scan_pages (scanner).
*/
@Database(
entities = [
FolderEntity::class, UserPreferenceEntity::class, FileEntity::class,
PendingOperationEntity::class,
PendingOperationEntity::class, ScanSessionEntity::class, ScanPageEntity::class,
],
version = 7,
version = 8,
exportSchema = false,
)
abstract class AppDatabase : RoomDatabase() {
@@ -31,4 +34,5 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun userPreferenceDao(): UserPreferenceDao
abstract fun fileDao(): FileDao
abstract fun pendingOperationDao(): PendingOperationDao
abstract fun scanDao(): ScanDao
}
@@ -20,6 +20,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase
* v7 : table `pending_operations` (outbox) — file des mutations locales à
* pousser vers `POST /sync/ops` (uuid 32-hex client-generated, cf.
* docs/api-v1.md §6.1).
* v8 : tables `scan_sessions` + `scan_pages` (scanner appareil photo) — session
* multi-pages persistée pour survivre au process death avant export SAF.
*/
object Migrations {
@@ -117,7 +119,46 @@ object Migrations {
}
}
private val MIGRATION_7_8 = object : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `scan_sessions` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`resource_id` TEXT NOT NULL,
`root_folder_id` TEXT,
`status` TEXT NOT NULL,
`created_at` INTEGER NOT NULL,
`updated_at` INTEGER NOT NULL
)
""".trimIndent(),
)
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_scan_sessions_resource_id` ON `scan_sessions` (`resource_id`)")
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `scan_pages` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`resource_id` TEXT NOT NULL,
`session_id` INTEGER NOT NULL,
`temp_uri` TEXT NOT NULL,
`corners_json` TEXT NOT NULL,
`width` INTEGER NOT NULL,
`height` INTEGER NOT NULL,
`sort_order` INTEGER NOT NULL,
`status` TEXT NOT NULL,
`created_at` INTEGER NOT NULL,
FOREIGN KEY(`session_id`) REFERENCES `scan_sessions`(`id`)
ON UPDATE NO ACTION ON DELETE CASCADE
)
""".trimIndent(),
)
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_scan_pages_resource_id` ON `scan_pages` (`resource_id`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_scan_pages_session_id` ON `scan_pages` (`session_id`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_scan_pages_sort_order` ON `scan_pages` (`sort_order`)")
}
}
val ALL: Array<Migration> = arrayOf(
MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7,
MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8,
)
}
@@ -6,6 +6,7 @@ import com.vaultdrop.mobile.data.local.AppDatabase
import com.vaultdrop.mobile.data.local.dao.FileDao
import com.vaultdrop.mobile.data.local.dao.FolderDao
import com.vaultdrop.mobile.data.local.dao.PendingOperationDao
import com.vaultdrop.mobile.data.local.dao.ScanDao
import com.vaultdrop.mobile.data.local.dao.UserPreferenceDao
import com.vaultdrop.mobile.data.local.migration.Migrations
import dagger.Module
@@ -37,4 +38,7 @@ object DatabaseModule {
@Provides
fun providePendingOperationDao(db: AppDatabase): PendingOperationDao = db.pendingOperationDao()
@Provides
fun provideScanDao(db: AppDatabase): ScanDao = db.scanDao()
}
@@ -11,11 +11,14 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.DocumentScanner
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
@@ -43,6 +46,7 @@ fun DashboardScreen(
connectionStatusViewModel: ConnectionStatusViewModel,
syncViewModel: SyncViewModel,
onOpenReview: () -> Unit,
onOpenScan: () -> Unit,
viewModel: DashboardViewModel = hiltViewModel(),
) {
val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle()
@@ -64,6 +68,18 @@ fun DashboardScreen(
bottomBar = {
FloatingNavBar(selected = selectedTab, onSelect = onTabSelected)
},
floatingActionButton = {
SmallFloatingActionButton(
onClick = onOpenScan,
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
) {
Icon(
imageVector = Icons.Filled.DocumentScanner,
contentDescription = stringResource(R.string.scan_title),
)
}
},
) { padding ->
Column(
modifier = Modifier
@@ -25,6 +25,7 @@ import com.vaultdrop.mobile.ui.folderdetail.FolderDetailScreen
import com.vaultdrop.mobile.ui.folderlist.FolderListScreen
import com.vaultdrop.mobile.ui.pdfbuilder.PdfBuilderScreen
import com.vaultdrop.mobile.ui.review.SwipeReviewScreen
import com.vaultdrop.mobile.ui.scan.ScanFlowScreen
import com.vaultdrop.mobile.ui.search.SearchScreen
import com.vaultdrop.mobile.ui.settings.SettingsScreen
import com.vaultdrop.mobile.ui.watchedfolders.WatchedFoldersScreen
@@ -42,6 +43,7 @@ object Routes {
const val ARG_DOCUMENT = "documentResourceId"
const val PDF_BUILDER = "pdfbuilder/{ids}"
const val ARG_BUILDER_IDS = "ids"
const val SCAN = "scan"
fun folder(folderResourceId: String): String = "folder/$folderResourceId"
fun document(documentResourceId: String): String = "document/$documentResourceId"
@@ -126,6 +128,12 @@ fun NavGraph(
connectionStatusViewModel = connectionStatusViewModel,
syncViewModel = syncViewModel,
onOpenReview = { navController.navigate(Routes.REVIEW) },
onOpenScan = { navController.navigate(Routes.SCAN) },
)
}
composable(Routes.SCAN) {
ScanFlowScreen(
onBack = { navController.popBackStack() },
)
}
composable(Routes.REVIEW) {
@@ -179,4 +179,37 @@
<string name="pdf_builder_preview">Aperçu</string>
<string name="pdf_builder_close_preview">Fermer l\'aperçu</string>
<string name="pdf_generated_folder">PDF générés</string>
<!-- Scanner appareil photo -->
<string name="scan_title">Scanner</string>
<string name="scan_capture">Capturer</string>
<string name="scan_pages_count">%1$d page(s)</string>
<string name="scan_view_session">Voir les pages</string>
<string name="scan_camera_hint">Cadre le document puis capture.</string>
<string name="scan_permission_rationale">Autorise la caméra pour scanner des documents.</string>
<string name="scan_permission_grant">Autoriser la caméra</string>
<string name="scan_crop_title">Recadrer</string>
<string name="scan_confirm_page">Valider la page</string>
<string name="scan_retake">Reprendre</string>
<string name="scan_render_color">Couleur</string>
<string name="scan_render_gray">N&amp;B</string>
<string name="scan_render_high">N&amp;B contrasté</string>
<string name="scan_add_page">Ajouter une page</string>
<string name="scan_session_title">Pages scannées</string>
<string name="scan_delete_page">Supprimer</string>
<string name="scan_move_up">Monter</string>
<string name="scan_move_down">Descendre</string>
<string name="scan_empty">Aucune page pour le moment.\nCapture ta première page.</string>
<string name="scan_export">Exporter vers le dossier VaultDrop</string>
<string name="scan_exporting">Export en cours…</string>
<string name="scan_abandon">Abandonner</string>
<string name="scan_abandon_title">Abandonner le scan ?</string>
<string name="scan_abandon_message">Les pages capturées seront supprimées.</string>
<string name="scan_confirm">Confirmer</string>
<string name="scan_cancel">Annuler</string>
<string name="scan_error_session">Session de scan indisponible.</string>
<string name="scan_error_capture">Impossible d\'utiliser cette capture.</string>
<string name="scan_error_page">Impossible d\'enregistrer la page.</string>
<string name="scan_error_export">Impossible d\'exporter les pages.</string>
<string name="scan_export_done">Pages exportées</string>
</resources>
+6
View File
@@ -17,6 +17,8 @@ moshi = "1.15.1"
securityCrypto = "1.0.0"
timber = "5.0.1"
coil = "2.7.0"
cameraX = "1.3.4"
opencv = "4.14.0"
junit = "4.13.2"
robolectric = "4.14.1"
androidxTestCore = "1.6.1"
@@ -29,6 +31,10 @@ androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecyc
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "cameraX" }
androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "cameraX" }
androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "cameraX" }
opencv = { group = "org.opencv", name = "opencv", version.ref = "opencv" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }