share folders and details

This commit is contained in:
m
2026-09-14 14:18:34 +02:00
parent e9c85febca
commit 191ca002bc
7 changed files with 1028 additions and 9 deletions
@@ -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<String>()
val snapshotFileIds = mutableSetOf<String>()
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)
}
}
@@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row 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.Close
import androidx.compose.material.icons.filled.CreateNewFolder import androidx.compose.material.icons.filled.CreateNewFolder
import androidx.compose.material.icons.filled.Folder 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.Refresh
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -92,6 +95,7 @@ fun FolderDetailScreen(
var showDeleteWarning by remember { mutableStateOf(false) } var showDeleteWarning by remember { mutableStateOf(false) }
var showDeleteConfirm by remember { mutableStateOf(false) } var showDeleteConfirm by remember { mutableStateOf(false) }
var showShareSheet by remember { mutableStateOf(false) } var showShareSheet by remember { mutableStateOf(false) }
var showMoreMenu by remember { mutableStateOf(false) }
var pendingDeleteMode by remember { mutableStateOf<FileDeleter.DeleteMode?>(null) } var pendingDeleteMode by remember { mutableStateOf<FileDeleter.DeleteMode?>(null) }
var pendingDeleteReview by remember { mutableStateOf<DeleteReview?>(null) } var pendingDeleteReview by remember { mutableStateOf<DeleteReview?>(null) }
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
@@ -178,14 +182,32 @@ fun FolderDetailScreen(
status = connectionStatus, status = connectionStatus,
onClick = connectionStatusViewModel::checkNow, onClick = connectionStatusViewModel::checkNow,
) )
IconButton(onClick = { showShareSheet = true }) { Box {
Icon( IconButton(onClick = { showMoreMenu = true }) {
Icons.Filled.Share, Icon(
contentDescription = stringResource(R.string.share_content_description), Icons.Filled.MoreVert,
) contentDescription = stringResource(R.string.more_actions),
} )
IconButton(onClick = viewModel::refresh) { }
Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh)) 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()
},
)
}
} }
} }
}, },
@@ -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
}
@@ -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<ShareUiState> = _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()
}
}
@@ -242,4 +242,6 @@
<string name="share_error_not_found">Aucun utilisateur connu sous ce nom.</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_error_generic">Impossible de partager pour le moment.</string>
<string name="share_content_description">Partager ce dossier</string> <string name="share_content_description">Partager ce dossier</string>
<string name="share_action">Partager</string>
<string name="more_actions">Plus d\'actions</string>
</resources> </resources>
@@ -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<Context>()
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<FileDto> = emptyList()
override suspend fun listFolders(): Response<ApiEnvelope<List<FolderDto>>> =
Response.success(ApiEnvelope(data = emptyList()))
override suspend fun listFiles(
folderId: String?,
page: Int?,
pageSize: Int?,
): Response<ApiEnvelope<List<FileDto>>> = Response.success(ApiEnvelope(data = files))
override suspend fun registerDevice(
body: DeviceRegistrationDto,
): Response<ApiEnvelope<DeviceRegistrationDto>> =
Response.success(ApiEnvelope(data = body))
override suspend fun login(
body: LoginRequestDto,
): Response<ApiEnvelope<LoginResponseDto>> = Response.success(ApiEnvelope())
override suspend fun syncOps(
body: SyncOpsRequest,
): Response<ApiEnvelope<SyncOpsResult>> = Response.success(ApiEnvelope(data = SyncOpsResult(applied = 0)))
override suspend fun resolveUser(
username: String,
): Response<ApiEnvelope<ResolvedUserDto>> = Response.success(ApiEnvelope())
override suspend fun syncPermissions(
after: Long?,
): Response<ApiEnvelope<List<ResourcePermissionDto>>> =
Response.success(ApiEnvelope(data = emptyList()))
}
@@ -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<Context>()
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<ResourcePermissionDto> = emptyList()
override suspend fun listFolders(): Response<ApiEnvelope<List<FolderDto>>> =
Response.success(ApiEnvelope(data = emptyList()))
override suspend fun listFiles(
folderId: String?,
page: Int?,
pageSize: Int?,
): Response<ApiEnvelope<List<FileDto>>> = Response.success(ApiEnvelope(data = emptyList()))
override suspend fun registerDevice(
body: DeviceRegistrationDto,
): Response<ApiEnvelope<DeviceRegistrationDto>> =
Response.success(ApiEnvelope(data = body))
override suspend fun login(
body: LoginRequestDto,
): Response<ApiEnvelope<LoginResponseDto>> = Response.success(ApiEnvelope())
override suspend fun syncOps(
body: SyncOpsRequest,
): Response<ApiEnvelope<SyncOpsResult>> = Response.success(ApiEnvelope(data = SyncOpsResult(applied = 0)))
override suspend fun resolveUser(
username: String,
): Response<ApiEnvelope<ResolvedUserDto>> = Response.success(ApiEnvelope())
override suspend fun syncPermissions(
after: Long?,
): Response<ApiEnvelope<List<ResourcePermissionDto>>> =
Response.success(ApiEnvelope(data = permissions))
}