From f2c8a272523bb2cdad18e3bba0632caa1cc0ad3d Mon Sep 17 00:00:00 2001 From: m Date: Sat, 12 Sep 2026 12:02:51 +0200 Subject: [PATCH] pdf build --- .../data/preferences/DefaultRootStore.kt | 37 ++ .../data/repository/FolderRepository.kt | 3 + .../mobile/features/sync/SyncViewModel.kt | 4 + .../mobile/ui/components/SelectionState.kt | 61 +++ .../mobile/ui/folderlist/FolderListScreen.kt | 106 ++++ .../ui/folderlist/FolderListViewModel.kt | 12 + .../mobile/ui/navigation/NavGraph.kt | 5 + .../mobile/ui/pdfbuilder/PdfBuilderEngine.kt | 315 +++++++++++ .../mobile/ui/pdfbuilder/PdfBuilderItem.kt | 32 ++ .../mobile/ui/pdfbuilder/PdfBuilderScreen.kt | 492 ++++++++++++++++++ .../ui/pdfbuilder/PdfBuilderViewModel.kt | 328 ++++++++++++ .../app/src/main/res/values-en/strings.xml | 8 + .../app/src/main/res/values/strings.xml | 8 + 13 files changed, 1411 insertions(+) create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/preferences/DefaultRootStore.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/components/SelectionState.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/pdfbuilder/PdfBuilderEngine.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/pdfbuilder/PdfBuilderItem.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/pdfbuilder/PdfBuilderScreen.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/pdfbuilder/PdfBuilderViewModel.kt diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/preferences/DefaultRootStore.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/preferences/DefaultRootStore.kt new file mode 100644 index 0000000..0b49036 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/preferences/DefaultRootStore.kt @@ -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" + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FolderRepository.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FolderRepository.kt index bed9617..d9739aa 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FolderRepository.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FolderRepository.kt @@ -36,6 +36,9 @@ class FolderRepository @Inject constructor( suspend fun getFolder(resourceId: String): FolderEntity? = 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 * n'ont pas d'uri (uri = NULL → cloud-only). En V1 le serveur ne renvoie diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/SyncViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/SyncViewModel.kt index ba34e3c..14ede44 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/SyncViewModel.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/sync/SyncViewModel.kt @@ -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 { /** Cadence de la boucle — même valeur que `useSyncDevice(30_000)` Expo. */ const val INTERVAL_MS = 30_000L diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/components/SelectionState.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/components/SelectionState.kt new file mode 100644 index 0000000..6004d44 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/components/SelectionState.kt @@ -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>(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), + ) +} \ No newline at end of file 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 7557038..b0deff9 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 @@ -12,6 +12,7 @@ import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable 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 @@ -41,7 +42,12 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar 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.graphics.Color 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.navigation.FloatingNavBar import com.vaultdrop.mobile.ui.navigation.NavTab +import kotlinx.coroutines.delay import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @@ -79,10 +86,47 @@ fun FolderListScreen( val uiState by viewModel.uiState.collectAsStateWithLifecycle() val importState by syncViewModel.importState.collectAsStateWithLifecycle() val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle() + val defaultRootId by viewModel.defaultRootId.collectAsStateWithLifecycle() val context = LocalContext.current val folderLabel = stringResource(R.string.folder) 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(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( ActivityResultContracts.OpenDocumentTree(), ) { 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() } 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). */ private fun Uri.displayName(context: Context): String? = runCatching { val docId = DocumentsContract.getTreeDocumentId(this) 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 397227b..14ffb0c 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 @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import com.vaultdrop.mobile.auth.TokenProvider import com.vaultdrop.mobile.data.local.entity.FileEntity 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.repository.FileRepository import com.vaultdrop.mobile.data.repository.FolderRepository @@ -27,16 +28,27 @@ class FolderListViewModel @Inject constructor( private val folderRepository: FolderRepository, private val fileRepository: FileRepository, private val tokenProvider: TokenProvider, + private val defaultRootStore: DefaultRootStore, ) : ViewModel() { private val _uiState = MutableStateFlow(FolderListUiState()) val uiState: StateFlow = _uiState.asStateFlow() + /** Racine VaultDrop obligatoire : null = premier lancement (onboarding). */ + private val _defaultRootId = MutableStateFlow(defaultRootStore.get()) + val defaultRootId: StateFlow = _defaultRootId.asStateFlow() + init { observeFiles() 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). */ private fun observeFiles() { viewModelScope.launch { diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt index ae16b0b..9974d38 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt @@ -149,6 +149,11 @@ fun NavGraph( PdfBuilderScreen( initialResourceIds = ids, onBack = { navController.popBackStack() }, + onOpenDocument = { id -> + navController.navigate(Routes.document(id)) { + popUpTo(Routes.PDF_BUILDER) { inclusive = true } + } + }, ) } } diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/pdfbuilder/PdfBuilderEngine.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/pdfbuilder/PdfBuilderEngine.kt new file mode 100644 index 0000000..3e6fbac --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/pdfbuilder/PdfBuilderEngine.kt @@ -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, + outputFile: File, + onProgress: (Float) -> Unit, + ): PdfBuildResult = withContext(Dispatchers.IO) { + val failed = ArrayList() + 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, +) \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/pdfbuilder/PdfBuilderItem.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/pdfbuilder/PdfBuilderItem.kt new file mode 100644 index 0000000..6c35eee --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/pdfbuilder/PdfBuilderItem.kt @@ -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) \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/pdfbuilder/PdfBuilderScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/pdfbuilder/PdfBuilderScreen.kt new file mode 100644 index 0000000..e7ae7aa --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/pdfbuilder/PdfBuilderScreen.kt @@ -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, + 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, + existingIds: Set, + onDismiss: () -> Unit, + onConfirm: (Set) -> 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)) +} \ No newline at end of file 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 new file mode 100644 index 0000000..4a67066 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/pdfbuilder/PdfBuilderViewModel.kt @@ -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>(emptyList()) + val items: StateFlow> = _items.asStateFlow() + + private val _buildState = MutableStateFlow(BuildPhase.Idle) + val buildState: StateFlow = _buildState.asStateFlow() + + private val _availableFiles = MutableStateFlow>(emptyList()) + val availableFiles: StateFlow> = _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) { + 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) { + 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 +} \ No newline at end of file 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 429184b..b914e83 100644 --- a/mobile-kotlin/app/src/main/res/values-en/strings.xml +++ b/mobile-kotlin/app/src/main/res/values-en/strings.xml @@ -3,6 +3,10 @@ Files Folder + Choose your VaultDrop folder + 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. + Choose my VaultDrop folder + VaultDrop Add a folder No folders yet. Tap "Add a folder" to sync a folder. No files yet. Tap "Add a folder" to sync your files. @@ -96,6 +100,10 @@ Select at least one file or add a note. Could not generate the PDF. Could not save the PDF. + The file name cannot be empty. + PDF name + e.g. january_invoices + Save PDF created Generated PDFs \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/res/values/strings.xml b/mobile-kotlin/app/src/main/res/values/strings.xml index 55be93b..26cfb57 100644 --- a/mobile-kotlin/app/src/main/res/values/strings.xml +++ b/mobile-kotlin/app/src/main/res/values/strings.xml @@ -2,6 +2,10 @@ VaultDrop + Choisis ton dossier VaultDrop + 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. + Choisir mon dossier VaultDrop + VaultDrop Fichiers Dossier Ajouter un dossier @@ -104,6 +108,10 @@ Sélectionne au moins un fichier ou ajoute une note. Impossible de générer le PDF. Impossible d\'enregistrer le PDF. + Le nom du fichier ne peut pas être vide. + Nom du PDF + ex. factures_janvier + Enregistrer PDF créé PDF générés \ No newline at end of file