diff --git a/ROADMAP.md b/ROADMAP.md index a55a5f8e..f15a8a68 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 - [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] #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 @@ -231,13 +232,14 @@ 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) 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 (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 e7ca7412..d15d3973 100644 --- a/android/README.md +++ b/android/README.md @@ -27,9 +27,15 @@ 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 +- 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 -- Search +- 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 @@ + + + > + @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/CameraUploadSettings.kt b/android/app/src/main/java/org/cloudreve/android/data/CameraUploadSettings.kt new file mode 100644 index 00000000..36ab0d2f --- /dev/null +++ b/android/app/src/main/java/org/cloudreve/android/data/CameraUploadSettings.kt @@ -0,0 +1,108 @@ +package org.cloudreve.android.data + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import org.cloudreve.android.util.CrUri + +private val Context.cameraUploadStore by preferencesDataStore(name = "camera_upload") + +/** + * Camera auto-upload preferences plus the incremental-sync bookkeeping + * (which MediaStore items were already uploaded or permanently failed). + * ID sets are bounded so storage stays small; evicted entries may rarely + * cause a duplicate upload of an old item. + */ +class CameraUploadSettings(private val context: Context) { + + private object Keys { + val enabled = booleanPreferencesKey("enabled") + val remoteFolder = stringPreferencesKey("remote_folder") + val wifiOnly = booleanPreferencesKey("wifi_only") + val includeVideos = booleanPreferencesKey("include_videos") + val syncedIds = stringSetPreferencesKey("synced_ids") + val failedIds = stringSetPreferencesKey("failed_ids") + val lastSyncTs = longPreferencesKey("last_sync_ts") + val lastSyncAt = longPreferencesKey("last_sync_at") + } + + data class Snapshot( + val enabled: Boolean = false, + val remoteFolder: String = DEFAULT_FOLDER, + val wifiOnly: Boolean = true, + val includeVideos: Boolean = true, + val lastSyncAt: Long = 0L, + ) + + val snapshot: Flow = 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/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..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 @@ -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,11 @@ 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.Settings import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.UploadFile import androidx.compose.material3.AlertDialog @@ -45,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 @@ -61,13 +67,19 @@ 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 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 @@ -86,6 +98,12 @@ 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("") } + var cameraSettingsOpen by remember { mutableStateOf(false) } + val searchFocus = remember { FocusRequester() } + + val inSearch = state.searchQuery != null val uploadLauncher = rememberLauncherForActivityResult( ActivityResultContracts.OpenMultipleDocuments() @@ -109,45 +127,92 @@ 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 = { cameraSettingsOpen = true }) { + Icon(Icons.Default.Settings, contentDescription = "Camera backup") + } + 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 +232,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() @@ -285,6 +368,10 @@ fun FilesScreen( }, ) } + + if (cameraSettingsOpen) { + CameraUploadDialog(onDismiss = { cameraSettingsOpen = false }) + } } private fun pathLabel(uri: String): String { @@ -292,6 +379,212 @@ 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() } + } + } + } + } + } +} + +@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/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 { 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(), + ) + } + } +} 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")); + } +} 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; + } +};