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