Merge pull request #217 from Dvorinka/feat/android-local-sync

feat(android): local sync folder — SAF tree mirrored to remote
pull/3589/head
Tomáš Dvořák 2 weeks ago committed by GitHub
commit ae8d8566b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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. 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 - **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) - **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 - **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 - **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 - OAuth sign-in: "Sign in with browser" opens the server's consent
page with PKCE; the `/callback/desktop` page deep-links the code page with PKCE; the `/callback/desktop` page deep-links the code
back through `cloudreve://mount`, exchanged at `/session/oauth/token` 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 ## Planned next
- Quick-share tile, local sync folder - Quick-share tile
- No iOS. Ever. - No iOS. Ever.

@ -59,6 +59,7 @@ dependencies {
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.navigation:navigation-compose:2.8.5") implementation("androidx.navigation:navigation-compose:2.8.5")
implementation("androidx.datastore:datastore-preferences:1.1.1") 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.work:work-runtime-ktx:2.10.0")
implementation("androidx.compose.ui:ui") 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.FileRepository
import org.cloudreve.android.data.SessionManager import org.cloudreve.android.data.SessionManager
import org.cloudreve.android.data.TaskNotifySettings import org.cloudreve.android.data.TaskNotifySettings
import org.cloudreve.android.data.SyncFolderSettings
class CloudreveApp : Application() { class CloudreveApp : Application() {
@ -25,6 +26,7 @@ class CloudreveApp : Application() {
lateinit var favoritesStore: FavoritesStore lateinit var favoritesStore: FavoritesStore
private set private set
lateinit var taskNotifySettings: TaskNotifySettings lateinit var taskNotifySettings: TaskNotifySettings
lateinit var syncFolderSettings: SyncFolderSettings
private set private set
override fun onCreate() { override fun onCreate() {
@ -35,5 +37,6 @@ class CloudreveApp : Application() {
cameraUploadSettings = CameraUploadSettings(this) cameraUploadSettings = CameraUploadSettings(this)
favoritesStore = FavoritesStore(this) favoritesStore = FavoritesStore(this)
taskNotifySettings = TaskNotifySettings(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.api.FileObject
import org.cloudreve.android.data.CameraUploadSettings import org.cloudreve.android.data.CameraUploadSettings
import org.cloudreve.android.data.FavoriteEntry import org.cloudreve.android.data.FavoriteEntry
import org.cloudreve.android.data.SyncFolderSettings
import org.cloudreve.android.util.CrUri import org.cloudreve.android.util.CrUri
import org.cloudreve.android.work.CameraUploadWorker import org.cloudreve.android.work.CameraUploadWorker
import org.cloudreve.android.work.SyncFolderWorker
import org.cloudreve.android.work.TaskPollWorker import org.cloudreve.android.work.TaskPollWorker
import org.cloudreve.android.work.UploadWorker import org.cloudreve.android.work.UploadWorker
import java.text.DecimalFormat import java.text.DecimalFormat
@ -212,7 +214,7 @@ fun FilesScreen(
) )
} }
IconButton(onClick = { cameraSettingsOpen = true }) { IconButton(onClick = { cameraSettingsOpen = true }) {
Icon(Icons.Default.Settings, contentDescription = "Camera backup") Icon(Icons.Default.Settings, contentDescription = "Backup & sync")
} }
IconButton(onClick = { mkdirOpen = true }) { IconButton(onClick = { mkdirOpen = true }) {
Icon(Icons.Default.CreateNewFolder, contentDescription = "New folder") Icon(Icons.Default.CreateNewFolder, contentDescription = "New folder")
@ -561,12 +563,33 @@ private fun CameraUploadDialog(onDismiss: () -> Unit) {
val app = context.applicationContext as CloudreveApp val app = context.applicationContext as CloudreveApp
val settings = app.cameraUploadSettings val settings = app.cameraUploadSettings
val notifySettings = app.taskNotifySettings val notifySettings = app.taskNotifySettings
val syncSettings = app.syncFolderSettings
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val snap by settings.snapshot.collectAsState(initial = null) val snap by settings.snapshot.collectAsState(initial = null)
val notifyEnabled by notifySettings.enabled.collectAsState(initial = false) val notifyEnabled by notifySettings.enabled.collectAsState(initial = false)
val syncSnap by syncSettings.snapshot.collectAsState(initial = null)
var folderText by remember(snap?.remoteFolder) { var folderText by remember(snap?.remoteFolder) {
mutableStateOf(snap?.remoteFolder ?: CameraUploadSettings.DEFAULT_FOLDER) 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( val notifPermLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission() ActivityResultContracts.RequestPermission()
@ -593,7 +616,7 @@ private fun CameraUploadDialog(onDismiss: () -> Unit) {
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("Camera backup") }, title = { Text("Backup & sync") },
text = { text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row( 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 = { confirmButton = {
TextButton(onClick = { TextButton(onClick = {
scope.launch { scope.launch {
settings.setRemoteFolder(folderText) settings.setRemoteFolder(folderText)
syncSettings.setRemoteFolder(syncFolderText)
CameraUploadWorker.syncNow(context, snap?.wifiOnly ?: true) CameraUploadWorker.syncNow(context, snap?.wifiOnly ?: true)
if (syncSnap?.enabled == true) {
SyncFolderWorker.syncNow(context, syncSnap?.wifiOnly ?: true)
}
} }
}) { Text("Sync now") } }) { Text("Sync now") }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { TextButton(onClick = {
scope.launch { settings.setRemoteFolder(folderText) } scope.launch {
settings.setRemoteFolder(folderText)
syncSettings.setRemoteFolder(syncFolderText)
}
onDismiss() onDismiss()
}) { Text("Done") } }) { 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