From 8e49b9273d988eb62d32b3f237d449bca316db52 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Sun, 20 Sep 2026 11:59:45 +0200 Subject: [PATCH 1/4] fix(desktop): tolerate missing `obfuscated` in thumb response (#167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CE server's /file/thumb payload carries only `url`/`expires`, but the cloudreve-api model required `obfuscated`, so every thumbnail request failed deserialization and Explorer got E_FAIL for online-only placeholders. Hydrated files were unaffected — Windows thumbs them locally without the handler. `obfuscated` is now #[serde(default)]: missing → false → the time-flow decode path only runs when a server actually emits the flag. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ROADMAP.md | 1 + .../cloudreve-api/src/models/explorer.rs | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 0a8812bd..a83e1e27 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -231,6 +231,7 @@ Goal: Windows + macOS + Linux from the `desktop/` tree in this repo. - Status: (1) notifications already per-OS (`win32_notif` / `notify_rust` / `mac_notification_sys`) — no abstraction needed; (2) hydration abstracted via `drive/placeholder` cfg swap — `cfapi` on Windows, `placeholder_non_windows` full-sync adapter elsewhere (FUSE / File Provider still open); (3) CI matrix builds + tests all 3 OSes; (4) packaging: `desktop-release.yml` on `desktop-v*` tags ships .msi/.exe (Windows), .dmg (macOS), .deb/.AppImage (Linux) — MSIX deferred (needs store signing). - Verified on Linux: `cargo test --workspace` green (49 tests), `cargo tauri build` produces working .deb + .AppImage. - Feature fallback on Linux/macOS until providers land: full sync without placeholders (download-on-access still works via sync engine). +- [x] #167 (upstream desktop#49) — online-only thumbnails missing in Explorer: root cause was a client/server contract mismatch — the CE `/file/thumb` response carries only `url`/`expires` while the `cloudreve-api` model required `obfuscated`, failing deserialization on every thumbnail request (`E_FAIL` to Explorer; hydrated files were unaffected since Windows thumbs them locally). `obfuscated` is now `#[serde(default)]`; the decode path still runs when a server emits the flag ## 7. Phase E — Android app (native, no iOS) diff --git a/desktop/crates/cloudreve-api/src/models/explorer.rs b/desktop/crates/cloudreve-api/src/models/explorer.rs index 285922bd..f64141c5 100644 --- a/desktop/crates/cloudreve-api/src/models/explorer.rs +++ b/desktop/crates/cloudreve-api/src/models/explorer.rs @@ -254,6 +254,7 @@ pub struct FileThumbResponse { pub url: String, #[serde(skip_serializing_if = "Option::is_none")] pub expires: Option, + #[serde(default)] pub obfuscated: bool, } @@ -575,3 +576,32 @@ pub enum FileEvent { /// Batch of file events with data Event(Vec), } + +#[cfg(test)] +mod tests { + use super::FileThumbResponse; + + #[test] + fn thumb_response_without_obfuscated_field_deserializes() { + // The CE server's /file/thumb response carries only `url`/`expires`; + // a missing `obfuscated` flag must not fail deserialization — otherwise + // every thumbnail request errors out and Explorer shows no thumbnails + // for online-only placeholders. + let res: FileThumbResponse = + serde_json::from_str(r#"{"url":"https://example.com/t.jpg","expires":null}"#) + .expect("response without `obfuscated` must deserialize"); + assert!(!res.obfuscated); + assert_eq!(res.url, "https://example.com/t.jpg"); + assert!(res.expires.is_none()); + } + + #[test] + fn thumb_response_with_obfuscated_field_deserializes() { + let res: FileThumbResponse = serde_json::from_str( + r#"{"url":"abc","expires":"2026-01-01T00:00:00Z","obfuscated":true}"#, + ) + .expect("response with `obfuscated` must deserialize"); + assert!(res.obfuscated); + assert_eq!(res.expires.as_deref(), Some("2026-01-01T00:00:00Z")); + } +} From c371fe2417c2a539c20d0cbf5a9d498319461f46 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Sun, 20 Sep 2026 12:04:49 +0200 Subject: [PATCH 2/4] feat(viewer): Android Motion Photo preview in image viewer (#89) Android/Samsung motion photos are JPEGs with an MP4 appended at EOF, located via XMP GCamera:MicroVideoOffset (or the Container:Directory video/mp4 Item:Length on newer devices). util/motionPhoto.ts fetches only the head (XMP scan) plus the video tail via Range requests, validates the ftyp box, and returns a blob URL. Photo.tsx feeds it into the existing LivePhotosKit player so motion photos get the same badge + press-to-play affordance as iOS live photos. Non-candidates and fetch failures degrade silently. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ROADMAP.md | 1 + .../ImageViewer/react-photo-view/Photo.tsx | 34 ++++-- frontend/src/util/motionPhoto.ts | 112 ++++++++++++++++++ 3 files changed, 137 insertions(+), 10 deletions(-) create mode 100644 frontend/src/util/motionPhoto.ts diff --git a/ROADMAP.md b/ROADMAP.md index 0a8812bd..a5f6e9ce 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -208,6 +208,7 @@ Order = user-visible value first; each ships with backend + UI + tests. - [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 - [x] #199 (upstream #3584) — markdown editor lag: root cause was per-keystroke React re-renders (changedValue state fed back into the editor's initial-markdown prop) re-running every plugin's `update()` hook (RealmWithPlugins has a dep-less effect). `MarkdownEditor` is now `memo`'d with a `useMemo`'d plugins array + stable `translation`; `MarkdownViewer` keeps edits in a ref (read at save) and passes the immutable loaded content — zero re-renders per keystroke - [ ] #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] #89 (upstream #3277) — Android Motion Photo preview: `util/motionPhoto.ts` extracts the MP4 appended at EOF of MicroVideo/MotionPhoto JPEGs — XMP `GCamera:MicroVideoOffset` or `Container:Directory` `video/mp4` `Item:Length`, fetched via Range requests (128 KiB head + video tail only), `ftyp` sanity-checked; `Photo.tsx` feeds the blob URL into the same LivePhotosKit player used for iOS live photos — badge + press-to-play for free; degrades silently on CORS/fetch failure - [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/frontend/src/component/Viewers/ImageViewer/react-photo-view/Photo.tsx b/frontend/src/component/Viewers/ImageViewer/react-photo-view/Photo.tsx index 12df7ab1..16b9b6cb 100644 --- a/frontend/src/component/Viewers/ImageViewer/react-photo-view/Photo.tsx +++ b/frontend/src/component/Viewers/ImageViewer/react-photo-view/Photo.tsx @@ -7,6 +7,7 @@ import { EntityType, FileResponse, Metadata } from "../../../../api/explorer.ts" import { useAppDispatch } from "../../../../redux/hooks.ts"; import { loadFileThumb } from "../../../../redux/thunks/file.ts"; import { getFileLinkedUri } from "../../../../util"; +import { fetchMotionPhotoVideoUrl, isMotionPhotoCandidate } from "../../../../util/motionPhoto.ts"; import { FileManagerIndex } from "../../../FileManager/FileManager.tsx"; import { LRUCache } from "../../../../util/lru.ts"; import FacebookCircularProgress from "../../../Common/CircularProgress.tsx"; @@ -154,12 +155,32 @@ export default function Photo({ }); }, []); - const loadLivePhoto = async (file: FileResponse, imgUrl: string) => { - if (!file.metadata?.[Metadata.live_photo]) { + const attachLivePhotoPlayer = (file: FileResponse, imgUrl: string, videoUrl: string) => { + const imgElement = document.getElementById(file.id); + if (!imgElement || !mountedRef.current) { return; } + const player = LivePhotosKit.Player(imgElement as HTMLElement); + playerRef.current = player; + player.photoSrc = imgUrl; + player.videoSrc = videoUrl; + player.proactivelyLoadsVideo = true; + }; + const loadLivePhoto = async (file: FileResponse, imgUrl: string) => { try { + if (!file.metadata?.[Metadata.live_photo]) { + // Android Motion Photo: a JPEG with an MP4 appended at EOF (#89). + if (!isMotionPhotoCandidate(file.name, file.size)) { + return; + } + const videoUrl = await fetchMotionPhotoVideoUrl(imgUrl); + if (videoUrl) { + attachLivePhotoPlayer(file, imgUrl, videoUrl); + } + return; + } + const fileExtended = await dispatch( getFileInfo( { @@ -183,14 +204,7 @@ export default function Photo({ }), ); - const imgElement = document.getElementById(file.id); - if (imgElement) { - const player = LivePhotosKit.Player(imgElement as HTMLElement); - playerRef.current = player; - player.photoSrc = imgUrl; - player.videoSrc = livePhotoEntityUrl.urls[0].url; - player.proactivelyLoadsVideo = true; - } + attachLivePhotoPlayer(file, imgUrl, livePhotoEntityUrl.urls[0].url); } catch (e) { console.error("Failed to load live photo:", e); } diff --git a/frontend/src/util/motionPhoto.ts b/frontend/src/util/motionPhoto.ts new file mode 100644 index 00000000..bf97bbdc --- /dev/null +++ b/frontend/src/util/motionPhoto.ts @@ -0,0 +1,112 @@ +/** + * Android Motion Photo (a.k.a. MicroVideo) support. + * + * Google/Samsung motion photos are a single JPEG with an MP4 appended at the + * end. XMP metadata in the JPEG header either carries `GCamera:MicroVideoOffset` + * (bytes from EOF to the start of the MP4) or a `Container:Directory` whose + * `video/mp4` item declares `Item:Length` (video is the last item). + */ + +// XMP packets live in the JPEG APP1 segment near the start of the file; +// 128 KiB of head data covers every layout seen in the wild. +const HEAD_SCAN_BYTES = 128 * 1024; + +// Motion photos carry a multi-MB video tail; smaller JPEGs cannot be one. +const MIN_FILE_SIZE = 512 * 1024; + +const MOTION_PHOTO_EXTENSIONS = new Set(["jpg", "jpeg"]); + +export const isMotionPhotoCandidate = (name: string, size: number): boolean => + MOTION_PHOTO_EXTENSIONS.has(name.toLowerCase().split(".").pop() ?? "") && + size >= MIN_FILE_SIZE; + +const bytesToLatin1 = (buf: ArrayBuffer): string => { + const bytes = new Uint8Array(buf); + let out = ""; + for (let i = 0; i < bytes.length; i += 8192) { + out += String.fromCharCode.apply( + null, + bytes.subarray(i, i + 8192) as unknown as number[], + ); + } + return out; +}; + +// Extracts the embedded video's byte length (counted from EOF) from XMP text. +const extractVideoLength = (text: string): number | null => { + const offset = text.match(/MicroVideoOffset="(\d+)"/); + if (offset) { + return parseInt(offset[1], 10); + } + + if (!/MicroVideo|MotionPhoto/.test(text)) { + return null; + } + + const mimeIdx = text.indexOf("video/mp4"); + if (mimeIdx < 0) { + return null; + } + // The item element serializes Mime before Length; search forward only so a + // preceding image item's Length is never picked up. + const window = text.slice(mimeIdx, mimeIdx + 512); + const length = window.match(/Item:Length="(\d+)"/); + return length ? parseInt(length[1], 10) : null; +}; + +// MP4 files start with an `ftyp` box; guards against bogus XMP offsets. +const looksLikeMp4 = (buf: ArrayBuffer): boolean => { + if (buf.byteLength < 12) { + return false; + } + const view = new DataView(buf, 4); + return view.getUint32(0) === 0x66747970; // "ftyp" +}; + +/** + * Returns a blob URL for the embedded MP4 of a motion photo, or null when the + * image has no embedded video or the bytes cannot be fetched (CORS, network). + * Uses HTTP Range requests so at most the head + video tail are transferred. + */ +export const fetchMotionPhotoVideoUrl = async ( + imageUrl: string, +): Promise => { + try { + const head = await fetch(imageUrl, { + headers: { Range: `bytes=0-${HEAD_SCAN_BYTES - 1}` }, + }); + if (!head.ok) { + return null; + } + const headBuf = await head.arrayBuffer(); + + const videoLength = extractVideoLength(bytesToLatin1(headBuf)); + if (!videoLength || videoLength <= 0) { + return null; + } + + let videoBuf: ArrayBuffer; + if (head.status === 206 && headBuf.byteLength <= HEAD_SCAN_BYTES) { + const tail = await fetch(imageUrl, { + headers: { Range: `bytes=-${videoLength}` }, + }); + if (!tail.ok) { + return null; + } + videoBuf = await tail.arrayBuffer(); + } else { + // Range ignored: the head fetch already holds the whole file. + if (headBuf.byteLength <= videoLength) { + return null; + } + videoBuf = headBuf.slice(headBuf.byteLength - videoLength); + } + + if (!looksLikeMp4(videoBuf)) { + return null; + } + return URL.createObjectURL(new Blob([videoBuf], { type: "video/mp4" })); + } catch { + return null; + } +}; From 1ec6e2349bad910deb38230d9b5b72ba0ea1fc1f Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Sun, 20 Sep 2026 12:09:14 +0200 Subject: [PATCH 3/4] feat(android): full-text file search Wire the existing GET /file/search endpoint into the files screen: search icon expands a query field in the app bar, results replace the file list with offset pagination on scroll, hits show parent path and content snippet. Folder hits exit search and navigate into place; file hits download and open. Back closes search before navigating up. 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 | 4 +- .../org/cloudreve/android/api/ApiService.kt | 6 + .../java/org/cloudreve/android/api/Dto.kt | 12 ++ .../cloudreve/android/data/FileRepository.kt | 4 + .../cloudreve/android/ui/files/FilesScreen.kt | 201 ++++++++++++++++-- .../android/ui/files/FilesViewModel.kt | 60 ++++++ 7 files changed, 267 insertions(+), 22 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0a8812bd..e930972d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -237,7 +237,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 { From 67c990eb4cda00344dcb4b6ebb5c96491326f105 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Sun, 20 Sep 2026 12:13:25 +0200 Subject: [PATCH 4/4] feat(android): camera auto-upload via periodic MediaStore sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CameraUploadWorker scans MediaStore for photos/videos newer than the last-sync watermark and streams them through the existing upload-session flow — 15-minute periodic work plus manual "sync now", unmetered-only when Wi-Fi is chosen, battery-not-low constrained. Synced and failed MediaStore IDs persist in DataStore so runs are incremental and bad files can't block the queue. Camera backup dialog in the files top bar: enable switch (requests READ_MEDIA_* on 33+ / READ_EXTERNAL_STORAGE below), remote folder, Wi-Fi only, include videos, last-backup timestamp. 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 +- android/app/src/main/AndroidManifest.xml | 5 + .../org/cloudreve/android/CloudreveApp.kt | 4 + .../android/data/CameraUploadSettings.kt | 108 ++++++++++ .../cloudreve/android/ui/files/FilesScreen.kt | 132 ++++++++++++ .../android/work/CameraUploadWorker.kt | 195 ++++++++++++++++++ 7 files changed, 450 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/java/org/cloudreve/android/data/CameraUploadSettings.kt create mode 100644 android/app/src/main/java/org/cloudreve/android/work/CameraUploadWorker.kt diff --git a/ROADMAP.md b/ROADMAP.md index cb27ffd5..f9539449 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 (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 (done — periodic WorkManager MediaStore sync, Wi-Fi-only constraint, ID dedup, settings dialog), 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 49f4ce10..d15d3973 100644 --- a/android/README.md +++ b/android/README.md @@ -30,8 +30,12 @@ CI runs `assembleDebug` on every PR. - 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 +- Camera auto-upload: periodic WorkManager sync of new MediaStore + 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 ## Planned next -- Camera auto-upload, offline-favorite files, DocumentsProvider +- Offline-favorite files, DocumentsProvider - No iOS. Ever. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f62ccfc0..7ac9fbc7 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -7,6 +7,11 @@ + + + = context.cameraUploadStore.data.map { + Snapshot( + enabled = it[Keys.enabled] ?: false, + remoteFolder = it[Keys.remoteFolder] ?: DEFAULT_FOLDER, + wifiOnly = it[Keys.wifiOnly] ?: true, + includeVideos = it[Keys.includeVideos] ?: true, + lastSyncAt = it[Keys.lastSyncAt] ?: 0L, + ) + } + + suspend fun snapshotNow(): Snapshot = snapshot.first() + + suspend fun setEnabled(enabled: Boolean) { + context.cameraUploadStore.edit { it[Keys.enabled] = enabled } + } + + suspend fun setRemoteFolder(folder: String) { + context.cameraUploadStore.edit { it[Keys.remoteFolder] = folder.trim() } + } + + suspend fun setWifiOnly(wifiOnly: Boolean) { + context.cameraUploadStore.edit { it[Keys.wifiOnly] = wifiOnly } + } + + suspend fun setIncludeVideos(include: Boolean) { + context.cameraUploadStore.edit { it[Keys.includeVideos] = include } + } + + suspend fun syncedIdsNow(): Set = + context.cameraUploadStore.data.first()[Keys.syncedIds] ?: emptySet() + + suspend fun failedIdsNow(): Set = + context.cameraUploadStore.data.first()[Keys.failedIds] ?: emptySet() + + suspend fun lastSyncTsNow(): Long = + context.cameraUploadStore.data.first()[Keys.lastSyncTs] ?: 0L + + suspend fun markSynced(id: String, dateAddedSec: Long) { + context.cameraUploadStore.edit { prefs -> + val synced = (prefs[Keys.syncedIds] ?: emptySet()).toMutableSet() + synced.add(id) + prefs[Keys.syncedIds] = synced.bounded() + if (dateAddedSec > (prefs[Keys.lastSyncTs] ?: 0L)) { + prefs[Keys.lastSyncTs] = dateAddedSec + } + prefs[Keys.lastSyncAt] = System.currentTimeMillis() + } + } + + suspend fun markFailed(id: String) { + context.cameraUploadStore.edit { prefs -> + val failed = (prefs[Keys.failedIds] ?: emptySet()).toMutableSet() + failed.add(id) + prefs[Keys.failedIds] = failed.bounded() + } + } + + private fun Set.bounded(): Set = + if (size <= MAX_TRACKED_IDS) this else this.drop(size - MAX_TRACKED_IDS).toSet() + + companion object { + const val DEFAULT_FOLDER = "${CrUri.MY_PREFIX}/Photos" + private const val MAX_TRACKED_IDS = 2000 + } +} 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 99cfc95b..72e9d47e 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 @@ -32,6 +32,7 @@ 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.Settings import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.UploadFile import androidx.compose.material3.AlertDialog @@ -49,6 +50,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -73,8 +75,11 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.content.FileProvider 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.util.CrUri +import org.cloudreve.android.work.CameraUploadWorker import org.cloudreve.android.work.UploadWorker import java.text.DecimalFormat @@ -95,6 +100,7 @@ fun FilesScreen( var deleteTarget by remember { mutableStateOf(null) } var searchOpen by remember { mutableStateOf(false) } var searchText by remember { mutableStateOf("") } + var cameraSettingsOpen by remember { mutableStateOf(false) } val searchFocus = remember { FocusRequester() } val inSearch = state.searchQuery != null @@ -191,6 +197,9 @@ fun FilesScreen( IconButton(onClick = { searchOpen = true }) { Icon(Icons.Default.Search, contentDescription = "Search") } + IconButton(onClick = { cameraSettingsOpen = true }) { + Icon(Icons.Default.Settings, contentDescription = "Camera backup") + } IconButton(onClick = { mkdirOpen = true }) { Icon(Icons.Default.CreateNewFolder, contentDescription = "New folder") } @@ -359,6 +368,10 @@ fun FilesScreen( }, ) } + + if (cameraSettingsOpen) { + CameraUploadDialog(onDismiss = { cameraSettingsOpen = false }) + } } private fun pathLabel(uri: String): String { @@ -453,6 +466,125 @@ private fun SearchResults( } } +@Composable +private fun CameraUploadDialog(onDismiss: () -> Unit) { + val context = LocalContext.current + val app = context.applicationContext as CloudreveApp + val settings = app.cameraUploadSettings + val scope = rememberCoroutineScope() + val snap by settings.snapshot.collectAsState(initial = null) + var folderText by remember(snap?.remoteFolder) { + mutableStateOf(snap?.remoteFolder ?: CameraUploadSettings.DEFAULT_FOLDER) + } + + val permLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { grants -> + if (grants.values.all { it }) { + scope.launch { + settings.setRemoteFolder(folderText) + settings.setEnabled(true) + CameraUploadWorker.apply(context, snap?.wifiOnly ?: true) + } + } + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Camera backup") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Back up new photos & videos", Modifier.weight(1f)) + Switch( + checked = snap?.enabled == true, + onCheckedChange = { want -> + if (want) { + permLauncher.launch(mediaPermissions()) + } else { + scope.launch { + settings.setEnabled(false) + CameraUploadWorker.cancel(context) + } + } + }, + ) + } + OutlinedTextField( + value = folderText, + onValueChange = { folderText = it }, + label = { Text("Remote folder") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Wi-Fi only", Modifier.weight(1f)) + Switch( + checked = snap?.wifiOnly ?: true, + onCheckedChange = { wifiOnly -> + scope.launch { + settings.setWifiOnly(wifiOnly) + if (snap?.enabled == true) { + CameraUploadWorker.apply(context, wifiOnly) + } + } + }, + ) + } + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Include videos", Modifier.weight(1f)) + Switch( + checked = snap?.includeVideos ?: true, + onCheckedChange = { scope.launch { settings.setIncludeVideos(it) } }, + ) + } + snap?.lastSyncAt?.takeIf { it > 0 }?.let { + Text( + "Last backup: " + java.text.DateFormat.getDateTimeInstance( + java.text.DateFormat.SHORT, java.text.DateFormat.SHORT, + ).format(java.util.Date(it)), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + confirmButton = { + TextButton(onClick = { + scope.launch { + settings.setRemoteFolder(folderText) + CameraUploadWorker.syncNow(context, snap?.wifiOnly ?: true) + } + }) { Text("Sync now") } + }, + dismissButton = { + TextButton(onClick = { + scope.launch { settings.setRemoteFolder(folderText) } + onDismiss() + }) { Text("Done") } + }, + ) +} + +private fun mediaPermissions(): Array = + if (android.os.Build.VERSION.SDK_INT >= 33) { + arrayOf( + android.Manifest.permission.READ_MEDIA_IMAGES, + android.Manifest.permission.READ_MEDIA_VIDEO, + ) + } else { + arrayOf(android.Manifest.permission.READ_EXTERNAL_STORAGE) + } + 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/work/CameraUploadWorker.kt b/android/app/src/main/java/org/cloudreve/android/work/CameraUploadWorker.kt new file mode 100644 index 00000000..44ab6e28 --- /dev/null +++ b/android/app/src/main/java/org/cloudreve/android/work/CameraUploadWorker.kt @@ -0,0 +1,195 @@ +package org.cloudreve.android.work + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.ContentUris +import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build +import android.provider.MediaStore +import androidx.core.app.NotificationCompat +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.ForegroundInfo +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import org.cloudreve.android.CloudreveApp +import java.io.File +import java.io.FileOutputStream +import java.util.concurrent.TimeUnit + +/** + * Periodically scans MediaStore for photos/videos captured since the last + * sync and uploads them to the configured Cloudreve folder. A bounded set of + * synced/failed MediaStore IDs prevents re-uploads; the date-added watermark + * bounds each scan. Runs unmetered-only when the user chose Wi-Fi only. + */ +class CameraUploadWorker(context: Context, params: WorkerParameters) : + CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + val app = applicationContext as CloudreveApp + val settings = app.cameraUploadSettings + val cfg = settings.snapshotNow() + if (!cfg.enabled) return Result.success() + + createChannel() + + val synced = settings.syncedIdsNow().toMutableSet() + val failed = settings.failedIdsNow() + val lastTs = settings.lastSyncTsNow() + + val items = queryNewMedia(lastTs, cfg.includeVideos) + .filter { it.id !in synced && it.id !in failed } + .take(MAX_PER_RUN) + + items.forEachIndexed { i, item -> + setForeground(progressInfo("Backing up ${item.name}", i, items.size)) + try { + val tmp = stageToCache(item) + try { + app.fileRepository.uploadFile(tmp, cfg.remoteFolder) { _, _ -> } + settings.markSynced(item.id, item.dateAddedSec) + } finally { + tmp.delete() + } + } catch (_: Exception) { + // Permanently skipped: retried MediaStore rows that keep + // failing would otherwise block every later photo. + settings.markFailed(item.id) + } + } + return Result.success() + } + + private data class MediaItem( + val id: String, + val contentUri: android.net.Uri, + val name: String, + val dateAddedSec: Long, + ) + + private fun queryNewMedia(sinceSec: Long, includeVideos: Boolean): List { + val out = mutableListOf() + val collection = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL) + val projection = arrayOf( + MediaStore.Files.FileColumns._ID, + MediaStore.Files.FileColumns.DISPLAY_NAME, + MediaStore.Files.FileColumns.DATE_ADDED, + MediaStore.Files.FileColumns.MEDIA_TYPE, + ) + val mediaTypes = buildList { + add(MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) + if (includeVideos) add(MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO) + } + val selection = "${MediaStore.Files.FileColumns.DATE_ADDED} > ? AND " + + "${MediaStore.Files.FileColumns.MEDIA_TYPE} IN (${mediaTypes.joinToString(",") { "?" }})" + val args = (listOf(sinceSec) + mediaTypes).map { it.toString() }.toTypedArray() + val order = "${MediaStore.Files.FileColumns.DATE_ADDED} ASC" + + applicationContext.contentResolver.query( + collection, projection, selection, args, order, + )?.use { cursor -> + val idIdx = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID) + val nameIdx = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DISPLAY_NAME) + val dateIdx = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATE_ADDED) + while (cursor.moveToNext()) { + val id = cursor.getLong(idIdx) + out.add( + MediaItem( + id = id.toString(), + contentUri = ContentUris.withAppendedId(collection, id), + name = cursor.getString(nameIdx) ?: "media_$id", + dateAddedSec = cursor.getLong(dateIdx), + ) + ) + } + } + return out + } + + private fun stageToCache(item: MediaItem): File { + val dir = File(applicationContext.cacheDir, "camera_staging").apply { mkdirs() } + val tmp = File(dir, item.name) + applicationContext.contentResolver.openInputStream(item.contentUri)!!.use { input -> + FileOutputStream(tmp).use { input.copyTo(it) } + } + return tmp + } + + private fun progressInfo(text: String, index: Int, total: Int): ForegroundInfo { + val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_sys_upload) + .setContentTitle("Cloudreve camera backup") + .setContentText("$text (${index + 1}/$total)") + .setOngoing(true) + .build() + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo(NOTIF_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + ForegroundInfo(NOTIF_ID, notification) + } + } + + private fun createChannel() { + val manager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.createNotificationChannel( + NotificationChannel(CHANNEL_ID, "Camera backup", NotificationManager.IMPORTANCE_LOW) + ) + } + + companion object { + private const val WORK_NAME = "camera_upload_periodic" + private const val WORK_ONCE = "camera_upload_once" + private const val CHANNEL_ID = "camera_upload" + private const val NOTIF_ID = 42 + private const val MAX_PER_RUN = 100 + + private fun constraints(wifiOnly: Boolean): Constraints = + Constraints.Builder() + .setRequiredNetworkType( + if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED + ) + .setRequiresBatteryNotLow(true) + .build() + + /** Schedules (or removes) the periodic sync and kicks an immediate run. */ + fun apply(context: Context, wifiOnly: Boolean) { + val wm = WorkManager.getInstance(context) + val periodic = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) + .setConstraints(constraints(wifiOnly)) + .build() + wm.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, periodic) + wm.enqueueUniqueWork( + WORK_ONCE, + ExistingWorkPolicy.REPLACE, + OneTimeWorkRequestBuilder() + .setConstraints(constraints(wifiOnly)) + .build(), + ) + } + + fun cancel(context: Context) { + val wm = WorkManager.getInstance(context) + wm.cancelUniqueWork(WORK_NAME) + wm.cancelUniqueWork(WORK_ONCE) + } + + /** Manual "sync now" — the periodic schedule is untouched. */ + fun syncNow(context: Context, wifiOnly: Boolean) { + WorkManager.getInstance(context).enqueueUniqueWork( + WORK_ONCE, + ExistingWorkPolicy.REPLACE, + OneTimeWorkRequestBuilder() + .setConstraints(constraints(wifiOnly)) + .build(), + ) + } + } +}