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.NavTab
import kotlinx.coroutines.delay
import timber.log.Timber
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@@ -103,24 +104,28 @@ 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) {
val rootId = syncViewModel.rootResourceId(target)
if (rootId != null) {
val vaultFolderId = viewModel.ensureVaultDropFolder(uri, rootId)
if (vaultFolderId != null) {
pendingDefaultPick = null
viewModel.setDefaultRoot(id)
viewModel.setDefaultRoot(vaultFolderId)
return@LaunchedEffect
}
}
delay(100)
attempts++
}
@@ -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 {
@@ -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 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/")) {
return folder.resourceId
folder to treeDocId.length
} else {
null
}
}
matches.maxByOrNull { it.second }?.first?.resourceId?.let { return it }
}
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_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_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_title">PDF name</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_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_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_title">Nom du PDF</string>
<string name="pdf_builder_name_hint">ex. factures_janvier</string>