diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0b43638..07c2c58 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,6 +3,7 @@ plugins { alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) alias(libs.plugins.aboutLibraries) + alias(libs.plugins.protobuf) } android { @@ -85,6 +86,8 @@ dependencies { implementation(libs.androidx.camera.camera2) implementation(libs.androidx.camera.lifecycle) implementation(libs.androidx.camera.view) + implementation(libs.androidx.datastore) + implementation(libs.protobuf.javalite) implementation(libs.litert) implementation(libs.litert.support) implementation(libs.litert.metadata) @@ -107,3 +110,19 @@ dependencies { aboutLibraries { android.registerAndroidTasks = true } + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:4.32.0" + } + generateProtoTasks { + all().forEach { task -> + task.builtins { + create("java") { + option("lite") + } + } + } + } +} + diff --git a/app/src/main/java/org/mydomain/myscan/MainActivity.kt b/app/src/main/java/org/mydomain/myscan/MainActivity.kt index 7d6898c..e96712a 100644 --- a/app/src/main/java/org/mydomain/myscan/MainActivity.kt +++ b/app/src/main/java/org/mydomain/myscan/MainActivity.kt @@ -29,6 +29,7 @@ import androidx.activity.viewModels import androidx.compose.runtime.getValue import androidx.core.content.FileProvider import androidx.core.net.toFile +import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.CoroutineScope @@ -72,11 +73,14 @@ class MainActivity : ComponentActivity() { ) when (val screen = currentScreen) { is Screen.Home -> { + val recentDocs by viewModel.recentDocuments.collectAsStateWithLifecycle() HomeScreen( cameraPermission = cameraPermission, currentDocument = document, navigation = navigation, onStartNewScan = navigation.toCameraScreen, + recentDocuments = recentDocs, + onOpenPdf = { file -> openPdf(file.toUri()) } ) } is Screen.Camera -> { @@ -149,6 +153,7 @@ class MainActivity : ComponentActivity() { appScope.launch { try { val targetFile = viewModel.saveFile(generatedPdf.file) + viewModel.addRecentDocument(targetFile.absolutePath, generatedPdf.pageCount) suspendCancellableCoroutine { continuation -> MediaScannerConnection.scanFile( diff --git a/app/src/main/java/org/mydomain/myscan/MainViewModel.kt b/app/src/main/java/org/mydomain/myscan/MainViewModel.kt index 744be4e..276bb46 100644 --- a/app/src/main/java/org/mydomain/myscan/MainViewModel.kt +++ b/app/src/main/java/org/mydomain/myscan/MainViewModel.kt @@ -21,6 +21,7 @@ import android.os.Environment import android.util.Log import androidx.camera.core.ImageProxy import androidx.core.net.toUri +import androidx.datastore.core.DataStore import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -37,7 +38,9 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.mydomain.myscan.data.recentDocumentsDataStore import org.mydomain.myscan.ui.PdfGenerationUiState +import org.mydomain.myscan.ui.RecentDocumentUiState import org.mydomain.myscan.view.DocumentUiModel import java.io.ByteArrayOutputStream import java.io.File @@ -46,6 +49,7 @@ class MainViewModel( private val imageSegmentationService: ImageSegmentationService, private val imageRepository: ImageRepository, private val pdfFileManager: PdfFileManager, + private val recentDocumentsDataStore: DataStore, ): ViewModel() { companion object { @@ -59,6 +63,7 @@ class MainViewModel( File(context.cacheDir, "pdfs"), Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), AndroidPdfWriter()), + context.recentDocumentsDataStore, ) as T } } @@ -314,6 +319,42 @@ class MainViewModel( fun cleanUpOldPdfs(thresholdInMillis: Int) { pdfFileManager.cleanUpOldFiles(thresholdInMillis) } + + + val recentDocuments: StateFlow> = + recentDocumentsDataStore.data.map { + it.documentsList.map { + doc -> + RecentDocumentUiState( + file = File(doc.filePath), + saveTimestamp = doc.createdAt, + pageCount = doc.pageCount, + ) + }.filter { doc -> doc.file.exists() } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList(), + ) + fun addRecentDocument(filePath: String, pageCount: Int) { + viewModelScope.launch { + recentDocumentsDataStore.updateData { current -> + val newDoc = RecentDocument.newBuilder() + .setFilePath(filePath) + .setPageCount(pageCount) + .setCreatedAt(System.currentTimeMillis()) + .build() + current.toBuilder() + .addDocuments(0, newDoc) + .also { builder -> + while (builder.documentsCount > 10) { + builder.removeDocuments(builder.documentsCount - 1) + } + } + .build() + } + } + } } data class GeneratedPdf( diff --git a/app/src/main/java/org/mydomain/myscan/data/RecentDocuments.kt b/app/src/main/java/org/mydomain/myscan/data/RecentDocuments.kt new file mode 100644 index 0000000..374e2fa --- /dev/null +++ b/app/src/main/java/org/mydomain/myscan/data/RecentDocuments.kt @@ -0,0 +1,47 @@ +/* + * 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 . + */ +package org.mydomain.myscan.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.mydomain.myscan.RecentDocuments +import java.io.InputStream +import java.io.OutputStream + +object RecentDocumentsSerializer : Serializer { + 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 by dataStore( + fileName = "recent_documents.pb", + serializer = RecentDocumentsSerializer +) diff --git a/app/src/main/java/org/mydomain/myscan/ui/UiState.kt b/app/src/main/java/org/mydomain/myscan/ui/UiState.kt index 8a477ef..2b24d14 100644 --- a/app/src/main/java/org/mydomain/myscan/ui/UiState.kt +++ b/app/src/main/java/org/mydomain/myscan/ui/UiState.kt @@ -16,6 +16,7 @@ package org.mydomain.myscan.ui import android.net.Uri import org.mydomain.myscan.GeneratedPdf +import java.io.File data class PdfGenerationUiState( val isGenerating: Boolean = false, @@ -25,3 +26,9 @@ data class PdfGenerationUiState( val saveDirectoryName: String? = null, val errorMessage: String? = null ) + +data class RecentDocumentUiState( + val file: File, + val saveTimestamp: Long, + val pageCount: Int, +) diff --git a/app/src/main/java/org/mydomain/myscan/view/HomeScreen.kt b/app/src/main/java/org/mydomain/myscan/view/HomeScreen.kt index fc4f7fe..c7a7aa6 100644 --- a/app/src/main/java/org/mydomain/myscan/view/HomeScreen.kt +++ b/app/src/main/java/org/mydomain/myscan/view/HomeScreen.kt @@ -15,6 +15,7 @@ package org.mydomain.myscan.view 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 @@ -27,10 +28,14 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PhotoCamera +import androidx.compose.material.icons.filled.PictureAsPdf import androidx.compose.material3.BottomAppBar import androidx.compose.material3.Button import androidx.compose.material3.Card 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.Scaffold import androidx.compose.material3.Text @@ -49,7 +54,10 @@ import org.mydomain.myscan.CameraPermissionState import org.mydomain.myscan.Navigation import org.mydomain.myscan.R import org.mydomain.myscan.rememberCameraPermissionState +import org.mydomain.myscan.ui.RecentDocumentUiState import org.mydomain.myscan.ui.theme.MyScanTheme +import java.io.File +import kotlin.math.min @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -57,7 +65,9 @@ fun HomeScreen( cameraPermission: CameraPermissionState, currentDocument: DocumentUiModel, navigation: Navigation, - onStartNewScan: () -> Unit + onStartNewScan: () -> Unit, + recentDocuments: List, + onOpenPdf: (File) -> Unit, ) { val showCloseDocDialog = rememberSaveable { mutableStateOf(false) } Scaffold ( @@ -104,6 +114,11 @@ fun HomeScreen( CurrentDocumentCard(currentDocument, navigation) } + if (recentDocuments.isNotEmpty()) { + SectionTitle(stringResource(R.string.last_saved_documents)) + RecentDocumentList(recentDocuments, onOpenPdf) + } + if (showCloseDocDialog.value) { NewDocumentDialog( onConfirm = onStartNewScan, @@ -166,6 +181,32 @@ private fun CurrentDocumentCard( } } +@Composable +private fun RecentDocumentList( + recentDocuments: List, + onOpenPdf: (File) -> Unit +) { + Column { + val maxListSize = 5 + recentDocuments.subList(0, min(maxListSize, recentDocuments.size)).forEach { doc -> + ListItem( + headlineContent = { Text(doc.file.name) }, + supportingContent = { + Text( + text = pageCountText(doc.pageCount) + " • " + + formatDate(doc.saveTimestamp, LocalContext.current) + ) + }, + leadingContent = { + Icon(Icons.Default.PictureAsPdf, contentDescription = null) + }, + modifier = Modifier.clickable { onOpenPdf(doc.file) } + ) + HorizontalDivider() + } + } +} + @Composable private fun SectionTitle(text: String) { Text( @@ -183,7 +224,9 @@ fun HomeScreenPreviewOnFirstLaunch() { cameraPermission = rememberCameraPermissionState(), currentDocument = DocumentUiModel(listOf()) { _ -> null }, navigation = dummyNavigation(), - onStartNewScan = {} + onStartNewScan = {}, + recentDocuments = listOf(), + onOpenPdf = {}, ) } } @@ -198,7 +241,12 @@ fun HomeScreenPreviewWithCurrentDocument() { listOf("gallica.bnf.fr-bpt6k5530456s-1.jpg"), LocalContext.current), navigation = dummyNavigation(), - onStartNewScan = {} + onStartNewScan = {}, + recentDocuments = listOf( + RecentDocumentUiState(File("/path/my_file.pdf"), 1755971180000, 3), + RecentDocumentUiState(File("/path/scan2.pdf"), 1755000500000, 1) + ), + onOpenPdf = {}, ) } } diff --git a/app/src/main/java/org/mydomain/myscan/view/Strings.kt b/app/src/main/java/org/mydomain/myscan/view/Strings.kt index 5be9978..2a2f288 100644 --- a/app/src/main/java/org/mydomain/myscan/view/Strings.kt +++ b/app/src/main/java/org/mydomain/myscan/view/Strings.kt @@ -14,12 +14,21 @@ */ package org.mydomain.myscan.view +import android.content.Context +import android.text.format.DateFormat import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext import org.mydomain.myscan.R +import java.util.Date @Composable fun pageCountText(quantity: Int): String { val context = LocalContext.current return context.resources.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) +} diff --git a/app/src/main/proto/recent_documents.proto b/app/src/main/proto/recent_documents.proto new file mode 100644 index 0000000..51a20d6 --- /dev/null +++ b/app/src/main/proto/recent_documents.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +option java_package = "org.mydomain.myscan"; +option java_multiple_files = true; + +message RecentDocument { + string file_path = 1; + int64 created_at = 2; // timestamp in ms + int32 page_count = 3; +} + +message RecentDocuments { + repeated RecentDocument documents = 1; +} diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 82a7570..ee00b3b 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -19,6 +19,7 @@ Exporter en PDF Nom de fichier Autoriser + Derniers documents enregistrés Bibliothèques Cette application utilise plusieurs bibliothèques open source, notamment : Bibliothèques open source diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 97506a3..9788a6b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -19,6 +19,7 @@ Export PDF Filename Grant permission + Last saved documents Libraries This application uses several open-source libraries, including: Open-source libraries diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e07a535..0fdb3de 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,12 +10,15 @@ lifecycleRuntimeKtx = "2.9.1" activityCompose = "1.10.1" composeBom = "2025.06.01" camerax = "1.4.2" +datastore = "1.1.7" litert = "1.4.0" opencv = "4.12.0" assertj = "3.27.3" pdfbox = "2.0.27.0" zoomable = "2.8.1" aboutLibraries = "12.2.4" +protobuf = "0.9.5" +protobufJavaLite = "4.32.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -40,6 +43,8 @@ androidx-camera-core = { group = "androidx.camera", name = "camera-core", versio 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" } +androidx-datastore = { group = "androidx.datastore", name = "datastore" , version.ref = "datastore" } +protobuf-javalite = { group = "com.google.protobuf", name="protobuf-javalite", version.ref = "protobufJavaLite"} litert = { group = "com.google.ai.edge.litert", name = "litert", version.ref = "litert" } litert-support = { group = "com.google.ai.edge.litert", name = "litert-support", version.ref = "litert" } litert-metadata = { group = "com.google.ai.edge.litert", name = "litert-metadata", version.ref = "litert" } @@ -56,4 +61,5 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } license = { id = "com.github.hierynomus.license", version.ref = "license" } aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutLibraries" } +protobuf = { id = "com.google.protobuf", version.ref = "protobuf" }