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
parent
7531d80b45
commit
0e46854b31
@ -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
|
||||
}
|
||||
}
|
||||
@ -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…
Reference in new issue