pdf build

This commit is contained in:
m
2026-09-12 12:02:51 +02:00
parent 3a232138bd
commit f2c8a27252
13 changed files with 1411 additions and 0 deletions
@@ -0,0 +1,37 @@
package com.vaultdrop.mobile.data.preferences
import android.content.Context
import android.content.SharedPreferences
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Racine VaultDrop : le dossier SAF unique et obligatoire choisi au premier
* lancement. Tous les exports (assemblage PDF) y sont écrits directement,
* sans sélecteur — la permission persistante sur l'arbre est acquise à ce
* moment-là.
*/
@Singleton
class DefaultRootStore @Inject constructor(
@ApplicationContext context: Context,
) {
private val prefs: SharedPreferences =
context.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE)
fun get(): String? = prefs.getString(KEY_ROOT_RESOURCE_ID, null)
fun set(resourceId: String) {
prefs.edit().putString(KEY_ROOT_RESOURCE_ID, resourceId).apply()
}
fun clear() {
prefs.edit().remove(KEY_ROOT_RESOURCE_ID).apply()
}
companion object {
private const val FILE_NAME = "vaultdrop_prefs"
private const val KEY_ROOT_RESOURCE_ID = "vaultdrop.default_root_resource_id"
}
}
@@ -36,6 +36,9 @@ class FolderRepository @Inject constructor(
suspend fun getFolder(resourceId: String): FolderEntity? = suspend fun getFolder(resourceId: String): FolderEntity? =
folderDao.getByResourceId(resourceId) folderDao.getByResourceId(resourceId)
/** Récupère un dossier par son uri SAF (utilisé pour retrouver la racine par défaut). */
suspend fun getByUri(uri: String): FolderEntity? = folderDao.getByUri(uri)
/** /**
* Fetch `GET /files/folders` et upsert dans Room. Les dossiers cloud * Fetch `GET /files/folders` et upsert dans Room. Les dossiers cloud
* n'ont pas d'uri (uri = NULL → cloud-only). En V1 le serveur ne renvoie * n'ont pas d'uri (uri = NULL → cloud-only). En V1 le serveur ne renvoie
@@ -92,6 +92,10 @@ class SyncViewModel @Inject constructor(
} }
} }
/** Id Room d'une racine SAF à partir de son uri (après un `importRoot`). */
suspend fun rootResourceId(uri: String): String? =
folderRepository.getByUri(uri)?.resourceId
companion object { companion object {
/** Cadence de la boucle — même valeur que `useSyncDevice(30_000)` Expo. */ /** Cadence de la boucle — même valeur que `useSyncDevice(30_000)` Expo. */
const val INTERVAL_MS = 30_000L const val INTERVAL_MS = 30_000L
@@ -0,0 +1,61 @@
package com.vaultdrop.mobile.ui.components
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* État du mode multi-sélection (long-press pour entrer, tap pour cocher).
* Volatile : perdu à la rotation, ce qui est acceptable pour une sélection
* transitoire.
*/
@Stable
class SelectionState {
var active by mutableStateOf(false)
private set
var ids by mutableStateOf<Set<String>>(emptySet())
private set
fun start(id: String) {
active = true
ids = setOf(id)
}
fun toggle(id: String) {
ids = if (id in ids) ids - id else ids + id
}
fun clear() {
active = false
ids = emptySet()
}
}
@Composable
fun rememberSelectionState(): SelectionState = remember { SelectionState() }
/** Pastille de sélection affichée sur une carte en mode multi-sélection. */
@Composable
fun SelectionStatusIcon(selected: Boolean, modifier: Modifier = Modifier) {
Icon(
imageVector = if (selected) Icons.Filled.CheckCircle else Icons.Filled.RadioButtonUnchecked,
contentDescription = null,
tint = if (selected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = modifier.size(24.dp),
)
}
@@ -12,6 +12,7 @@ import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -41,7 +42,12 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -63,6 +69,7 @@ import com.vaultdrop.mobile.ui.components.ServerStatusBadge
import com.vaultdrop.mobile.ui.components.rememberSelectionState 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 java.util.Locale import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -79,10 +86,47 @@ fun FolderListScreen(
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val importState by syncViewModel.importState.collectAsStateWithLifecycle() val importState by syncViewModel.importState.collectAsStateWithLifecycle()
val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle() val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle()
val defaultRootId by viewModel.defaultRootId.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
val folderLabel = stringResource(R.string.folder) val folderLabel = stringResource(R.string.folder)
val selection = rememberSelectionState() val selection = rememberSelectionState()
// Racine par défaut : dossier VaultDrop choisi au premier lancement.
val defaultRootLabel = stringResource(R.string.default_root_folder_label)
var pendingDefaultPick by remember { mutableStateOf<Uri?>(null) }
val pickDefaultRootLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocumentTree(),
) { uri: Uri? ->
if (uri == null) return@rememberLauncherForActivityResult
runCatching {
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
}
pendingDefaultPick = uri
syncViewModel.importRoot(uri = uri.toString(), name = defaultRootLabel)
}
// Attend la création de la racine (importRoot la sauvegarde en Room puis
// lance le walk) et l'enregistre comme racine par défaut.
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
}
delay(100)
attempts++
}
pendingDefaultPick = null
}
val pickFolderLauncher = rememberLauncherForActivityResult( val pickFolderLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocumentTree(), ActivityResultContracts.OpenDocumentTree(),
) { uri: Uri? -> ) { uri: Uri? ->
@@ -100,6 +144,15 @@ fun FolderListScreen(
) )
} }
if (defaultRootId == null) {
DefaultRootOnboarding(
importing = importState.isImporting,
error = importState.error,
onPickFolder = { pickDefaultRootLauncher.launch(null) },
)
return
}
BackHandler(enabled = selection.active) { selection.clear() } BackHandler(enabled = selection.active) { selection.clear() }
Scaffold( Scaffold(
@@ -165,6 +218,59 @@ fun FolderListScreen(
} }
} }
/** Écran obligatoire du premier lancement : choix/création de la racine VaultDrop. */
@Composable
private fun DefaultRootOnboarding(
importing: Boolean,
error: String?,
onPickFolder: () -> Unit,
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = stringResource(R.string.default_root_title),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
Text(
text = stringResource(R.string.default_root_message),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Button(
onClick = onPickFolder,
enabled = !importing,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.Add, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.default_root_pick))
}
if (importing) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
error?.let { message ->
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
)
}
}
}
}
/** Nom affiché d'un dossier SAF via DocumentsContract (DISPLAY_NAME). */ /** Nom affiché d'un dossier SAF via DocumentsContract (DISPLAY_NAME). */
private fun Uri.displayName(context: Context): String? = runCatching { private fun Uri.displayName(context: Context): String? = runCatching {
val docId = DocumentsContract.getTreeDocumentId(this) val docId = DocumentsContract.getTreeDocumentId(this)
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
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
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
@@ -27,16 +28,27 @@ class FolderListViewModel @Inject constructor(
private val folderRepository: FolderRepository, private val folderRepository: FolderRepository,
private val fileRepository: FileRepository, private val fileRepository: FileRepository,
private val tokenProvider: TokenProvider, private val tokenProvider: TokenProvider,
private val defaultRootStore: DefaultRootStore,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(FolderListUiState()) private val _uiState = MutableStateFlow(FolderListUiState())
val uiState: StateFlow<FolderListUiState> = _uiState.asStateFlow() val uiState: StateFlow<FolderListUiState> = _uiState.asStateFlow()
/** Racine VaultDrop obligatoire : null = premier lancement (onboarding). */
private val _defaultRootId = MutableStateFlow(defaultRootStore.get())
val defaultRootId: StateFlow<String?> = _defaultRootId.asStateFlow()
init { init {
observeFiles() observeFiles()
refresh() refresh()
} }
/** Enregistre la racine choisie au premier lancement (persistant). */
fun setDefaultRoot(resourceId: String) {
defaultRootStore.set(resourceId)
_defaultRootId.value = 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 {
@@ -149,6 +149,11 @@ fun NavGraph(
PdfBuilderScreen( PdfBuilderScreen(
initialResourceIds = ids, initialResourceIds = ids,
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
onOpenDocument = { id ->
navController.navigate(Routes.document(id)) {
popUpTo(Routes.PDF_BUILDER) { inclusive = true }
}
},
) )
} }
} }
@@ -0,0 +1,315 @@
package com.vaultdrop.mobile.ui.pdfbuilder
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.Typeface
import android.graphics.pdf.PdfDocument
import android.graphics.pdf.PdfRenderer
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import com.vaultdrop.mobile.data.local.entity.FileEntity
import com.vaultdrop.mobile.domain.FileCategory
import com.vaultdrop.mobile.ui.components.categoryValue
import com.vaultdrop.mobile.ui.document.content.DocumentContent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import java.io.File
import javax.inject.Inject
import kotlin.coroutines.coroutineContext
/**
* Générateur de PDF d'assemblage (APIs système, aucune dépendance).
*
* - PDF source → pages au format source conservé, rendues en bitmap
* (`PdfRenderer`, mode PRINT) puis dessinées dans le nouveau document.
* - Image → page A4 portrait/paysage selon l'orientation, image centrée.
* - Note → texte paginé vectoriel (`StaticLayout` : sélectionnable, léger).
*
* Les bitmaps sont recyclés page par page et la génération est annulable
* (`ensureActive` par page). L'échec d'un item n'arrête pas le lot : les IDs
* échoués sont retournés dans [PdfBuildResult].
*/
class PdfBuilderEngine @Inject constructor() {
suspend fun buildPdf(
context: Context,
items: List<PdfBuilderItem>,
outputFile: File,
onProgress: (Float) -> Unit,
): PdfBuildResult = withContext(Dispatchers.IO) {
val failed = ArrayList<String>()
val total = items.size.coerceAtLeast(1)
outputFile.outputStream().buffered().use { out ->
val document = PdfDocument()
try {
items.forEachIndexed { index, item ->
coroutineContext.ensureActive()
onProgress(index.toFloat() / total)
val ok = when (item) {
is PdfBuilderItem.FileItem -> renderFileItem(context, document, item.file)
is PdfBuilderItem.NoteItem -> renderNoteItem(document, item.body)
}
if (!ok) failed += item.id
onProgress((index + 1).toFloat() / total)
}
document.writeTo(out)
} finally {
document.close()
}
}
PdfBuildResult(outputFile = outputFile, failedIds = failed)
}
// ------------------------------------------------------------------ PDF
private suspend fun renderFileItem(
context: Context,
document: PdfDocument,
file: FileEntity,
): Boolean = when (file.categoryValue()) {
FileCategory.PDF -> appendPdf(context, document, file)
FileCategory.IMAGE -> appendImage(context, document, file)
FileCategory.TEXT -> appendTextFile(context, document, file)
else -> false
}
private suspend fun appendPdf(
context: Context,
document: PdfDocument,
file: FileEntity,
): Boolean {
val uri = file.uri ?: return false
val pfd = DocumentContent.openFileDescriptor(context.contentResolver, uri)
?: return false
var renderer: PdfRenderer? = null
var appended = false
try {
renderer = runCatching { PdfRenderer(pfd) }.getOrNull() ?: return false
repeat(renderer.pageCount) { pageIndex ->
coroutineContext.ensureActive()
val page = renderer.openPage(pageIndex)
try {
val srcW = page.width.toFloat()
val srcH = page.height.toFloat()
if (srcW <= 0f || srcH <= 0f) return@repeat
val maxDim = maxOf(srcW, srcH)
val scale = minOf(1f, MAX_PAGE_POINTS / maxDim, MAX_IMAGE_PX / maxDim)
val pageW = (srcW * scale).toInt().coerceAtLeast(1)
val pageH = (srcH * scale).toInt().coerceAtLeast(1)
val bitmap = Bitmap.createBitmap(pageW, pageH, Bitmap.Config.ARGB_8888)
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_PRINT)
appendBitmapPage(document, bitmap, pageW, pageH, dst = null)
bitmap.recycle()
appended = true
} finally {
page.close()
}
}
return appended
} catch (_: Exception) {
return appended
} finally {
renderer?.close()
pfd.close()
}
}
// ---------------------------------------------------------------- Image
private fun appendImage(
context: Context,
document: PdfDocument,
file: FileEntity,
): Boolean { val uri = file.uri ?: return false
val sample = runCatching {
DocumentContent.openInputStream(context.contentResolver, uri)?.use { input ->
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeStream(input, null, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return false
var sampler = 1
while (bounds.outWidth / sampler > MAX_IMAGE_PX ||
bounds.outHeight / sampler > MAX_IMAGE_PX
) {
sampler *= 2
}
sampler
}
}.getOrNull() ?: return false
val bitmap = DocumentContent.openInputStream(context.contentResolver, uri)
?.use { input ->
val options = BitmapFactory.Options().apply { inSampleSize = sample }
BitmapFactory.decodeStream(input, null, options)
}
?: return false
val landscape = bitmap.width > bitmap.height
val pageWidth = if (landscape) A4_H.toInt() else A4_W.toInt()
val pageHeight = if (landscape) A4_W.toInt() else A4_H.toInt()
val dst = fitRect(bitmap.width, bitmap.height, pageWidth.toFloat(), pageHeight.toFloat())
appendBitmapPage(document, bitmap, pageWidth, pageHeight, dst)
bitmap.recycle()
return true
}
// ------------------------------------------------------------------ Texte
private suspend fun appendTextFile(
context: Context,
document: PdfDocument,
file: FileEntity,
): Boolean {
val uri = file.uri ?: return false
val stream = DocumentContent.openInputStream(context.contentResolver, uri)
?: return false
stream.use { input ->
val buffer = ByteArray(MAX_NOTE_CHARS * 4)
var offset = 0
while (offset < buffer.size) {
val read = input.read(buffer, offset, buffer.size - offset)
if (read < 0) break
offset += read
}
val body = String(buffer, 0, offset, Charsets.UTF_8)
.removePrefix("\uFEFF")
.take(MAX_NOTE_CHARS)
return renderNoteItem(document, body)
}
}
private suspend fun renderNoteItem(
document: PdfDocument,
body: String,
): Boolean {
val clean = body.replace('\u0000', ' ').trim()
if (clean.isEmpty()) return true
val textWidth = (A4_W - NOTE_MARGIN * 2f).toInt()
val textHeight = A4_H - NOTE_MARGIN * 2f
val paint = TextPaint().apply {
isAntiAlias = true
color = Color.BLACK
textSize = NOTE_TEXT_SIZE
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL)
}
var remaining = clean
var pages = 0
try {
while (remaining.isNotEmpty() && pages < MAX_NOTE_PAGES) {
coroutineContext.ensureActive()
val layout = buildLayout(remaining, textWidth, paint)
if (layout.lineCount == 0) break
val lineHeight: Float = if (layout.lineCount >= 2) {
(layout.getLineTop(1) - layout.getLineTop(0)).toFloat()
} else {
paint.fontSpacing
}
if (lineHeight <= 0f) break
val linesThatFit = (textHeight / lineHeight).toInt().coerceAtLeast(1)
val lastLine = (linesThatFit - 1).coerceAtMost(layout.lineCount - 1)
val endOffset = layout.getLineEnd(lastLine)
if (endOffset <= 0) break
val pageText = remaining.substring(0, endOffset)
val pageLayout = buildLayout(pageText, textWidth, paint)
val info = PdfDocument.PageInfo.Builder(A4_W.toInt(), A4_H.toInt(), pages).create()
val page = document.startPage(info)
page.canvas.drawColor(Color.WHITE)
page.canvas.save()
page.canvas.translate(NOTE_MARGIN, NOTE_MARGIN)
pageLayout.draw(page.canvas)
page.canvas.restore()
document.finishPage(page)
remaining = remaining.substring(endOffset).trimStart()
pages++
}
return true
} catch (_: Exception) {
return false
}
}
private fun buildLayout(text: String, width: Int, paint: TextPaint): StaticLayout =
StaticLayout.Builder.obtain(text, 0, text.length, paint, width)
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
.setLineSpacing(0f, LINE_SPACING)
.setIncludePad(false)
.build()
// ------------------------------------------------------------- Common
private fun appendBitmapPage(
document: PdfDocument,
bitmap: Bitmap,
pageWidth: Int,
pageHeight: Int,
dst: RectF?,
) {
val info = PdfDocument.PageInfo.Builder(
pageWidth.coerceAtLeast(1),
pageHeight.coerceAtLeast(1),
document.pages.size,
).create()
val page = document.startPage(info)
page.canvas.drawColor(Color.WHITE)
if (dst != null) {
page.canvas.drawBitmap(bitmap, null, dst, SCALE_PAINT)
} else {
page.canvas.drawBitmap(
bitmap,
null,
RectF(0f, 0f, pageWidth.toFloat(), pageHeight.toFloat()),
SCALE_PAINT,
)
}
document.finishPage(page)
}
/** Rectangle d'affichage d'une image centrée dans une page, marges incluses. */
private fun fitRect(bmpW: Int, bmpH: Int, pageW: Float, pageH: Float): RectF {
val availW = pageW - IMAGE_MARGIN * 2f
val availH = pageH - IMAGE_MARGIN * 2f
val scale = minOf(availW / bmpW, availH / bmpH).coerceAtMost(1f)
val w = bmpW * scale
val h = bmpH * scale
val left = (pageW - w) / 2f
val top = (pageH - h) / 2f
return RectF(left, top, left + w, top + h)
}
companion object {
private const val A4_W = 595f
private const val A4_H = 842f
private const val NOTE_MARGIN = 48f
private const val NOTE_TEXT_SIZE = 11f
private const val LINE_SPACING = 1.4f
private const val IMAGE_MARGIN = 40f
/** Bornes mémoire : pages et bitmaps jamais au-delà. */
private const val MAX_PAGE_POINTS = 1200f
private const val MAX_IMAGE_PX = 3000
private const val MAX_NOTE_CHARS = 100_000
private const val MAX_NOTE_PAGES = 30
private val SCALE_PAINT = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
}
}
data class PdfBuildResult(
val outputFile: File,
val failedIds: List<String>,
)
@@ -0,0 +1,32 @@
package com.vaultdrop.mobile.ui.pdfbuilder
import com.vaultdrop.mobile.data.local.entity.FileEntity
import com.vaultdrop.mobile.domain.FileCategory
import com.vaultdrop.mobile.ui.components.categoryValue
/**
* Item de l'assemblage PDF : un fichier lisible localement (PDF / image / texte)
* ou une note de texte libre. L'ordre de la liste est l'ordre du PDF final.
*/
sealed interface PdfBuilderItem {
val id: String
data class FileItem(
val file: FileEntity,
val failed: Boolean = false,
) : PdfBuilderItem {
override val id: String get() = file.resourceId
}
data class NoteItem(
override val id: String,
val body: String,
) : PdfBuilderItem
}
/** Fichier pouvant entrer dans un assemblage : copie locale + catégorie lisible. */
fun FileEntity.isPdfBuilderSource(): Boolean =
uri != null && categoryValue() in PDF_BUILDER_CATEGORIES
private val PDF_BUILDER_CATEGORIES =
setOf(FileCategory.PDF, FileCategory.IMAGE, FileCategory.TEXT)
@@ -0,0 +1,492 @@
package com.vaultdrop.mobile.ui.pdfbuilder
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.MergeType
import androidx.compose.material.icons.filled.TextSnippet
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vaultdrop.mobile.R
import com.vaultdrop.mobile.data.local.entity.FileEntity
import com.vaultdrop.mobile.ui.components.FileCategoryIcon
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PdfBuilderScreen(
initialResourceIds: List<String>,
onBack: () -> Unit,
onOpenDocument: (String) -> Unit,
viewModel: PdfBuilderViewModel = hiltViewModel(),
) {
val items by viewModel.items.collectAsStateWithLifecycle()
val buildPhase by viewModel.buildState.collectAsStateWithLifecycle()
val availableFiles by viewModel.availableFiles.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
var showNoteDialog by remember { mutableStateOf(false) }
var showFileSheet by remember { mutableStateOf(false) }
var showNameDialog by remember { mutableStateOf(false) }
// Nom suggéré par défaut : date du jour au format jour-mois-année.
val defaultPdfName = remember {
"${LocalDate.now().format(DEFAULT_DATE_FORMAT)}.pdf"
}
LaunchedEffect(initialResourceIds) {
viewModel.loadInitial(initialResourceIds)
}
LaunchedEffect(buildPhase) {
when (buildPhase) {
is BuildPhase.Ready -> showNameDialog = true
is BuildPhase.Saved -> onOpenDocument((buildPhase as BuildPhase.Saved).resourceId)
is BuildPhase.Failed -> {
snackbarHostState.showSnackbar((buildPhase as BuildPhase.Failed).message)
viewModel.acknowledgeFailure()
}
else -> Unit
}
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.pdf_builder_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back),
)
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding),
) {
when (buildPhase) {
is BuildPhase.Building -> LinearProgressIndicator(
progress = { (buildPhase as BuildPhase.Building).progress },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
else -> Spacer(Modifier.height(8.dp))
}
if (showNameDialog) {
NamePdfDialog(
defaultName = defaultPdfName,
onDismiss = { showNameDialog = false },
onConfirm = { name ->
showNameDialog = false
viewModel.saveAndImport(name)
},
)
}
if (showNoteDialog) {
AddNoteDialog(
onDismiss = { showNoteDialog = false },
onConfirm = { body ->
viewModel.addNote(body)
showNoteDialog = false
},
)
}
if (showFileSheet) {
SelectFilesSheet(
files = availableFiles,
existingIds = items.mapNotNull { item ->
(item as? PdfBuilderItem.FileItem)?.file?.resourceId
}.toSet(),
onDismiss = { showFileSheet = false },
onConfirm = { ids ->
viewModel.addFiles(ids.toList())
showFileSheet = false
},
)
}
if (items.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.pdf_builder_empty),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 32.dp),
)
}
} else {
LazyColumn(
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(items, key = { it.id }) { item ->
BuilderItemRow(
item = item,
onMoveUp = { viewModel.moveUp(item.id) },
onMoveDown = { viewModel.moveDown(item.id) },
onRemove = { viewModel.removeItem(item.id) },
)
}
}
}
BuilderActions(
onAddFile = { showFileSheet = true },
onAddNote = { showNoteDialog = true },
onAssemble = {
if (buildPhase is BuildPhase.Ready) showNameDialog = true
else viewModel.generate()
},
enabled = items.isNotEmpty(),
building = buildPhase is BuildPhase.Building,
modifier = Modifier.padding(16.dp),
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SelectFilesSheet(
files: List<FileEntity>,
existingIds: Set<String>,
onDismiss: () -> Unit,
onConfirm: (Set<String>) -> Unit,
) {
var selected by remember(files) { mutableStateOf(existingIds.toMutableSet()) }
val sheetState = rememberModalBottomSheetState()
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Text(
text = stringResource(R.string.pdf_builder_pick_files),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 16.dp),
)
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(files, key = { it.resourceId }) { file ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
if (file.resourceId in selected) selected.remove(file.resourceId)
else selected.add(file.resourceId)
}
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
FileCategoryIcon(file = file, size = 24.dp)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = file.name,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = formatSize(file.size),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.width(8.dp))
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
tint = if (file.resourceId in selected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f)
},
)
}
}
}
Button(
onClick = { onConfirm(selected) },
enabled = selected.isNotEmpty(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
Icon(Icons.Filled.Add, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.pdf_builder_add_files))
}
}
}
@Composable
private fun NamePdfDialog(
defaultName: String,
onDismiss: () -> Unit,
onConfirm: (String) -> Unit,
) {
var name by remember { mutableStateOf(defaultName) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.pdf_builder_name_title)) },
text = {
OutlinedTextField(
value = name,
onValueChange = { name = it },
placeholder = { Text(stringResource(R.string.pdf_builder_name_hint)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
},
confirmButton = {
TextButton(
onClick = { onConfirm(name.trim()) },
enabled = name.isNotBlank(),
) {
Text(stringResource(R.string.pdf_builder_name_save))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.pdf_builder_cancel))
}
},
)
}
@Composable
private fun AddNoteDialog(
onDismiss: () -> Unit,
onConfirm: (String) -> Unit,
) {
var body by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.pdf_builder_add_note)) },
text = {
OutlinedTextField(
value = body,
onValueChange = { body = it },
placeholder = { Text(stringResource(R.string.pdf_builder_note_hint)) },
minLines = 5,
modifier = Modifier.fillMaxWidth(),
)
},
confirmButton = {
TextButton(
onClick = { onConfirm(body) },
enabled = body.isNotBlank(),
) {
Text(stringResource(R.string.pdf_builder_add))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.pdf_builder_cancel))
}
},
)
}
@Composable
private fun BuilderItemRow(
item: PdfBuilderItem,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
onRemove: () -> Unit,
) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
when (item) {
is PdfBuilderItem.FileItem -> FileCategoryIcon(file = item.file, size = 24.dp)
is PdfBuilderItem.NoteItem -> Icon(
imageVector = Icons.Filled.TextSnippet,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp),
)
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
val isFailed = item is PdfBuilderItem.FileItem && item.failed
Text(
text = when (item) {
is PdfBuilderItem.FileItem -> item.file.name
is PdfBuilderItem.NoteItem -> stringResource(R.string.pdf_builder_note)
},
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = if (isFailed) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurface,
)
Text(
text = when (item) {
is PdfBuilderItem.FileItem -> if (isFailed) {
stringResource(R.string.pdf_builder_unreadable)
} else {
formatSize(item.file.size)
}
is PdfBuilderItem.NoteItem -> item.body
.replace('\n', ' ')
.take(80)
},
style = MaterialTheme.typography.labelMedium,
color = if (isFailed) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.width(4.dp))
IconButton(onClick = onMoveUp) {
Icon(Icons.Filled.KeyboardArrowUp, contentDescription = stringResource(R.string.pdf_builder_move_up))
}
IconButton(onClick = onMoveDown) {
Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.pdf_builder_move_down))
}
IconButton(onClick = onRemove) {
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.pdf_builder_remove))
}
}
}
}
@Composable
private fun BuilderActions(
onAddFile: () -> Unit,
onAddNote: () -> Unit,
onAssemble: () -> Unit,
enabled: Boolean,
building: Boolean,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = onAddFile,
enabled = !building,
modifier = Modifier.weight(1f),
) {
Icon(Icons.Filled.Add, contentDescription = null)
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.pdf_builder_add_files))
}
OutlinedButton(
onClick = onAddNote,
enabled = !building,
modifier = Modifier.weight(1f),
) {
Icon(Icons.Filled.TextSnippet, contentDescription = null)
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.pdf_builder_add_note))
}
}
Button(
onClick = onAssemble,
enabled = enabled && !building,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.MergeType, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.pdf_builder_assemble))
}
}
}
private val DEFAULT_DATE_FORMAT: DateTimeFormatter =
DateTimeFormatter.ofPattern("dd-MM-yyyy")
private fun formatSize(bytes: Long): String {
if (bytes < 1_024) return "$bytes o"
if (bytes < 1_024 * 1_024) {
return String.format(Locale.getDefault(), "%.1f ko", bytes / 1_024f)
}
return String.format(Locale.getDefault(), "%.1f Mo", bytes / (1_024f * 1_024f))
}
@@ -0,0 +1,328 @@
package com.vaultdrop.mobile.ui.pdfbuilder
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vaultdrop.mobile.R
import com.vaultdrop.mobile.data.local.entity.FileEntity
import com.vaultdrop.mobile.data.preferences.DefaultRootStore
import com.vaultdrop.mobile.data.repository.FileRepository
import com.vaultdrop.mobile.data.repository.FolderRepository
import com.vaultdrop.mobile.data.repository.SaveFileInput
import com.vaultdrop.mobile.domain.GenerateId
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
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 kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import javax.inject.Inject
/**
* ViewModel de l'assemblage PDF : panier ordonné (fichiers + notes), génération
* vers le cache puis sauvegarde (ACTION_CREATE_DOCUMENT) avec ré-import Room.
*
* L'URI créé est attaché au dossier SAF importé qui le contient (par préfixe de
* documentId). Sinon il atterrit dans un dossier racine local « PDF générés ».
* Dans les deux cas `FileRepository.saveLocalFile` déduplique par URI : un
* rescan SAF ne crée jamais de doublon.
*/
@HiltViewModel
class PdfBuilderViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val fileRepository: FileRepository,
private val folderRepository: FolderRepository,
private val defaultRootStore: DefaultRootStore,
private val generateId: GenerateId,
private val engine: PdfBuilderEngine,
) : ViewModel() {
private val _items = MutableStateFlow<List<PdfBuilderItem>>(emptyList())
val items: StateFlow<List<PdfBuilderItem>> = _items.asStateFlow()
private val _buildState = MutableStateFlow<BuildPhase>(BuildPhase.Idle)
val buildState: StateFlow<BuildPhase> = _buildState.asStateFlow()
private val _availableFiles = MutableStateFlow<List<FileEntity>>(emptyList())
val availableFiles: StateFlow<List<FileEntity>> = _availableFiles.asStateFlow()
init {
viewModelScope.launch {
fileRepository.observeAllVisible().collect { files ->
_availableFiles.value = files
}
}
}
/** Charge la sélection initiale (ids des fichiers cochés sur les écrans de liste). */
fun loadInitial(resourceIds: List<String>) {
if (resourceIds.isEmpty()) return
viewModelScope.launch {
val loaded = resourceIds.mapNotNull { fileRepository.getFile(it) }
.map { file ->
PdfBuilderItem.FileItem(file = file, failed = !file.isPdfBuilderSource())
}
_items.update { existing ->
existing + loaded.filterNot { item ->
existing.any { existingItem -> existingItem.id == item.id }
}
}
}
}
fun addFiles(resourceIds: List<String>) {
if (resourceIds.isEmpty()) return
viewModelScope.launch {
val loaded = resourceIds.mapNotNull { fileRepository.getFile(it) }
.map { file ->
PdfBuilderItem.FileItem(file = file, failed = !file.isPdfBuilderSource())
}
_items.update { existing ->
existing + loaded.filterNot { item ->
existing.any { existingItem -> existingItem.id == item.id }
}
}
}
}
fun addNote(body: String) {
val clean = body.trim()
if (clean.isEmpty()) return
_items.update {
it + PdfBuilderItem.NoteItem(
id = generateId.newResourceId(),
body = clean.take(MAX_NOTE_CHARS),
)
}
}
fun removeItem(id: String) {
_items.update { list -> list.filterNot { it.id == id } }
}
fun moveUp(id: String) {
_items.update { list ->
val index = list.indexOfFirst { it.id == id }
if (index <= 0) list
else list.toMutableList().apply {
add(index - 1, removeAt(index))
}
}
}
fun moveDown(id: String) {
_items.update { list ->
val index = list.indexOfFirst { it.id == id }
if (index < 0 || index >= list.size - 1) list
else list.toMutableList().apply {
add(index + 1, removeAt(index))
}
}
}
/** Lance la génération vers un fichier cache ; émet les échecs sur les items. */
fun generate() {
val current = _buildState.value
if (current is BuildPhase.Building) return
if (_items.value.isEmpty()) {
_buildState.value = BuildPhase.Failed(context.getString(R.string.pdf_builder_no_items))
return
}
_buildState.value = BuildPhase.Building(0f)
val snapshot = _items.value
val cacheFile = File(context.cacheDir, "vaultdrop_build_${System.currentTimeMillis()}.pdf")
viewModelScope.launch {
runCatching {
engine.buildPdf(
context = context,
items = snapshot,
outputFile = cacheFile,
onProgress = { p -> _buildState.value = BuildPhase.Building(p) },
)
}.onSuccess { result ->
if (result.failedIds.isNotEmpty()) {
val failedSet = result.failedIds.toHashSet()
_items.update { list ->
list.map { item ->
if (item is PdfBuilderItem.FileItem && item.id in failedSet) {
item.copy(failed = true)
} else item
}
}
}
_buildState.value = BuildPhase.Ready(result.outputFile)
}.onFailure { e ->
Timber.e(e, "PDF build failed")
cacheFile.delete()
_buildState.value = BuildPhase.Failed(
context.getString(R.string.pdf_builder_error),
)
}
}
}
/** Consomme un échec (le message a été affiché). */
fun acknowledgeFailure() {
if (_buildState.value is BuildPhase.Failed) _buildState.value = BuildPhase.Idle
}
/**
* Écrit le cache directement dans la racine VaultDrop (DocumentsContract.
* createDocument sur l'arbre) puis ré-importe le fichier dans Room.
*
* Le nom est obligatoire (Option C) : en vide, l'export est refusé.
* `takePersistableUriPermission` est best-effort — l'insert Room ne dépend
* jamais de cette permission.
*/
fun saveAndImport(name: String) {
val ready = _buildState.value as? BuildPhase.Ready ?: return
val cacheFile = ready.file
val trimmed = name.trim()
if (trimmed.isBlank()) {
_buildState.value = BuildPhase.Failed(
context.getString(R.string.pdf_builder_name_required),
)
return
}
val rootResourceId = defaultRootStore.get()
if (rootResourceId == null) {
_buildState.value = BuildPhase.Failed(
context.getString(R.string.pdf_builder_save_error),
)
return
}
viewModelScope.launch {
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 fileName = if (trimmed.lowercase().endsWith(".pdf")) trimmed else "$trimmed.pdf"
val createdUri = DocumentsContract.createDocument(
context.contentResolver,
rootUri,
"application/pdf",
fileName,
) ?: error("cannot create document in default root")
withContext(Dispatchers.IO) {
copyInto(createdUri, cacheFile, context.contentResolver)
}
val folderId = resolveTargetFolder(createdUri)
val actualName = displayName(createdUri) ?: fileName
val bytes = cacheFile.length()
val inserted = fileRepository.saveLocalFile(
input = SaveFileInput(
uri = createdUri.toString(),
name = actualName,
extension = "pdf",
size = bytes,
mimeType = "application/pdf",
lastModified = System.currentTimeMillis(),
exists = true,
syncStatus = "local",
),
folderResourceId = folderId,
)
cacheFile.delete()
// Best-effort : prolonge l'accès au-delà de l'intent initial.
runCatching {
context.contentResolver.takePersistableUriPermission(
createdUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
}.onFailure { Timber.w(it, "persistable uri permission absent") }
inserted.resourceId
}.onSuccess { resourceId ->
_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))
}
}
}
// ------------------------------------------------------------ helpers
private fun copyInto(uri: Uri, source: File, resolver: ContentResolver) {
val target = resolver.openOutputStream(uri)
?: error("cannot open output stream")
target.use { out ->
source.inputStream().use { input ->
input.copyTo(out)
}
}
}
private fun displayName(uri: Uri): String? = runCatching {
context.contentResolver.query(
uri,
arrayOf(OpenableColumns.DISPLAY_NAME),
null,
null,
null,
)?.use { cursor ->
if (cursor.moveToFirst()) cursor.getString(0) else null
}
}.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 ».
*/
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
}
}
}
return generatedFolderResourceId()
}
private suspend fun generatedFolderResourceId(): String {
val label = context.getString(R.string.pdf_generated_folder)
folderRepository.getRootFolders()
.firstOrNull { it.uri == null && it.name == label && it.parentResourceId == null }
?.let { return it.resourceId }
return folderRepository.saveFolder(
input = com.vaultdrop.mobile.data.repository.SaveFolderInput(
uri = null,
name = label,
exists = true,
),
parentResourceId = null,
).resourceId
}
companion object {
private const val MAX_NOTE_CHARS = 100_000
}
}
sealed interface BuildPhase {
data object Idle : BuildPhase
data class Building(val progress: Float) : BuildPhase
data class Ready(val file: File) : BuildPhase
data class Saved(val resourceId: String) : BuildPhase
data class Failed(val message: String) : BuildPhase
}
@@ -3,6 +3,10 @@
<string name="files">Files</string> <string name="files">Files</string>
<string name="folder">Folder</string> <string name="folder">Folder</string>
<string name="default_root_title">Choose your VaultDrop folder</string>
<string name="default_root_message">VaultDrop needs a folder to store all the PDFs it creates. You can also create a new VaultDrop folder right in the picker. This step is required, once only.</string>
<string name="default_root_pick">Choose my VaultDrop folder</string>
<string name="default_root_folder_label">VaultDrop</string>
<string name="add_folder">Add a folder</string> <string name="add_folder">Add a folder</string>
<string name="no_folders_yet">No folders yet. Tap "Add a folder" to sync a folder.</string> <string name="no_folders_yet">No folders yet. Tap "Add a folder" to sync a folder.</string>
<string name="no_files_yet">No files yet. Tap "Add a folder" to sync your files.</string> <string name="no_files_yet">No files yet. Tap "Add a folder" to sync your files.</string>
@@ -96,6 +100,10 @@
<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_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>
<string name="pdf_builder_name_save">Save</string>
<string name="pdf_builder_saved">PDF created</string> <string name="pdf_builder_saved">PDF created</string>
<string name="pdf_generated_folder">Generated PDFs</string> <string name="pdf_generated_folder">Generated PDFs</string>
</resources> </resources>
@@ -2,6 +2,10 @@
<string name="app_name">VaultDrop</string> <string name="app_name">VaultDrop</string>
<!-- Écran Fichiers --> <!-- Écran Fichiers -->
<string name="default_root_title">Choisis ton dossier VaultDrop</string>
<string name="default_root_message">VaultDrop a besoin d\'un dossier pour stocker tous les PDF créés. Tu peux aussi créer un nouveau dossier VaultDrop directement dans le sélecteur. Cette étape est obligatoire, une seule fois.</string>
<string name="default_root_pick">Choisir mon dossier VaultDrop</string>
<string name="default_root_folder_label">VaultDrop</string>
<string name="files">Fichiers</string> <string name="files">Fichiers</string>
<string name="folder">Dossier</string> <string name="folder">Dossier</string>
<string name="add_folder">Ajouter un dossier</string> <string name="add_folder">Ajouter un dossier</string>
@@ -104,6 +108,10 @@
<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_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>
<string name="pdf_builder_name_save">Enregistrer</string>
<string name="pdf_builder_saved">PDF créé</string> <string name="pdf_builder_saved">PDF créé</string>
<string name="pdf_generated_folder">PDF générés</string> <string name="pdf_generated_folder">PDF générés</string>
</resources> </resources>