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(),
+ )
+ }
+ }
+}