auth settings

This commit is contained in:
m
2026-09-12 01:08:20 +02:00
parent 45028dd70a
commit 0041a1a48a
9 changed files with 753 additions and 0 deletions
@@ -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())
}
}
@@ -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<String> = _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<Unit> = 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"
}
}
@@ -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,
)
}
}
@@ -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))
}
@@ -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,
)
}
}
@@ -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
}
@@ -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,
)
}
@@ -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<Bitmap?>(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<Int, Bitmap>(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,
)
}
}
@@ -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<String?>(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<String, Boolean>? {
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