From 9f3a80abe2ca394a9a082501a3f101796c055261 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Sun, 20 Sep 2026 12:20:18 +0200 Subject: [PATCH] feat(android): offline favorites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Keep offline" in a file's menu downloads it to filesDir/offline and registers the entry in a DataStore-backed FavoritesStore (path → local file, size, savedAt, remote updated_at for staleness). A star icon in the files top bar opens the offline list: tap opens via FileProvider, refresh re-downloads, remove deletes the copy. Favorites state lives in the files UI state so row menus reflect current membership; downloads share a common downloadTo() helper with the cache path. 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 | 6 +- .../org/cloudreve/android/CloudreveApp.kt | 3 + .../org/cloudreve/android/MainActivity.kt | 2 +- .../cloudreve/android/data/FavoritesStore.kt | 67 ++++++++++++ .../cloudreve/android/ui/files/FilesScreen.kt | 100 ++++++++++++++++++ .../android/ui/files/FilesViewModel.kt | 88 ++++++++++++++- 7 files changed, 260 insertions(+), 8 deletions(-) create mode 100644 android/app/src/main/java/org/cloudreve/android/data/FavoritesStore.kt diff --git a/ROADMAP.md b/ROADMAP.md index f9539449..82c71a48 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, full-text search (done — query bar + offset pagination + parent-path/snippet rows), camera-upload (done — periodic WorkManager MediaStore sync, Wi-Fi-only constraint, ID dedup, settings dialog), 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 (done — periodic WorkManager MediaStore sync, Wi-Fi-only constraint, ID dedup, settings dialog), offline-favorite files (done — "Keep offline" downloads to filesDir/offline, DataStore registry, star dialog with open/refresh/remove), 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 d15d3973..804ed214 100644 --- a/android/README.md +++ b/android/README.md @@ -34,8 +34,12 @@ CI runs `assembleDebug` on every PR. photos/videos to a configurable remote folder, Wi-Fi-only constraint, media-permission gated toggle, synced/failed ID dedup, manual "sync now" — settings dialog in the files top bar +- Offline favorites: "Keep offline" in a file's menu downloads to + `filesDir/offline/` and registers the entry (DataStore JSON); + star icon in the top bar opens the offline list — open via + FileProvider, re-download to refresh, remove to delete ## Planned next -- Offline-favorite files, DocumentsProvider +- DocumentsProvider - No iOS. Ever. diff --git a/android/app/src/main/java/org/cloudreve/android/CloudreveApp.kt b/android/app/src/main/java/org/cloudreve/android/CloudreveApp.kt index 25acafc8..af6e2c4d 100644 --- a/android/app/src/main/java/org/cloudreve/android/CloudreveApp.kt +++ b/android/app/src/main/java/org/cloudreve/android/CloudreveApp.kt @@ -3,6 +3,7 @@ package org.cloudreve.android import android.app.Application import org.cloudreve.android.api.ApiClient import org.cloudreve.android.data.CameraUploadSettings +import org.cloudreve.android.data.FavoritesStore import org.cloudreve.android.data.FileRepository import org.cloudreve.android.data.SessionManager @@ -15,6 +16,7 @@ class CloudreveApp : Application() { lateinit var fileRepository: FileRepository private set lateinit var cameraUploadSettings: CameraUploadSettings + lateinit var favoritesStore: FavoritesStore private set override fun onCreate() { @@ -23,5 +25,6 @@ class CloudreveApp : Application() { apiClient = ApiClient(sessionManager) fileRepository = FileRepository(apiClient) cameraUploadSettings = CameraUploadSettings(this) + favoritesStore = FavoritesStore(this) } } diff --git a/android/app/src/main/java/org/cloudreve/android/MainActivity.kt b/android/app/src/main/java/org/cloudreve/android/MainActivity.kt index 063ffcda..48b54964 100644 --- a/android/app/src/main/java/org/cloudreve/android/MainActivity.kt +++ b/android/app/src/main/java/org/cloudreve/android/MainActivity.kt @@ -66,7 +66,7 @@ private class AppViewModelFactory(private val app: CloudreveApp) : ViewModelProv modelClass.isAssignableFrom(LoginViewModel::class.java) -> LoginViewModel(app.sessionManager, app.apiClient) as T modelClass.isAssignableFrom(FilesViewModel::class.java) -> - FilesViewModel(app.fileRepository) as T + FilesViewModel(app.fileRepository, app.favoritesStore) as T else -> throw IllegalArgumentException("Unknown ViewModel ${modelClass.name}") } } diff --git a/android/app/src/main/java/org/cloudreve/android/data/FavoritesStore.kt b/android/app/src/main/java/org/cloudreve/android/data/FavoritesStore.kt new file mode 100644 index 00000000..874d3b32 --- /dev/null +++ b/android/app/src/main/java/org/cloudreve/android/data/FavoritesStore.kt @@ -0,0 +1,67 @@ +package org.cloudreve.android.data + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +private val Context.favoritesStore by preferencesDataStore(name = "favorites") + +@Serializable +data class FavoriteEntry( + /** cloudreve:// URI of the remote file. */ + val path: String, + val name: String, + val size: Long, + /** Absolute local path under filesDir/offline. */ + val localFile: String, + val savedAt: Long, + /** Remote updated_at at download time — staleness signal for refresh. */ + val remoteUpdatedAt: String = "", +) + +/** + * Offline-favorite registry: remote path → downloaded local copy. + * JSON list in DataStore — jarvis: ceiling a few hundred entries, + * upgrade to Room if sync status or per-file history is ever needed. + */ +class FavoritesStore(private val context: Context) { + + private object Keys { + val entriesJson = stringPreferencesKey("entries_json") + } + + private val json = Json { ignoreUnknownKeys = true } + + val entries: Flow> = context.favoritesStore.data.map { + decode(it[Keys.entriesJson]) + } + + suspend fun listNow(): List = entries.first() + + suspend fun put(entry: FavoriteEntry) { + context.favoritesStore.edit { prefs -> + val list = decode(prefs[Keys.entriesJson]) + .filterNot { it.path == entry.path } + entry + prefs[Keys.entriesJson] = json.encodeToString(list) + } + } + + suspend fun remove(path: String) { + context.favoritesStore.edit { prefs -> + val list = decode(prefs[Keys.entriesJson]).filterNot { it.path == path } + prefs[Keys.entriesJson] = json.encodeToString(list) + } + } + + private fun decode(raw: String?): List = + raw?.let { + runCatching { json.decodeFromString>(it) }.getOrNull() + } ?: emptyList() +} 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 72e9d47e..939ee1f4 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 @@ -34,6 +34,8 @@ import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.StarBorder import androidx.compose.material.icons.filled.UploadFile import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator @@ -78,6 +80,7 @@ import kotlinx.coroutines.launch import org.cloudreve.android.CloudreveApp import org.cloudreve.android.api.FileObject import org.cloudreve.android.data.CameraUploadSettings +import org.cloudreve.android.data.FavoriteEntry import org.cloudreve.android.util.CrUri import org.cloudreve.android.work.CameraUploadWorker import org.cloudreve.android.work.UploadWorker @@ -101,6 +104,7 @@ fun FilesScreen( var searchOpen by remember { mutableStateOf(false) } var searchText by remember { mutableStateOf("") } var cameraSettingsOpen by remember { mutableStateOf(false) } + var favoritesOpen by remember { mutableStateOf(false) } val searchFocus = remember { FocusRequester() } val inSearch = state.searchQuery != null @@ -197,6 +201,15 @@ fun FilesScreen( IconButton(onClick = { searchOpen = true }) { Icon(Icons.Default.Search, contentDescription = "Search") } + IconButton(onClick = { favoritesOpen = true }) { + Icon( + if (state.favorites.isEmpty()) Icons.Default.StarBorder + else Icons.Default.Star, + contentDescription = "Offline files", + tint = if (state.favorites.isEmpty()) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.primary, + ) + } IconButton(onClick = { cameraSettingsOpen = true }) { Icon(Icons.Default.Settings, contentDescription = "Camera backup") } @@ -274,6 +287,10 @@ fun FilesScreen( FileRow( file = file, thumbUrl = thumbUrlFor(viewModel, file), + isFavorite = file.path in state.favoritePaths, + onToggleFavorite = { + viewModel.toggleFavorite(context, file) + }, onClick = { if (file.isFolder) { viewModel.navigateTo(file) @@ -372,6 +389,77 @@ fun FilesScreen( if (cameraSettingsOpen) { CameraUploadDialog(onDismiss = { cameraSettingsOpen = false }) } + if (favoritesOpen) { + FavoritesDialog( + favorites = state.favorites, + onDismiss = { favoritesOpen = false }, + onOpen = { entry -> openFile(context, java.io.File(entry.localFile)) }, + onRefresh = { entry -> viewModel.refreshFavorite(context, entry) }, + onRemove = { entry -> viewModel.removeFavorite(entry) }, + ) + } +} + +@Composable +private fun FavoritesDialog( + favorites: List, + onDismiss: () -> Unit, + onOpen: (FavoriteEntry) -> Unit, + onRefresh: (FavoriteEntry) -> Unit, + onRemove: (FavoriteEntry) -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Offline files") }, + text = { + if (favorites.isEmpty()) { + Text( + "Nothing saved for offline use yet. Use a file's menu → Keep offline.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + LazyColumn { + items(favorites, key = { it.path }) { entry -> + ListItem( + modifier = Modifier.clickable { onOpen(entry) }, + headlineContent = { + Text(entry.name, maxLines = 1, overflow = TextOverflow.Ellipsis) + }, + supportingContent = { + Text( + "${formatSize(entry.size)} · saved " + + java.text.DateFormat.getDateInstance(java.text.DateFormat.SHORT) + .format(java.util.Date(entry.savedAt)), + style = MaterialTheme.typography.labelMedium, + ) + }, + trailingContent = { + Row { + IconButton(onClick = { onRefresh(entry) }) { + Icon( + Icons.Default.Refresh, + contentDescription = "Re-download", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = { onRemove(entry) }) { + Icon( + Icons.Default.Delete, + contentDescription = "Remove", + tint = MaterialTheme.colorScheme.error, + ) + } + } + }, + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text("Done") } + }, + ) } private fun pathLabel(uri: String): String { @@ -610,6 +698,8 @@ private fun thumbUrlFor(viewModel: FilesViewModel, file: FileObject): String? { private fun FileRow( file: FileObject, thumbUrl: String?, + isFavorite: Boolean, + onToggleFavorite: () -> Unit, onClick: () -> Unit, onRename: () -> Unit, onDelete: () -> Unit, @@ -655,6 +745,16 @@ private fun FileRow( leadingIcon = { Icon(Icons.Default.Download, null) }, onClick = { menuOpen = false; onDownload() }, ) + DropdownMenuItem( + text = { Text(if (isFavorite) "Remove offline copy" else "Keep offline") }, + leadingIcon = { + Icon( + if (isFavorite) Icons.Default.Star else Icons.Default.StarBorder, + null, + ) + }, + onClick = { menuOpen = false; onToggleFavorite() }, + ) } DropdownMenuItem( text = { Text("Share") }, 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 2a673c7c..bd5637ec 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 @@ -9,6 +9,8 @@ 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.FavoriteEntry +import org.cloudreve.android.data.FavoritesStore import org.cloudreve.android.data.FileRepository import org.cloudreve.android.util.CrUri import java.io.File @@ -28,13 +30,28 @@ data class FilesUiState( val searchLoading: Boolean = false, val searchLoadingMore: Boolean = false, val searchError: String? = null, -) + val favorites: List = emptyList(), + val favoriteSaving: Boolean = false, +) { + val favoritePaths: Set get() = favorites.map { it.path }.toSet() +} -class FilesViewModel(private val repo: FileRepository) : ViewModel() { +class FilesViewModel( + private val repo: FileRepository, + private val favorites: FavoritesStore, +) : ViewModel() { private val _state = MutableStateFlow(FilesUiState()) val state: StateFlow = _state + init { + viewModelScope.launch { + favorites.entries.collect { list -> + _state.value = _state.value.copy(favorites = list) + } + } + } + fun refresh() { val uri = _state.value.currentUri _state.value = _state.value.copy(loading = true, error = null, files = emptyList(), nextToken = null) @@ -165,12 +182,73 @@ class FilesViewModel(private val repo: FileRepository) : ViewModel() { } /** Downloads into app cache and returns the file for the caller to open/share. */ - suspend fun downloadToCache(context: Context, file: FileObject): File { + suspend fun downloadToCache(context: Context, file: FileObject): File = + downloadTo(File(context.cacheDir, "downloads"), file, file.name) + + /** Saves the file under filesDir/offline and registers it as a favorite. */ + fun toggleFavorite(context: Context, file: FileObject) { + if (file.isFolder) return + val existing = _state.value.favorites.firstOrNull { it.path == file.path } + if (existing != null) { + removeFavorite(existing) + return + } + if (_state.value.favoriteSaving) return + _state.value = _state.value.copy(favoriteSaving = true) + viewModelScope.launch { + runCatching { + val local = downloadTo( + File(context.filesDir, "offline"), + file, + "${file.path.hashCode()}_${file.name}", + ) + favorites.put( + FavoriteEntry( + path = file.path, + name = file.name, + size = file.size, + localFile = local.absolutePath, + savedAt = System.currentTimeMillis(), + remoteUpdatedAt = file.updatedAt, + ) + ) + } + .onSuccess { _state.value = _state.value.copy(snackbar = "Saved for offline") } + .onFailure { _state.value = _state.value.copy(snackbar = it.message) } + _state.value = _state.value.copy(favoriteSaving = false) + } + } + + /** Re-downloads a favorite to refresh its local copy. */ + fun refreshFavorite(context: Context, entry: FavoriteEntry) { + viewModelScope.launch { + runCatching { + val url = repo.downloadUrl(entry.path) + val resp = repo.download(url) + if (!resp.isSuccessful) throw Exception("Download failed: HTTP ${resp.code()}") + val out = File(entry.localFile) + resp.body()!!.byteStream().use { input -> + FileOutputStream(out).use { input.copyTo(it) } + } + favorites.put(entry.copy(savedAt = System.currentTimeMillis())) + } + .onFailure { _state.value = _state.value.copy(snackbar = it.message) } + } + } + + fun removeFavorite(entry: FavoriteEntry) { + viewModelScope.launch { + File(entry.localFile).delete() + favorites.remove(entry.path) + } + } + + private suspend fun downloadTo(dir: File, file: FileObject, name: String): File { val url = repo.downloadUrl(file.path) val resp = repo.download(url) if (!resp.isSuccessful) throw Exception("Download failed: HTTP ${resp.code()}") - val dir = File(context.cacheDir, "downloads").apply { mkdirs() } - val out = File(dir, file.name) + dir.mkdirs() + val out = File(dir, name) resp.body()!!.byteStream().use { input -> FileOutputStream(out).use { input.copyTo(it) } }