From dce1f7dacded8f84aa4f8bf13a1bdabac00ce968 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Sun, 20 Sep 2026 11:54:20 +0200 Subject: [PATCH 1/2] Ignore SIGHUP instead of shutting down the server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream report (cloudreve/cloudreve#3586) reads as a crash: the server dies two minutes after launch. The attached log shows a clean signal-driven shutdown — SIGHUP was registered in signal.Notify, and the reporter's terminal/SSH session closing sent SIGHUP to the process. For a long-running server this is the wrong semantics: a terminal hangup should not stop the service. SIGHUP's default disposition is terminate, so it must be explicitly ignored rather than merely removed from Notify. Verified live: server survives kill -HUP (HTTP stays responsive) and still shuts down gracefully on SIGTERM/SIGINT/SIGQUIT. Fixes #200. Authored By: TDvorak Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ROADMAP.md | 2 +- cmd/server.go | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0a6cbd22..0d128c39 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -207,7 +207,7 @@ Order = user-visible value first; each ships with backend + UI + tests. - #3454 (PG FK on upload) is **Pro-only** — `audit_logs` doesn't exist in this codebase. When B.5 adds our own audit log: insert the audit row in the same tx *after* the file row, never before. - [ ] #199 (upstream #3584) — markdown editor lag: profile the MDX editor path; likely re-render-per-keystroke, evaluate debounce/virtualization or lighter editor before swapping libraries - [x] #198 (upstream #3581) — "import files" task shows source storage policy "unknown": `ImportTaskState.PolicyName` baked at creation (resolved best-effort via `StoragePolicyClient`), summary emits `dst_policy_name` so admin views of other users' tasks work without a policy lookup; `policyOptionCache` retyped to `StoragePolicyBrief[]` and populated from `getAllowedPolicies()` at session init as fallback for legacy tasks -- [ ] #200 (upstream #3586) — Pro crash on SIGHUP; log shows a clean signal-driven shutdown, no stack trace — watch for a CE repro, likely not actionable yet +- [x] #200 (upstream #3586) — SIGHUP "crash": the upstream log was a clean signal-driven shutdown (SIGHUP was registered in `signal.Notify`), triggered when the reporter's terminal/SSH session closed. `signal.Ignore(syscall.SIGHUP)` now — default disposition would terminate the process; verified live: server survives `kill -HUP` (HTTP stays 200) and still shuts down cleanly on SIGTERM - [x] `desloppify` pass — 73 review items dispositioned (46 fixed, 27 honestly skipped), strict score 77.1 (was 18.9); scorecard lives in README. `security-reviewer` pass done incrementally per batch (OAuth secrets, SSRF, process exec, path safety) - [x] Tag management page (upstream #2962) — owner-scoped `tag:` metadata stats/rename/recolor/delete in `inventory.FileClient`, `GET/PATCH/DELETE /file/tag` routes, Settings → Tags tab with merge-on-rename semantics - [x] Download URL shuffling (#173) — `download_cdn_shuffle` distributes generated download URLs randomly across the site URL + `download_cdn_routes` endpoints (`setting.DownloadURLBase`, honors `UseFirstSiteUrl`); covers entity downloads, archive sessions, and redirect-type direct links; manual route picker hidden client-side while active diff --git a/cmd/server.go b/cmd/server.go index 08b51178..44636236 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -31,9 +31,13 @@ var serverCmd = &cobra.Command{ server.PrintBanner() - // Graceful shutdown after received signal. + // Graceful shutdown after received signal. SIGHUP (terminal hangup) + // is explicitly ignored: its default disposition terminates the + // process, which kills servers accidentally left running in an SSH + // session. Use SIGINT/SIGTERM/SIGQUIT to stop the server. sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGQUIT) + signal.Ignore(syscall.SIGHUP) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT) go shutdown(sigChan, logger, server) if err := server.Start(); err != nil { From 9f3a80abe2ca394a9a082501a3f101796c055261 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Sun, 20 Sep 2026 12:20:18 +0200 Subject: [PATCH 2/2] 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) } }