Merge pull request #214 from Dvorinka/feat/android-offline-fav

feat(android): offline favorites
pull/3589/head
Tomáš Dvořák 2 weeks ago committed by GitHub
commit 627c8928cb
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, 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

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

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

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

@ -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<List<FavoriteEntry>> = context.favoritesStore.data.map {
decode(it[Keys.entriesJson])
}
suspend fun listNow(): List<FavoriteEntry> = 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<FavoriteEntry> =
raw?.let {
runCatching { json.decodeFromString<List<FavoriteEntry>>(it) }.getOrNull()
} ?: emptyList()
}

@ -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<FavoriteEntry>,
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") },

@ -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<FavoriteEntry> = emptyList(),
val favoriteSaving: Boolean = false,
) {
val favoritePaths: Set<String> 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<FilesUiState> = _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) }
}

Loading…
Cancel
Save