From e30051d2387149c92f1f5f4b34c569dfa70e002e Mon Sep 17 00:00:00 2001 From: m Date: Sat, 12 Sep 2026 12:17:00 +0200 Subject: [PATCH] generate pdf and save them to the directory --- .../mobile/ui/folderlist/FolderListScreen.kt | 23 +++++--- .../ui/folderlist/FolderListViewModel.kt | 45 +++++++++++++++ .../ui/pdfbuilder/PdfBuilderViewModel.kt | 55 +++++++++++++++---- .../app/src/main/res/values-en/strings.xml | 1 + .../app/src/main/res/values/strings.xml | 1 + 5 files changed, 105 insertions(+), 20 deletions(-) diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt index b0deff9..a255f37 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt @@ -70,6 +70,7 @@ import com.vaultdrop.mobile.ui.components.rememberSelectionState import com.vaultdrop.mobile.ui.navigation.FloatingNavBar import com.vaultdrop.mobile.ui.navigation.NavTab import kotlinx.coroutines.delay +import timber.log.Timber import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @@ -103,23 +104,27 @@ fun FolderListScreen( uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, ) - } + }.onFailure { Timber.w(it, "persistable permission absent on default root pick") } pendingDefaultPick = uri - syncViewModel.importRoot(uri = uri.toString(), name = defaultRootLabel) + val rootName = uri.displayName(context) ?: uri.lastPathSegment ?: defaultRootLabel + syncViewModel.importRoot(uri = uri.toString(), name = rootName) } - // Attend la création de la racine (importRoot la sauvegarde en Room puis - // lance le walk) et l'enregistre comme racine par défaut. + // Attend la création de la racine (importRoot la sauvegarde puis lance le + // walk), crée le sous-dossier « VaultDrop » et l'enregistre comme racine. LaunchedEffect(pendingDefaultPick) { val uri = pendingDefaultPick ?: return@LaunchedEffect val target = uri.toString() var attempts = 0 while (attempts < 100) { // ~10 s max - val id = syncViewModel.rootResourceId(target) - if (id != null) { - pendingDefaultPick = null - viewModel.setDefaultRoot(id) - return@LaunchedEffect + val rootId = syncViewModel.rootResourceId(target) + if (rootId != null) { + val vaultFolderId = viewModel.ensureVaultDropFolder(uri, rootId) + if (vaultFolderId != null) { + pendingDefaultPick = null + viewModel.setDefaultRoot(vaultFolderId) + return@LaunchedEffect + } } delay(100) attempts++ diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt index 14ffb0c..6614f65 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt @@ -1,7 +1,11 @@ package com.vaultdrop.mobile.ui.folderlist +import android.content.Context +import android.net.Uri +import android.provider.DocumentsContract import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vaultdrop.mobile.R import com.vaultdrop.mobile.auth.TokenProvider import com.vaultdrop.mobile.data.local.entity.FileEntity import com.vaultdrop.mobile.data.local.referenceDate @@ -9,13 +13,16 @@ import com.vaultdrop.mobile.data.preferences.DefaultRootStore import com.vaultdrop.mobile.data.remote.ApiException import com.vaultdrop.mobile.data.repository.FileRepository import com.vaultdrop.mobile.data.repository.FolderRepository +import com.vaultdrop.mobile.data.repository.SaveFolderInput import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import timber.log.Timber +import java.io.FileNotFoundException import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -29,6 +36,7 @@ class FolderListViewModel @Inject constructor( private val fileRepository: FileRepository, private val tokenProvider: TokenProvider, private val defaultRootStore: DefaultRootStore, + @ApplicationContext private val context: Context, ) : ViewModel() { private val _uiState = MutableStateFlow(FolderListUiState()) @@ -49,6 +57,43 @@ class FolderListViewModel @Inject constructor( _defaultRootId.value = resourceId } + /** + * Crée (ou retrouve) un sous-dossier « VaultDrop » dans l'arbre choisi et + * l'enregistre en Room comme enfant de la racine. Retourne son id — c'est + * lui qui devient la racine par défaut où les PDF sont écrits. + */ + suspend fun ensureVaultDropFolder(rootUri: Uri, rootRoomId: String): String? { + val resolver = context.contentResolver + val treeDocId = DocumentsContract.getTreeDocumentId(rootUri) + val label = context.getString(R.string.default_root_folder_label) + val parentUri = if (DocumentsContract.isTreeUri(rootUri)) { + DocumentsContract.buildDocumentUriUsingTree(rootUri, treeDocId) + } else { + rootUri + } + val vaultFolderUri = runCatching { + DocumentsContract.createDocument( + resolver, + parentUri, + DocumentsContract.Document.MIME_TYPE_DIR, + label, + ) + }.getOrElse { e -> + if (e is FileNotFoundException) { + DocumentsContract.buildDocumentUriUsingTree(rootUri, "$treeDocId/$label") + } else { + Timber.w(e, "cannot create VaultDrop folder") + null + } + } ?: return null + + val saved = folderRepository.saveFolder( + input = SaveFolderInput(uri = vaultFolderUri.toString(), name = label, exists = true), + parentResourceId = rootRoomId, + ) + return saved.resourceId + } + /** Grille d'accueil : tous les fichiers visibles, groupés par jour (date de référence). */ private fun observeFiles() { viewModelScope.launch { diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/pdfbuilder/PdfBuilderViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/pdfbuilder/PdfBuilderViewModel.kt index 4a67066..5aa75ba 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/pdfbuilder/PdfBuilderViewModel.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/pdfbuilder/PdfBuilderViewModel.kt @@ -206,7 +206,8 @@ class PdfBuilderViewModel @Inject constructor( runCatching { val root = folderRepository.getFolder(rootResourceId) ?: error("default root not found: $rootResourceId") - val rootUri = checkNotNull(Uri.parse(root.uri)) { "default root has no uri" } + val rootUri = parentDocumentUri(root.uri) + ?: error("default root has no uri") val fileName = if (trimmed.lowercase().endsWith(".pdf")) trimmed else "$trimmed.pdf" val createdUri = DocumentsContract.createDocument( @@ -251,13 +252,39 @@ class PdfBuilderViewModel @Inject constructor( _buildState.value = BuildPhase.Saved(resourceId) }.onFailure { e -> Timber.e(e, "PDF save failed") - _buildState.value = BuildPhase.Failed(context.getString(R.string.pdf_builder_save_error)) + if (e is SecurityException) { + defaultRootStore.clear() + _buildState.value = BuildPhase.Failed( + context.getString(R.string.pdf_builder_save_error_permission), + ) + } else { + _buildState.value = BuildPhase.Failed( + e.message ?: context.getString(R.string.pdf_builder_save_error), + ) + } } } } // ------------------------------------------------------------ helpers + /** + * `DocumentsContract.createDocument` attend un URI *document*, pas un URI + * *tree*. Convertit un tree URI en document URI (équivalent au dossier + * racine de l'arbre) — les deux autorités sont identiques. + */ + private fun parentDocumentUri(uri: String?): Uri? { + val raw = uri?.let(Uri::parse) ?: return null + return if (DocumentsContract.isTreeUri(raw)) { + DocumentsContract.buildDocumentUriUsingTree( + raw, + DocumentsContract.getTreeDocumentId(raw), + ) + } else { + raw + } + } + private fun copyInto(uri: Uri, source: File, resolver: ContentResolver) { val target = resolver.openOutputStream(uri) ?: error("cannot open output stream") @@ -281,20 +308,26 @@ class PdfBuilderViewModel @Inject constructor( }.getOrNull() /** - * Associe l'URI créé au dossier SAF importé qui le contient (par préfixe - * du documentId), sinon à un dossier racine local « PDF générés ». + * Associe l'URI créé au dossier (racine ou sous-dossier) dont le documentId + * est le préfixe — plus long match gagne (ex. le sous-dossier « VaultDrop » + * plutôt que l'arbre racine). Sinon dossier racine local « PDF générés ». */ private suspend fun resolveTargetFolder(uri: Uri): String { val documentId = runCatching { DocumentsContract.getDocumentId(uri) }.getOrNull() if (documentId != null) { - folderRepository.getRootFolders().filter { it.uri != null }.forEach { folder -> - val treeDocId = runCatching { - DocumentsContract.getDocumentId(Uri.parse(folder.uri)) - }.getOrNull() - if (treeDocId != null && documentId.startsWith("$treeDocId/")) { - return folder.resourceId + val matches = folderRepository.getAll() + .filter { it.uri != null } + .mapNotNull { folder -> + val treeDocId = runCatching { + DocumentsContract.getDocumentId(Uri.parse(folder.uri)) + }.getOrNull() + if (treeDocId != null && documentId.startsWith("$treeDocId/")) { + folder to treeDocId.length + } else { + null + } } - } + matches.maxByOrNull { it.second }?.first?.resourceId?.let { return it } } return generatedFolderResourceId() } diff --git a/mobile-kotlin/app/src/main/res/values-en/strings.xml b/mobile-kotlin/app/src/main/res/values-en/strings.xml index b914e83..b84b5e6 100644 --- a/mobile-kotlin/app/src/main/res/values-en/strings.xml +++ b/mobile-kotlin/app/src/main/res/values-en/strings.xml @@ -100,6 +100,7 @@ Select at least one file or add a note. Could not generate the PDF. Could not save the PDF. + Permission denied for the VaultDrop folder. Choose a folder again at next launch. The file name cannot be empty. PDF name e.g. january_invoices diff --git a/mobile-kotlin/app/src/main/res/values/strings.xml b/mobile-kotlin/app/src/main/res/values/strings.xml index 26cfb57..f7f8a87 100644 --- a/mobile-kotlin/app/src/main/res/values/strings.xml +++ b/mobile-kotlin/app/src/main/res/values/strings.xml @@ -108,6 +108,7 @@ Sélectionne au moins un fichier ou ajoute une note. Impossible de générer le PDF. Impossible d\'enregistrer le PDF. + Permission refusée pour le dossier VaultDrop. Choisis à nouveau un dossier au prochain lancement. Le nom du fichier ne peut pas être vide. Nom du PDF ex. factures_janvier