From 1ec6e2349bad910deb38230d9b5b72ba0ea1fc1f Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Sun, 20 Sep 2026 12:09:14 +0200 Subject: [PATCH] feat(android): full-text file search Wire the existing GET /file/search endpoint into the files screen: search icon expands a query field in the app bar, results replace the file list with offset pagination on scroll, hits show parent path and content snippet. Folder hits exit search and navigate into place; file hits download and open. Back closes search before navigating up. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ROADMAP.md | 2 +- android/README.md | 4 +- .../org/cloudreve/android/api/ApiService.kt | 6 + .../java/org/cloudreve/android/api/Dto.kt | 12 ++ .../cloudreve/android/data/FileRepository.kt | 4 + .../cloudreve/android/ui/files/FilesScreen.kt | 201 ++++++++++++++++-- .../android/ui/files/FilesViewModel.kt | 60 ++++++ 7 files changed, 267 insertions(+), 22 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0a8812bd..e930972d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -237,7 +237,7 @@ Goal: Windows + macOS + Linux from the `desktop/` tree in this repo. Lives in `android/` in this repo. Kotlin + Jetpack Compose, Material 3. - **API**: `api/v4` REST + OAuth token (entities exist: `oauthclient`, `oauthgrant`) — same surface the desktop `cloudreve-api` crate documents; port its models as the spec -- **Core features**: browse/download/upload files, share links, camera-upload (auto photo backup), offline-favorite files, local sync folder via SAF/WorkManager +- **Core features**: browse/download/upload files, share links, full-text search (done — query bar + offset pagination + parent-path/snippet rows), camera-upload (auto photo backup), offline-favorite files, local sync folder via SAF/WorkManager - **System integration** (the "native, complete" ask): share-sheet target (upload to Cloudreve from any app), DocumentsProvider (Cloudreve in Files app), quick-share tile, notifications on share/task events - **Auth**: webview OAuth flow → token; later passkey if backend exposes - **WebDAV bridge**: `/dav` works as fallback file access until SDK matures diff --git a/android/README.md b/android/README.md index e7ca7412..49f4ce10 100644 --- a/android/README.md +++ b/android/README.md @@ -27,9 +27,11 @@ CI runs `assembleDebug` on every PR. upload-session flow (local + presigned remote policies) - Thumbnails via `/file/thumb` (Coil, lazily resolved per row) - Share-link creation (`PUT /share`), link copied to clipboard +- Full-text search (`GET /file/search`): query bar in the files screen, + offset pagination, result rows show parent path + content snippet; + folder hits navigate into place ## Planned next - Camera auto-upload, offline-favorite files, DocumentsProvider -- Search - No iOS. Ever. diff --git a/android/app/src/main/java/org/cloudreve/android/api/ApiService.kt b/android/app/src/main/java/org/cloudreve/android/api/ApiService.kt index 38b60623..4cc8ef0e 100644 --- a/android/app/src/main/java/org/cloudreve/android/api/ApiService.kt +++ b/android/app/src/main/java/org/cloudreve/android/api/ApiService.kt @@ -66,6 +66,12 @@ interface ApiService { @GET("api/v4/file/thumb") suspend fun thumb(@Query("uri") uri: String): Response> + @GET("api/v4/file/search") + suspend fun searchFiles( + @Query("query") query: String, + @Query("offset") offset: Int = 0, + ): Response> + @PUT("api/v4/share") suspend fun createShare(@Body request: ShareCreateRequest): Response> } diff --git a/android/app/src/main/java/org/cloudreve/android/api/Dto.kt b/android/app/src/main/java/org/cloudreve/android/api/Dto.kt index d4af8e5b..5e34ee2c 100644 --- a/android/app/src/main/java/org/cloudreve/android/api/Dto.kt +++ b/android/app/src/main/java/org/cloudreve/android/api/Dto.kt @@ -172,6 +172,18 @@ data class ShareCreateRequest( @SerialName("upload_only") val uploadOnly: Boolean = false, ) +@Serializable +data class SearchHit( + val file: FileObject = FileObject(), + val content: String = "", +) + +@Serializable +data class SearchResponse( + val hits: List = emptyList(), + val total: Long = 0, +) + @Serializable data class SiteConfig( val authn: Boolean = false, diff --git a/android/app/src/main/java/org/cloudreve/android/data/FileRepository.kt b/android/app/src/main/java/org/cloudreve/android/data/FileRepository.kt index 3e7b25d1..52831217 100644 --- a/android/app/src/main/java/org/cloudreve/android/data/FileRepository.kt +++ b/android/app/src/main/java/org/cloudreve/android/data/FileRepository.kt @@ -10,6 +10,7 @@ import org.cloudreve.android.api.FileObject import org.cloudreve.android.api.FileUrlRequest import org.cloudreve.android.api.ListFileResponse import org.cloudreve.android.api.RenameFileRequest +import org.cloudreve.android.api.SearchResponse import org.cloudreve.android.api.ShareCreateRequest import org.cloudreve.android.api.UploadSessionResponse import org.cloudreve.android.util.CrUri @@ -58,6 +59,9 @@ class FileRepository(private val api: ApiClient) { return "$base/s/$id" } + suspend fun search(query: String, offset: Int = 0): SearchResponse = + api.service().searchFiles(query = query, offset = offset).unwrap() + suspend fun mkdir(uri: String) { api.service().createFile(CreateFileRequest(uri = uri, type = "folder")).unwrapEmpty() } diff --git a/android/app/src/main/java/org/cloudreve/android/ui/files/FilesScreen.kt b/android/app/src/main/java/org/cloudreve/android/ui/files/FilesScreen.kt index 21a18228..99cfc95b 100644 --- a/android/app/src/main/java/org/cloudreve/android/ui/files/FilesScreen.kt +++ b/android/app/src/main/java/org/cloudreve/android/ui/files/FilesScreen.kt @@ -17,6 +17,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ExitToApp @@ -26,8 +28,10 @@ import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.DriveFileRenameOutline import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.UploadFile import androidx.compose.material3.AlertDialog @@ -61,7 +65,10 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.content.FileProvider @@ -86,6 +93,11 @@ fun FilesScreen( var mkdirOpen by remember { mutableStateOf(false) } var renameTarget by remember { mutableStateOf(null) } var deleteTarget by remember { mutableStateOf(null) } + var searchOpen by remember { mutableStateOf(false) } + var searchText by remember { mutableStateOf("") } + val searchFocus = remember { FocusRequester() } + + val inSearch = state.searchQuery != null val uploadLauncher = rememberLauncherForActivityResult( ActivityResultContracts.OpenMultipleDocuments() @@ -109,45 +121,89 @@ fun FilesScreen( LaunchedEffect(listState) { snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index } .collect { last -> - if (last != null && last >= state.files.size - 5) viewModel.loadMore() + if (last == null) return@collect + if (inSearch) { + if (last >= state.searchResults.size - 5) viewModel.searchMore() + } else if (last >= state.files.size - 5) { + viewModel.loadMore() + } } } - BackHandler(enabled = !CrUri.isRoot(state.currentUri)) { - viewModel.navigateUp() + LaunchedEffect(searchOpen) { + if (searchOpen) searchFocus.requestFocus() + } + + fun closeSearch() { + viewModel.clearSearch() + searchOpen = false + searchText = "" + } + + BackHandler(enabled = inSearch || !CrUri.isRoot(state.currentUri)) { + if (inSearch) closeSearch() else viewModel.navigateUp() } Scaffold( topBar = { TopAppBar( title = { - Column { - Text("Files") - Text( - pathLabel(state.currentUri), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + if (inSearch || searchOpen) { + OutlinedTextField( + value = searchText, + onValueChange = { searchText = it }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(searchFocus), + placeholder = { Text("Search files") }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions( + onSearch = { viewModel.search(searchText) }, + ), ) + } else { + Column { + Text("Files") + Text( + pathLabel(state.currentUri), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } }, navigationIcon = { - if (!CrUri.isRoot(state.currentUri)) { + if (inSearch || searchOpen) { + IconButton(onClick = { closeSearch() }) { + Icon(Icons.Default.Close, contentDescription = "Close search") + } + } else if (!CrUri.isRoot(state.currentUri)) { IconButton(onClick = { viewModel.navigateUp() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Up") } } }, actions = { - IconButton(onClick = { mkdirOpen = true }) { - Icon(Icons.Default.CreateNewFolder, contentDescription = "New folder") - } - IconButton(onClick = { viewModel.refresh() }) { - Icon(Icons.Default.Refresh, contentDescription = "Refresh") - } - IconButton(onClick = onSignOut) { - Icon(Icons.AutoMirrored.Filled.ExitToApp, contentDescription = "Sign out") + if (!inSearch && !searchOpen) { + IconButton(onClick = { searchOpen = true }) { + Icon(Icons.Default.Search, contentDescription = "Search") + } + IconButton(onClick = { mkdirOpen = true }) { + Icon(Icons.Default.CreateNewFolder, contentDescription = "New folder") + } + IconButton(onClick = { viewModel.refresh() }) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh") + } + IconButton(onClick = onSignOut) { + Icon(Icons.AutoMirrored.Filled.ExitToApp, contentDescription = "Sign out") + } + } else if (!inSearch) { + IconButton(onClick = { viewModel.search(searchText) }) { + Icon(Icons.Default.Search, contentDescription = "Search") + } } }, ) @@ -167,6 +223,24 @@ fun FilesScreen( .padding(padding), ) { when { + inSearch -> { + SearchResults( + state = state, + viewModel = viewModel, + listState = listState, + onOpenFolder = { file -> + closeSearch() + viewModel.navigateTo(file) + }, + onOpenFile = { file -> + scope.launch { + runCatching { viewModel.downloadToCache(context, file) } + .onSuccess { local -> openFile(context, local) } + .onFailure { snackbar.showSnackbar(it.message ?: "Download failed") } + } + }, + ) + } state.loading && state.files.isEmpty() -> { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() @@ -292,6 +366,93 @@ private fun pathLabel(uri: String): String { return if (path.isEmpty()) "My files" else "My files / ${java.net.URLDecoder.decode(path, "UTF-8")}" } +@Composable +private fun SearchResults( + state: FilesUiState, + viewModel: FilesViewModel, + listState: androidx.compose.foundation.lazy.LazyListState, + onOpenFolder: (FileObject) -> Unit, + onOpenFile: (FileObject) -> Unit, +) { + when { + state.searchLoading -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + state.searchError != null -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(state.searchError ?: "Search failed", color = MaterialTheme.colorScheme.error) + TextButton(onClick = { viewModel.search(state.searchQuery ?: "") }) { + Text("Retry") + } + } + } + } + state.searchResults.isEmpty() -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + "No results for \"${state.searchQuery}\"", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + else -> { + LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) { + items(state.searchResults, key = { it.file.id + it.file.path }) { hit -> + val file = hit.file + ListItem( + modifier = Modifier.clickable { + if (file.isFolder) onOpenFolder(file) else onOpenFile(file) + }, + headlineContent = { + Text(file.name, maxLines = 1, overflow = TextOverflow.Ellipsis) + }, + supportingContent = { + Column { + Text( + pathLabel(CrUri.parent(file.path)), + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (hit.content.isNotBlank()) { + Text( + hit.content.trim(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + }, + leadingContent = { + Icon( + if (file.isFolder) Icons.Default.Folder + else Icons.AutoMirrored.Filled.InsertDriveFile, + contentDescription = null, + tint = if (file.isFolder) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) + HorizontalDivider() + } + if (state.searchLoadingMore) { + item { + Row( + Modifier.fillMaxWidth().padding(12.dp), + horizontalArrangement = Arrangement.Center, + ) { CircularProgressIndicator() } + } + } + } + } + } +} + private fun openFile(context: android.content.Context, local: java.io.File) { val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", local) val intent = Intent(Intent.ACTION_VIEW).apply { diff --git a/android/app/src/main/java/org/cloudreve/android/ui/files/FilesViewModel.kt b/android/app/src/main/java/org/cloudreve/android/ui/files/FilesViewModel.kt index ef4f0bb3..2a673c7c 100644 --- a/android/app/src/main/java/org/cloudreve/android/ui/files/FilesViewModel.kt +++ b/android/app/src/main/java/org/cloudreve/android/ui/files/FilesViewModel.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import org.cloudreve.android.api.FileObject +import org.cloudreve.android.api.SearchHit import org.cloudreve.android.data.FileRepository import org.cloudreve.android.util.CrUri import java.io.File @@ -21,6 +22,12 @@ data class FilesUiState( val nextToken: String? = null, val error: String? = null, val snackbar: String? = null, + val searchQuery: String? = null, + val searchResults: List = emptyList(), + val searchTotal: Long = 0, + val searchLoading: Boolean = false, + val searchLoadingMore: Boolean = false, + val searchError: String? = null, ) class FilesViewModel(private val repo: FileRepository) : ViewModel() { @@ -78,6 +85,59 @@ class FilesViewModel(private val repo: FileRepository) : ViewModel() { return true } + fun search(query: String) { + val q = query.trim() + if (q.isEmpty()) return + _state.value = _state.value.copy( + searchQuery = q, + searchResults = emptyList(), + searchTotal = 0, + searchLoading = true, + searchError = null, + ) + viewModelScope.launch { + try { + val res = repo.search(q, 0) + _state.value = _state.value.copy( + searchLoading = false, + searchResults = res.hits, + searchTotal = res.total, + ) + } catch (e: Exception) { + _state.value = _state.value.copy(searchLoading = false, searchError = e.message) + } + } + } + + fun searchMore() { + val s = _state.value + val q = s.searchQuery ?: return + if (s.searchLoadingMore || s.searchResults.size >= s.searchTotal) return + _state.value = s.copy(searchLoadingMore = true) + viewModelScope.launch { + try { + val res = repo.search(q, s.searchResults.size) + _state.value = _state.value.copy( + searchLoadingMore = false, + searchResults = _state.value.searchResults + res.hits, + ) + } catch (e: Exception) { + _state.value = _state.value.copy(searchLoadingMore = false, snackbar = e.message) + } + } + } + + fun clearSearch() { + _state.value = _state.value.copy( + searchQuery = null, + searchResults = emptyList(), + searchTotal = 0, + searchLoading = false, + searchLoadingMore = false, + searchError = null, + ) + } + fun mkdir(name: String) { if (name.isBlank()) return viewModelScope.launch {