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.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<FileDeleter.DeleteMode?>(null) }
|
||||
var pendingDeleteReview by remember { mutableStateOf<DeleteReview?>(null) }
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
@@ -178,14 +182,32 @@ fun FolderDetailScreen(
|
||||
status = connectionStatus,
|
||||
onClick = connectionStatusViewModel::checkNow,
|
||||
)
|
||||
IconButton(onClick = { showShareSheet = true }) {
|
||||
Box {
|
||||
IconButton(onClick = { showMoreMenu = true }) {
|
||||
Icon(
|
||||
Icons.Filled.Share,
|
||||
contentDescription = stringResource(R.string.share_content_description),
|
||||
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()
|
||||
},
|
||||
)
|
||||
}
|
||||
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_generic">Impossible de partager pour le moment.</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>
|
||||
+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