diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ServerUrlInterceptor.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ServerUrlInterceptor.kt new file mode 100644 index 0000000..555672d --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ServerUrlInterceptor.kt @@ -0,0 +1,42 @@ +package com.vaultdrop.mobile.data.remote + +import com.vaultdrop.mobile.domain.ServerConfigStore +import javax.inject.Inject +import javax.inject.Singleton +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Interceptor +import okhttp3.Response +import timber.log.Timber + +/** + * Réécrit chaque requête vers la base URL configurée à l'exécution (Réglages). + * La base Retrofit n'est qu'un placeholder ; c'est cet interceptor qui décide + * du schéma/host/port réels, en préservant le préfixe de chemin de la base + * configurée (ex. `/api/v1`) ainsi que la query d'origine. + */ +@Singleton +class ServerUrlInterceptor @Inject constructor( + private val serverConfigStore: ServerConfigStore, +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + + val targetUrl = runCatching { + (serverConfigStore.current.trimEnd('/') + "/").toHttpUrl() + }.getOrNull() + + if (targetUrl == null) { + Timber.w("server-url: base URL invalide, requête non réécrite") + return chain.proceed(request) + } + + val original = request.url + val rewritten = targetUrl.newBuilder() + .encodedPath(targetUrl.encodedPath.trimEnd('/') + "/" + original.encodedPath.trimStart('/')) + .encodedQuery(original.encodedQuery) + .build() + + return chain.proceed(request.newBuilder().url(rewritten).build()) + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/ServerConfigStore.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/ServerConfigStore.kt new file mode 100644 index 0000000..96aafc8 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/ServerConfigStore.kt @@ -0,0 +1,76 @@ +package com.vaultdrop.mobile.domain + +import com.vaultdrop.mobile.data.local.dao.UserPreferenceDao +import com.vaultdrop.mobile.data.local.entity.UserPreferenceEntity +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request + +/** + * Base URL du serveur configurée à l'exécution dans les Réglages. Persistée en + * `user_preferences` (clé `SERVER_URL`), exposée en [StateFlow] et lue par + * `ServerUrlInterceptor` pour réécrire chaque requête. Le client OkHttp dédié + * (`@Named("health")`, sans intercepteurs) sert au test de connexion direct. + */ +@Singleton +class ServerConfigStore @Inject constructor( + private val userPreferenceDao: UserPreferenceDao, + @Named("health") private val healthClient: OkHttpClient, +) { + + private val loaded = AtomicBoolean(false) + + private val _baseUrl = MutableStateFlow(DEFAULT_BASE_URL) + val baseUrl: StateFlow = _baseUrl.asStateFlow() + + /** Dernière base effective — lu par `ServerUrlInterceptor` (filaire, volatile). */ + val current: String + get() = _baseUrl.value + + /** Charge la valeur persistée au boot (idempotent). */ + suspend fun load() { + if (!loaded.compareAndSet(false, true)) return + val stored = userPreferenceDao.getValue(PrefKeys.SERVER_URL) + _baseUrl.value = stored?.takeIf { it.isNotBlank() }?.let(::normalize) ?: DEFAULT_BASE_URL + } + + suspend fun set(url: String) { + val normalized = normalize(url).ifEmpty { DEFAULT_BASE_URL } + _baseUrl.value = normalized + userPreferenceDao.upsert( + UserPreferenceEntity(PrefKeys.SERVER_URL, normalized, System.currentTimeMillis()), + ) + } + + /** Trim, ajoute `http://` si le schéma est absent, retire les `/` finaux. */ + fun normalize(url: String): String { + var result = url.trim() + if (result.isNotEmpty() && !result.contains("://")) result = "http://$result" + return result.trimEnd('/') + } + + /** Ping la route publique `/health` de la base donnée. Échoue si injoignable. */ + suspend fun checkHealth(baseUrlToTest: String): Result = withContext(Dispatchers.IO) { + runCatching { + val base = normalize(baseUrlToTest) + require(base.isNotEmpty()) { "URL vide" } + val healthUrl = (base + "/health").toHttpUrl() + healthClient.newCall(Request.Builder().url(healthUrl).get().build()).execute().use { response -> + require(response.isSuccessful) { "HTTP ${response.code}" } + } + } + } + + companion object { + const val DEFAULT_BASE_URL = "http://10.0.2.2:8080/api/v1" + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/CloudOnlyPlaceholder.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/CloudOnlyPlaceholder.kt new file mode 100644 index 0000000..7d11f30 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/CloudOnlyPlaceholder.kt @@ -0,0 +1,48 @@ +package com.vaultdrop.mobile.ui.document.content + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.vaultdrop.mobile.R +import com.vaultdrop.mobile.data.local.entity.FileEntity +import com.vaultdrop.mobile.ui.components.FileCategoryIcon + +/** + * Fichier cloud-only : aucune copie physique sur l'appareil (uri = NULL), le + * contenu n'est donc pas lisible hors ligne. On montre les métadonnées et un + * état explicite — pas de bouton de lecture. + */ +@Composable +fun CloudOnlyPlaceholder( + file: FileEntity, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + FileCategoryIcon(file = file, size = 64.dp) + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(R.string.document_cloud_only), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.document_cloud_only_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/DocumentContent.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/DocumentContent.kt new file mode 100644 index 0000000..5e2c51d --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/DocumentContent.kt @@ -0,0 +1,28 @@ +package com.vaultdrop.mobile.ui.document.content + +import android.content.ContentResolver +import android.net.Uri +import android.os.ParcelFileDescriptor +import java.io.InputStream + +/** + * Accès au contenu physique d'un document SAF (`content://`). + * + * La permission persistable de lecture est prise à l'import du dossier + * (`takePersistableUriPermission`), le `ContentResolver` peut donc lire le + * fichier sans resaisie utilisateur. + */ +object DocumentContent { + + fun openFileDescriptor( + contentResolver: ContentResolver, + uri: String, + ): ParcelFileDescriptor? = + contentResolver.openFileDescriptor(Uri.parse(uri), "r") + + fun openInputStream( + contentResolver: ContentResolver, + uri: String, + ): InputStream? = + contentResolver.openInputStream(Uri.parse(uri)) +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/DocumentContentViewer.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/DocumentContentViewer.kt new file mode 100644 index 0000000..5e42e39 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/DocumentContentViewer.kt @@ -0,0 +1,57 @@ +package com.vaultdrop.mobile.ui.document.content + +import android.content.ContentResolver +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.vaultdrop.mobile.data.local.entity.FileEntity +import com.vaultdrop.mobile.domain.FileCategory +import com.vaultdrop.mobile.ui.components.categoryValue + +/** + * Lecteur central d'un document : dispatch par état puis par catégorie. + * + * - `uri == null` → fichier cloud-only, aucun contenu local + * - PDF → rendu `PdfRenderer` intégré + * - IMAGE → rendu Coil intégré + * - TEXT → lecture texte brut intégrée + * - OFFICE / VIDEO / AUDIO / OTHER → délégation à une application externe + */ +@Composable +fun DocumentContentViewer( + file: FileEntity, + onOpenExternalFailed: () -> Unit, + modifier: Modifier = Modifier, +) { + val contentResolver: ContentResolver = LocalContext.current.contentResolver + + if (file.uri == null) { + CloudOnlyPlaceholder(file, modifier) + return + } + + when (file.categoryValue()) { + FileCategory.PDF -> PdfPageViewer( + file = file, + contentResolver = contentResolver, + onOpenExternalFailed = onOpenExternalFailed, + modifier = modifier, + ) + FileCategory.IMAGE -> ImageViewer( + contentResolver = contentResolver, + uri = file.uri, + contentDescription = file.name, + modifier = modifier, + ) + FileCategory.TEXT -> TextDocumentViewer( + contentResolver = contentResolver, + uri = file.uri, + modifier = modifier, + ) + else -> ExternalOpenFallback( + file = file, + onOpenExternalFailed = onOpenExternalFailed, + modifier = modifier, + ) + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/ExternalOpenFallback.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/ExternalOpenFallback.kt new file mode 100644 index 0000000..078a673 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/ExternalOpenFallback.kt @@ -0,0 +1,94 @@ +package com.vaultdrop.mobile.ui.document.content + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.vaultdrop.mobile.R +import com.vaultdrop.mobile.data.local.entity.FileEntity +import com.vaultdrop.mobile.ui.components.FileCategoryIcon + +/** + * Repli pour les formats sans lecteur intégré (OFFICE, VIDÉO, AUDIO, AUTRE) : + * on délègue la lecture à une application externe via un intent `ACTION_VIEW` + * avec permission de lecture accordée sur le `content://`. + */ +@Composable +fun ExternalOpenFallback( + file: FileEntity, + onOpenExternalFailed: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + FileCategoryIcon(file = file, size = 64.dp) + Spacer(Modifier.height(12.dp)) + Text( + text = file.name, + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.document_open_with_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + ExternalOpenButton( + file = file, + onOpenExternalFailed = onOpenExternalFailed, + ) + } +} + +/** Bouton « Ouvrir avec » — déclenche l'intent externe, remonte l'échec. */ +@Composable +fun ExternalOpenButton( + file: FileEntity, + onOpenExternalFailed: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + Button( + onClick = { + if (!openExternally(context, file)) onOpenExternalFailed() + }, + modifier = modifier, + ) { + Text(stringResource(R.string.document_open_with)) + } +} + +/** Tente d'ouvrir le fichier dans une application externe. Retourne `false` si aucune. */ +fun openExternally(context: Context, file: FileEntity): Boolean { + val uri = file.uri ?: return false + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(Uri.parse(uri), file.mimeType ?: "*/*") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + val canOpen = intent.resolveActivity(context.packageManager) != null + if (canOpen) { + try { + context.startActivity(intent) + } catch (_: ActivityNotFoundException) { + return false + } + } + return canOpen +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/ImageViewer.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/ImageViewer.kt new file mode 100644 index 0000000..4130fb4 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/ImageViewer.kt @@ -0,0 +1,27 @@ +package com.vaultdrop.mobile.ui.document.content + +import android.content.ContentResolver +import android.net.Uri +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import coil.compose.AsyncImage + +/** Affichage d'une image via Coil (gère nativement les URIs `content://`). */ +@Composable +fun ImageViewer( + contentResolver: ContentResolver, + uri: String, + contentDescription: String, + modifier: Modifier = Modifier, +) { + AsyncImage( + model = Uri.parse(uri), + contentDescription = contentDescription, + modifier = modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + alignment = Alignment.Center, + ) +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/PdfPageViewer.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/PdfPageViewer.kt new file mode 100644 index 0000000..bb2290f --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/PdfPageViewer.kt @@ -0,0 +1,269 @@ +package com.vaultdrop.mobile.ui.document.content + +import android.content.ContentResolver +import android.graphics.Bitmap +import android.graphics.pdf.PdfRenderer +import android.os.ParcelFileDescriptor +import android.util.LruCache +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BrokenImage +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.asImageBitmap +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.vaultdrop.mobile.R +import com.vaultdrop.mobile.data.local.entity.FileEntity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +/** + * Lecteur PDF via `PdfRenderer` (API système, aucune dépendance). + * + * Le document est rendu page par page à la demande (une `LazyColumn`), la + * largeur cible borne les bitmaps et un cache LRU limite la mémoire. Le + * `PdfRenderer` n'est pas thread-safe : toutes les lectures passent par un + * `Mutex` et un dispatcher IO. + */ +@Composable +fun PdfPageViewer( + file: FileEntity, + contentResolver: ContentResolver, + onOpenExternalFailed: () -> Unit, + modifier: Modifier = Modifier, +) { + val uri = file.uri + if (uri == null) { + CloudOnlyPlaceholder(file, modifier) + return + } + + val document = remember(uri) { PdfDocumentState(contentResolver, uri) } + var pageCount by remember(uri) { mutableIntStateOf(0) } + var loadFailed by remember(uri) { mutableStateOf(false) } + + // Le state vit en mémoire (cache + dossier ouvert) tant que ce lecteur est + // affiché ; il est fermé à la sortie ou au changement de document. + DisposableEffect(document) { + onDispose { document.close() } + } + + LaunchedEffect(uri) { + pageCount = 0 + loadFailed = false + if (document.load()) { + pageCount = document.pageCount + } else { + loadFailed = true + } + } + + when { + loadFailed -> UnreadableDocument( + file = file, + message = stringResource(R.string.document_cannot_read), + onOpenExternalFailed = onOpenExternalFailed, + modifier = modifier, + ) + pageCount == 0 -> Box(modifier, contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + else -> BoxWithConstraints(modifier.fillMaxSize()) { + val density = LocalDensity.current + val maxWidthPx = with(density) { maxWidth.toPx().toInt() } + val maxHeightPx = with(density) { maxHeight.toPx().toInt() } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(pageCount, key = { it }) { page -> + PdfPageItem( + document = document, + page = page, + maxWidthPx = maxWidthPx, + maxHeightPx = maxHeightPx, + ) + } + } + } + } +} + +@Composable +private fun PdfPageItem( + document: PdfDocumentState, + page: Int, + maxWidthPx: Int, + maxHeightPx: Int, +) { + var bitmap by remember(page) { mutableStateOf(null) } + var failed by remember(page) { mutableStateOf(false) } + LaunchedEffect(document, page, maxWidthPx, maxHeightPx) { + failed = false + bitmap = document.bitmap(page, maxWidthPx, maxHeightPx) + if (bitmap == null) failed = true + } + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + contentAlignment = Alignment.Center, + ) { + when { + bitmap != null -> Image( + bitmap = bitmap!!.asImageBitmap(), + contentDescription = null, + modifier = Modifier.fillMaxWidth(), + ) + failed -> Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(vertical = 32.dp), + ) { + Icon( + imageVector = Icons.Filled.BrokenImage, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.height(32.dp), + ) + Text( + text = stringResource(R.string.document_page_unreadable), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + else -> Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(vertical = 32.dp), + ) { + CircularProgressIndicator() + } + } + } +} + +/** + * État d'un document PDF : ouvre le `PdfRenderer` à la demande, rend chaque + * page via le cache LRU et verrouille l'accès au renderer (non thread-safe). + */ +private class PdfDocumentState( + private val contentResolver: ContentResolver, + private val uri: String, +) { + private var renderer: PdfRenderer? = null + private val cache = LruCache(MAX_CACHED_PAGES) + private val renderMutex = Mutex() + + val pageCount: Int get() = renderer?.pageCount ?: 0 + + /** Ouvre le document si ce n'est pas déjà fait. Échoue si illisible. */ + suspend fun load(): Boolean = withContext(Dispatchers.IO) { + if (renderer != null) return@withContext true + val opened = runCatching { + val pfd: ParcelFileDescriptor? = DocumentContent.openFileDescriptor(contentResolver, uri) + if (pfd != null) PdfRenderer(pfd) else null + }.getOrNull() + if (opened == null) false else { + renderer = opened + true + } + } + + /** Bitmap de la page (mise à l'échelle pour tenir dans les limites). */ + suspend fun bitmap(page: Int, maxWidthPx: Int, maxHeightPx: Int): Bitmap? = + withContext(Dispatchers.IO) { + cache.get(page) ?: renderMutex.withLock { + cache.get(page) ?: runCatching { renderPage(page, maxWidthPx, maxHeightPx) } + .getOrNull() + ?.also { cache.put(page, it) } + } + } + + private fun renderPage(page: Int, maxWidthPx: Int, maxHeightPx: Int): Bitmap { + val current = renderer ?: error("document non ouvert") + val pdfPage = current.openPage(page) + try { + val scale = minOf( + 1f, + maxWidthPx.toFloat() / pdfPage.width, + maxHeightPx.toFloat() / pdfPage.height, + ) + val width = (pdfPage.width * scale).toInt().coerceAtLeast(1) + val height = (pdfPage.height * scale).toInt().coerceAtLeast(1) + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + pdfPage.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + return bitmap + } finally { + pdfPage.close() + } + } + + fun close() { + cache.snapshot().values.forEach { it.recycle() } + cache.evictAll() + renderer?.close() + renderer = null + } + + companion object { + private const val MAX_CACHED_PAGES = 6 + } +} + +/** Contenu "document non lisible" (charge, page corrompue, PDF chiffré…). */ +@Composable +internal fun UnreadableDocument( + file: FileEntity, + message: String, + onOpenExternalFailed: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + androidx.compose.material3.Icon( + imageVector = Icons.Filled.BrokenImage, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.height(48.dp), + ) + Spacer(Modifier.height(12.dp)) + Text(message, style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.height(16.dp)) + ExternalOpenButton( + file = file, + onOpenExternalFailed = onOpenExternalFailed, + ) + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/TextDocumentViewer.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/TextDocumentViewer.kt new file mode 100644 index 0000000..dedae72 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/content/TextDocumentViewer.kt @@ -0,0 +1,112 @@ +package com.vaultdrop.mobile.ui.document.content + +import android.content.ContentResolver +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.unit.dp +import com.vaultdrop.mobile.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Lecture de documents texte bruts (txt, md, log, json…). + * + * Le flux est borné (MAX_BYTES) pour ne jamais charger un dump de plusieurs + * dizaines de Mo en mémoire : au-delà, un bandeau précise que l'aperçu est + * tronqué. + */ +@Composable +fun TextDocumentViewer( + contentResolver: ContentResolver, + uri: String, + modifier: Modifier = Modifier, +) { + var text by remember(uri) { mutableStateOf(null) } + var truncated by remember(uri) { mutableStateOf(false) } + var failed by remember(uri) { mutableStateOf(false) } + + LaunchedEffect(uri) { + text = null + truncated = false + failed = false + val result = withContext(Dispatchers.IO) { readCapped(contentResolver, uri) } + result?.let { + text = it.first + truncated = it.second + } ?: run { failed = true } + } + + Box(modifier.fillMaxSize()) { + when { + failed -> Text( + text = stringResource(R.string.document_cannot_read), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.align(Alignment.Center), + ) + text == null -> Text( + text = stringResource(R.string.document_loading), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.align(Alignment.Center), + ) + else -> Column(Modifier.fillMaxSize()) { + if (truncated) { + Text( + text = stringResource(R.string.document_truncated_notice), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) + } + SelectionContainer(Modifier.weight(1f)) { + LazyColumn( + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + ) { + item { + Text( + text = text.orEmpty(), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } + } + } +} + +/** Lit le flux UTF-8 borné à MAX_BYTES (+1 pour détecter la troncature). */ +private fun readCapped(contentResolver: ContentResolver, uri: String): Pair? { + val stream = DocumentContent.openInputStream(contentResolver, uri) ?: return null + stream.use { input -> + val buf = ByteArray(MAX_BYTES + 1) + var offset = 0 + while (offset < buf.size) { + val read = input.read(buf, offset, buf.size - offset) + if (read < 0) break + offset += read + } + val truncated = offset > MAX_BYTES + val length = minOf(offset, MAX_BYTES) + val decoded = String(buf, 0, length, Charsets.UTF_8) + .removePrefix("\uFEFF") + return decoded to truncated + } +} + +private const val MAX_BYTES = 512 * 1024 \ No newline at end of file