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 { defaultConfig {
applicationId = "com.vaultdrop.mobile" applicationId = "com.vaultdrop.mobile"
minSdk = 26 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 versionCode = 1
versionName = "0.1.0" 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 { buildTypes {
@@ -73,6 +82,11 @@ dependencies {
ksp(libs.androidx.hilt.compiler) ksp(libs.androidx.hilt.compiler)
implementation(libs.androidx.work.runtime.ktx) 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)
implementation(libs.retrofit.converter.moshi) implementation(libs.retrofit.converter.moshi)
implementation(libs.okhttp) implementation(libs.okhttp)
@@ -3,6 +3,11 @@
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <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 <application
android:name=".VaultDropApplication" 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.FileDao
import com.vaultdrop.mobile.data.local.dao.FolderDao import com.vaultdrop.mobile.data.local.dao.FolderDao
import com.vaultdrop.mobile.data.local.dao.PendingOperationDao 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.dao.UserPreferenceDao
import com.vaultdrop.mobile.data.local.entity.FileEntity import com.vaultdrop.mobile.data.local.entity.FileEntity
import com.vaultdrop.mobile.data.local.entity.FolderEntity import com.vaultdrop.mobile.data.local.entity.FolderEntity
import com.vaultdrop.mobile.data.local.entity.PendingOperationEntity 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 import com.vaultdrop.mobile.data.local.entity.UserPreferenceEntity
/* /*
* DB SQLite locale, `dot.db` (même nom que la version Expo). * DB SQLite locale, `dot.db` (même nom que la version Expo).
* v1: folders ; v2: user_preferences ; v3: files ; v4: category sur files ; * v1: folders ; v2: user_preferences ; v3: files ; v4: category sur files ;
* v5: created_in_app sur folders ; v6: processed sur files (mode review) ; * 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( @Database(
entities = [ entities = [
FolderEntity::class, UserPreferenceEntity::class, FileEntity::class, FolderEntity::class, UserPreferenceEntity::class, FileEntity::class,
PendingOperationEntity::class, PendingOperationEntity::class, ScanSessionEntity::class, ScanPageEntity::class,
], ],
version = 7, version = 8,
exportSchema = false, exportSchema = false,
) )
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@@ -31,4 +34,5 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun userPreferenceDao(): UserPreferenceDao abstract fun userPreferenceDao(): UserPreferenceDao
abstract fun fileDao(): FileDao abstract fun fileDao(): FileDao
abstract fun pendingOperationDao(): PendingOperationDao 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 à * v7 : table `pending_operations` (outbox) — file des mutations locales à
* pousser vers `POST /sync/ops` (uuid 32-hex client-generated, cf. * pousser vers `POST /sync/ops` (uuid 32-hex client-generated, cf.
* docs/api-v1.md §6.1). * 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 { 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( 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.FileDao
import com.vaultdrop.mobile.data.local.dao.FolderDao import com.vaultdrop.mobile.data.local.dao.FolderDao
import com.vaultdrop.mobile.data.local.dao.PendingOperationDao 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.dao.UserPreferenceDao
import com.vaultdrop.mobile.data.local.migration.Migrations import com.vaultdrop.mobile.data.local.migration.Migrations
import dagger.Module import dagger.Module
@@ -37,4 +38,7 @@ object DatabaseModule {
@Provides @Provides
fun providePendingOperationDao(db: AppDatabase): PendingOperationDao = db.pendingOperationDao() 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.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.DocumentScanner
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -43,6 +46,7 @@ fun DashboardScreen(
connectionStatusViewModel: ConnectionStatusViewModel, connectionStatusViewModel: ConnectionStatusViewModel,
syncViewModel: SyncViewModel, syncViewModel: SyncViewModel,
onOpenReview: () -> Unit, onOpenReview: () -> Unit,
onOpenScan: () -> Unit,
viewModel: DashboardViewModel = hiltViewModel(), viewModel: DashboardViewModel = hiltViewModel(),
) { ) {
val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle() val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle()
@@ -64,6 +68,18 @@ fun DashboardScreen(
bottomBar = { bottomBar = {
FloatingNavBar(selected = selectedTab, onSelect = onTabSelected) 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 -> ) { padding ->
Column( Column(
modifier = Modifier 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.folderlist.FolderListScreen
import com.vaultdrop.mobile.ui.pdfbuilder.PdfBuilderScreen import com.vaultdrop.mobile.ui.pdfbuilder.PdfBuilderScreen
import com.vaultdrop.mobile.ui.review.SwipeReviewScreen 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.search.SearchScreen
import com.vaultdrop.mobile.ui.settings.SettingsScreen import com.vaultdrop.mobile.ui.settings.SettingsScreen
import com.vaultdrop.mobile.ui.watchedfolders.WatchedFoldersScreen import com.vaultdrop.mobile.ui.watchedfolders.WatchedFoldersScreen
@@ -42,6 +43,7 @@ object Routes {
const val ARG_DOCUMENT = "documentResourceId" const val ARG_DOCUMENT = "documentResourceId"
const val PDF_BUILDER = "pdfbuilder/{ids}" const val PDF_BUILDER = "pdfbuilder/{ids}"
const val ARG_BUILDER_IDS = "ids" const val ARG_BUILDER_IDS = "ids"
const val SCAN = "scan"
fun folder(folderResourceId: String): String = "folder/$folderResourceId" fun folder(folderResourceId: String): String = "folder/$folderResourceId"
fun document(documentResourceId: String): String = "document/$documentResourceId" fun document(documentResourceId: String): String = "document/$documentResourceId"
@@ -126,6 +128,12 @@ fun NavGraph(
connectionStatusViewModel = connectionStatusViewModel, connectionStatusViewModel = connectionStatusViewModel,
syncViewModel = syncViewModel, syncViewModel = syncViewModel,
onOpenReview = { navController.navigate(Routes.REVIEW) }, onOpenReview = { navController.navigate(Routes.REVIEW) },
onOpenScan = { navController.navigate(Routes.SCAN) },
)
}
composable(Routes.SCAN) {
ScanFlowScreen(
onBack = { navController.popBackStack() },
) )
} }
composable(Routes.REVIEW) { composable(Routes.REVIEW) {
@@ -179,4 +179,37 @@
<string name="pdf_builder_preview">Aperçu</string> <string name="pdf_builder_preview">Aperçu</string>
<string name="pdf_builder_close_preview">Fermer l\'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> <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> </resources>
+6
View File
@@ -17,6 +17,8 @@ moshi = "1.15.1"
securityCrypto = "1.0.0" securityCrypto = "1.0.0"
timber = "5.0.1" timber = "5.0.1"
coil = "2.7.0" coil = "2.7.0"
cameraX = "1.3.4"
opencv = "4.14.0"
junit = "4.13.2" junit = "4.13.2"
robolectric = "4.14.1" robolectric = "4.14.1"
androidxTestCore = "1.6.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-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-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-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-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }