diff --git a/ROADMAP.md b/ROADMAP.md index 4aeabad9..cb27ffd5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 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 {