share folders and files

This commit is contained in:
m
2026-09-14 14:09:41 +02:00
parent 09669a1e7b
commit e9c85febca
29 changed files with 2289 additions and 101 deletions
@@ -58,4 +58,31 @@ interface PendingOperationDao {
@Query("SELECT COUNT(*) FROM pending_operations WHERE status = 'pending'")
suspend fun countPending(): Int
/**
* Une op `move_resource` encore en attente de push existe-t-elle pour cette
* ressource ? L'antichambre outbox est alors la source de vérité du
* placement : ni le refresh serveur ni le walk SAF ne doivent écraser le
* `folder_resource_id` local avant que le serveur ait accusé le move.
*/
@Query("""
SELECT COUNT(*) FROM pending_operations
WHERE resource_id = :resourceId
AND operation = 'move_resource'
AND status = 'pending'
""")
suspend fun countPendingMoveOperations(resourceId: String): Int
/**
* Un `move_resource` (pending ou synced) existe-t-il pour cette ressource ?
* La réconciliation du walk ne doit jamais masquer (`exists = 0`) une ligne
* en cours de transition vers une autre arborescence physique.
*/
@Query("""
SELECT COUNT(*) FROM pending_operations
WHERE resource_id = :resourceId
AND operation = 'move_resource'
AND status IN ('pending', 'synced')
""")
suspend fun countMoveOperations(resourceId: String): Int
}
@@ -9,6 +9,7 @@ import com.vaultdrop.mobile.data.remote.dto.FileDto
import com.vaultdrop.mobile.data.remote.dto.FolderDto
import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto
import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto
import com.vaultdrop.mobile.data.remote.dto.ResolvedUserDto
import com.vaultdrop.mobile.data.remote.dto.ResourcePermissionDto
import com.vaultdrop.mobile.data.remote.dto.SyncOpDto
import com.vaultdrop.mobile.data.remote.dto.SyncOpsRequest
@@ -67,6 +68,10 @@ class ApiClient @Inject constructor(
suspend fun syncOps(operations: List<SyncOpDto>): SyncOpsResult =
unwrap({ apiService.syncOps(SyncOpsRequest(operations)) })
/** Résout un destinataire par username EXACT — 404 si inconnu. */
suspend fun resolveUser(username: String): ResolvedUserDto =
unwrap({ apiService.resolveUser(username.trim()) })
/** Snapshot des permissions effectives (delta si `after` ms fourni). */
suspend fun syncPermissions(after: Long? = null): List<ResourcePermissionDto> =
unwrap({ apiService.syncPermissions(after) })
@@ -6,6 +6,7 @@ import com.vaultdrop.mobile.data.remote.dto.FileDto
import com.vaultdrop.mobile.data.remote.dto.FolderDto
import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto
import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto
import com.vaultdrop.mobile.data.remote.dto.ResolvedUserDto
import com.vaultdrop.mobile.data.remote.dto.ResourcePermissionDto
import com.vaultdrop.mobile.data.remote.dto.SyncOpsRequest
import com.vaultdrop.mobile.data.remote.dto.SyncOpsResult
@@ -51,6 +52,12 @@ interface ApiService {
@Body body: SyncOpsRequest,
): Response<ApiEnvelope<SyncOpsResult>>
/** Résolution d'un destinataire par username EXACT (jamais d'énumération). */
@GET("users/resolve")
suspend fun resolveUser(
@Query("username") username: String,
): Response<ApiEnvelope<ResolvedUserDto>>
/** Snapshot des permissions effectives (delta si `after` fourni, ms epoch). */
@GET("sync/permissions")
suspend fun syncPermissions(
@@ -68,6 +68,12 @@ data class UserDto(
@Json(name = "is_admin") val isAdmin: Boolean = false,
)
/** Réponse de `GET /users/resolve` — `{ id, username }` uniquement. */
data class ResolvedUserDto(
@Json(name = "id") val id: String,
@Json(name = "username") val username: String,
)
/** Miroir de `api/types.ts` — `LoginRequest`. */
data class LoginRequestDto(
@Json(name = "username") val username: String,
@@ -121,4 +127,6 @@ data class ResourcePermissionDto(
@Json(name = "expiresAt") val expiresAt: Any? = null,
@Json(name = "cachedAt") val cachedAt: Long,
@Json(name = "updatedAt") val updatedAt: Long,
@Json(name = "name") val name: String = "",
@Json(name = "parentId") val parentId: String? = null,
)
@@ -130,12 +130,18 @@ class FileRepository @Inject constructor(
val category = computeCategory(input.mimeType, input.extension).dbValue
// Un `move_resource` encore en attente fait de la cible outbox la
// source de vérité du placement : un snapshot SAF périmé (walk lancé
// avant le move) ne doit pas ré-attribuer le dossier à l'ancien parent.
val movePending = existing != null && outboxRepository.hasPendingMoveOperation(existing.resourceId)
val effectiveFolder = if (movePending) existing.folderResourceId else folderResourceId
val entity = FileEntity(
id = existing?.id ?: 0L,
resourceId = existing?.resourceId ?: input.resourceId ?: generateId.newResourceId(),
uri = input.uri,
name = input.name,
folderResourceId = folderResourceId,
folderResourceId = effectiveFolder,
extension = input.extension ?: existing?.extension,
size = input.size,
mimeType = input.mimeType,
@@ -192,12 +198,21 @@ class FileRepository @Inject constructor(
/**
* `GET /files?folderId=...` (1re page, tri serveur) puis upsert cloud de
* chaque fichier. Ne supprime jamais de lignes locales.
*
* Garde-fou outbox : un fichier dont le `move_resource` n'a pas encore été
* poussé garde son placement local. Le serveur renvoie encore l'ancien
* dossier tant que l'op est pendante — écraser la ligne la ferait disparaître
* du dossier cible (et réapparaître dans l'ancien).
*/
suspend fun refreshFromServer(folderResourceId: String) {
val files = apiClient.listFiles(folderId = folderResourceId, pageSize = PAGE_SIZE)
if (files.isEmpty()) return
val now = System.currentTimeMillis()
files.forEach { dto ->
if (outboxRepository.hasPendingMoveOperation(dto.id)) {
// L'antichambre outbox fait foi tant que le move n'est pas synced.
return@forEach
}
fileDao.upsert(toEntity(dto, folderResourceId, now))
}
}
@@ -103,6 +103,41 @@ class OutboxRepository @Inject constructor(
resourceType = resourceType,
)
/**
* Raccourci `share` — payload `{ granteeUserId, access }` (voir
* docs/api-v1.md §6.1). `access`: viewer | commenter | editor.
*/
suspend fun enqueueShare(
resourceId: String,
resourceType: String,
granteeUserId: String,
access: String,
inherit: Boolean = true,
expiresAt: Long? = null,
): String = enqueue(
operation = PendingOperationType.SHARE,
resourceId = resourceId,
resourceType = resourceType,
payload = buildMap {
put("granteeUserId", granteeUserId)
put("access", access)
put("inherit", inherit)
expiresAt?.let { put("expiresAt", it) }
},
)
/** Raccourci `revoke_share` — payload `{ granteeUserId }`. */
suspend fun enqueueRevokeShare(
resourceId: String,
resourceType: String,
granteeUserId: String,
): String = enqueue(
operation = PendingOperationType.REVOKE_SHARE,
resourceId = resourceId,
resourceType = resourceType,
payload = mapOf("granteeUserId" to granteeUserId),
)
/** Nombre d'ops en attente de push (stats UI optionnelles). */
suspend fun countPending(): Int = pendingOperationDao.countPending()
@@ -113,4 +148,21 @@ class OutboxRepository @Inject constructor(
*/
suspend fun hasCreateOperation(resourceId: String): Boolean =
pendingOperationDao.countCreateOperations(resourceId) > 0
/**
* Un `move_resource` est-il encore en attente de push ? Tant que l'op est
* pendante, le `folder_resource_id` local reflète la cible future : ni le
* refresh serveur (qui renvoie l'ancien dossier) ni le walk SAF (snapshot
* périmé) ne doivent l'écraser.
*/
suspend fun hasPendingMoveOperation(resourceId: String): Boolean =
pendingOperationDao.countPendingMoveOperations(resourceId) > 0
/**
* Un `move_resource` (pending ou synced) a-t-il jamais été journalisé ?
* Utilisé par la réconciliation du walk : une ligne en transition physique
* ne doit pas être masquée (`exists = 0`) avant convergence.
*/
suspend fun hasMoveOperation(resourceId: String): Boolean =
pendingOperationDao.countMoveOperations(resourceId) > 0
}
@@ -6,6 +6,7 @@ import com.vaultdrop.mobile.data.local.AppDatabase
import com.vaultdrop.mobile.data.local.entity.FolderEntity
import com.vaultdrop.mobile.data.repository.FileRepository
import com.vaultdrop.mobile.data.repository.FolderRepository
import com.vaultdrop.mobile.data.repository.OutboxRepository
import com.vaultdrop.mobile.data.repository.SaveFileInput
import com.vaultdrop.mobile.data.repository.SaveFolderInput
import com.vaultdrop.mobile.domain.DeviceIdentity
@@ -34,6 +35,7 @@ class DeviceSync @Inject constructor(
private val scanner: SafScanner,
private val folderRepository: FolderRepository,
private val fileRepository: FileRepository,
private val outboxRepository: OutboxRepository,
private val deviceIdentity: DeviceIdentity,
) {
@@ -119,7 +121,13 @@ class DeviceSync @Inject constructor(
}
for (file in fileRepository.getAll()) {
val fileUri = file.uri
if (file.exists != 0 && fileUri != null && isChildOf(fileUri, rootUri) && fileUri !in seen) {
// Un `move_resource` (pending ou synced) fait de l'outbox la
// source de vérité du placement : on ne masque jamais une ligne
// en transition vers une autre arborescence physique (le
// snapshot peut être périmé par rapport au move en cours).
if (file.exists != 0 && fileUri != null && isChildOf(fileUri, rootUri) && fileUri !in seen &&
!outboxRepository.hasMoveOperation(file.resourceId)
) {
fileRepository.markMissing(file.resourceId, now)
missing++
}
@@ -10,6 +10,7 @@ import com.vaultdrop.mobile.data.local.entity.PendingOpStatus
import com.vaultdrop.mobile.data.local.entity.PendingOperationEntity
import com.vaultdrop.mobile.data.repository.FolderRepository
import com.vaultdrop.mobile.data.repository.SaveFolderInput
import com.vaultdrop.mobile.data.repository.ShareRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
@@ -46,6 +47,7 @@ class SyncViewModel @Inject constructor(
private val deviceSync: DeviceSync,
private val folderRepository: FolderRepository,
private val pendingOperationDao: PendingOperationDao,
private val shareRepository: ShareRepository,
@ApplicationContext private val appContext: Context,
) : ViewModel() {
@@ -107,6 +109,10 @@ class SyncViewModel @Inject constructor(
OutboxSyncWorker.enqueue(appContext)
}
.onFailure { e -> Timber.w(e, "syncAll failed, retrying later") }
// Hydrate les ressources partagées depuis le snapshot serveur.
// Échec réseau toléré : le prochain tick réessaiera.
runCatching { shareRepository.syncSnapshot() }
.onFailure { e -> Timber.d("syncSnapshot failed, retrying later: %s", e.message) }
_walkInProgress.value = false
delay(INTERVAL_MS)
}
@@ -7,6 +7,7 @@ import com.vaultdrop.mobile.auth.SessionManager
import com.vaultdrop.mobile.data.remote.ApiClient
import com.vaultdrop.mobile.data.remote.ApiException
import com.vaultdrop.mobile.data.repository.AuthRepository
import com.vaultdrop.mobile.data.repository.ShareRepository
import com.vaultdrop.mobile.features.sync.OutboxSyncWorker
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -25,6 +26,7 @@ class AuthViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val sessionManager: SessionManager,
private val apiClient: ApiClient,
private val shareRepository: ShareRepository,
@ApplicationContext private val appContext: Context,
) : ViewModel() {
@@ -49,6 +51,11 @@ class AuthViewModel @Inject constructor(
authRepository.registerDevice()
// Session restaurée → drainer l'outbox laissée en attente.
OutboxSyncWorker.enqueue(appContext)
if (session != null) {
// Snapshot complet des permissions partagées (convergence).
runCatching { shareRepository.syncSnapshot() }
.onFailure { e -> Timber.d("syncSnapshot on restore failed: %s", e.message) }
}
}
}
@@ -66,6 +73,9 @@ class AuthViewModel @Inject constructor(
_authState.value = AuthState.SignedIn(response.user)
// Connexion réussie → pousser les mutations locales en attente.
OutboxSyncWorker.enqueue(appContext)
// Snapshot complet des permissions partagées (convergence).
runCatching { shareRepository.syncSnapshot() }
.onFailure { e -> Timber.d("syncSnapshot on login failed: %s", e.message) }
}
.onFailure { e ->
val error = when (e) {
@@ -19,6 +19,7 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.CreateNewFolder
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -65,6 +66,8 @@ import com.vaultdrop.mobile.ui.components.DeleteReview
import com.vaultdrop.mobile.ui.components.DeleteWarningDialog
import com.vaultdrop.mobile.ui.components.reviewDelete
import com.vaultdrop.mobile.ui.navigation.SelectionNavBar
import com.vaultdrop.mobile.ui.share.ShareBottomSheet
import com.vaultdrop.mobile.ui.share.ShareViewModel
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@@ -78,14 +81,17 @@ fun FolderDetailScreen(
connectionStatusViewModel: ConnectionStatusViewModel,
syncViewModel: SyncViewModel,
viewModel: FolderDetailViewModel = hiltViewModel(),
shareViewModel: ShareViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle()
val shareState by shareViewModel.uiState.collectAsStateWithLifecycle()
val selection = rememberSelectionState()
var showCreateDialog by remember { mutableStateOf(false) }
var showMoveDialog by remember { mutableStateOf(false) }
var showDeleteWarning by remember { mutableStateOf(false) }
var showDeleteConfirm by remember { mutableStateOf(false) }
var showShareSheet by remember { mutableStateOf(false) }
var pendingDeleteMode by remember { mutableStateOf<FileDeleter.DeleteMode?>(null) }
var pendingDeleteReview by remember { mutableStateOf<DeleteReview?>(null) }
val snackbarHostState = remember { SnackbarHostState() }
@@ -172,6 +178,12 @@ fun FolderDetailScreen(
status = connectionStatus,
onClick = connectionStatusViewModel::checkNow,
)
IconButton(onClick = { showShareSheet = true }) {
Icon(
Icons.Filled.Share,
contentDescription = stringResource(R.string.share_content_description),
)
}
IconButton(onClick = viewModel::refresh) {
Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
}
@@ -280,6 +292,22 @@ fun FolderDetailScreen(
},
)
}
if (showShareSheet) {
ShareBottomSheet(
resourceName = uiState.folder?.name ?: stringResource(R.string.folder),
onDismiss = {
showShareSheet = false
shareViewModel.reset()
},
onShare = { username, access ->
shareViewModel.share(folderResourceId, "folder", username, access)
},
sharing = shareState.sharing,
error = shareState.error,
enqueued = shareState.enqueued,
)
}
}
@Composable
@@ -305,6 +305,7 @@ fun FolderListScreen(
atRoot = uiState.browseFolderId == null,
browseFolderName = uiState.browseFolderName,
subFolders = uiState.browseSubFolders,
browseFiles = uiState.browseFiles,
sections = uiState.sections,
isImporting = importState.isImporting,
error = uiState.error ?: importState.error,
@@ -467,6 +468,7 @@ private fun HomeViewContent(
atRoot: Boolean,
browseFolderName: String?,
subFolders: List<FolderEntity>,
browseFiles: List<FileEntity>,
sections: List<FileSection>,
isImporting: Boolean,
error: String?,
@@ -491,11 +493,14 @@ private fun HomeViewContent(
atRoot = atRoot,
browseFolderName = browseFolderName,
subFolders = subFolders,
browseFiles = browseFiles,
isImporting = isImporting,
error = error,
selection = selection,
onBrowseUp = onBrowseUp,
onOpenBrowseFolder = onOpenBrowseFolder,
onCreateFolder = onCreateFolder,
onOpenDocument = onOpenDocument,
modifier = modifier,
)
}
@@ -592,13 +597,17 @@ private fun FolderBrowserContent(
atRoot: Boolean,
browseFolderName: String?,
subFolders: List<FolderEntity>,
browseFiles: List<FileEntity>,
isImporting: Boolean,
error: String?,
selection: SelectionState,
onBrowseUp: () -> Unit,
onOpenBrowseFolder: (String) -> Unit,
onCreateFolder: () -> Unit,
onOpenDocument: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val empty = subFolders.isEmpty() && (moveMode || browseFiles.isEmpty()) && !isImporting
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp),
@@ -677,7 +686,20 @@ private fun FolderBrowserContent(
FolderRow(folder, onClick = { onOpenBrowseFolder(folder.resourceId) })
}
if (subFolders.isEmpty() && !isImporting) {
// En mode déplacement, le navigateur sert au choix de la cible : les
// fichiers du dossier courant ne sont pas affichés (reste un pur
// explorateur de dossiers).
if (!moveMode) {
items(browseFiles, key = { it.resourceId }) { file ->
BrowseFileRow(
file = file,
selection = selection,
onOpenDocument = { onOpenDocument(file.resourceId) },
)
}
}
if (empty) {
item(key = "empty") {
Text(
text = stringResource(if (atRoot) R.string.no_folders_yet else R.string.empty_folder),
@@ -856,6 +878,67 @@ private fun FileCard(
}
}
/** Carte fichier dans l'explorateur Dossiers — clic pour ouvrir, clic long pour la sélection. */
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun BrowseFileRow(
file: FileEntity,
selection: SelectionState,
onOpenDocument: () -> Unit,
) {
val selected = file.resourceId in selection.ids
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 6.dp)
.combinedClickable(
onClick = {
if (selection.active) selection.toggle(file.resourceId) else onOpenDocument()
},
onLongClick = {
if (!selection.active) selection.start(file.resourceId)
},
),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
FileCategoryIcon(file = file, size = 26.dp)
Column(modifier = Modifier.weight(1f)) {
Text(
text = file.name,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Medium,
)
Text(
text = formatSize(file.size),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (selection.active) {
SelectionStatusIcon(selected = selected)
} else {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilePendingReviewBadge(file = file)
FileSyncStatusIcon(file = file, size = 20.dp)
}
}
}
}
}
/** Miroir de `formatSize` (app/index.tsx). */
@Composable
private fun formatSize(bytes: Long): String {
@@ -37,6 +37,8 @@ data class FolderListUiState(
val browseFolderName: String? = null,
/** Sous-dossiers visibles du dossier courant de l'explorateur. */
val browseSubFolders: List<FolderEntity> = emptyList(),
/** Fichiers visibles du dossier courant de l'explorateur. */
val browseFiles: List<FileEntity> = emptyList(),
val sections: List<FileSection> = emptyList(),
val isRefreshing: Boolean = false,
val error: String? = null,
@@ -147,6 +147,16 @@ class FolderListViewModel @Inject constructor(
_uiState.update { it.copy(browseSubFolders = subFolders) }
}
}
viewModelScope.launch {
_defaultRootId.combine(_browseFolderId) { root, browse -> browse ?: root }
.flatMapLatest { parentId ->
if (parentId == null) flowOf(emptyList())
else fileRepository.observeFiles(parentId)
}
.collect { files ->
_uiState.update { it.copy(browseFiles = files) }
}
}
}
/** Descend dans l'explorateur Dossiers (aussi en mode déplacement). */
@@ -229,4 +229,17 @@
<string name="delete_confirm_button">Confirmer</string>
<string name="delete_cancel">Annuler</string>
<string name="delete_error">Erreur lors de la suppression</string>
<!-- Partage (share grants) -->
<string name="share_title">Partager avec un utilisateur</string>
<string name="share_username_hint">Nom d\'utilisateur du destinataire</string>
<string name="share_access_label">Niveau d\'accès</string>
<string name="share_access_viewer">Lecture (viewer)</string>
<string name="share_access_commenter">Commentaire (commenter)</string>
<string name="share_access_editor">Édition (editor)</string>
<string name="share_submit">Partager</string>
<string name="share_cancel">Annuler</string>
<string name="share_error_not_found">Aucun utilisateur connu sous ce nom.</string>
<string name="share_error_generic">Impossible de partager pour le moment.</string>
<string name="share_content_description">Partager ce dossier</string>
</resources>