Merge master into feat/ignore-sighup

pull/3589/head
Tomas Dvorak 2 weeks ago
commit 4e27cb7dae

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

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

@ -7,6 +7,11 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<application
android:name=".CloudreveApp"

@ -2,6 +2,7 @@ package org.cloudreve.android
import android.app.Application
import org.cloudreve.android.api.ApiClient
import org.cloudreve.android.data.CameraUploadSettings
import org.cloudreve.android.data.FileRepository
import org.cloudreve.android.data.SessionManager
@ -13,11 +14,14 @@ class CloudreveApp : Application() {
private set
lateinit var fileRepository: FileRepository
private set
lateinit var cameraUploadSettings: CameraUploadSettings
private set
override fun onCreate() {
super.onCreate()
sessionManager = SessionManager(this)
apiClient = ApiClient(sessionManager)
fileRepository = FileRepository(apiClient)
cameraUploadSettings = CameraUploadSettings(this)
}
}

@ -66,6 +66,12 @@ interface ApiService {
@GET("api/v4/file/thumb")
suspend fun thumb(@Query("uri") uri: String): Response<ApiResponse<ThumbResponse>>
@GET("api/v4/file/search")
suspend fun searchFiles(
@Query("query") query: String,
@Query("offset") offset: Int = 0,
): Response<ApiResponse<SearchResponse>>
@PUT("api/v4/share")
suspend fun createShare(@Body request: ShareCreateRequest): Response<ApiResponse<String>>
}

@ -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<SearchHit> = emptyList(),
val total: Long = 0,
)
@Serializable
data class SiteConfig(
val authn: Boolean = false,

@ -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<Snapshot> = 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<String> =
context.cameraUploadStore.data.first()[Keys.syncedIds] ?: emptySet()
suspend fun failedIdsNow(): Set<String> =
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<String>.bounded(): Set<String> =
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
}
}

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

@ -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<FileObject?>(null) }
var deleteTarget by remember { mutableStateOf<FileObject?>(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<String> =
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 {

@ -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<SearchHit> = 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 {

@ -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<MediaItem> {
val out = mutableListOf<MediaItem>()
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<CameraUploadWorker>(15, TimeUnit.MINUTES)
.setConstraints(constraints(wifiOnly))
.build()
wm.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, periodic)
wm.enqueueUniqueWork(
WORK_ONCE,
ExistingWorkPolicy.REPLACE,
OneTimeWorkRequestBuilder<CameraUploadWorker>()
.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<CameraUploadWorker>()
.setConstraints(constraints(wifiOnly))
.build(),
)
}
}
}

@ -254,6 +254,7 @@ pub struct FileThumbResponse {
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires: Option<String>,
#[serde(default)]
pub obfuscated: bool,
}
@ -575,3 +576,32 @@ pub enum FileEvent {
/// Batch of file events with data
Event(Vec<FileEventData>),
}
#[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"));
}
}

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

@ -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<string | null> => {
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;
}
};
Loading…
Cancel
Save