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