generate pdf and save them to the directory

This commit is contained in:
m
2026-09-12 12:17:00 +02:00
parent f2c8a27252
commit e30051d238
5 changed files with 105 additions and 20 deletions
@@ -70,6 +70,7 @@ import com.vaultdrop.mobile.ui.components.rememberSelectionState
import com.vaultdrop.mobile.ui.navigation.FloatingNavBar import com.vaultdrop.mobile.ui.navigation.FloatingNavBar
import com.vaultdrop.mobile.ui.navigation.NavTab import com.vaultdrop.mobile.ui.navigation.NavTab
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import timber.log.Timber
import java.util.Locale import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -103,23 +104,27 @@ fun FolderListScreen(
uri, uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, 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 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 // Attend la création de la racine (importRoot la sauvegarde puis lance le
// lance le walk) et l'enregistre comme racine par défaut. // walk), crée le sous-dossier « VaultDrop » et l'enregistre comme racine.
LaunchedEffect(pendingDefaultPick) { LaunchedEffect(pendingDefaultPick) {
val uri = pendingDefaultPick ?: return@LaunchedEffect val uri = pendingDefaultPick ?: return@LaunchedEffect
val target = uri.toString() val target = uri.toString()
var attempts = 0 var attempts = 0
while (attempts < 100) { // ~10 s max while (attempts < 100) { // ~10 s max
val id = syncViewModel.rootResourceId(target) val rootId = syncViewModel.rootResourceId(target)
if (id != null) { if (rootId != null) {
pendingDefaultPick = null val vaultFolderId = viewModel.ensureVaultDropFolder(uri, rootId)
viewModel.setDefaultRoot(id) if (vaultFolderId != null) {
return@LaunchedEffect pendingDefaultPick = null
viewModel.setDefaultRoot(vaultFolderId)
return@LaunchedEffect
}
} }
delay(100) delay(100)
attempts++ attempts++
@@ -1,7 +1,11 @@
package com.vaultdrop.mobile.ui.folderlist 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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vaultdrop.mobile.R
import com.vaultdrop.mobile.auth.TokenProvider import com.vaultdrop.mobile.auth.TokenProvider
import com.vaultdrop.mobile.data.local.entity.FileEntity import com.vaultdrop.mobile.data.local.entity.FileEntity
import com.vaultdrop.mobile.data.local.referenceDate 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.remote.ApiException
import com.vaultdrop.mobile.data.repository.FileRepository import com.vaultdrop.mobile.data.repository.FileRepository
import com.vaultdrop.mobile.data.repository.FolderRepository import com.vaultdrop.mobile.data.repository.FolderRepository
import com.vaultdrop.mobile.data.repository.SaveFolderInput
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import java.io.FileNotFoundException
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@@ -29,6 +36,7 @@ class FolderListViewModel @Inject constructor(
private val fileRepository: FileRepository, private val fileRepository: FileRepository,
private val tokenProvider: TokenProvider, private val tokenProvider: TokenProvider,
private val defaultRootStore: DefaultRootStore, private val defaultRootStore: DefaultRootStore,
@ApplicationContext private val context: Context,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(FolderListUiState()) private val _uiState = MutableStateFlow(FolderListUiState())
@@ -49,6 +57,43 @@ class FolderListViewModel @Inject constructor(
_defaultRootId.value = resourceId _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). */ /** Grille d'accueil : tous les fichiers visibles, groupés par jour (date de référence). */
private fun observeFiles() { private fun observeFiles() {
viewModelScope.launch { viewModelScope.launch {
@@ -206,7 +206,8 @@ class PdfBuilderViewModel @Inject constructor(
runCatching { runCatching {
val root = folderRepository.getFolder(rootResourceId) val root = folderRepository.getFolder(rootResourceId)
?: error("default root not found: $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 fileName = if (trimmed.lowercase().endsWith(".pdf")) trimmed else "$trimmed.pdf"
val createdUri = DocumentsContract.createDocument( val createdUri = DocumentsContract.createDocument(
@@ -251,13 +252,39 @@ class PdfBuilderViewModel @Inject constructor(
_buildState.value = BuildPhase.Saved(resourceId) _buildState.value = BuildPhase.Saved(resourceId)
}.onFailure { e -> }.onFailure { e ->
Timber.e(e, "PDF save failed") 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 // ------------------------------------------------------------ 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) { private fun copyInto(uri: Uri, source: File, resolver: ContentResolver) {
val target = resolver.openOutputStream(uri) val target = resolver.openOutputStream(uri)
?: error("cannot open output stream") ?: error("cannot open output stream")
@@ -281,20 +308,26 @@ class PdfBuilderViewModel @Inject constructor(
}.getOrNull() }.getOrNull()
/** /**
* Associe l'URI créé au dossier SAF importé qui le contient (par préfixe * Associe l'URI créé au dossier (racine ou sous-dossier) dont le documentId
* du documentId), sinon à un dossier racine local « PDF générés ». * 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 { private suspend fun resolveTargetFolder(uri: Uri): String {
val documentId = runCatching { DocumentsContract.getDocumentId(uri) }.getOrNull() val documentId = runCatching { DocumentsContract.getDocumentId(uri) }.getOrNull()
if (documentId != null) { if (documentId != null) {
folderRepository.getRootFolders().filter { it.uri != null }.forEach { folder -> val matches = folderRepository.getAll()
val treeDocId = runCatching { .filter { it.uri != null }
DocumentsContract.getDocumentId(Uri.parse(folder.uri)) .mapNotNull { folder ->
}.getOrNull() val treeDocId = runCatching {
if (treeDocId != null && documentId.startsWith("$treeDocId/")) { DocumentsContract.getDocumentId(Uri.parse(folder.uri))
return folder.resourceId }.getOrNull()
if (treeDocId != null && documentId.startsWith("$treeDocId/")) {
folder to treeDocId.length
} else {
null
}
} }
} matches.maxByOrNull { it.second }?.first?.resourceId?.let { return it }
} }
return generatedFolderResourceId() return generatedFolderResourceId()
} }
@@ -100,6 +100,7 @@
<string name="pdf_builder_no_items">Select at least one file or add a note.</string> <string name="pdf_builder_no_items">Select at least one file or add a note.</string>
<string name="pdf_builder_error">Could not generate the PDF.</string> <string name="pdf_builder_error">Could not generate the PDF.</string>
<string name="pdf_builder_save_error">Could not save the PDF.</string> <string name="pdf_builder_save_error">Could not save the PDF.</string>
<string name="pdf_builder_save_error_permission">Permission denied for the VaultDrop folder. Choose a folder again at next launch.</string>
<string name="pdf_builder_name_required">The file name cannot be empty.</string> <string name="pdf_builder_name_required">The file name cannot be empty.</string>
<string name="pdf_builder_name_title">PDF name</string> <string name="pdf_builder_name_title">PDF name</string>
<string name="pdf_builder_name_hint">e.g. january_invoices</string> <string name="pdf_builder_name_hint">e.g. january_invoices</string>
@@ -108,6 +108,7 @@
<string name="pdf_builder_no_items">Sélectionne au moins un fichier ou ajoute une note.</string> <string name="pdf_builder_no_items">Sélectionne au moins un fichier ou ajoute une note.</string>
<string name="pdf_builder_error">Impossible de générer le PDF.</string> <string name="pdf_builder_error">Impossible de générer le PDF.</string>
<string name="pdf_builder_save_error">Impossible d\'enregistrer le PDF.</string> <string name="pdf_builder_save_error">Impossible d\'enregistrer le PDF.</string>
<string name="pdf_builder_save_error_permission">Permission refusée pour le dossier VaultDrop. Choisis à nouveau un dossier au prochain lancement.</string>
<string name="pdf_builder_name_required">Le nom du fichier ne peut pas être vide.</string> <string name="pdf_builder_name_required">Le nom du fichier ne peut pas être vide.</string>
<string name="pdf_builder_name_title">Nom du PDF</string> <string name="pdf_builder_name_title">Nom du PDF</string>
<string name="pdf_builder_name_hint">ex. factures_janvier</string> <string name="pdf_builder_name_hint">ex. factures_janvier</string>