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>
pull/3589/head
Tomas Dvorak 2 weeks ago
parent a18706fe00
commit 1ec6e2349b

@ -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

@ -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.

@ -66,6 +66,12 @@ interface ApiService {
@GET("api/v4/file/thumb")
suspend fun thumb(@Query("uri") uri: String): Response<ApiResponse<ThumbResponse>>
@GET("api/v4/file/search")
suspend fun searchFiles(
@Query("query") query: String,
@Query("offset") offset: Int = 0,
): Response<ApiResponse<SearchResponse>>
@PUT("api/v4/share")
suspend fun createShare(@Body request: ShareCreateRequest): Response<ApiResponse<String>>
}

@ -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<SearchHit> = emptyList(),
val total: Long = 0,
)
@Serializable
data class SiteConfig(
val authn: Boolean = false,

@ -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()
}

@ -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<FileObject?>(null) }
var deleteTarget by remember { mutableStateOf<FileObject?>(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 {

@ -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<SearchHit> = 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 {

Loading…
Cancel
Save