From 67c990eb4cda00344dcb4b6ebb5c96491326f105 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Sun, 20 Sep 2026 12:13:25 +0200 Subject: [PATCH] feat(android): camera auto-upload via periodic MediaStore sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CameraUploadWorker scans MediaStore for photos/videos newer than the last-sync watermark and streams them through the existing upload-session flow — 15-minute periodic work plus manual "sync now", unmetered-only when Wi-Fi is chosen, battery-not-low constrained. Synced and failed MediaStore IDs persist in DataStore so runs are incremental and bad files can't block the queue. Camera backup dialog in the files top bar: enable switch (requests READ_MEDIA_* on 33+ / READ_EXTERNAL_STORAGE below), remote folder, Wi-Fi only, include videos, last-backup timestamp. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ROADMAP.md | 2 +- android/README.md | 6 +- android/app/src/main/AndroidManifest.xml | 5 + .../org/cloudreve/android/CloudreveApp.kt | 4 + .../android/data/CameraUploadSettings.kt | 108 ++++++++++ .../cloudreve/android/ui/files/FilesScreen.kt | 132 ++++++++++++ .../android/work/CameraUploadWorker.kt | 195 ++++++++++++++++++ 7 files changed, 450 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/java/org/cloudreve/android/data/CameraUploadSettings.kt create mode 100644 android/app/src/main/java/org/cloudreve/android/work/CameraUploadWorker.kt diff --git a/ROADMAP.md b/ROADMAP.md index cb27ffd5..f9539449 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -239,7 +239,7 @@ Goal: Windows + macOS + Linux from the `desktop/` tree in this repo. Lives in `android/` in this repo. Kotlin + Jetpack Compose, Material 3. - **API**: `api/v4` REST + OAuth token (entities exist: `oauthclient`, `oauthgrant`) — same surface the desktop `cloudreve-api` crate documents; port its models as the spec -- **Core features**: browse/download/upload files, share links, full-text search (done — query bar + offset pagination + parent-path/snippet rows), camera-upload (auto photo backup), offline-favorite files, local sync folder via SAF/WorkManager +- **Core features**: browse/download/upload files, share links, full-text search (done — query bar + offset pagination + parent-path/snippet rows), camera-upload (done — periodic WorkManager MediaStore sync, Wi-Fi-only constraint, ID dedup, settings dialog), offline-favorite files, local sync folder via SAF/WorkManager - **System integration** (the "native, complete" ask): share-sheet target (upload to Cloudreve from any app), DocumentsProvider (Cloudreve in Files app), quick-share tile, notifications on share/task events - **Auth**: webview OAuth flow → token; later passkey if backend exposes - **WebDAV bridge**: `/dav` works as fallback file access until SDK matures diff --git a/android/README.md b/android/README.md index 49f4ce10..d15d3973 100644 --- a/android/README.md +++ b/android/README.md @@ -30,8 +30,12 @@ CI runs `assembleDebug` on every PR. - Full-text search (`GET /file/search`): query bar in the files screen, offset pagination, result rows show parent path + content snippet; folder hits navigate into place +- Camera auto-upload: periodic WorkManager sync of new MediaStore + photos/videos to a configurable remote folder, Wi-Fi-only constraint, + media-permission gated toggle, synced/failed ID dedup, manual + "sync now" — settings dialog in the files top bar ## Planned next -- Camera auto-upload, offline-favorite files, DocumentsProvider +- Offline-favorite files, DocumentsProvider - No iOS. Ever. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f62ccfc0..7ac9fbc7 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -7,6 +7,11 @@ + + + = context.cameraUploadStore.data.map { + Snapshot( + enabled = it[Keys.enabled] ?: false, + remoteFolder = it[Keys.remoteFolder] ?: DEFAULT_FOLDER, + wifiOnly = it[Keys.wifiOnly] ?: true, + includeVideos = it[Keys.includeVideos] ?: true, + lastSyncAt = it[Keys.lastSyncAt] ?: 0L, + ) + } + + suspend fun snapshotNow(): Snapshot = snapshot.first() + + suspend fun setEnabled(enabled: Boolean) { + context.cameraUploadStore.edit { it[Keys.enabled] = enabled } + } + + suspend fun setRemoteFolder(folder: String) { + context.cameraUploadStore.edit { it[Keys.remoteFolder] = folder.trim() } + } + + suspend fun setWifiOnly(wifiOnly: Boolean) { + context.cameraUploadStore.edit { it[Keys.wifiOnly] = wifiOnly } + } + + suspend fun setIncludeVideos(include: Boolean) { + context.cameraUploadStore.edit { it[Keys.includeVideos] = include } + } + + suspend fun syncedIdsNow(): Set = + context.cameraUploadStore.data.first()[Keys.syncedIds] ?: emptySet() + + suspend fun failedIdsNow(): Set = + context.cameraUploadStore.data.first()[Keys.failedIds] ?: emptySet() + + suspend fun lastSyncTsNow(): Long = + context.cameraUploadStore.data.first()[Keys.lastSyncTs] ?: 0L + + suspend fun markSynced(id: String, dateAddedSec: Long) { + context.cameraUploadStore.edit { prefs -> + val synced = (prefs[Keys.syncedIds] ?: emptySet()).toMutableSet() + synced.add(id) + prefs[Keys.syncedIds] = synced.bounded() + if (dateAddedSec > (prefs[Keys.lastSyncTs] ?: 0L)) { + prefs[Keys.lastSyncTs] = dateAddedSec + } + prefs[Keys.lastSyncAt] = System.currentTimeMillis() + } + } + + suspend fun markFailed(id: String) { + context.cameraUploadStore.edit { prefs -> + val failed = (prefs[Keys.failedIds] ?: emptySet()).toMutableSet() + failed.add(id) + prefs[Keys.failedIds] = failed.bounded() + } + } + + private fun Set.bounded(): Set = + if (size <= MAX_TRACKED_IDS) this else this.drop(size - MAX_TRACKED_IDS).toSet() + + companion object { + const val DEFAULT_FOLDER = "${CrUri.MY_PREFIX}/Photos" + private const val MAX_TRACKED_IDS = 2000 + } +} diff --git a/android/app/src/main/java/org/cloudreve/android/ui/files/FilesScreen.kt b/android/app/src/main/java/org/cloudreve/android/ui/files/FilesScreen.kt index 99cfc95b..72e9d47e 100644 --- a/android/app/src/main/java/org/cloudreve/android/ui/files/FilesScreen.kt +++ b/android/app/src/main/java/org/cloudreve/android/ui/files/FilesScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.UploadFile import androidx.compose.material3.AlertDialog @@ -49,6 +50,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -73,8 +75,11 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.content.FileProvider import kotlinx.coroutines.launch +import org.cloudreve.android.CloudreveApp import org.cloudreve.android.api.FileObject +import org.cloudreve.android.data.CameraUploadSettings import org.cloudreve.android.util.CrUri +import org.cloudreve.android.work.CameraUploadWorker import org.cloudreve.android.work.UploadWorker import java.text.DecimalFormat @@ -95,6 +100,7 @@ fun FilesScreen( var deleteTarget by remember { mutableStateOf(null) } var searchOpen by remember { mutableStateOf(false) } var searchText by remember { mutableStateOf("") } + var cameraSettingsOpen by remember { mutableStateOf(false) } val searchFocus = remember { FocusRequester() } val inSearch = state.searchQuery != null @@ -191,6 +197,9 @@ fun FilesScreen( IconButton(onClick = { searchOpen = true }) { Icon(Icons.Default.Search, contentDescription = "Search") } + IconButton(onClick = { cameraSettingsOpen = true }) { + Icon(Icons.Default.Settings, contentDescription = "Camera backup") + } IconButton(onClick = { mkdirOpen = true }) { Icon(Icons.Default.CreateNewFolder, contentDescription = "New folder") } @@ -359,6 +368,10 @@ fun FilesScreen( }, ) } + + if (cameraSettingsOpen) { + CameraUploadDialog(onDismiss = { cameraSettingsOpen = false }) + } } private fun pathLabel(uri: String): String { @@ -453,6 +466,125 @@ private fun SearchResults( } } +@Composable +private fun CameraUploadDialog(onDismiss: () -> Unit) { + val context = LocalContext.current + val app = context.applicationContext as CloudreveApp + val settings = app.cameraUploadSettings + val scope = rememberCoroutineScope() + val snap by settings.snapshot.collectAsState(initial = null) + var folderText by remember(snap?.remoteFolder) { + mutableStateOf(snap?.remoteFolder ?: CameraUploadSettings.DEFAULT_FOLDER) + } + + val permLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { grants -> + if (grants.values.all { it }) { + scope.launch { + settings.setRemoteFolder(folderText) + settings.setEnabled(true) + CameraUploadWorker.apply(context, snap?.wifiOnly ?: true) + } + } + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Camera backup") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Back up new photos & videos", Modifier.weight(1f)) + Switch( + checked = snap?.enabled == true, + onCheckedChange = { want -> + if (want) { + permLauncher.launch(mediaPermissions()) + } else { + scope.launch { + settings.setEnabled(false) + CameraUploadWorker.cancel(context) + } + } + }, + ) + } + OutlinedTextField( + value = folderText, + onValueChange = { folderText = it }, + label = { Text("Remote folder") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Wi-Fi only", Modifier.weight(1f)) + Switch( + checked = snap?.wifiOnly ?: true, + onCheckedChange = { wifiOnly -> + scope.launch { + settings.setWifiOnly(wifiOnly) + if (snap?.enabled == true) { + CameraUploadWorker.apply(context, wifiOnly) + } + } + }, + ) + } + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Include videos", Modifier.weight(1f)) + Switch( + checked = snap?.includeVideos ?: true, + onCheckedChange = { scope.launch { settings.setIncludeVideos(it) } }, + ) + } + snap?.lastSyncAt?.takeIf { it > 0 }?.let { + Text( + "Last backup: " + java.text.DateFormat.getDateTimeInstance( + java.text.DateFormat.SHORT, java.text.DateFormat.SHORT, + ).format(java.util.Date(it)), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + confirmButton = { + TextButton(onClick = { + scope.launch { + settings.setRemoteFolder(folderText) + CameraUploadWorker.syncNow(context, snap?.wifiOnly ?: true) + } + }) { Text("Sync now") } + }, + dismissButton = { + TextButton(onClick = { + scope.launch { settings.setRemoteFolder(folderText) } + onDismiss() + }) { Text("Done") } + }, + ) +} + +private fun mediaPermissions(): Array = + if (android.os.Build.VERSION.SDK_INT >= 33) { + arrayOf( + android.Manifest.permission.READ_MEDIA_IMAGES, + android.Manifest.permission.READ_MEDIA_VIDEO, + ) + } else { + arrayOf(android.Manifest.permission.READ_EXTERNAL_STORAGE) + } + private fun openFile(context: android.content.Context, local: java.io.File) { val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", local) val intent = Intent(Intent.ACTION_VIEW).apply { diff --git a/android/app/src/main/java/org/cloudreve/android/work/CameraUploadWorker.kt b/android/app/src/main/java/org/cloudreve/android/work/CameraUploadWorker.kt new file mode 100644 index 00000000..44ab6e28 --- /dev/null +++ b/android/app/src/main/java/org/cloudreve/android/work/CameraUploadWorker.kt @@ -0,0 +1,195 @@ +package org.cloudreve.android.work + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.ContentUris +import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build +import android.provider.MediaStore +import androidx.core.app.NotificationCompat +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.ForegroundInfo +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import org.cloudreve.android.CloudreveApp +import java.io.File +import java.io.FileOutputStream +import java.util.concurrent.TimeUnit + +/** + * Periodically scans MediaStore for photos/videos captured since the last + * sync and uploads them to the configured Cloudreve folder. A bounded set of + * synced/failed MediaStore IDs prevents re-uploads; the date-added watermark + * bounds each scan. Runs unmetered-only when the user chose Wi-Fi only. + */ +class CameraUploadWorker(context: Context, params: WorkerParameters) : + CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + val app = applicationContext as CloudreveApp + val settings = app.cameraUploadSettings + val cfg = settings.snapshotNow() + if (!cfg.enabled) return Result.success() + + createChannel() + + val synced = settings.syncedIdsNow().toMutableSet() + val failed = settings.failedIdsNow() + val lastTs = settings.lastSyncTsNow() + + val items = queryNewMedia(lastTs, cfg.includeVideos) + .filter { it.id !in synced && it.id !in failed } + .take(MAX_PER_RUN) + + items.forEachIndexed { i, item -> + setForeground(progressInfo("Backing up ${item.name}", i, items.size)) + try { + val tmp = stageToCache(item) + try { + app.fileRepository.uploadFile(tmp, cfg.remoteFolder) { _, _ -> } + settings.markSynced(item.id, item.dateAddedSec) + } finally { + tmp.delete() + } + } catch (_: Exception) { + // Permanently skipped: retried MediaStore rows that keep + // failing would otherwise block every later photo. + settings.markFailed(item.id) + } + } + return Result.success() + } + + private data class MediaItem( + val id: String, + val contentUri: android.net.Uri, + val name: String, + val dateAddedSec: Long, + ) + + private fun queryNewMedia(sinceSec: Long, includeVideos: Boolean): List { + val out = mutableListOf() + val collection = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL) + val projection = arrayOf( + MediaStore.Files.FileColumns._ID, + MediaStore.Files.FileColumns.DISPLAY_NAME, + MediaStore.Files.FileColumns.DATE_ADDED, + MediaStore.Files.FileColumns.MEDIA_TYPE, + ) + val mediaTypes = buildList { + add(MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) + if (includeVideos) add(MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO) + } + val selection = "${MediaStore.Files.FileColumns.DATE_ADDED} > ? AND " + + "${MediaStore.Files.FileColumns.MEDIA_TYPE} IN (${mediaTypes.joinToString(",") { "?" }})" + val args = (listOf(sinceSec) + mediaTypes).map { it.toString() }.toTypedArray() + val order = "${MediaStore.Files.FileColumns.DATE_ADDED} ASC" + + applicationContext.contentResolver.query( + collection, projection, selection, args, order, + )?.use { cursor -> + val idIdx = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID) + val nameIdx = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DISPLAY_NAME) + val dateIdx = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATE_ADDED) + while (cursor.moveToNext()) { + val id = cursor.getLong(idIdx) + out.add( + MediaItem( + id = id.toString(), + contentUri = ContentUris.withAppendedId(collection, id), + name = cursor.getString(nameIdx) ?: "media_$id", + dateAddedSec = cursor.getLong(dateIdx), + ) + ) + } + } + return out + } + + private fun stageToCache(item: MediaItem): File { + val dir = File(applicationContext.cacheDir, "camera_staging").apply { mkdirs() } + val tmp = File(dir, item.name) + applicationContext.contentResolver.openInputStream(item.contentUri)!!.use { input -> + FileOutputStream(tmp).use { input.copyTo(it) } + } + return tmp + } + + private fun progressInfo(text: String, index: Int, total: Int): ForegroundInfo { + val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_sys_upload) + .setContentTitle("Cloudreve camera backup") + .setContentText("$text (${index + 1}/$total)") + .setOngoing(true) + .build() + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo(NOTIF_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + ForegroundInfo(NOTIF_ID, notification) + } + } + + private fun createChannel() { + val manager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.createNotificationChannel( + NotificationChannel(CHANNEL_ID, "Camera backup", NotificationManager.IMPORTANCE_LOW) + ) + } + + companion object { + private const val WORK_NAME = "camera_upload_periodic" + private const val WORK_ONCE = "camera_upload_once" + private const val CHANNEL_ID = "camera_upload" + private const val NOTIF_ID = 42 + private const val MAX_PER_RUN = 100 + + private fun constraints(wifiOnly: Boolean): Constraints = + Constraints.Builder() + .setRequiredNetworkType( + if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED + ) + .setRequiresBatteryNotLow(true) + .build() + + /** Schedules (or removes) the periodic sync and kicks an immediate run. */ + fun apply(context: Context, wifiOnly: Boolean) { + val wm = WorkManager.getInstance(context) + val periodic = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) + .setConstraints(constraints(wifiOnly)) + .build() + wm.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, periodic) + wm.enqueueUniqueWork( + WORK_ONCE, + ExistingWorkPolicy.REPLACE, + OneTimeWorkRequestBuilder() + .setConstraints(constraints(wifiOnly)) + .build(), + ) + } + + fun cancel(context: Context) { + val wm = WorkManager.getInstance(context) + wm.cancelUniqueWork(WORK_NAME) + wm.cancelUniqueWork(WORK_ONCE) + } + + /** Manual "sync now" — the periodic schedule is untouched. */ + fun syncNow(context: Context, wifiOnly: Boolean) { + WorkManager.getInstance(context).enqueueUniqueWork( + WORK_ONCE, + ExistingWorkPolicy.REPLACE, + OneTimeWorkRequestBuilder() + .setConstraints(constraints(wifiOnly)) + .build(), + ) + } + } +}