add application migration

This commit is contained in:
m
2026-09-11 18:21:10 +02:00
parent 2de3754ca9
commit eddf40d806
12 changed files with 428 additions and 14 deletions
@@ -11,11 +11,11 @@ import com.vaultdrop.mobile.data.local.entity.UserPreferenceEntity
/*
* DB SQLite locale, `dot.db` (même nom que la version Expo).
* v1: folders ; v2: user_preferences ; v3: files.
* v1: folders ; v2: user_preferences ; v3: files ; v4: category sur files.
*/
@Database(
entities = [FolderEntity::class, UserPreferenceEntity::class, FileEntity::class],
version = 3,
version = 4,
exportSchema = false,
)
abstract class AppDatabase : RoomDatabase() {
@@ -24,6 +24,15 @@ interface FileDao {
@Query("SELECT * FROM files WHERE uri = :uri LIMIT 1")
suspend fun getByUri(uri: String): FileEntity?
@Query("""
SELECT * FROM files
WHERE "exists" = 1
AND (:query IS NULL OR name LIKE '%' || :query || '%')
AND (:category IS NULL OR category = :category)
ORDER BY name ASC
""")
fun searchWithFilters(query: String?, category: String?): Flow<List<FileEntity>>
@Upsert
suspend fun upsert(file: FileEntity)
@@ -21,6 +21,7 @@ import androidx.room.PrimaryKey
Index(value = ["resource_id"], unique = true),
Index(value = ["uri"], unique = true),
Index(value = ["folder_resource_id"]),
Index(value = ["category"]),
],
)
data class FileEntity(
@@ -46,6 +47,8 @@ data class FileEntity(
val lastModified: Long? = null,
@ColumnInfo(name = "owner_id")
val ownerId: String? = null,
@ColumnInfo(name = "category")
val category: String? = null,
@ColumnInfo(name = "sync_status")
val syncStatus: String = "local",
@ColumnInfo(name = "added_at")
@@ -10,6 +10,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
* v2 : table `user_preferences` — clé/valeur pour l'identité device, le miroir
* du compte actif et les préférences utilisateur.
* v3 : table `files` — miroir de `FileRow` JS (métadonnées locales + cloud).
* v4 : ajout colonne `category` + index sur `files`.
*/
object Migrations {
@@ -44,7 +45,7 @@ object Migrations {
`exists` INTEGER,
`last_modified` INTEGER,
`owner_id` TEXT,
`sync_status` TEXT NOT NULL DEFAULT 'local',
`sync_status` TEXT NOT NULL,
`added_at` INTEGER NOT NULL,
`updated_at` INTEGER NOT NULL
)
@@ -56,5 +57,14 @@ object Migrations {
}
}
val ALL: Array<Migration> = arrayOf(MIGRATION_1_2, MIGRATION_2_3)
private val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
// Pas de `DEFAULT NULL` explicite : Room s'attend à l'absence de défaut
// (une colonne TEXT nullable a déjà NULL comme défaut implicite).
db.execSQL("ALTER TABLE `files` ADD COLUMN `category` TEXT")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_files_category` ON `files` (`category`)")
}
}
val ALL: Array<Migration> = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
}
@@ -6,6 +6,7 @@ import com.vaultdrop.mobile.data.local.entity.FileStatus
import com.vaultdrop.mobile.data.remote.ApiClient
import com.vaultdrop.mobile.data.remote.dto.FileDto
import com.vaultdrop.mobile.domain.GenerateId
import com.vaultdrop.mobile.domain.computeCategory
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
@@ -31,6 +32,13 @@ class FileRepository @Inject constructor(
/** Fichiers visibles de toute l'arborescence — écran d'accueil (grille par date). */
fun observeAllVisible(): Flow<List<FileEntity>> = fileDao.observeAllVisible()
/** Recherche/filtrage par nom et catégorie (utilisé par SearchViewModel). */
fun searchFiles(query: String?, category: String?): Flow<List<FileEntity>> =
fileDao.searchWithFilters(
query = query?.trim()?.takeIf { it.isNotEmpty() },
category = category,
)
suspend fun getFile(resourceId: String): FileEntity? =
fileDao.getByResourceId(resourceId)
@@ -49,6 +57,8 @@ class FileRepository @Inject constructor(
val existing = input.resourceId?.let { fileDao.getByResourceId(it) }
?: fileDao.getByUri(input.uri)
val category = computeCategory(input.mimeType, input.extension).dbValue
val entity = FileEntity(
id = existing?.id ?: 0L,
resourceId = existing?.resourceId ?: input.resourceId ?: generateId.newResourceId(),
@@ -58,6 +68,7 @@ class FileRepository @Inject constructor(
extension = input.extension ?: existing?.extension,
size = input.size,
mimeType = input.mimeType,
category = category,
exists = if (input.exists) 1 else 0,
lastModified = input.lastModified,
ownerId = ownerId ?: existing?.ownerId,
@@ -88,15 +99,18 @@ class FileRepository @Inject constructor(
private suspend fun toEntity(dto: FileDto, folderResourceId: String, now: Long): FileEntity {
val existing = fileDao.getByResourceId(dto.id)
val extension = dto.name.substringAfterLast('.', "")
.takeIf { it.isNotEmpty() && it != dto.name }
return FileEntity(
id = existing?.id ?: 0L,
resourceId = dto.id,
uri = null,
name = dto.name,
folderResourceId = dto.folderId ?: folderResourceId,
extension = dto.name.substringAfterLast('.', "").takeIf { it.isNotEmpty() && it != dto.name },
extension = extension,
size = dto.size,
mimeType = dto.mimeType,
category = computeCategory(dto.mimeType, extension).dbValue,
exists = 1,
lastModified = existing?.lastModified,
ownerId = existing?.ownerId,
@@ -0,0 +1,51 @@
package com.vaultdrop.mobile.domain
enum class FileCategory {
PDF,
OFFICE,
IMAGE,
TEXT,
VIDEO,
AUDIO,
OTHER;
val dbValue: String get() = name
}
fun computeCategory(mimeType: String?, extension: String?): FileCategory {
val mime = mimeType?.lowercase().orEmpty()
val ext = extension?.lowercase().orEmpty()
return when {
mime == "application/pdf" -> FileCategory.PDF
mime.startsWith("image/") -> FileCategory.IMAGE
mime.startsWith("video/") -> FileCategory.VIDEO
mime.startsWith("audio/") -> FileCategory.AUDIO
mime.startsWith("text/") || ext in TEXT_EXTENSIONS -> FileCategory.TEXT
mime in OFFICE_MIMES || ext in OFFICE_EXTENSIONS -> FileCategory.OFFICE
else -> FileCategory.OTHER
}
}
private val OFFICE_MIMES = setOf(
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.oasis.opendocument.text",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.oasis.opendocument.spreadsheet",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.oasis.opendocument.presentation",
)
private val OFFICE_EXTENSIONS = setOf(
"doc", "docx", "odt", "rtf",
"xls", "xlsx", "ods", "csv",
"ppt", "pptx", "odp",
)
private val TEXT_EXTENSIONS = setOf(
"txt", "md", "html", "xml", "json", "css", "js", "kt", "py", "java",
"log", "ini", "cfg", "yaml", "yml", "toml",
)
@@ -132,18 +132,25 @@ class SafScanner @Inject constructor(
while (cursor.moveToNext()) {
val docId = cursor.getString(iDoc)
if (docId == dirDocId) continue // certains providers renvoient le dossier lui-même
val uri = DocumentsContract.buildDocumentUriUsingTree(treeUri, docId).toString()
val mime = if (iMime >= 0) cursor.getString(iMime) else null
val name = if (iName >= 0) cursor.getString(iName)
else docId.substringAfterLast('/')
else docId.substringAfterLast('/')
val mime = if (iMime >= 0) cursor.getString(iMime) else null
if (name.startsWith(".")) continue
if (mime == DocumentsContract.Document.MIME_TYPE_DIR) {
val uri = DocumentsContract.buildDocumentUriUsingTree(treeUri, docId).toString()
folders += ChildDir(docId, uri, name)
} else {
val size = if (iSize >= 0 && !cursor.isNull(iSize)) cursor.getLong(iSize) else 0L
if (size == 0L) continue
if (KNOWN_NOISE_EXTENSIONS.any { name.endsWith(it, ignoreCase = true) }) continue
val uri = DocumentsContract.buildDocumentUriUsingTree(treeUri, docId).toString()
files += FileNode(
uri = uri,
name = name,
size = if (iSize >= 0 && !cursor.isNull(iSize)) cursor.getLong(iSize) else 0L,
size = size,
mimeType = mime,
lastModified = if (iLast >= 0 && !cursor.isNull(iLast)) cursor.getLong(iLast) else null,
)
@@ -172,5 +179,14 @@ class SafScanner @Inject constructor(
private companion object {
/** Nombre de dossiers visités entre deux `yield()` (coopération inter-coroutines). */
const val YIELD_EVERY = 64
/** Extensions de bruit système / OS / téléchargements incomplets à ignorer. */
val KNOWN_NOISE_EXTENSIONS = setOf(
".tmp", ".log", ".bak", ".dat", ".db", ".db-wal", ".db-shm",
".nomedia", ".thumbnails",
".apk", ".dex", ".odex",
".part", ".crdownload",
".DS_Store",
)
}
}
@@ -65,6 +65,7 @@ fun NavGraph() {
SearchScreen(
selectedTab = selectedTab,
onTabSelected = onTabSelected,
onOpenDocument = { id -> navController.navigate(Routes.document(id)) },
)
}
composable(Routes.SETTINGS) {
@@ -1,26 +1,65 @@
package com.vaultdrop.mobile.ui.search
import androidx.compose.foundation.BorderStroke
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
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vaultdrop.mobile.R
import com.vaultdrop.mobile.data.local.entity.FileEntity
import com.vaultdrop.mobile.domain.FileCategory
import com.vaultdrop.mobile.ui.navigation.FloatingNavBar
import com.vaultdrop.mobile.ui.navigation.NavTab
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchScreen(
selectedTab: NavTab,
onTabSelected: (NavTab) -> Unit,
onOpenDocument: (String) -> Unit,
viewModel: SearchViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(title = { Text(stringResource(R.string.search)) })
@@ -29,14 +68,196 @@ fun SearchScreen(
FloatingNavBar(selected = selectedTab, onSelect = onTabSelected)
},
) { padding ->
Box(
modifier = Modifier.fillMaxSize(),
SearchContent(
uiState = uiState,
onQueryChange = viewModel::onQueryChange,
onCategorySelect = viewModel::onCategorySelect,
onOpenDocument = onOpenDocument,
modifier = Modifier.padding(padding),
)
}
}
@Composable
private fun SearchContent(
uiState: SearchUiState,
onQueryChange: (String) -> Unit,
onCategorySelect: (String?) -> Unit,
onOpenDocument: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
.padding(top = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = uiState.query,
onValueChange = onQueryChange,
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
placeholder = { Text(stringResource(R.string.search_hint)) },
singleLine = true,
)
LazyRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
item(key = "all") {
CategoryChip(
label = stringResource(R.string.category_all),
selected = uiState.selectedCategory == null,
onClick = { onCategorySelect(null) },
)
}
items(FileCategory.entries, key = { it.name }) { category ->
CategoryChip(
label = categoryLabel(category),
selected = uiState.selectedCategory == category.dbValue,
onClick = { onCategorySelect(category.dbValue) },
)
}
}
SearchResults(
uiState = uiState,
onOpenDocument = onOpenDocument,
modifier = Modifier.weight(1f),
)
}
}
@Composable
private fun CategoryChip(
label: String,
selected: Boolean,
onClick: () -> Unit,
) {
FilterChip(
selected = selected,
onClick = onClick,
label = { Text(label) },
colors = FilterChipDefaults.filterChipColors(
containerColor = Color(0xFFF7F8FA),
),
)
}
/** Libellé français de la catégorie (store = enum.name, affichage = label localisé). */
@Composable
private fun categoryLabel(category: FileCategory): String = when (category) {
FileCategory.PDF -> stringResource(R.string.category_pdf)
FileCategory.OFFICE -> stringResource(R.string.category_office)
FileCategory.IMAGE -> stringResource(R.string.category_image)
FileCategory.TEXT -> stringResource(R.string.category_text)
FileCategory.VIDEO -> stringResource(R.string.category_video)
FileCategory.AUDIO -> stringResource(R.string.category_audio)
FileCategory.OTHER -> stringResource(R.string.category_other)
}
@Composable
private fun SearchResults(
uiState: SearchUiState,
onOpenDocument: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val hasQueryOrCategory = uiState.query.isNotBlank() || uiState.selectedCategory != null
when {
!hasQueryOrCategory -> Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.search),
text = stringResource(R.string.search_empty),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp),
)
}
uiState.results.isEmpty() -> Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.search_no_results),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
else -> LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(uiState.results, key = { it.resourceId }) { file ->
SearchResultCard(file = file, onClick = { onOpenDocument(file.resourceId) })
}
}
}
}
@Composable
private fun SearchResultCard(file: FileEntity, onClick: () -> Unit, modifier: Modifier = Modifier) {
Card(
onClick = onClick,
colors = CardDefaults.cardColors(containerColor = Color(0xFFF7F8FA)),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
border = BorderStroke(1.dp, Color(0xFFEAEAEA)),
shape = RoundedCornerShape(10.dp),
modifier = modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.InsertDriveFile,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp),
)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = file.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(2.dp))
Text(
text = file.extension?.let { it.uppercase(Locale.getDefault()) } ?: formatSize(file.size),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.width(12.dp))
Text(
text = formatSize(file.size),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun formatSize(bytes: Long): String {
if (bytes < 1_024) {
return "$bytes ${stringResource(R.string.unit_bytes)}"
}
if (bytes < 1_024 * 1_024) {
val kb = bytes / 1_024f
return String.format(Locale.getDefault(), "%.1f %s", kb, stringResource(R.string.unit_kilobytes))
}
val mb = bytes / (1_024f * 1_024f)
return String.format(Locale.getDefault(), "%.1f %s", mb, stringResource(R.string.unit_megabytes))
}
@@ -0,0 +1,63 @@
package com.vaultdrop.mobile.ui.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vaultdrop.mobile.data.local.entity.FileEntity
import com.vaultdrop.mobile.data.repository.FileRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
@HiltViewModel
class SearchViewModel @Inject constructor(
private val fileRepository: FileRepository,
) : ViewModel() {
private val _searchQuery = MutableStateFlow("")
private val _selectedCategory = MutableStateFlow<String?>(null)
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
val uiState: StateFlow<SearchUiState> = combine(
_searchQuery.debounce(SEARCH_DEBOUNCE_MS),
_selectedCategory,
) { query, category -> CategoryQuery(query, category) }
.flatMapLatest { (query, category) ->
fileRepository.searchFiles(query, category)
.map { files -> SearchUiState(query, category, files) }
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS),
initialValue = SearchUiState(),
)
fun onQueryChange(query: String) {
_searchQuery.value = query
}
fun onCategorySelect(category: String?) {
_selectedCategory.value = category
}
private data class CategoryQuery(val query: String, val category: String?)
companion object {
private const val SEARCH_DEBOUNCE_MS = 300L
private const val STOP_TIMEOUT_MS = 5_000L
}
}
data class SearchUiState(
val query: String = "",
val selectedCategory: String? = null,
val results: List<FileEntity> = emptyList(),
)
@@ -26,9 +26,22 @@
<string name="nav_files">Files</string>
<string name="nav_search">Search</string>
<string name="nav_settings">Settings</string>
<string name="search">Search</string>
<string name="settings">Settings</string>
<!-- Search -->
<string name="search">Search</string>
<string name="search_hint">Search for a document…</string>
<string name="search_empty">Type a search or pick a category</string>
<string name="search_no_results">No results</string>
<string name="category_all">All</string>
<string name="category_pdf">PDF</string>
<string name="category_office">Office</string>
<string name="category_image">Image</string>
<string name="category_text">Text</string>
<string name="category_video">Video</string>
<string name="category_audio">Audio</string>
<string name="category_other">Other</string>
<!-- Login -->
<string name="login_title">Sign in</string>
<string name="login_subtitle">Sign in to your account</string>
@@ -27,9 +27,22 @@
<string name="nav_files">Fichiers</string>
<string name="nav_search">Recherche</string>
<string name="nav_settings">Réglages</string>
<string name="search">Recherche</string>
<string name="settings">Réglages</string>
<!-- Recherche -->
<string name="search">Recherche</string>
<string name="search_hint">Rechercher un document…</string>
<string name="search_empty">Saisis une recherche ou choisis une catégorie</string>
<string name="search_no_results">Aucun résultat</string>
<string name="category_all">Tous</string>
<string name="category_pdf">PDF</string>
<string name="category_office">Bureautique</string>
<string name="category_image">Image</string>
<string name="category_text">Texte</string>
<string name="category_video">Vidéo</string>
<string name="category_audio">Audio</string>
<string name="category_other">Autre</string>
<!-- Login -->
<string name="login_title">Connexion</string>
<string name="login_subtitle">Connecte-toi à ton compte</string>