feat(android): local sync folder — SAF tree mirrored to remote

Adds a periodic WorkManager worker that uploads new/changed files from
a user-picked document tree into a remote Cloudreve folder. Upload-only
mirror; unchanged files are skipped via a bounded <uri>|<mtime>|<size>
signature set, and remote subdirectories are created lazily as needed.

- SyncFolderSettings: DataStore prefs (enabled, persisted tree URI,
  remote folder, Wi-Fi-only, signature set)
- SyncFolderWorker: recursive DocumentFile walk, staged uploads through
  the existing upload-session flow, foreground progress notification
- Backup & sync dialog gains a folder-sync section: SAF picker with
  persisted read permission, remote folder, Wi-Fi toggle, sync-now

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
pull/3589/head
Tomas Dvorak 2 weeks ago
parent 7531d80b45
commit 0e46854b31

@ -241,7 +241,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 (done — periodic WorkManager MediaStore sync, Wi-Fi-only constraint, ID dedup, settings dialog), offline-favorite files (done — "Keep offline" downloads to filesDir/offline, DataStore registry, star dialog with open/refresh/remove), 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 (done — "Keep offline" downloads to filesDir/offline, DataStore registry, star dialog with open/refresh/remove), local sync folder (done — SAF tree → remote mirror, upload-only, mtime/size dedup)
- **System integration** (the "native, complete" ask): share-sheet target (done — SEND/SEND_MULTIPLE → UploadWorker), DocumentsProvider (done — Files-app browse/open/thumb/rename/delete/search), quick-share tile, notifications on share/task events (done — periodic /workflow poll → terminal-state notifications)
- **Auth**: OAuth flow (done — browser consent + PKCE + `cloudreve://mount` deep link); later passkey if backend exposes
- **WebDAV bridge**: `/dav` works as fallback file access until SDK matures

@ -47,8 +47,12 @@ CI runs `assembleDebug` on every PR.
- OAuth sign-in: "Sign in with browser" opens the server's consent
page with PKCE; the `/callback/desktop` page deep-links the code
back through `cloudreve://mount`, exchanged at `/session/oauth/token`
- Local sync folder: pick a device folder (SAF, persisted permission)
and a remote folder — periodic WorkManager worker mirrors new/changed
files upstream, subdirectory structure preserved, unchanged files
skipped via mtime/size signatures, Wi-Fi-only option
## Planned next
- Quick-share tile, local sync folder
- Quick-share tile
- No iOS. Ever.

