Merge pull request #212 from Dvorinka/feat/android-search

feat(android): full-text file search
pull/3589/head
Tomáš Dvořák 2 weeks ago committed by GitHub
commit 7012862c27
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -239,7 +239,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