share folders and details
This commit is contained in:
+161
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-6
@@ -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 {
|
||||||
|
IconButton(onClick = { showMoreMenu = true }) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Share,
|
Icons.Filled.MoreVert,
|
||||||
contentDescription = stringResource(R.string.share_content_description),
|
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()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = viewModel::refresh) {
|
|
||||||
Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.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>
|
||||||
+296
@@ -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()))
|
||||||
|
}
|
||||||
+253
@@ -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))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user