@ -59,6 +59,7 @@ dependencies {
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.navigation:navigation-compose:2.8.5")
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("androidx.documentfile:documentfile:1.0.1")
implementation("androidx.work:work-runtime-ktx:2.10.0")
implementation("androidx.compose.ui:ui")

@ -8,6 +8,7 @@ import org.cloudreve.android.data.FavoritesStore
import org.cloudreve.android.data.FileRepository
import org.cloudreve.android.data.SessionManager
import org.cloudreve.android.data.TaskNotifySettings
import org.cloudreve.android.data.SyncFolderSettings
class CloudreveApp : Application() {
@ -25,6 +26,7 @@ class CloudreveApp : Application() {
lateinit var favoritesStore: FavoritesStore
private set
lateinit var taskNotifySettings: TaskNotifySettings
lateinit var syncFolderSettings: SyncFolderSettings
private set
override fun onCreate() {
@ -35,5 +37,6 @@ class CloudreveApp : Application() {
cameraUploadSettings = CameraUploadSettings(this)
favoritesStore = FavoritesStore(this)
taskNotifySettings = TaskNotifySettings(this)
syncFolderSettings = SyncFolderSettings(this)
}
}

@ -0,0 +1,91 @@
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.syncFolderStore by preferencesDataStore(name = "sync_folder")
/**
* Local sync folder preferences: a user-picked SAF tree mirrored
* (upload-only) into a remote Cloudreve folder by SyncFolderWorker.
* `synced` holds "<document uri>|<lastModified>|<size>" signatures so
* unchanged files are skipped; bounded like the camera-upload ID sets.
*/
class SyncFolderSettings(private val context: Context) {
private object Keys {
val enabled = booleanPreferencesKey("enabled")
val treeUri = stringPreferencesKey("tree_uri")
val remoteFolder = stringPreferencesKey("remote_folder")
val wifiOnly = booleanPreferencesKey("wifi_only")
val synced = stringSetPreferencesKey("synced")
val lastSyncAt = longPreferencesKey("last_sync_at")
}
data class Snapshot(
val enabled: Boolean = false,
val treeUri: String? = null,
val remoteFolder: String = DEFAULT_FOLDER,
val wifiOnly: Boolean = true,
val lastSyncAt: Long = 0L,
)
val snapshot: Flow<Snapshot> = context.syncFolderStore.data.map {
Snapshot(
enabled = it[Keys.enabled] ?: false,
treeUri = it[Keys.treeUri],
remoteFolder = it[Keys.remoteFolder] ?: DEFAULT_FOLDER,
wifiOnly = it[Keys.wifiOnly] ?: true,
lastSyncAt = it[Keys.lastSyncAt] ?: 0L,
)
}
suspend fun snapshotNow(): Snapshot = snapshot.first()
suspend fun setEnabled(enabled: Boolean) {
context.syncFolderStore.edit { it[Keys.enabled] = enabled }
}
suspend fun setTreeUri(uri: String?) {
context.syncFolderStore.edit { prefs ->
if (uri == null) prefs.remove(Keys.treeUri) else prefs[Keys.treeUri] = uri
}
}
suspend fun setRemoteFolder(folder: String) {
context.syncFolderStore.edit { it[Keys.remoteFolder] = folder.trim() }
}
suspend fun setWifiOnly(wifiOnly: Boolean) {
context.syncFolderStore.edit { it[Keys.wifiOnly] = wifiOnly }
}
suspend fun syncedNow(): Set<String> =
context.syncFolderStore.data.first()[Keys.synced] ?: emptySet()
suspend fun markSynced(signature: String) {
context.syncFolderStore.edit { prefs ->
val synced = (prefs[Keys.synced] ?: emptySet()).toMutableSet()
synced.add(signature)
prefs[Keys.synced] = synced.bounded()
prefs[Keys.lastSyncAt] = System.currentTimeMillis()
}
}
private fun Set<String>.bounded(): Set<String> =
if (size <= MAX_TRACKED) this else this.drop(size - MAX_TRACKED).toSet()
companion object {
const val DEFAULT_FOLDER = "${CrUri.MY_PREFIX}/Sync"
private const val MAX_TRACKED = 4000
}
}

@ -81,8 +81,10 @@ import org.cloudreve.android.CloudreveApp
import org.cloudreve.android.api.FileObject
import org.cloudreve.android.data.CameraUploadSettings
import org.cloudreve.android.data.FavoriteEntry
import org.cloudreve.android.data.SyncFolderSettings
import org.cloudreve.android.util.CrUri
import org.cloudreve.android.work.CameraUploadWorker
import org.cloudreve.android.work.SyncFolderWorker
import org.cloudreve.android.work.TaskPollWorker
import org.cloudreve.android.work.UploadWorker
import java.text.DecimalFormat
@ -212,7 +214,7 @@ fun FilesScreen(
)
}
IconButton(onClick = { cameraSettingsOpen = true }) {
Icon(Icons.Default.Settings, contentDescription = "Camera backup")
Icon(Icons.Default.Settings, contentDescription = "Backup & sync")
}
IconButton(onClick = { mkdirOpen = true }) {
Icon(Icons.Default.CreateNewFolder, contentDescription = "New folder")
@ -561,12 +563,33 @@ private fun CameraUploadDialog(onDismiss: () -> Unit) {
val app = context.applicationContext as CloudreveApp
val settings = app.cameraUploadSettings
val notifySettings = app.taskNotifySettings
val syncSettings = app.syncFolderSettings
val scope = rememberCoroutineScope()
val snap by settings.snapshot.collectAsState(initial = null)
val notifyEnabled by notifySettings.enabled.collectAsState(initial = false)
val syncSnap by syncSettings.snapshot.collectAsState(initial = null)
var folderText by remember(snap?.remoteFolder) {
mutableStateOf(snap?.remoteFolder ?: CameraUploadSettings.DEFAULT_FOLDER)
}
var syncFolderText by remember(syncSnap?.remoteFolder) {
mutableStateOf(syncSnap?.remoteFolder ?: SyncFolderSettings.DEFAULT_FOLDER)
}
val treeLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocumentTree()
) { uri ->
if (uri != null) {
context.contentResolver.takePersistableUriPermission(
uri, Intent.FLAG_GRANT_READ_URI_PERMISSION,
)
scope.launch {
syncSettings.setTreeUri(uri.toString())
syncSettings.setRemoteFolder(syncFolderText)
syncSettings.setEnabled(true)
SyncFolderWorker.apply(context, syncSnap?.wifiOnly ?: true)
}
}
}
val notifPermLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
@ -593,7 +616,7 @@ private fun CameraUploadDialog(onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Camera backup") },
title = { Text("Backup & sync") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
@ -685,19 +708,86 @@ private fun CameraUploadDialog(onDismiss: () -> Unit) {
},
)
}
HorizontalDivider()
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text("Mirror a local folder", Modifier.weight(1f))
Switch(
checked = syncSnap?.enabled == true && syncSnap?.treeUri != null,
onCheckedChange = { want ->
if (want) {
treeLauncher.launch(null)
} else {
scope.launch {
syncSettings.setEnabled(false)
SyncFolderWorker.cancel(context)
}
}
},
)
}
syncSnap?.treeUri?.let {
Text(
"Local: " + android.net.Uri.parse(it).lastPathSegment.orEmpty(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
OutlinedTextField(
value = syncFolderText,
onValueChange = { syncFolderText = it },
label = { Text("Remote folder (sync)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text("Sync Wi-Fi only", Modifier.weight(1f))
Switch(
checked = syncSnap?.wifiOnly ?: true,
onCheckedChange = { wifiOnly ->
scope.launch {
syncSettings.setWifiOnly(wifiOnly)
if (syncSnap?.enabled == true) {
SyncFolderWorker.apply(context, wifiOnly)
}
}
},
)
}
syncSnap?.lastSyncAt?.takeIf { it > 0 }?.let {
Text(
"Last folder sync: " + 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)
syncSettings.setRemoteFolder(syncFolderText)
CameraUploadWorker.syncNow(context, snap?.wifiOnly ?: true)
if (syncSnap?.enabled == true) {
SyncFolderWorker.syncNow(context, syncSnap?.wifiOnly ?: true)
}
}
}) { Text("Sync now") }
},
dismissButton = {
TextButton(onClick = {
scope.launch { settings.setRemoteFolder(folderText) }
scope.launch {
settings.setRemoteFolder(folderText)
syncSettings.setRemoteFolder(syncFolderText)
}
onDismiss()
}) { Text("Done") }
},

@ -0,0 +1,203 @@
package org.cloudreve.android.work
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.pm.ServiceInfo
import android.net.Uri
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.documentfile.provider.DocumentFile
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 org.cloudreve.android.util.CrUri
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.TimeUnit
/**
* Periodically mirrors a user-picked SAF directory tree into a remote
* Cloudreve folder (upload-only — remote files are never deleted).
* Files unchanged since their last upload are skipped via a
* "<uri>|<mtime>|<size>" signature set kept in SyncFolderSettings.
* Subdirectory structure is mirrored; remote folders are created lazily
* with mkdir errors swallowed (already-exists is expected).
*/
class SyncFolderWorker(context: Context, params: WorkerParameters) :
CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val app = applicationContext as CloudreveApp
val settings = app.syncFolderSettings
val cfg = settings.snapshotNow()
val treeUri = cfg.treeUri?.let { Uri.parse(it) }
if (!cfg.enabled || treeUri == null) return Result.success()
val root = DocumentFile.fromTreeUri(applicationContext, treeUri)
?: return Result.failure()
createChannel()
val synced = settings.syncedNow()
val ensuredDirs = mutableSetOf<String>()
var uploaded = 0
walk(root, "").forEach { entry ->
if (uploaded >= MAX_PER_RUN) return@forEach
if (entry.signature in synced) return@forEach
setForeground(progressInfo(entry.name, uploaded))
try {
ensureRemoteDirs(app, cfg.remoteFolder, entry.relativeDir, ensuredDirs)
val remoteParent = CrUri.join(cfg.remoteFolder, entry.relativeDir)
val tmp = stageToCache(entry)
try {
app.fileRepository.uploadFile(tmp, remoteParent) { _, _ -> }
settings.markSynced(entry.signature)
uploaded++
} finally {
tmp.delete()
}
} catch (_: Exception) {
// Skipped for this run — no failure set here, so a transient
// failure simply retries on the next pass.
}
}
return Result.success()
}
private data class Entry(
val doc: DocumentFile,
val name: String,
val relativeDir: String,
val signature: String,
)
/** Flattens the tree into uploadable file entries with relative dirs. */
private fun walk(root: DocumentFile, prefix: String): List<Entry> {
val out = mutableListOf<Entry>()
val stack = ArrayDeque<Pair<DocumentFile, String>>()
stack.add(root to prefix)
while (stack.isNotEmpty()) {
val (dir, rel) = stack.removeLast()
dir.listFiles().forEach { child ->
val name = child.name ?: return@forEach
when {
child.isDirectory -> stack.add(child to if (rel.isEmpty()) name else "$rel/$name")
child.isFile -> out.add(
Entry(
doc = child,
name = name,
relativeDir = rel,
signature = "${child.uri}|${child.lastModified()}|${child.length()}",
)
)
}
}
}
return out
}
private suspend fun ensureRemoteDirs(
app: CloudreveApp,
base: String,
relativeDir: String,
ensured: MutableSet<String>,
) {
if (relativeDir.isEmpty()) return
var current = base
relativeDir.split('/').forEach { segment ->
current = CrUri.join(current, segment)
if (ensured.add(current)) {
runCatching { app.fileRepository.mkdir(current) }
}
}
}
private fun stageToCache(entry: Entry): File {
val dir = File(applicationContext.cacheDir, "sync_staging").apply { mkdirs() }
val tmp = File(dir, "sync-${entry.signature.hashCode()}-${entry.name}")
applicationContext.contentResolver.openInputStream(entry.doc.uri)!!.use { input ->
FileOutputStream(tmp).use { input.copyTo(it) }
}
return tmp
}
private fun progressInfo(text: String, done: Int): ForegroundInfo {
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_sys_upload)
.setContentTitle("Cloudreve folder sync")
.setContentText(if (done == 0) text else "$text ($done uploaded)")
.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, "Folder sync", NotificationManager.IMPORTANCE_LOW)
)
}
companion object {
private const val WORK_NAME = "sync_folder_periodic"
private const val WORK_ONCE = "sync_folder_once"
private const val CHANNEL_ID = "sync_folder"
private const val NOTIF_ID = 43
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()
fun apply(context: Context, wifiOnly: Boolean) {
val wm = WorkManager.getInstance(context)
val periodic = PeriodicWorkRequestBuilder<SyncFolderWorker>(15, TimeUnit.MINUTES)
.setConstraints(constraints(wifiOnly))
.build()
wm.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, periodic)
wm.enqueueUniqueWork(
WORK_ONCE,
ExistingWorkPolicy.REPLACE,
OneTimeWorkRequestBuilder<SyncFolderWorker>()
.setConstraints(constraints(wifiOnly))
.build(),
)
}
fun cancel(context: Context) {
val wm = WorkManager.getInstance(context)
wm.cancelUniqueWork(WORK_NAME)
wm.cancelUniqueWork(WORK_ONCE)
}
fun syncNow(context: Context, wifiOnly: Boolean) {
WorkManager.getInstance(context).enqueueUniqueWork(
WORK_ONCE,
ExistingWorkPolicy.REPLACE,
OneTimeWorkRequestBuilder<SyncFolderWorker>()
.setConstraints(constraints(wifiOnly))
.build(),
)
}
}
}
Loading…
Cancel
Save