auth settings
This commit is contained in:
+42
@@ -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"
|
||||
}
|
||||
}
|
||||
+48
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+28
@@ -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))
|
||||
}
|
||||
+57
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+94
@@ -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
|
||||
}
|
||||
+27
@@ -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,
|
||||
)
|
||||
}
|
||||
+269
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+112
@@ -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
|
||||
Reference in New Issue
Block a user