add saf config

This commit is contained in:
m
2026-09-12 20:48:00 +02:00
parent ec99d1ac18
commit 7cc8c6eb24
11 changed files with 359 additions and 57 deletions
@@ -24,6 +24,10 @@ interface FolderDao {
@Query("SELECT * FROM folders WHERE parent_resource_id IS NULL ORDER BY name ASC")
fun observeRootFolders(): Flow<List<FolderEntity>>
/** Racines physiques surveillées (uri SAF non nulle) — distinctes des racines cloud-only. */
@Query("SELECT * FROM folders WHERE parent_resource_id IS NULL AND uri IS NOT NULL ORDER BY name ASC")
fun observeSafRoots(): Flow<List<FolderEntity>>
@Query("SELECT * FROM folders WHERE parent_resource_id IS NULL ORDER BY name ASC")
suspend fun getRootFolders(): List<FolderEntity>
@@ -25,6 +25,9 @@ class FolderRepository @Inject constructor(
fun observeRootFolders(): Flow<List<FolderEntity>> = folderDao.observeRootFolders()
/** Racines SAF surveillées par le sync (celles qui déclenchent un walk périodique). */
fun observeSafRoots(): Flow<List<FolderEntity>> = folderDao.observeSafRoots()
/** Sous-dossiers visibles d'un dossier (filtre `exists = 1` dans le DAO). */
fun observeSubFolders(parentResourceId: String): Flow<List<FolderEntity>> =
folderDao.observeByParent(parentResourceId)
@@ -1,8 +1,10 @@
package com.vaultdrop.mobile.features.saf
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.provider.DocumentsContract
import android.provider.OpenableColumns
/**
* Conversions d'URI SAF partagées par SafFolderCreator et FileMover.
@@ -38,4 +40,19 @@ object SafUris {
raw
}
}
}
}
/** Nom affiché d'une racine SAF via DocumentsContract (colonne DISPLAY_NAME). */
fun Uri.safDisplayName(context: Context): String? = runCatching {
val docId = DocumentsContract.getTreeDocumentId(this)
val docUri = DocumentsContract.buildDocumentUriUsingTree(this, docId)
context.contentResolver.query(
docUri,
arrayOf(OpenableColumns.DISPLAY_NAME),
null,
null,
null,
)?.use { cursor ->
if (cursor.moveToFirst()) cursor.getString(0) else null
}
}.getOrNull()
@@ -0,0 +1,73 @@
package com.vaultdrop.mobile.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vaultdrop.mobile.R
import com.vaultdrop.mobile.ui.watchedfolders.WatchedFoldersViewModel
/** Seuil au-delà duquel le badge affiche « 99+ » au lieu du compte exact. */
private const val COUNT_CAP = 99
/**
* Pilule du header affichant le nombre de dossiers SAF surveillés. Un appui
* ouvre l'écran listant ces dossiers.
*/
@Composable
fun WatchedFoldersBadge(
onClick: () -> Unit,
viewModel: WatchedFoldersViewModel = hiltViewModel(),
) {
val count by viewModel.count.collectAsStateWithLifecycle(initialValue = 0)
val label = if (count > COUNT_CAP) {
stringResource(R.string.watched_count_many)
} else {
count.toString()
}
val contentDescription = stringResource(R.string.settings_saf_folders)
Row(
modifier = Modifier
.clip(RoundedCornerShape(percent = 50))
.clickable(
role = Role.Button,
onClickLabel = contentDescription,
onClick = onClick,
)
.padding(horizontal = 10.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(
imageVector = Icons.Filled.Visibility,
contentDescription = contentDescription,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -1,10 +1,7 @@
package com.vaultdrop.mobile.ui.folderlist
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -73,6 +70,7 @@ import com.vaultdrop.mobile.R
import com.vaultdrop.mobile.data.local.entity.FileEntity
import com.vaultdrop.mobile.data.local.entity.FolderEntity
import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel
import com.vaultdrop.mobile.features.saf.safDisplayName
import com.vaultdrop.mobile.features.sync.SyncViewModel
import com.vaultdrop.mobile.ui.components.FileCategoryIcon
import com.vaultdrop.mobile.ui.components.FolderNameDialog
@@ -104,7 +102,6 @@ fun FolderListScreen(
val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle()
val defaultRootId by viewModel.defaultRootId.collectAsStateWithLifecycle()
val context = LocalContext.current
val folderLabel = stringResource(R.string.folder)
val selection = rememberSelectionState()
var showCreateDialog by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
@@ -146,7 +143,7 @@ fun FolderListScreen(
)
}.onFailure { Timber.w(it, "persistable permission absent on default root pick") }
pendingDefaultPick = uri
val rootName = uri.displayName(context) ?: uri.lastPathSegment ?: defaultRootLabel
val rootName = uri.safDisplayName(context) ?: uri.lastPathSegment ?: defaultRootLabel
syncViewModel.importRoot(uri = uri.toString(), name = rootName)
}
@@ -172,23 +169,6 @@ fun FolderListScreen(
pendingDefaultPick = null
}
val pickFolderLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocumentTree(),
) { uri: Uri? ->
if (uri == null) return@rememberLauncherForActivityResult
// Permissions persistantes : l'app ré-ouvrira le dossier aux prochains lancements.
runCatching {
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
}
syncViewModel.importRoot(
uri = uri.toString(),
name = uri.displayName(context) ?: uri.lastPathSegment ?: folderLabel,
)
}
if (defaultRootId == null) {
DefaultRootOnboarding(
importing = importState.isImporting,
@@ -289,7 +269,6 @@ fun FolderListScreen(
selection = selection,
onBrowseUp = viewModel::browseUp,
onOpenBrowseFolder = viewModel::openBrowseFolder,
onAddFolder = { pickFolderLauncher.launch(null) },
onCreateFolder = { showCreateDialog = true },
onOpenDocument = onOpenDocument,
modifier = Modifier.weight(1f),
@@ -416,7 +395,6 @@ private fun HomeViewContent(
selection: SelectionState,
onBrowseUp: () -> Unit,
onOpenBrowseFolder: (String) -> Unit,
onAddFolder: () -> Unit,
onCreateFolder: () -> Unit,
onOpenDocument: (String) -> Unit,
modifier: Modifier = Modifier,
@@ -439,7 +417,6 @@ private fun HomeViewContent(
error = error,
onBrowseUp = onBrowseUp,
onOpenBrowseFolder = onOpenBrowseFolder,
onAddFolder = onAddFolder,
onCreateFolder = onCreateFolder,
modifier = modifier,
)
@@ -541,7 +518,6 @@ private fun FolderBrowserContent(
error: String?,
onBrowseUp: () -> Unit,
onOpenBrowseFolder: (String) -> Unit,
onAddFolder: () -> Unit,
onCreateFolder: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -582,25 +558,12 @@ private fun FolderBrowserContent(
if (!moveMode) {
item(key = "create") {
if (atRoot) {
Button(
onClick = onAddFolder,
enabled = !isImporting,
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
) {
Icon(Icons.Filled.Add, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.add_folder))
}
}
OutlinedButton(
onClick = onCreateFolder,
enabled = !isImporting,
modifier = Modifier
.fillMaxWidth()
.padding(top = if (atRoot) 8.dp else 4.dp, bottom = 4.dp),
.padding(top = 8.dp, bottom = 4.dp),
) {
Icon(Icons.Filled.CreateNewFolder, contentDescription = null)
Spacer(Modifier.width(8.dp))
@@ -705,21 +668,6 @@ private fun DefaultRootOnboarding(
}
}
/** Nom affiché d'un dossier SAF via DocumentsContract (DISPLAY_NAME). */
private fun Uri.displayName(context: Context): String? = runCatching {
val docId = DocumentsContract.getTreeDocumentId(this)
val docUri = DocumentsContract.buildDocumentUriUsingTree(this, docId)
context.contentResolver.query(
docUri,
arrayOf(OpenableColumns.DISPLAY_NAME),
null,
null,
null,
)?.use { cursor ->
if (cursor.moveToFirst()) cursor.getString(0) else null
}
}.getOrNull()
@Composable
private fun SectionHeader(label: String) {
Text(
@@ -27,6 +27,7 @@ import com.vaultdrop.mobile.ui.pdfbuilder.PdfBuilderScreen
import com.vaultdrop.mobile.ui.review.SwipeReviewScreen
import com.vaultdrop.mobile.ui.search.SearchScreen
import com.vaultdrop.mobile.ui.settings.SettingsScreen
import com.vaultdrop.mobile.ui.watchedfolders.WatchedFoldersScreen
object Routes {
const val FILES = "files"
@@ -34,6 +35,7 @@ object Routes {
const val SETTINGS = "settings"
const val DASHBOARD = "dashboard"
const val REVIEW = "review"
const val WATCHED_FOLDERS = "watched"
const val FOLDER_DETAIL = "folder/{folderResourceId}"
const val ARG_FOLDER = "folderResourceId"
const val DOCUMENT = "document/{documentResourceId}"
@@ -67,6 +69,8 @@ fun NavGraph(
val importState by syncViewModel.importState.collectAsStateWithLifecycle()
val onOpenWatchedFolders = { navController.navigate(Routes.WATCHED_FOLDERS) }
val onTabSelected: (NavTab) -> Unit = { tab ->
if (tab.route != selectedTab.route) {
navController.navigate(tab.route) {
@@ -108,6 +112,7 @@ fun NavGraph(
SettingsScreen(
selectedTab = selectedTab,
onTabSelected = onTabSelected,
onOpenWatchedFolders = onOpenWatchedFolders,
authViewModel = authViewModel,
connectionStatusViewModel = connectionStatusViewModel,
)
@@ -126,6 +131,12 @@ fun NavGraph(
connectionStatusViewModel = connectionStatusViewModel,
)
}
composable(Routes.WATCHED_FOLDERS) {
WatchedFoldersScreen(
onBack = { navController.popBackStack() },
syncViewModel = syncViewModel,
)
}
composable(
route = Routes.FOLDER_DETAIL,
arguments = listOf(
@@ -1,5 +1,6 @@
package com.vaultdrop.mobile.ui.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -14,6 +15,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
@@ -51,6 +53,7 @@ import com.vaultdrop.mobile.ui.auth.AuthState
import com.vaultdrop.mobile.ui.auth.AuthViewModel
import com.vaultdrop.mobile.ui.auth.authErrorResFor
import com.vaultdrop.mobile.ui.components.ServerStatusBadge
import com.vaultdrop.mobile.ui.components.WatchedFoldersBadge
import com.vaultdrop.mobile.ui.navigation.FloatingNavBar
import com.vaultdrop.mobile.ui.navigation.NavTab
@@ -59,6 +62,7 @@ import com.vaultdrop.mobile.ui.navigation.NavTab
fun SettingsScreen(
selectedTab: NavTab,
onTabSelected: (NavTab) -> Unit,
onOpenWatchedFolders: () -> Unit,
authViewModel: AuthViewModel,
connectionStatusViewModel: ConnectionStatusViewModel,
viewModel: SettingsViewModel = hiltViewModel(),
@@ -119,6 +123,17 @@ fun SettingsScreen(
},
)
HorizontalDivider()
SettingsRow(
icon = { Icon(Icons.Filled.Folder, contentDescription = null) },
title = stringResource(R.string.settings_watched_folders),
subtitle = stringResource(R.string.settings_watched_folders_hint),
trailing = {
WatchedFoldersBadge(onClick = onOpenWatchedFolders)
},
onClick = onOpenWatchedFolders,
)
HorizontalDivider()
SettingsSectionTitle(stringResource(R.string.settings_server))
@@ -241,6 +256,8 @@ fun SettingsScreen(
}
}
}
HorizontalDivider()
Spacer(Modifier.height(16.dp))
}
}
@@ -251,11 +268,13 @@ private fun SettingsRow(
icon: @Composable () -> Unit,
title: String,
subtitle: String,
trailing: @Composable () -> Unit,
trailing: @Composable () -> Unit = {},
onClick: (() -> Unit)? = null,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = onClick != null, onClick = { onClick?.invoke() })
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -0,0 +1,189 @@
package com.vaultdrop.mobile.ui.watchedfolders
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vaultdrop.mobile.R
import com.vaultdrop.mobile.data.local.entity.FolderEntity
import com.vaultdrop.mobile.features.saf.safDisplayName
import com.vaultdrop.mobile.features.sync.SyncViewModel
/**
* Dossiers SAF surveillés par l'application. D'ici on ajoute une racine
* (picker OpenDocumentTree) et on consulte la liste des racines déjà suivies.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WatchedFoldersScreen(
onBack: () -> Unit,
syncViewModel: SyncViewModel,
viewModel: WatchedFoldersViewModel = hiltViewModel(),
) {
val folders by viewModel.folders.collectAsStateWithLifecycle(initialValue = emptyList())
val importState by syncViewModel.importState.collectAsStateWithLifecycle()
val context = LocalContext.current
val folderLabel = stringResource(R.string.folder)
val pickFolderLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocumentTree(),
) { uri: Uri? ->
if (uri == null) return@rememberLauncherForActivityResult
// Permissions persistantes : l'app ré-ouvrira le dossier aux prochains lancements.
runCatching {
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
}
syncViewModel.importRoot(
uri = uri.toString(),
name = uri.safDisplayName(context) ?: uri.lastPathSegment ?: folderLabel,
)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.settings_saf_folders)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back),
)
}
},
)
},
) { padding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentPadding = PaddingValues(horizontal = 16.dp),
) {
item(key = "add") {
Button(
onClick = { pickFolderLauncher.launch(null) },
enabled = !importState.isImporting,
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
) {
Icon(Icons.Filled.Add, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.add_folder))
}
}
if (importState.isImporting) {
item(key = "importing") {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
)
}
}
items(folders, key = { it.resourceId }) { root ->
WatchedFolderRow(root)
HorizontalDivider()
}
if (folders.isEmpty() && !importState.isImporting) {
item(key = "empty") {
Text(
text = stringResource(R.string.settings_saf_folders_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 32.dp),
)
}
}
}
}
}
@Composable
private fun WatchedFolderRow(folder: FolderEntity) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = Icons.Filled.Folder,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.padding(8.dp),
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = folder.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
folder.uri?.let { uri ->
Text(
text = uri,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@@ -0,0 +1,28 @@
package com.vaultdrop.mobile.ui.watchedfolders
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vaultdrop.mobile.data.local.entity.FolderEntity
import com.vaultdrop.mobile.data.repository.FolderRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
/** État des dossiers SAF surveillés, partagé entre le badge du header et l'écran de liste. */
@HiltViewModel
class WatchedFoldersViewModel @Inject constructor(
folderRepository: FolderRepository,
) : ViewModel() {
/** Liste live des racines SAF surveillées (uri non nulle). */
val folders: Flow<List<FolderEntity>> = folderRepository.observeSafRoots()
/** Nombre de racines surveillées — l'UI plafonne l'affichage à 99 (« 99+ »). */
val count: StateFlow<Int> = folderRepository.observeSafRoots()
.map { it.size }
.stateIn(viewModelScope, SharingStarted.Eagerly, 0)
}
@@ -71,6 +71,11 @@
<string name="settings_connect">Sign in</string>
<string name="settings_disconnect">Sign out</string>
<string name="settings_connected_as">Signed in as %1$s</string>
<string name="settings_saf_folders">Watched SAF folders</string>
<string name="settings_saf_folders_empty">No watched folders yet.</string>
<string name="settings_watched_folders">Watched system folders</string>
<string name="settings_watched_folders_hint">View watched folders</string>
<string name="watched_count_many">99+</string>
<!-- Search -->
<string name="search">Search</string>
@@ -88,6 +88,11 @@
<string name="settings_connect">Se connecter</string>
<string name="settings_disconnect">Se déconnecter</string>
<string name="settings_connected_as">Connecté en tant que %1$s</string>
<string name="settings_saf_folders">Dossiers SAF surveillés</string>
<string name="settings_saf_folders_empty">Aucun dossier surveillé pour le moment.</string>
<string name="settings_watched_folders">Dossiers système surveillés</string>
<string name="settings_watched_folders_hint">Consulter les dossiers surveillés</string>
<string name="watched_count_many">99+</string>
<!-- Recherche -->
<string name="search">Recherche</string>