diff --git a/ROADMAP.md b/ROADMAP.md index 9c21c1ff..8a777585 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/android/README.md b/android/README.md index 84579152..7de31f71 100644 --- a/android/README.md +++ b/android/README.md @@ -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. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index a32116da..1f6cada7 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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") diff --git a/android/app/src/main/java/org/cloudreve/android/CloudreveApp.kt b/android/app/src/main/java/org/cloudreve/android/CloudreveApp.kt index 69f68ea0..0d8b1b73 100644 --- a/android/app/src/main/java/org/cloudreve/android/CloudreveApp.kt +++ b/android/app/src/main/java/org/cloudreve/android/CloudreveApp.kt @@ -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) } } diff --git a/android/app/src/main/java/org/cloudreve/android/data/SyncFolderSettings.kt b/android/app/src/main/java/org/cloudreve/android/data/SyncFolderSettings.kt new file mode 100644 index 00000000..c630182d --- /dev/null +++ b/android/app/src/main/java/org/cloudreve/android/data/SyncFolderSettings.kt @@ -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 "||" 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 = 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 = + 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.bounded(): Set = + 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 + } +} 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 ce0c5442..3e2d09d7 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 @@ -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") } }, diff --git a/android/app/src/main/java/org/cloudreve/android/work/SyncFolderWorker.kt b/android/app/src/main/java/org/cloudreve/android/work/SyncFolderWorker.kt new file mode 100644 index 00000000..6c8cf0cf --- /dev/null +++ b/android/app/src/main/java/org/cloudreve/android/work/SyncFolderWorker.kt @@ -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 + * "||" 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() + 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 { + val out = mutableListOf() + val stack = ArrayDeque>() + 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, + ) { + 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(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) + } + + fun syncNow(context: Context, wifiOnly: Boolean) { + WorkManager.getInstance(context).enqueueUniqueWork( + WORK_ONCE, + ExistingWorkPolicy.REPLACE, + OneTimeWorkRequestBuilder() + .setConstraints(constraints(wifiOnly)) + .build(), + ) + } + } +}