diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/ShareRepository.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/ShareRepository.kt new file mode 100644 index 0000000..3127303 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/ShareRepository.kt @@ -0,0 +1,161 @@ +package com.vaultdrop.mobile.data.repository + +import androidx.room.withTransaction +import com.vaultdrop.mobile.data.local.AppDatabase +import com.vaultdrop.mobile.data.local.dao.FileDao +import com.vaultdrop.mobile.data.local.dao.FolderDao +import com.vaultdrop.mobile.data.local.entity.FileEntity +import com.vaultdrop.mobile.data.local.entity.FolderEntity +import com.vaultdrop.mobile.data.remote.ApiClient +import com.vaultdrop.mobile.data.remote.dto.ResourcePermissionDto +import com.vaultdrop.mobile.domain.DeviceIdentity +import com.vaultdrop.mobile.domain.SyncPlacement +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Hydratation des ressources partagées dans Room depuis le snapshot serveur. + * + * Le snapshot (`GET /sync/permissions`) contient les permissions effectives + * de l'utilisateur. Les ressources non possédées (viewer/commenter/editor) + * sont importées en tant que lignes cloud-only (`uri = NULL`, `sync_status = "cloud"`). + * + * Un pull complet (`after=0`) est effectué au login et à la restauration de + * session pour garantir la convergence (révocations = disparition du snapshot). + */ +@Singleton +class ShareRepository @Inject constructor( + private val apiClient: ApiClient, + private val appDatabase: AppDatabase, + private val deviceIdentity: DeviceIdentity, +) { + + /** + * Récupère le snapshot complet des permissions et hydrate Room. + * + * Pour chaque permission non-owner : + * - Folders : upsert cloud-only (uri=null, name, parentId, ownerId) + * - Files : upsert cloud-only (uri=null, name, folderResourceId, ownerId, processed=true) + * + * Convergence : les lignes cloud-only absentes du snapshot complet sont + * marquées `exists = 0`. + */ + suspend fun syncSnapshot() { + val currentUserId = deviceIdentity.getOrCreate() + val permissions = apiClient.syncPermissions() + + val now = System.currentTimeMillis() + val folderDao = appDatabase.folderDao() + val fileDao = appDatabase.fileDao() + + val snapshotFolderIds = mutableSetOf() + val snapshotFileIds = mutableSetOf() + + appDatabase.withTransaction { + for (perm in permissions) { + when { + perm.effectiveAccess == "owner" -> Unit + perm.resourceType == "folder" -> { + snapshotFolderIds += perm.resourceId + upsertSharedFolder(perm, now, folderDao) + } + perm.resourceType == "file" -> { + snapshotFileIds += perm.resourceId + upsertSharedFile(perm, now, fileDao) + } + } + } + + // Convergence : marquer les lignes cloud-only absentes du snapshot + // comme disparues (révocation / expiration côté serveur). + for (folder in folderDao.getAll()) { + if (isCloudOnlyForOtherUser(folder.uri, folder.ownerId, currentUserId, folder.exists) + && folder.resourceId !in snapshotFolderIds + ) { + folderDao.markMissing(folder.resourceId, now) + } + } + for (file in fileDao.getAll()) { + if (isCloudOnlyForOtherUser(file.uri, file.ownerId, currentUserId, file.exists) + && file.resourceId !in snapshotFileIds + ) { + fileDao.markMissing(file.resourceId, now) + } + } + } + + Timber.d( + "syncSnapshot: hydrated %d folders, %d files", + snapshotFolderIds.size, + snapshotFileIds.size, + ) + } + + /** + * Ligne hébergée cloud (uri NULL), non possédée localement, encore visible. + * Absente du snapshot → ressource partagée révoquée/expirée → marquer disparue. + */ + private fun isCloudOnlyForOtherUser( + uri: String?, + ownerId: String?, + currentNodeId: String, + exists: Int?, + ): Boolean = uri == null && ownerId != null && ownerId != currentNodeId && exists != 0 + + private suspend fun upsertSharedFolder( + perm: ResourcePermissionDto, + now: Long, + folderDao: FolderDao, + ) { + val existing = folderDao.getByResourceId(perm.resourceId) + if (existing != null && existing.uri != null) { + // Une copie physique locale existe déjà — ne jamais écraser l'uri. + return + } + val entity = FolderEntity( + id = existing?.id ?: 0L, + resourceId = perm.resourceId, + uri = null, + name = perm.name.ifBlank { existing?.name ?: "" }, + exists = 1, + parentResourceId = perm.parentId ?: existing?.parentResourceId, + ownerId = perm.ownerId ?: existing?.ownerId, + syncStatus = SyncPlacement.confirmed(false), + addedAt = existing?.addedAt ?: now, + updatedAt = now, + ) + folderDao.upsert(entity) + } + + private suspend fun upsertSharedFile( + perm: ResourcePermissionDto, + now: Long, + fileDao: FileDao, + ) { + val existing = fileDao.getByResourceId(perm.resourceId) + if (existing != null && existing.uri != null) { + // Une copie physique locale existe déjà — ne jamais écraser l'uri. + return + } + val entity = FileEntity( + id = existing?.id ?: 0L, + resourceId = perm.resourceId, + uri = null, + name = perm.name.ifBlank { existing?.name ?: "" }, + folderResourceId = perm.parentId ?: existing?.folderResourceId ?: "", + extension = existing?.extension, + size = existing?.size ?: 0L, + mimeType = existing?.mimeType, + category = existing?.category, + exists = 1, + lastModified = existing?.lastModified, + ownerId = perm.ownerId ?: existing?.ownerId, + syncStatus = SyncPlacement.confirmed(false), + processed = true, + addedAt = existing?.addedAt ?: now, + updatedAt = now, + ) + fileDao.upsert(entity) + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailScreen.kt index 405acce..f0b97d3 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderdetail/FolderDetailScreen.kt @@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -18,10 +19,12 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.MoreVert 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.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -92,6 +95,7 @@ fun FolderDetailScreen( var showDeleteWarning by remember { mutableStateOf(false) } var showDeleteConfirm by remember { mutableStateOf(false) } var showShareSheet by remember { mutableStateOf(false) } + var showMoreMenu by remember { mutableStateOf(false) } var pendingDeleteMode by remember { mutableStateOf(null) } var pendingDeleteReview by remember { mutableStateOf(null) } val snackbarHostState = remember { SnackbarHostState() } @@ -178,14 +182,32 @@ 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)) + Box { + IconButton(onClick = { showMoreMenu = true }) { + Icon( + Icons.Filled.MoreVert, + contentDescription = stringResource(R.string.more_actions), + ) + } + DropdownMenu( + expanded = showMoreMenu, + onDismissRequest = { showMoreMenu = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.share_action)) }, + onClick = { + showMoreMenu = false + showShareSheet = true + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.refresh)) }, + onClick = { + showMoreMenu = false + viewModel.refresh() + }, + ) + } } } }, diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/share/ShareBottomSheet.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/share/ShareBottomSheet.kt new file mode 100644 index 0000000..f3ab93a --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/share/ShareBottomSheet.kt @@ -0,0 +1,193 @@ +package com.vaultdrop.mobile.ui.share + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +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.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +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.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.vaultdrop.mobile.R + +/** Niveau d'accès sélectionnable — miroir de `ShareAccessLevel`. */ +private data class AccessOption( + val labelRes: Int, + val value: String, +) + +private val ACCESS_OPTIONS = listOf( + AccessOption(R.string.share_access_viewer, ShareAccessLevel.VIEWER), + AccessOption(R.string.share_access_commenter, ShareAccessLevel.COMMENTER), + AccessOption(R.string.share_access_editor, ShareAccessLevel.EDITOR), +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ShareBottomSheet( + resourceName: String, + onDismiss: () -> Unit, + onShare: (username: String, access: String) -> Unit, + sharing: Boolean, + error: String?, + enqueued: Boolean, +) { + var username by remember { mutableStateOf("") } + var access by remember { mutableStateOf(ShareAccessLevel.VIEWER) } + val sheetState = rememberModalBottomSheetState() + + LaunchedEffect(enqueued) { + if (enqueued) onDismiss() + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.share_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = resourceName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + + OutlinedTextField( + value = username, + onValueChange = { username = it }, + label = { Text(stringResource(R.string.share_username_hint)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + Text( + text = stringResource(R.string.share_access_label), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + AccessDropdown( + selected = access, + onSelect = { access = it }, + modifier = Modifier.fillMaxWidth(), + ) + + error?.let { code -> + Text( + text = stringResource(shareErrorResFor(code)), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + + Button( + onClick = { onShare(username.trim(), access) }, + enabled = username.isNotBlank() && !sharing, + modifier = Modifier.fillMaxWidth(), + ) { + if (sharing) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + } else { + Icon(Icons.Filled.Share, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.share_submit)) + } + } + OutlinedButton( + onClick = onDismiss, + enabled = !sharing, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.share_cancel)) + } + } + } +} + +@Composable +private fun AccessDropdown( + selected: String, + onSelect: (String) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + val selectedLabel = ACCESS_OPTIONS.firstOrNull { it.value == selected }?.labelRes + ?: ACCESS_OPTIONS.first().labelRes + + Box(modifier = modifier) { + OutlinedButton( + onClick = { expanded = true }, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(selectedLabel), + modifier = Modifier.weight(1f), + textAlign = TextAlign.Start, + ) + Icon( + imageVector = Icons.Filled.KeyboardArrowDown, + contentDescription = null, + ) + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + ACCESS_OPTIONS.forEach { option -> + DropdownMenuItem( + text = { Text(stringResource(option.labelRes)) }, + onClick = { + onSelect(option.value) + expanded = false + }, + ) + } + } + } +} + +private fun shareErrorResFor(code: String): Int = when (code) { + "USER_NOT_FOUND" -> R.string.share_error_not_found + else -> R.string.share_error_generic +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/share/ShareViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/share/ShareViewModel.kt new file mode 100644 index 0000000..415d2f2 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/share/ShareViewModel.kt @@ -0,0 +1,92 @@ +package com.vaultdrop.mobile.ui.share + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vaultdrop.mobile.data.remote.ApiClient +import com.vaultdrop.mobile.data.remote.ApiException +import com.vaultdrop.mobile.data.repository.OutboxRepository +import com.vaultdrop.mobile.features.sync.OutboxSyncWorker +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +data class ShareUiState( + val sharing: Boolean = false, + /** Code d'erreur contractuel (`USER_NOT_FOUND`, `SHARE_FAILED`) ou null. */ + val error: String? = null, + /** L'op `share` a été enqueued avec succès (prête pour le drain). */ + val enqueued: Boolean = false, +) + +/** Accès partage — valeur du payload `access` (docs/api-v1.md §6.1). */ +object ShareAccessLevel { + const val VIEWER = "viewer" + const val COMMENTER = "commenter" + const val EDITOR = "editor" +} + +/** + * Partage d'une ressource locale avec un autre utilisateur : résout le + * destinataire par username (exact), puis enqueue l'op `share` dans l'outbox + * (drainée par [OutboxSyncWorker]). + */ +@HiltViewModel +class ShareViewModel @Inject constructor( + private val apiClient: ApiClient, + private val outboxRepository: OutboxRepository, + @ApplicationContext private val appContext: Context, +) : ViewModel() { + + private val _uiState = MutableStateFlow(ShareUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + /** + * Partage [resourceId] avec l'utilisateur [username] au niveau [access]. + * + * 1. `GET /users/resolve` (username exact) → user id ; + * 2. enqueue `share` dans l'outbox ; + * 3. déclenche le drain du worker. + */ + fun share(resourceId: String, resourceType: String, username: String, access: String) { + if (_uiState.value.sharing) return + viewModelScope.launch { + _uiState.update { it.copy(sharing = true, error = null, enqueued = false) } + runCatching { + val user = apiClient.resolveUser(username) + outboxRepository.enqueueShare( + resourceId = resourceId, + resourceType = resourceType, + granteeUserId = user.id, + access = access, + ) + }.onSuccess { + OutboxSyncWorker.enqueue(appContext) + Timber.d("share: op enqueued for %s", resourceId) + _uiState.update { it.copy(sharing = false, enqueued = true) } + }.onFailure { e -> + val error = when { + e is ApiException && e.code == "NOT_FOUND" -> "USER_NOT_FOUND" + else -> "SHARE_FAILED" + } + Timber.w(e, "share failed") + _uiState.update { it.copy(sharing = false, error = error) } + } + } + } + + /** Consomme l'état clone (succès/erreur affiché) avant de rouvrir le sheet. */ + fun reset() { + _uiState.value = ShareUiState() + } + + override fun onCleared() { + super.onCleared() + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/res/values/strings.xml b/mobile-kotlin/app/src/main/res/values/strings.xml index 4258373..4ce99fe 100644 --- a/mobile-kotlin/app/src/main/res/values/strings.xml +++ b/mobile-kotlin/app/src/main/res/values/strings.xml @@ -242,4 +242,6 @@ Aucun utilisateur connu sous ce nom. Impossible de partager pour le moment. Partager ce dossier +Partager + Plus d\'actions \ No newline at end of file diff --git a/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/data/repository/MoveProtectionTest.kt b/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/data/repository/MoveProtectionTest.kt new file mode 100644 index 0000000..2b109b8 --- /dev/null +++ b/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/data/repository/MoveProtectionTest.kt @@ -0,0 +1,296 @@ +package com.vaultdrop.mobile.data.repository + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import com.vaultdrop.mobile.data.local.AppDatabase +import com.vaultdrop.mobile.data.local.dao.FileDao +import com.vaultdrop.mobile.data.local.dao.PendingOperationDao +import com.vaultdrop.mobile.data.local.entity.FileEntity +import com.vaultdrop.mobile.data.local.entity.FileStatus +import com.vaultdrop.mobile.data.local.entity.PendingOperationEntity +import com.vaultdrop.mobile.data.local.entity.PendingOperationType +import com.vaultdrop.mobile.data.local.entity.PendingOpStatus +import com.vaultdrop.mobile.data.remote.ApiClient +import com.vaultdrop.mobile.data.remote.ApiService +import com.vaultdrop.mobile.data.remote.dto.ApiEnvelope +import com.vaultdrop.mobile.data.remote.dto.DeviceRegistrationDto +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 +import com.vaultdrop.mobile.domain.GenerateId +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import retrofit2.Response + +/** + * Garde-fous outbox du déplacement local : tant qu'un `move_resource` est en + * attente de push, ni le refresh serveur ni le walk SAF ne doivent écraser le + * placement local (`folder_resource_id`) ou masquer la ligne. Une fois l'op + * `synced` (ou jamais journalisée), le serveur / le walk redevient source de + * vérité. Et la réconciliation ne doit jamais pairer un `markMissing`. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class MoveProtectionTest { + + private lateinit var db: AppDatabase + private lateinit var fileDao: FileDao + private lateinit var opsDao: PendingOperationDao + private lateinit var fileRepository: FileRepository + private lateinit var outboxRepository: OutboxRepository + private lateinit var apiService: MoveProtectionApiService + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) + .allowMainThreadQueries() + .build() + fileDao = db.fileDao() + opsDao = db.pendingOperationDao() + + val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() + apiService = MoveProtectionApiService() + val apiClient = ApiClient(apiService, moshi) + val generateId = GenerateId() + + outboxRepository = OutboxRepository(opsDao, generateId, moshi) + fileRepository = FileRepository(fileDao, apiClient, generateId, db, outboxRepository) + } + + @After + fun tearDown() { + db.close() + } + + // --- refreshFromServer : le move en attente prime sur le serveur --------- + + @Test + fun refresh_serveur_ne_reenvoie_pas_le_fichier_a_son_ancien_dossier_si_move_pending() = runTest { + // Placement local : le fichier est déjà dans le dossier cible TARGET. + fileDao.upsert(file(TARGET_FOLDER, exists = 1)) + // L'op move_resource n'a pas encore été poussée. + opsDao.insert(op(status = PendingOpStatus.PENDING)) + // Le serveur renvoie encore l'ancien dossier (op pas appliquée). + apiService.files = listOf( + FileDto(id = FILE_ID, name = NAME, size = SIZE, mimeType = MIME, folderId = SOURCE_FOLDER), + ) + + fileRepository.refreshFromServer(SOURCE_FOLDER) + + val row = fileDao.getByResourceId(FILE_ID)!! + assertEquals("placement local cible préservé", TARGET_FOLDER, row.folderResourceId) + assertEquals("ligne jamais masquée", 1, row.exists) + } + + @Test + fun refresh_serveur_applique_la_position_une_fois_move_synced() = runTest { + // L'op a été appliquée côté serveur — le serveur redevient source de + // vérité (la ligne se ré-attribue au dossier renvoyé). + fileDao.upsert(file(TARGET_FOLDER, exists = 1)) + opsDao.insert(op(status = PendingOpStatus.SYNCED)) + apiService.files = listOf( + FileDto(id = FILE_ID, name = NAME, size = SIZE, mimeType = MIME, folderId = SOURCE_FOLDER), + ) + + fileRepository.refreshFromServer(SOURCE_FOLDER) + + val row = fileDao.getByResourceId(FILE_ID)!! + assertEquals(SOURCE_FOLDER, row.folderResourceId) + } + + @Test + fun refresh_sans_op_outbox_suit_le_serveur() = runTest { + // Aucun move jamais journalisé → suivi serveur direct. + fileDao.upsert(file(SOURCE_FOLDER, exists = 1)) + apiService.files = listOf( + FileDto(id = FILE_ID, name = NAME_DIFF, size = SIZE, mimeType = MIME, folderId = SOURCE_FOLDER), + ) + + fileRepository.refreshFromServer(SOURCE_FOLDER) + + val row = fileDao.getByResourceId(FILE_ID)!! + assertEquals("métadonnées serveur prises en compte", NAME_DIFF, row.name) + assertEquals(SOURCE_FOLDER, row.folderResourceId) + } + + // --- saveLocalFile : le walk SAF ne doit pas ré-attribuer le dossier ----- + + @Test + fun walk_saf_ne_reenvoie_pas_le_fichier_dans_l_ancien_dossier_si_move_pending() = runTest { + // Repli métadonnée seule : le fichier est physiquement resté dans + // l'ancien dossier (uri inchangé) mais Room/en cours = dossier cible. + fileDao.upsert(file(TARGET_FOLDER, exists = 1).copy(uri = PRE_MOVE_URI)) + opsDao.insert(op(status = PendingOpStatus.PENDING)) + + // Le walk retrouve le fichier à son emplacement physique (ancien + // dossier) : il ne doit pas ré-attribuer le placement cible. + fileRepository.saveLocalFile( + input = SaveFileInput( + uri = PRE_MOVE_URI, + name = NAME, + extension = "txt", + size = SIZE, + mimeType = MIME, + lastModified = 123L, + exists = true, + ), + folderResourceId = SOURCE_FOLDER, + ) + + val row = fileDao.getByResourceId(FILE_ID)!! + assertEquals("le walk ne doit pas ré-attribuer le dossier", TARGET_FOLDER, row.folderResourceId) + } + + @Test + fun walk_saf_reenvoie_le_fichier_dans_son_dossier_physique_sans_op_outbox() = runTest { + // Pas de move jamais journalisé : le walk est la référence physique. + fileDao.upsert(file(TARGET_FOLDER, exists = 0).copy(uri = PRE_MOVE_URI)) + + fileRepository.saveLocalFile( + input = SaveFileInput( + uri = PRE_MOVE_URI, + name = NAME, + extension = "txt", + size = SIZE, + mimeType = MIME, + lastModified = 123L, + exists = true, + ), + folderResourceId = SOURCE_FOLDER, + ) + + val row = fileDao.getByResourceId(FILE_ID)!! + assertEquals(SOURCE_FOLDER, row.folderResourceId) + assertEquals("la ligne émerge à nouveau", 1, row.exists) + } + + // --- prédicats outbox ---------------------------------------------------- + + @Test + fun hasPendingMoveOperation_discrimine_pending_seul() = runTest { + opsDao.insert(op(status = PendingOpStatus.PENDING)) + assertTrue(outboxRepository.hasPendingMoveOperation(FILE_ID)) + } + + @Test + fun hasPendingMoveOperation_faux_quand_synced_absent_failed() = runTest { + opsDao.insert(op(status = PendingOpStatus.SYNCED)) + assertTrue("synced compte comme move (transition)", outboxRepository.hasMoveOperation(FILE_ID)) + assertFalse(outboxRepository.hasPendingMoveOperation(FILE_ID)) + + opsDao.insert( + op(status = PendingOpStatus.FAILED, resourceId = "9".repeat(32)), + ) + assertFalse(outboxRepository.hasPendingMoveOperation("9".repeat(32))) + assertFalse(outboxRepository.hasMoveOperation("9".repeat(32))) + } + + @Test + fun un_autre_type_d_op_ne_compte_pas_comme_move() = runTest { + opsDao.insert( + op(status = PendingOpStatus.PENDING, operation = PendingOperationType.CREATE_RESOURCE, resourceId = "8".repeat(32)), + ) + assertFalse(outboxRepository.hasPendingMoveOperation("8".repeat(32))) + assertFalse(outboxRepository.hasMoveOperation("8".repeat(32))) + } + + // --- fixtures ------------------------------------------------------------ + + private fun file(folderId: String, exists: Int) = FileEntity( + resourceId = FILE_ID, + uri = PRE_MOVE_URI, + name = NAME, + folderResourceId = folderId, + extension = "txt", + size = SIZE, + mimeType = MIME, + category = "document", + exists = exists, + syncStatus = FileStatus.LOCAL, + processed = true, + addedAt = NOW, + updatedAt = NOW, + ) + + private fun op( + status: String, + resourceId: String = FILE_ID, + operation: String = PendingOperationType.MOVE_RESOURCE, + ) = PendingOperationEntity( + operationId = String.format("%032x", opSeq++), + resourceId = resourceId, + resourceType = "file", + operation = operation, + payload = """{"toFolderResourceId":"$TARGET_FOLDER"}""", + status = status, + createdAt = NOW, + updatedAt = NOW, + ) + + private var opSeq = 0 + + private companion object { + const val NOW = 1_700_000_000_000L + const val SOURCE_FOLDER = "11111111111111111111111111111111" + const val TARGET_FOLDER = "22222222222222222222222222222222" + const val FILE_ID = "aabbccddeeff11223344556677889900" + const val NAME = "document.txt" + const val NAME_DIFF = "document_renomme.txt" + const val SIZE = 1024L + const val MIME = "text/plain" + const val PRE_MOVE_URI = "content://tree/11111111111111111111111111111111/doc" + } +} + +/** Fake `ApiService` — seul `listFiles` est réellement consommé. */ +private class MoveProtectionApiService : ApiService { + var files: List = emptyList() + + override suspend fun listFolders(): Response>> = + Response.success(ApiEnvelope(data = emptyList())) + + override suspend fun listFiles( + folderId: String?, + page: Int?, + pageSize: Int?, + ): Response>> = Response.success(ApiEnvelope(data = files)) + + override suspend fun registerDevice( + body: DeviceRegistrationDto, + ): Response> = + Response.success(ApiEnvelope(data = body)) + + override suspend fun login( + body: LoginRequestDto, + ): Response> = Response.success(ApiEnvelope()) + + override suspend fun syncOps( + body: SyncOpsRequest, + ): Response> = Response.success(ApiEnvelope(data = SyncOpsResult(applied = 0))) + + override suspend fun resolveUser( + username: String, + ): Response> = Response.success(ApiEnvelope()) + + override suspend fun syncPermissions( + after: Long?, + ): Response>> = + Response.success(ApiEnvelope(data = emptyList())) +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/data/repository/ShareRepositoryTest.kt b/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/data/repository/ShareRepositoryTest.kt new file mode 100644 index 0000000..87b9694 --- /dev/null +++ b/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/data/repository/ShareRepositoryTest.kt @@ -0,0 +1,253 @@ +package com.vaultdrop.mobile.data.repository + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import com.vaultdrop.mobile.data.local.AppDatabase +import com.vaultdrop.mobile.data.local.dao.FileDao +import com.vaultdrop.mobile.data.local.dao.FolderDao +import com.vaultdrop.mobile.data.local.dao.UserPreferenceDao +import com.vaultdrop.mobile.data.local.entity.FileEntity +import com.vaultdrop.mobile.data.local.entity.FileStatus +import com.vaultdrop.mobile.data.local.entity.FolderEntity +import com.vaultdrop.mobile.data.remote.ApiClient +import com.vaultdrop.mobile.data.remote.ApiService +import com.vaultdrop.mobile.data.remote.dto.ApiEnvelope +import com.vaultdrop.mobile.data.remote.dto.DeviceRegistrationDto +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 +import com.vaultdrop.mobile.domain.DeviceIdentity +import com.vaultdrop.mobile.domain.GenerateId +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import retrofit2.Response + +/** + * Contrat de l'hydratation du snapshot `GET /sync/permissions` : + * - les ressources non-possédées (viewer/commenter/editor) sont importées + * cloud-only (`uri = NULL`, `sync_status = "cloud"`) avec name/parentId ; + * - une permission `owner` est ignorée (la ressource est déjà connue via le + * walk SAF / l'owning) ; + * - une copie physique locale (uri non null) n'est jamais écrasée ; + * - une ressource cloud-only absente du snapshot est marquée `exists = 0` + * (convergence après révocation/expiration). + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class ShareRepositoryTest { + + private lateinit var db: AppDatabase + private lateinit var folderDao: FolderDao + private lateinit var fileDao: FileDao + private lateinit var shareRepository: ShareRepository + private lateinit var apiService: FakeApiService + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) + .allowMainThreadQueries() + .build() + folderDao = db.folderDao() + fileDao = db.fileDao() + + val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() + apiService = FakeApiService() + val apiClient = ApiClient(apiService, moshi) + val deviceIdentity = DeviceIdentity(db.userPreferenceDao(), GenerateId()) + + shareRepository = ShareRepository(apiClient, db, deviceIdentity) + } + + @After + fun tearDown() { + db.close() + } + + // --- hydration ----------------------------------------------------------- + + @Test + fun hydrate_importe_dossier_et_fichier_partages_cloud_only() = runTest { + apiService.permissions = listOf( + perm(id = FOLDER_ID, type = "folder", access = "editor", name = "Shared Folder", parentId = null), + perm(id = FILE_ID, type = "file", access = "viewer", name = "shared.pdf", parentId = FOLDER_ID), + ) + + shareRepository.syncSnapshot() + + val folder = folderDao.getByResourceId(FOLDER_ID)!! + assertEquals("Shared Folder", folder.name) + assertNull(folder.uri) + assertEquals(1, folder.exists) + assertEquals(FileStatus.CLOUD, folder.syncStatus) + assertEquals(null, folder.parentResourceId) + + val file = fileDao.getByResourceId(FILE_ID)!! + assertEquals("shared.pdf", file.name) + assertNull(file.uri) + assertEquals(1, file.exists) + assertEquals(FILE_STATUS, file.syncStatus) + assertEquals(FOLDER_ID, file.folderResourceId) + assertTrue("un fichier partagé est déjà traité (pas de review)", file.processed) + } + + @Test + fun permission_owner_ignoree() = runTest { + apiService.permissions = listOf( + perm(id = OWNED_ID, type = "file", access = "owner", name = "mine.pdf", parentId = null), + ) + + shareRepository.syncSnapshot() + + assertNull("la ressource possédée ne doit pas être hydratée", fileDao.getByResourceId(OWNED_ID)) + } + + @Test + fun copie_locale_preservee() = runTest { + fileDao.upsert( + FileEntity( + resourceId = FILE_ID, + uri = "content://tree/physical", + name = "local.pdf", + folderResourceId = FOLDER_ID, + size = 1, + syncStatus = FileStatus.LOCAL_CLOUD, + processed = false, + addedAt = NOW, + updatedAt = NOW, + ), + ) + apiService.permissions = listOf( + perm(id = FILE_ID, type = "file", access = "editor", name = "server_name.pdf", parentId = FOLDER_ID), + ) + + shareRepository.syncSnapshot() + + val file = fileDao.getByResourceId(FILE_ID)!! + assertEquals("content://tree/physical", file.uri) + assertEquals(FileStatus.LOCAL_CLOUD, file.syncStatus) + } + + // --- convergence --------------------------------------------------------- + + @Test + fun absence_du_snapshot_marque_la_ressource_partagee_disparue() = runTest { + folderDao.upsert( + folder(FOLDER_ID, uri = null, ownerId = OTHER_USER), + ) + apiService.permissions = emptyList() + + shareRepository.syncSnapshot() + + val folder = folderDao.getByResourceId(FOLDER_ID)!! + assertEquals(0, folder.exists) + assertNull(folder.uri) + } + + @Test + fun ligne_cloud_only_propre_non_touchee_par_convergence() = runTest { + // Une ligne cloud-only dont `ownerId` est NULL (pas encore typée) ne + // doit pas être marquée disparue : elle ne correspond pas à une + // ressource "shared with me". + folderDao.upsert( + folder("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", uri = null, ownerId = null), + ) + apiService.permissions = emptyList() + + shareRepository.syncSnapshot() + + assertEquals(1, folderDao.getByResourceId("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee")!!.exists) + } + + // --- fixtures ------------------------------------------------------------ + + private fun perm(id: String, type: String, access: String, name: String, parentId: String?) = + ResourcePermissionDto( + resourceId = id.padEnd(32, '0'), + resourceType = type, + effectiveAccess = access, + inherit = true, + ownerId = OTHER_USER, + sharedById = OWNER_ID, + expiresAt = null, + cachedAt = NOW, + updatedAt = NOW, + name = name, + parentId = parentId?.padEnd(32, '0'), + ) + + private fun folder(id: String, uri: String?, ownerId: String?) = FolderEntity( + resourceId = id.padEnd(32, '0'), + uri = uri, + name = id, + exists = 1, + parentResourceId = null, + ownerId = ownerId, + syncStatus = if (uri == null) FileStatus.CLOUD else FileStatus.LOCAL, + addedAt = NOW, + updatedAt = NOW, + ) + + private companion object { + const val NOW = 1_700_000_000_000L + const val OTHER_USER = "11111111111111111111111111111111" + const val OWNER_ID = "22222222222222222222222222222222" + const val FOLDER_ID = "f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0" + const val FILE_ID = "aabbccddeeff11223344556677889900" + const val OWNED_ID = "99999999999999999999999999999999" + const val FILE_STATUS = FileStatus.CLOUD + } +} + +/** Fake `ApiService` — uniquement le snapshot est réellement consommé. */ +private class FakeApiService : ApiService { + var permissions: List = emptyList() + + override suspend fun listFolders(): Response>> = + Response.success(ApiEnvelope(data = emptyList())) + + override suspend fun listFiles( + folderId: String?, + page: Int?, + pageSize: Int?, + ): Response>> = Response.success(ApiEnvelope(data = emptyList())) + + override suspend fun registerDevice( + body: DeviceRegistrationDto, + ): Response> = + Response.success(ApiEnvelope(data = body)) + + override suspend fun login( + body: LoginRequestDto, + ): Response> = Response.success(ApiEnvelope()) + + override suspend fun syncOps( + body: SyncOpsRequest, + ): Response> = Response.success(ApiEnvelope(data = SyncOpsResult(applied = 0))) + + override suspend fun resolveUser( + username: String, + ): Response> = Response.success(ApiEnvelope()) + + override suspend fun syncPermissions( + after: Long?, + ): Response>> = + Response.success(ApiEnvelope(data = permissions)) +} \ No newline at end of file