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. 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 - **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 - **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 - **Auth**: webview OAuth flow → token; later passkey if backend exposes
- **WebDAV bridge**: `/dav` works as fallback file access until SDK matures - **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) upload-session flow (local + presigned remote policies)
- Thumbnails via `/file/thumb` (Coil, lazily resolved per row) - Thumbnails via `/file/thumb` (Coil, lazily resolved per row)
- Share-link creation (`PUT /share`), link copied to clipboard - 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 ## Planned next
- Camera auto-upload, offline-favorite files, DocumentsProvider - Camera auto-upload, offline-favorite files, DocumentsProvider
- Search
- No iOS. Ever. - No iOS. Ever.

@ -66,6 +66,12 @@ interface ApiService {
@GET("api/v4/file/thumb") @GET("api/v4/file/thumb")
suspend fun thumb(@Query("uri") uri: String): Response<ApiResponse<ThumbResponse>> 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") @PUT("api/v4/share")
suspend fun createShare(@Body request: ShareCreateRequest): Response<ApiResponse<String>> suspend fun createShare(@Body request: ShareCreateRequest): Response<ApiResponse<String>>
} }

@ -172,6 +172,18 @@ data class ShareCreateRequest(
@SerialName("upload_only") val uploadOnly: Boolean = false, @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 @Serializable
data class SiteConfig( data class SiteConfig(
val authn: Boolean = false, 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.FileUrlRequest
import org.cloudreve.android.api.ListFileResponse import org.cloudreve.android.api.ListFileResponse
import org.cloudreve.android.api.RenameFileRequest import org.cloudreve.android.api.RenameFileRequest
import org.cloudreve.android.api.SearchResponse
import org.cloudreve.android.api.ShareCreateRequest import org.cloudreve.android.api.ShareCreateRequest
import org.cloudreve.android.api.UploadSessionResponse import org.cloudreve.android.api.UploadSessionResponse
import org.cloudreve.android.util.CrUri import org.cloudreve.android.util.CrUri
@ -58,6 +59,9 @@ class FileRepository(private val api: ApiClient) {
return "$base/s/$id" 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) { suspend fun mkdir(uri: String) {
api.service().createFile(CreateFileRequest(uri = uri, type = "folder")).unwrapEmpty() 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.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState 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.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ExitToApp 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.Download
import androidx.compose.material.icons.filled.DriveFileRenameOutline import androidx.compose.material.icons.filled.DriveFileRenameOutline
import androidx.compose.material.icons.filled.Folder 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.MoreVert
import androidx.compose.material.icons.filled.Refresh 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.Share
import androidx.compose.material.icons.filled.UploadFile import androidx.compose.material.icons.filled.UploadFile
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@ -61,7 +65,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.platform.LocalContext
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
@ -86,6 +93,11 @@ fun FilesScreen(
var mkdirOpen by remember { mutableStateOf(false) } var mkdirOpen by remember { mutableStateOf(false) }
var renameTarget by remember { mutableStateOf<FileObject?>(null) } var renameTarget by remember { mutableStateOf<FileObject?>(null) }
var deleteTarget 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( val uploadLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenMultipleDocuments() ActivityResultContracts.OpenMultipleDocuments()
@ -109,18 +121,48 @@ fun FilesScreen(
LaunchedEffect(listState) { LaunchedEffect(listState) {
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index } snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index }
.collect { last -> .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()
}
}
} }
LaunchedEffect(searchOpen) {
if (searchOpen) searchFocus.requestFocus()
}
fun closeSearch() {
viewModel.clearSearch()
searchOpen = false
searchText = ""
} }
BackHandler(enabled = !CrUri.isRoot(state.currentUri)) { BackHandler(enabled = inSearch || !CrUri.isRoot(state.currentUri)) {
viewModel.navigateUp() if (inSearch) closeSearch() else viewModel.navigateUp()
} }
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { title = {
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 { Column {
Text("Files") Text("Files")
Text( Text(
@ -131,15 +173,24 @@ fun FilesScreen(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
}
}, },
navigationIcon = { 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() }) { IconButton(onClick = { viewModel.navigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Up") Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Up")
} }
} }
}, },
actions = { actions = {
if (!inSearch && !searchOpen) {
IconButton(onClick = { searchOpen = true }) {
Icon(Icons.Default.Search, contentDescription = "Search")
}
IconButton(onClick = { mkdirOpen = true }) { IconButton(onClick = { mkdirOpen = true }) {
Icon(Icons.Default.CreateNewFolder, contentDescription = "New folder") Icon(Icons.Default.CreateNewFolder, contentDescription = "New folder")
} }
@ -149,6 +200,11 @@ fun FilesScreen(
IconButton(onClick = onSignOut) { IconButton(onClick = onSignOut) {
Icon(Icons.AutoMirrored.Filled.ExitToApp, contentDescription = "Sign out") 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), .padding(padding),
) { ) {
when { 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() -> { state.loading && state.files.isEmpty() -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator() 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")}" 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) { private fun openFile(context: android.content.Context, local: java.io.File) {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", local) val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", local)
val intent = Intent(Intent.ACTION_VIEW).apply { val intent = Intent(Intent.ACTION_VIEW).apply {

@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.cloudreve.android.api.FileObject import org.cloudreve.android.api.FileObject
import org.cloudreve.android.api.SearchHit
import org.cloudreve.android.data.FileRepository import org.cloudreve.android.data.FileRepository
import org.cloudreve.android.util.CrUri import org.cloudreve.android.util.CrUri
import java.io.File import java.io.File
@ -21,6 +22,12 @@ data class FilesUiState(
val nextToken: String? = null, val nextToken: String? = null,
val error: String? = null, val error: String? = null,
val snackbar: 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() { class FilesViewModel(private val repo: FileRepository) : ViewModel() {
@ -78,6 +85,59 @@ class FilesViewModel(private val repo: FileRepository) : ViewModel() {
return true 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) { fun mkdir(name: String) {
if (name.isBlank()) return if (name.isBlank()) return
viewModelScope.launch { viewModelScope.launch {

Loading…
Cancel
Save