From faaec5365f37b1e2354b293db08941e2b602f921 Mon Sep 17 00:00:00 2001 From: ayushya Date: Sat, 19 Sep 2026 00:52:56 -0400 Subject: [PATCH] Add device sync: mirror folders/files locally, background WorkManager engine - New native sync engine (SyncEngine/SyncWorker/ConflictResolveWorker, WorkManager-based) that mirrors selected Nextcloud folders or files to app-private local storage, periodically and on-demand ("Sync now"), with never-auto-resolved conflict notifications. - Single files (not just folders) can now be marked "Sync to device". - Live sync status (syncing/synced/conflicts) pushes from native to Dart over a new EventChannel; the persistent header chip/panel now reflects device-sync status instead of the WebDAV-refresh loading state, with a cloud_off/cloud_sync/cloud_done/cloud_alert icon set and an expandable conflicts list with in-app "Keep local"/"Use server" resolution. Per-item cloud_done/sync badges show on Files tiles. - Share/Download short-circuit to the local copy for already-synced files instead of a fresh network fetch. - Settings gained a Device Sync section (per-folder list, "Sync everything", Wi-Fi-only toggle, manual "Sync now"). - Fixed two release-only bugs found via on-device testing: Android's HttpURLConnection silently rejects the PROPFIND method (switched to OkHttp), and R8 was stripping WorkManager's reflection-instantiated internals (broadened proguard-rules.pro to keep androidx.work.** wholesale rather than chasing individual classes). Co-Authored-By: Claude Sonnet 5 --- .claude/context/architecture.md | 8 +- .claude/context/server.md | 157 ++++++- .claude/context/styling.md | 12 +- android/app/build.gradle.kts | 12 + android/app/proguard-rules.pro | 18 + android/app/src/main/AndroidManifest.xml | 7 + .../dev/ayushya/noo/ConflictResolveWorker.kt | 101 +++++ .../kotlin/dev/ayushya/noo/MainActivity.kt | 160 +++++++ .../dev/ayushya/noo/SyncConflictReceiver.kt | 49 +++ .../main/kotlin/dev/ayushya/noo/SyncEngine.kt | 399 ++++++++++++++++++ .../kotlin/dev/ayushya/noo/SyncStatusBus.kt | 80 ++++ .../main/kotlin/dev/ayushya/noo/SyncWorker.kt | 272 ++++++++++++ lib/models/sync_status.dart | 38 ++ lib/providers/server_provider.dart | 180 ++++++++ lib/services/account_store.dart | 2 + lib/services/sync_service.dart | 144 +++++++ lib/views/account_view.dart | 126 ++++++ lib/views/files_view.dart | 110 ++++- lib/widgets/share_sheet.dart | 14 +- lib/widgets/sync_status_badge.dart | 34 ++ lib/widgets/synced_header_scaffold.dart | 142 ++++++- 21 files changed, 2023 insertions(+), 42 deletions(-) create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/main/kotlin/dev/ayushya/noo/ConflictResolveWorker.kt create mode 100644 android/app/src/main/kotlin/dev/ayushya/noo/SyncConflictReceiver.kt create mode 100644 android/app/src/main/kotlin/dev/ayushya/noo/SyncEngine.kt create mode 100644 android/app/src/main/kotlin/dev/ayushya/noo/SyncStatusBus.kt create mode 100644 android/app/src/main/kotlin/dev/ayushya/noo/SyncWorker.kt create mode 100644 lib/models/sync_status.dart create mode 100644 lib/services/sync_service.dart create mode 100644 lib/widgets/sync_status_badge.dart diff --git a/.claude/context/architecture.md b/.claude/context/architecture.md index 057f6f0..d956d0f 100644 --- a/.claude/context/architecture.md +++ b/.claude/context/architecture.md @@ -54,10 +54,10 @@ There is exactly one `ChangeNotifier`: [`ServerProvider`](../../lib/providers/se - UI settings that persist across launches — split into **global** (theme mode, seed color, dynamic-color toggle, bottom-bar opacity/blur, tap-tab-to-scroll-top, seek bar style, tab order/visibility/default, swipe - actions, login lock — see below) and **per-account** (grid/list view, - favorites-only, show-hidden, storage scope, Photos sort, Files' - per-folder sort map, cache policy) — see `server.md` for exactly which is - which and why + actions, login lock, sync-on-cellular — see below) and **per-account** + (grid/list view, favorites-only, show-hidden, storage scope, Photos sort, + Files' per-folder sort map, cache policy, synced folders) — see + `server.md` for exactly which is which and why - **Login lock** (`loginLockEnabled`/`lockAccountSwitching`/ `lockHiddenFiles`/`needsUnlock`): an app-wide PIN/biometric gate via `AppLockService` (a thin wrapper over `local_auth` — this app never diff --git a/.claude/context/server.md b/.claude/context/server.md index 07ecec7..44a88aa 100644 --- a/.claude/context/server.md +++ b/.claude/context/server.md @@ -157,12 +157,12 @@ rather than needing a rewrite for multi-account support. (`views/move_copy_destination_picker.dart`) is covered in `architecture.md`, including why it can't reuse the Files tab's shared navigation state the way `ShareUploadView` does. -- **Uploads** (`uploadFileFromPath(folderPath, fileName, localFilePath, - {onProgress})`) stream the local file via `Dio().put()` with an explicit - `Content-Length` and `onSendProgress`, mirroring the download path. The - `ServerProvider` wrapper always uploads into `_currentFolderPath` — the - share-to-upload flow (`ShareUploadView`) gets a caller-chosen destination - by navigating there first (`navigateToAbsoluteFolder`), then uploading. +- **Uploads**: there's no more in-app-only upload path - `NextcloudService`/ + `ServerProvider.uploadFileFromPath` were removed once the Files tab's "+" + → "Upload File" was unified with the share-to-upload flow (below). Every + upload, however it's triggered, now goes through `ShareUploadView` + (caller-chosen destination via `navigateToAbsoluteFolder`, then + `UploadService`/`ShareUploadService.kt`). - **Receiving a shared file from another app**: hand-rolled in `MainActivity.kt` (Android `ACTION_SEND`/`ACTION_SEND_MULTIPLE`, `android:launchMode` `singleTask` in the manifest so a second share while @@ -246,6 +246,151 @@ Google Drive/Instagram's "choose a file" flow), the reverse direction of backing out (system back while picking, or a picked-item mismatch) with `RESULT_CANCELED` instead. +## Device sync + +Mirrors selected folders to app-private local storage +(`getExternalFilesDir(null)/sync//...` - wiped on uninstall, no +extra storage permission needed) and keeps them updated in the background, +even with the app fully closed. Same rationale as the upload/download +services for going native instead of a Dart background-task plugin (see +above): a periodic job has to run without the Flutter engine loaded, and a +notification action has to resolve without launching the UI. Rather than +add `workmanager` (whose Dart `callbackDispatcher` spins up a second, +minimal Flutter engine that has to re-register every plugin it touches), +the whole engine is plain Kotlin using Android's WorkManager directly. + +- [`SyncEngine.kt`](../../android/app/src/main/kotlin/dev/ayushya/noo/SyncEngine.kt) + holds the shared primitives, reused by both workers below: a Depth-1 (or + Depth-0, for refreshing one item) PROPFIND (`propfindChildren`/ + `propfindSelf`) requesting `d:getetag` alongside the usual props - unlike + every PROPFIND in `nextcloud_service.dart`, which never requests it + (`etag` is a dead field on `NextcloudItem` today); plain + `HttpURLConnection` GET/PUT (`downloadFile`/`uploadFile`, same style as + `DownloadService.kt`/`ShareUploadService.kt`); and `diffFolder`, which + compares one folder's freshly-walked manifest against the persisted + per-account sync-state map (native `SharedPreferences`, JSON keyed by + `oc:fileid` - stable across renames/moves, unlike `path`) to decide, per + file: download (new, or server `etag` changed), upload (local file's + mtime/size changed and the server didn't), delete locally (missing + server-side, unchanged locally), respect a local deletion (file's gone + and the server didn't change either - don't recreate it), or flag a + **conflict** (both changed since the last recorded state). +- [`SyncWorker.kt`](../../android/app/src/main/kotlin/dev/ayushya/noo/SyncWorker.kt) + (`CoroutineWorker`) is both the periodic job and the one-off "Sync now": + for each configured path, `propfindSelf`s it first to check whether it's + a file or a folder - a folder gets the full recursive + `walkRemoteTree`, a file is diffed directly as a one-item list (nothing + else about `diffFolder`/download/upload/delete cares whether its entries + came from a walk or a single lookup, so single-file sync needed no engine + changes, just this one branch) - applies `diffFolder`'s decisions, then + posts a summary notification (files updated/uploaded/removed) and, for + any conflicts, one notification per file with two actions. +- **Live status reaches Dart via a push channel, not polling** - + [`SyncStatusBus.kt`](../../android/app/src/main/kotlin/dev/ayushya/noo/SyncStatusBus.kt) + is a plain in-process pub/sub (no IPC needed - the workers and + `MainActivity` share one process) that `SyncWorker`/`ConflictResolveWorker` + publish into (syncing started/stopped, which `fileId`s are mid-transfer + right now, new/resolved conflicts) and `MainActivity.kt`'s + `dev.ayushya.noo/sync_service/status` `EventChannel` forwards to Dart, + same pattern as the share/pick-intent channels. It deliberately does + *not* track "which files are already synced" itself - that's read fresh + from `SyncEngine`'s durable per-account state map + (`SyncEngine.loadState(accountId).keys`) each time a snapshot is built, + so there's one source of truth for "synced" instead of two that could + drift. `SyncService.getStatus()` (one-shot, seeds `ServerProvider` right + after login/account-switch) and `SyncService.statusStream` (live) both + return the same snapshot shape. `ServerProvider.syncHeaderStatus` + (off/syncing/done/alert - drives `SyncedHeaderScaffold`'s persistent + chip/panel, replacing what used to be the WebDAV-refresh-loading + indicator there) and `syncStatusFor(item)` (none/syncing/synced/conflict + - drives the small corner badge on Files' tiles, `SyncStatusBadge`) are + both computed from this state, not fetched per-item. +- **In-app conflict resolution reuses the exact same enqueue path as the + notification actions** - `ConflictResolveWorker.enqueue(...)` is a + shared companion function; `SyncConflictReceiver` (the notification + action) and `MainActivity.kt`'s `resolveConflict` MethodChannel method + (the sync header's "Keep local"/"Use server" buttons, + `ServerProvider.resolveSyncConflict`) both just call it, so there's one + resolution code path regardless of which surface triggered it. +- **Conflicts are never auto-resolved.** The notification's "Keep local"/ + "Use server" actions are `PendingIntent.getBroadcast`s (same shape as the + Cancel action on upload/download notifications, just broadcast instead of + service-targeted) to + [`SyncConflictReceiver`](../../android/app/src/main/kotlin/dev/ayushya/noo/SyncConflictReceiver.kt) + - a manifest-registered `BroadcastReceiver` (works even with the app + process dead) that can't itself block on network, so it just dismisses + the notification and enqueues a one-shot + [`ConflictResolveWorker`](../../android/app/src/main/kotlin/dev/ayushya/noo/ConflictResolveWorker.kt) + to actually push the local copy up or pull the server copy down and + refresh that file's recorded state. +- `MainActivity.kt`'s `dev.ayushya.noo/sync_service` channel + (`reschedule`/`cancel`/`syncNow`) is the only bridge from Dart: a periodic + `WorkRequest`'s input `Data` and `Constraints` are fixed at enqueue time, + so changing the synced-folder list, the active account, or the Wi-Fi-only + setting means cancelling and re-enqueueing, not updating in place. + [`SyncService`](../../lib/services/sync_service.dart) (Dart) wraps this - + `ServerProvider` calls `reschedule` after every successful login/account + switch and every synced-folder/`syncOnCellular` change, and `cancel` on + logout/last-account-removed. The network constraint is + `NetworkType.UNMETERED` by default (`!syncOnCellular`, Wi-Fi only) or + `NetworkType.CONNECTED` if the user's opted into cellular sync. +- Synced-path list (`ServerProvider.syncedPaths` - files or folders, not + just folders despite the name of the underlying pref/native `Data` key, + which stayed `ui_synced_folders`/`folders` to avoid a storage-key + migration for a rename) follows the standard per-account-pref pattern + (JSON-encoded string list, in `AccountStore.perAccountPrefKeys`); so does + `syncEverything` (`ui_sync_everything`, per account) - when on, + `SyncService` sends `['/']` as the path list instead of `syncedPaths`, + mirroring the whole account rather than requiring per-item opt-in. + `syncOnCellular` is a plain global pref. All three are managed from + Settings → Device Sync (a "Sync everything" switch, the path list with + remove buttons - hidden while "Sync everything" is on - the cellular + toggle, and a manual "Sync now"); individual files or folders are + additionally toggled from Files' selection toolbar ("Sync to device", + single-selection, either item type). +- **`android/app/proguard-rules.pro` exists specifically for this feature, + and keeps `androidx.work.**` wholesale rather than naming individual + classes.** Flutter's own Gradle plugin auto-enables R8 minification for + release builds (`FlutterPlugin.kt` sets `isMinifyEnabled = true` + unconditionally for the `release` build type, and auto-wires this exact + file if it exists - nothing in this project's own `build.gradle.kts` + opts into it), which broke device sync on real-device testing **twice** + in a row: first `WorkDatabase` (WorkManager locates its bundled Room + database by reflecting off the abstract database class's own, + possibly-renamed, name), then - after narrowly keeping just that class - + `OverwritingInputMerger` (WorkManager's default input merger, also + reflection-instantiated) broke the exact same way and silently ate every + `enqueueUniqueWork` call, including "Sync now", with zero indication + beyond a `WM-InputMerger` `NoSuchMethodException` in logcat - no crash, + no Dart-visible error, just a folder that stayed empty. R8's member-level + shrinking strips whatever a class's *reflection-only* callers don't + reference directly, even when the class itself survives a plain `-keep + class` with no wildcard, and WorkManager reflects into more of its own + internals than any one test pass is likely to exercise - hence the + wholesale keep instead of chasing individual classes one crash at a + time. If adding another native background component reached only via + reflection (not a manifest-declared component, which AGP already keeps + automatically), don't assume default AndroidX consumer rules cover it - + verify on an actual release build, not just `flutter analyze`/a debug + build, since minification only applies to release. +- **Files land under `Android/data//files/sync/...` + (`getExternalFilesDir`), which no third-party file manager can browse + without root** - Android's scoped storage sandboxes that whole directory + tree from other apps by design, same as any app-private storage. This + surprised real-device testing (a file manager app logged "Can't read + directory ... trying su" and came up empty even though the sync had + actually worked) - it's expected, not a bug. Confirm synced files + landed via `adb shell run-as`/a rooted shell, not a regular file + manager UI. +- **Already-synced files skip the network** in two places: + `ServerProvider.localSyncedFilePath(item)` is a pure function of the + remote path (mirrors `SyncEngine.kt`'s `syncRoot` layout exactly, so Dart + never needs to read the native sync-state `SharedPreferences`) that + returns the local mirror path if it exists on disk. `ShareSheet`'s "Share + file directly" and `FilesView._downloadSelected` (only when *every* + selected file is already synced - a mixed selection still goes through + the normal `DownloadService` batch) both check it first. + ## Multi-account storage & session persistence [`AccountStore`](../../lib/services/account_store.dart) owns everything diff --git a/.claude/context/styling.md b/.claude/context/styling.md index f9e14c7..05db049 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -63,7 +63,17 @@ widgets. Key points: - [`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) — the pull-to-sync `CustomScrollView` header shared by 5 of the 6 tabs (see `architecture.md`); also where the pull-to-refresh gesture thresholds and - the classic Material refresh spinner live. + the classic Material refresh spinner live. Its persistent chip/panel + (icon + "Sync off"/"Syncing…"/"Synced"/"Sync issue") reflects device-sync + status (`ServerProvider.syncHeaderStatus`), not the WebDAV-refresh + loading state the pull gesture itself triggers - that has its own, + separate floating spinner bubble, so nothing was lost by handing the + persistent text/icon over. +- [`SyncStatusBadge`](../../lib/widgets/sync_status_badge.dart) — the small + corner badge over a thumbnail showing per-item device-sync status + (`cloud_done`/`sync`, nothing for not-synced/conflict); used in Files' + list and grid tiles today. Reuse this rather than a new ad hoc badge if + another view starts showing sync status per item. - [`SeekBarPainter`/`SeekBarPreview`](../../lib/widgets/seek_bar_painter.dart) — the four `MediaProgressBarStyle` presets (Default/Wavy/Slim/Squiggly) for the video player's seek bar, plus a perpetually-animated diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index cee381b..8d31ce1 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -84,6 +84,18 @@ kotlin { } } +dependencies { + // Device sync's periodic/one-off background jobs (SyncWorker, + // ConflictResolveWorker) - see their doc comments for why this is + // plain WorkManager rather than a Dart-side background-task plugin. + implementation("androidx.work:work-runtime-ktx:2.9.1") + // PROPFIND (WebDAV directory listing) - Android's HttpURLConnection + // hard-rejects any method outside {OPTIONS,GET,HEAD,POST,PUT,DELETE, + // TRACE,PATCH} (ProtocolException), unlike plain OpenJDK. OkHttp has + // no such whitelist. See SyncEngine.kt. + implementation("com.squareup.okhttp3:okhttp:4.12.0") +} + flutter { source = "../.." } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..563ef41 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,18 @@ +# Device sync (SyncWorker/ConflictResolveWorker) - WorkManager instantiates +# a lot of its own internals via reflection (Workers by class name recorded +# at enqueue time, its bundled Room database's *_Impl class off the +# abstract database class's own name, its default InputMerger, etc). R8's +# member-level shrinking silently breaks any of these - e.g. stripping an +# "unused" no-arg constructor - even when the class itself survives a +# plain `-keep class` with no wildcard, and narrowly keeping only the +# classes hit by one test pass just means the next reflection path (a +# different WorkManager-internal class) breaks instead. Two separate +# instances of this already bit real testing (WorkDatabase, then +# OverwritingInputMerger) before landing on this broad keep - see +# android/app/build.gradle.kts (androidx.work dependency) and +# .claude/context/server.md's "Device sync" section. +-keep class androidx.work.** { *; } +-keep class * extends androidx.room.RoomDatabase { *; } +-keep class dev.ayushya.noo.SyncWorker { *; } +-keep class dev.ayushya.noo.ConflictResolveWorker { *; } +-keep class dev.ayushya.noo.SyncConflictReceiver { *; } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 9d781c6..ed692ca 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -76,6 +76,13 @@ android:name=".DownloadService" android:exported="false" android:foregroundServiceType="dataSync"/> + + diff --git a/android/app/src/main/kotlin/dev/ayushya/noo/ConflictResolveWorker.kt b/android/app/src/main/kotlin/dev/ayushya/noo/ConflictResolveWorker.kt new file mode 100644 index 0000000..95d4ac4 --- /dev/null +++ b/android/app/src/main/kotlin/dev/ayushya/noo/ConflictResolveWorker.kt @@ -0,0 +1,101 @@ +package dev.ayushya.noo + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File + +/** + * Resolves one sync conflict, triggered either by [SyncConflictReceiver] + * (a notification action) or directly from Dart (in-app resolution via the + * `dev.ayushya.noo/sync_service` MethodChannel's `resolveConflict`, see + * `MainActivity.kt`) - "local" pushes the on-device copy up (overwriting + * the server), "server" pulls the server copy down (overwriting the local + * mirror). Either way, the sync-state entry for this file is refreshed + * afterward so the next [SyncWorker] pass doesn't immediately re-flag it. + */ +class ConflictResolveWorker(appContext: Context, params: WorkerParameters) : + CoroutineWorker(appContext, params) { + + companion object { + const val KEY_ACCOUNT_ID = "accountId" + const val KEY_SERVER_URL = "serverUrl" + const val KEY_USERNAME = "username" + const val KEY_AUTH_HEADER = "authHeader" + const val KEY_FILE_ID = "fileId" + const val KEY_REMOTE_PATH = "remotePath" + const val KEY_REL_PATH = "relPath" + const val KEY_RESOLUTION = "resolution" + + /** The one place a resolution gets enqueued - both callers above share it. */ + fun enqueue( + context: Context, + accountId: String?, + serverUrl: String?, + username: String?, + authHeader: String?, + fileId: String?, + remotePath: String?, + relPath: String?, + resolution: String?, + ) { + val data = workDataOf( + KEY_ACCOUNT_ID to accountId, + KEY_SERVER_URL to serverUrl, + KEY_USERNAME to username, + KEY_AUTH_HEADER to authHeader, + KEY_FILE_ID to fileId, + KEY_REMOTE_PATH to remotePath, + KEY_REL_PATH to relPath, + KEY_RESOLUTION to resolution, + ) + val request = OneTimeWorkRequestBuilder() + .setInputData(data) + .build() + WorkManager.getInstance(context).enqueue(request) + } + } + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + val accountId = inputData.getString(KEY_ACCOUNT_ID) ?: return@withContext Result.failure() + val serverUrl = inputData.getString(KEY_SERVER_URL) ?: return@withContext Result.failure() + val username = inputData.getString(KEY_USERNAME) ?: return@withContext Result.failure() + val authHeader = inputData.getString(KEY_AUTH_HEADER) ?: return@withContext Result.failure() + val fileId = inputData.getString(KEY_FILE_ID) ?: return@withContext Result.failure() + val remotePath = inputData.getString(KEY_REMOTE_PATH) ?: return@withContext Result.failure() + val relPath = inputData.getString(KEY_REL_PATH) ?: return@withContext Result.failure() + val resolution = inputData.getString(KEY_RESOLUTION) ?: return@withContext Result.failure() + + val syncRoot = SyncEngine.syncRoot(applicationContext, accountId) + val localFile = File(syncRoot, relPath) + val ok = when (resolution) { + "local" -> localFile.exists() && + SyncEngine.uploadFile(serverUrl, username, authHeader, remotePath, localFile) + "server" -> SyncEngine.downloadFile(serverUrl, username, authHeader, remotePath, localFile) + else -> false + } + if (!ok) return@withContext Result.retry() + + // Refresh the recorded state from the server's post-resolution + // etag, so this file isn't immediately re-flagged as a conflict on + // the next sync pass. + val fresh = SyncEngine.propfindSelf(serverUrl, username, authHeader, remotePath) + val state = SyncEngine.loadState(applicationContext, accountId).toMutableMap() + state[fileId] = SyncEngine.FileState( + relPath = relPath, + etag = fresh?.etag ?: "", + lastModified = fresh?.lastModified ?: 0L, + size = localFile.length(), + localMTime = localFile.lastModified(), + ) + SyncEngine.saveState(applicationContext, accountId, state) + SyncStatusBus.removeConflict(accountId, fileId) + + Result.success() + } +} diff --git a/android/app/src/main/kotlin/dev/ayushya/noo/MainActivity.kt b/android/app/src/main/kotlin/dev/ayushya/noo/MainActivity.kt index 41caf57..b3ab514 100644 --- a/android/app/src/main/kotlin/dev/ayushya/noo/MainActivity.kt +++ b/android/app/src/main/kotlin/dev/ayushya/noo/MainActivity.kt @@ -12,12 +12,22 @@ import android.provider.OpenableColumns import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.content.FileProvider +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.workDataOf import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel +import org.json.JSONArray import java.io.File +import java.util.concurrent.TimeUnit // FlutterFragmentActivity (not the default FlutterActivity) is required by // local_auth's Android implementation, which hosts its biometric/device @@ -44,6 +54,8 @@ class MainActivity : FlutterFragmentActivity() { private val downloadServiceChannelName = "dev.ayushya.noo/download_service" private val pickIntentChannelName = "dev.ayushya.noo/pick_intent" private val newPickChannelName = "dev.ayushya.noo/pick_intent/new" + private val syncServiceChannelName = "dev.ayushya.noo/sync_service" + private val syncStatusChannelName = "dev.ayushya.noo/sync_service/status" private val notificationPermissionRequestCode = 4202 // Lazy, not a field initializer - `packageName` reads through the // Activity's base Context, which isn't attached yet while this class's @@ -53,6 +65,7 @@ class MainActivity : FlutterFragmentActivity() { private val mainHandler = Handler(Looper.getMainLooper()) private var newShareSink: EventChannel.EventSink? = null private var newPickSink: EventChannel.EventSink? = null + private var syncStatusListener: ((SyncStatusBus.Status) -> Unit)? = null override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) @@ -114,6 +127,74 @@ class MainActivity : FlutterFragmentActivity() { newPickSink = null } }) + + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, syncServiceChannelName) + .setMethodCallHandler { call, result -> + when (call.method) { + "reschedule" -> rescheduleSyncWork(call, result) + "cancel" -> cancelSyncWork(result) + "syncNow" -> syncNow(call, result) + "getSyncStatus" -> result.success(syncStatusMap(SyncStatusBus.snapshot())) + "resolveConflict" -> resolveConflict(call, result) + else -> result.notImplemented() + } + } + + EventChannel(flutterEngine.dartExecutor.binaryMessenger, syncStatusChannelName) + .setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink) { + val listener: (SyncStatusBus.Status) -> Unit = { status -> + mainHandler.post { events.success(syncStatusMap(status)) } + } + syncStatusListener = listener + SyncStatusBus.subscribe(listener) + } + override fun onCancel(arguments: Any?) { + syncStatusListener?.let { SyncStatusBus.unsubscribe(it) } + syncStatusListener = null + } + }) + } + + private fun syncStatusMap(status: SyncStatusBus.Status): Map { + val syncedFileIds = status.accountId?.let { + SyncEngine.loadState(applicationContext, it).keys.toList() + } ?: emptyList() + return mapOf( + "accountId" to status.accountId, + "syncing" to status.syncing, + "syncingFileIds" to status.syncingFileIds.toList(), + "syncedFileIds" to syncedFileIds, + "conflicts" to status.conflicts.map { + mapOf( + "accountId" to it.accountId, + "fileId" to it.fileId, + "remotePath" to it.remotePath, + "relPath" to it.relPath, + "name" to it.name, + ) + }, + ) + } + + /// In-app conflict resolution (the header's "Keep local"/"Use server" + /// buttons) - shares [ConflictResolveWorker.enqueue] with + /// [SyncConflictReceiver], the only other caller, so there's one + /// resolution code path regardless of whether it's triggered from a + /// notification or from inside the app. + private fun resolveConflict(call: MethodCall, result: MethodChannel.Result) { + ConflictResolveWorker.enqueue( + this, + accountId = call.argument("accountId"), + serverUrl = call.argument("serverUrl"), + username = call.argument("username"), + authHeader = call.argument("authHeader"), + fileId = call.argument("fileId"), + remotePath = call.argument("remotePath"), + relPath = call.argument("relPath"), + resolution = call.argument("resolution"), + ) + result.success(null) } override fun onNewIntent(intent: Intent) { @@ -262,6 +343,85 @@ class MainActivity : FlutterFragmentActivity() { result.success(null) } + /// Shared arg-parsing for `reschedule`/`syncNow` - both need the same + /// account/credentials/folder-list shape, just enqueue differently. + private fun syncWorkData(call: MethodCall): androidx.work.Data? { + val accountId = call.argument("accountId") + val serverUrl = call.argument("serverUrl") + val username = call.argument("username") + val authHeader = call.argument("authHeader") + val foldersJson = call.argument("folders") + if (accountId == null || serverUrl == null || username == null || + authHeader == null || foldersJson == null + ) { + return null + } + return workDataOf( + SyncWorker.KEY_ACCOUNT_ID to accountId, + SyncWorker.KEY_SERVER_URL to serverUrl, + SyncWorker.KEY_USERNAME to username, + SyncWorker.KEY_AUTH_HEADER to authHeader, + SyncWorker.KEY_FOLDERS to foldersJson, + ) + } + + /// Re-enqueues (or cancels, if the folder list is now empty) the + /// periodic device-sync job - called from Dart whenever the synced- + /// folder list, active account, or Wi-Fi-only setting changes, since a + /// periodic WorkRequest's input Data/constraints are fixed at enqueue + /// time and can only be changed by cancelling and re-enqueueing. + private fun rescheduleSyncWork(call: MethodCall, result: MethodChannel.Result) { + val data = syncWorkData(call) + val foldersJson = call.argument("folders") + val wifiOnly = call.argument("wifiOnly") ?: true + val workManager = WorkManager.getInstance(this) + + if (data == null || foldersJson == null || JSONArray(foldersJson).length() == 0) { + workManager.cancelUniqueWork(SyncWorker.UNIQUE_PERIODIC_NAME) + result.success(null) + return + } + + val constraints = Constraints.Builder() + .setRequiredNetworkType(if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED) + .build() + // 1 hour is WorkManager's own practical floor for a "battery- + // friendly" cadence well above its hard 15-minute minimum; there's + // no per-user interval setting for this in v1. + val request = PeriodicWorkRequestBuilder(1, TimeUnit.HOURS) + .setInputData(data) + .setConstraints(constraints) + .build() + workManager.enqueueUniquePeriodicWork( + SyncWorker.UNIQUE_PERIODIC_NAME, + ExistingPeriodicWorkPolicy.UPDATE, + request, + ) + result.success(null) + } + + private fun cancelSyncWork(result: MethodChannel.Result) { + WorkManager.getInstance(this).cancelUniqueWork(SyncWorker.UNIQUE_PERIODIC_NAME) + result.success(null) + } + + /// One-off immediate run (Settings' "Sync now"), independent of the + /// periodic schedule. + private fun syncNow(call: MethodCall, result: MethodChannel.Result) { + val data = syncWorkData(call) + if (data == null) { + result.error("bad_args", "Missing required sync arguments", null) + return + } + val request = OneTimeWorkRequestBuilder().setInputData(data).build() + WorkManager.getInstance(this).enqueueUniqueWork( + SyncWorker.UNIQUE_ONE_OFF_NAME, + ExistingWorkPolicy.REPLACE, + request, + ) + result.success(null) + } + /// Non-null only when this Activity was launched (or re-delivered a new /// Intent) as another app's GET_CONTENT picker - the mimeType filter and /// multi-select flag the caller asked for, plus a best-effort display diff --git a/android/app/src/main/kotlin/dev/ayushya/noo/SyncConflictReceiver.kt b/android/app/src/main/kotlin/dev/ayushya/noo/SyncConflictReceiver.kt new file mode 100644 index 0000000..37c2bbe --- /dev/null +++ b/android/app/src/main/kotlin/dev/ayushya/noo/SyncConflictReceiver.kt @@ -0,0 +1,49 @@ +package dev.ayushya.noo + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationManagerCompat + +/** + * Handles the "Keep local" / "Use server" actions on a sync-conflict + * notification (see `SyncWorker.notifyConflicts`). A `BroadcastReceiver` + * can't block on network itself (~10s execution budget), so this only + * dismisses the notification and hands off to a one-shot + * [ConflictResolveWorker] for the actual upload/download. + */ +class SyncConflictReceiver : BroadcastReceiver() { + companion object { + const val ACTION_RESOLVE = "dev.ayushya.noo.action.RESOLVE_SYNC_CONFLICT" + const val EXTRA_ACCOUNT_ID = "accountId" + const val EXTRA_SERVER_URL = "serverUrl" + const val EXTRA_USERNAME = "username" + const val EXTRA_AUTH_HEADER = "authHeader" + const val EXTRA_FILE_ID = "fileId" + const val EXTRA_REMOTE_PATH = "remotePath" + const val EXTRA_REL_PATH = "relPath" + const val EXTRA_RESOLUTION = "resolution" // "local" | "server" + const val EXTRA_NOTIFICATION_ID = "notificationId" + } + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != ACTION_RESOLVE) return + + val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1) + if (notificationId != -1) { + NotificationManagerCompat.from(context).cancel(notificationId) + } + + ConflictResolveWorker.enqueue( + context, + accountId = intent.getStringExtra(EXTRA_ACCOUNT_ID), + serverUrl = intent.getStringExtra(EXTRA_SERVER_URL), + username = intent.getStringExtra(EXTRA_USERNAME), + authHeader = intent.getStringExtra(EXTRA_AUTH_HEADER), + fileId = intent.getStringExtra(EXTRA_FILE_ID), + remotePath = intent.getStringExtra(EXTRA_REMOTE_PATH), + relPath = intent.getStringExtra(EXTRA_REL_PATH), + resolution = intent.getStringExtra(EXTRA_RESOLUTION), + ) + } +} diff --git a/android/app/src/main/kotlin/dev/ayushya/noo/SyncEngine.kt b/android/app/src/main/kotlin/dev/ayushya/noo/SyncEngine.kt new file mode 100644 index 0000000..6cd50c1 --- /dev/null +++ b/android/app/src/main/kotlin/dev/ayushya/noo/SyncEngine.kt @@ -0,0 +1,399 @@ +package dev.ayushya.noo + +import android.content.Context +import android.net.Uri +import android.util.Log +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.concurrent.TimeUnit +import javax.xml.parsers.DocumentBuilderFactory +import org.w3c.dom.Element + +/** + * The actual WebDAV walk/diff/GET/PUT engine for device sync - shared by + * [SyncWorker] (periodic/manual full-folder sync) and + * [ConflictResolveWorker] (single-file resolution from a notification + * action), since both need the same PROPFIND/GET/PUT primitives and the + * same on-disk sync-state bookkeeping. + * + * Deliberately plain Kotlin over `HttpURLConnection`, not + * `NextcloudService`'s Dart/Dio code - this has to run from a + * [androidx.work.CoroutineWorker], independent of the Flutter engine even + * being loaded, the same reason `DownloadService.kt`/`ShareUploadService.kt` + * re-implement GET/PUT natively instead of calling back into Dart. + */ +object SyncEngine { + data class RemoteEntry( + val path: String, + val fileId: String, + val etag: String, + val lastModified: Long, + val size: Long, + val isFolder: Boolean, + ) + + data class FileState( + val relPath: String, + val etag: String, + val lastModified: Long, + val size: Long, + val localMTime: Long, + ) + + sealed class SyncAction { + data class Download(val entry: RemoteEntry) : SyncAction() + data class Upload(val relPath: String, val fileId: String) : SyncAction() + data class Delete(val relPath: String, val fileId: String) : SyncAction() + data class Conflict(val entry: RemoteEntry, val relPath: String) : SyncAction() + } + + private const val TAG = "NooSync" + private const val STATE_PREFS = "noo_sync_state" + private val rfc1123 = + SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US) + + // Only used for PROPFIND - unlike HttpURLConnection, OkHttp doesn't + // reject non-standard HTTP methods. + private val httpClient = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + fun syncRoot(context: Context, accountId: String): File { + val base = context.getExternalFilesDir(null) ?: context.filesDir + return File(base, "sync/$accountId").apply { mkdirs() } + } + + private fun stateKey(accountId: String) = "state_$accountId" + + fun loadState(context: Context, accountId: String): MutableMap { + val prefs = context.getSharedPreferences(STATE_PREFS, Context.MODE_PRIVATE) + val json = prefs.getString(stateKey(accountId), null) ?: return mutableMapOf() + val obj = JSONObject(json) + val result = mutableMapOf() + for (fileId in obj.keys()) { + val entry = obj.getJSONObject(fileId) + result[fileId] = FileState( + relPath = entry.getString("relPath"), + etag = entry.getString("etag"), + lastModified = entry.getLong("lastModified"), + size = entry.getLong("size"), + localMTime = entry.getLong("localMTime"), + ) + } + return result + } + + fun saveState(context: Context, accountId: String, state: Map) { + val obj = JSONObject() + for ((fileId, s) in state) { + obj.put( + fileId, + JSONObject().apply { + put("relPath", s.relPath) + put("etag", s.etag) + put("lastModified", s.lastModified) + put("size", s.size) + put("localMTime", s.localMTime) + }, + ) + } + val prefs = context.getSharedPreferences(STATE_PREFS, Context.MODE_PRIVATE) + prefs.edit().putString(stateKey(accountId), obj.toString()).apply() + } + + /** Depth-1 PROPFIND of [remotePath], returning its direct children only. */ + fun propfindChildren( + serverUrl: String, + username: String, + authHeader: String, + remotePath: String, + ): List = propfind(serverUrl, username, authHeader, remotePath, depth = "1", skipSelf = true) + + /** Depth-0 PROPFIND of [remotePath] itself (e.g. to refresh its etag after a PUT/GET). */ + fun propfindSelf( + serverUrl: String, + username: String, + authHeader: String, + remotePath: String, + ): RemoteEntry? = propfind(serverUrl, username, authHeader, remotePath, depth = "0", skipSelf = false).firstOrNull() + + private fun propfind( + serverUrl: String, + username: String, + authHeader: String, + remotePath: String, + depth: String, + skipSelf: Boolean, + ): List { + var cleanPath = remotePath.trim() + if (!cleanPath.startsWith("/")) cleanPath = "/$cleanPath" + if (depth == "1" && !cleanPath.endsWith("/")) cleanPath = "$cleanPath/" + val encodedPath = cleanPath.split("/").joinToString("/") { Uri.encode(it) } + val cleanServer = serverUrl.trimEnd('/') + val url = "$cleanServer/remote.php/dav/files/$username$encodedPath" + + val body = """ + + + + + + + + +""" + val request = Request.Builder() + .url(url) + .method("PROPFIND", body.toRequestBody("application/xml".toMediaType())) + .header("Authorization", authHeader) + .header("Depth", depth) + .build() + + return try { + httpClient.newCall(request).execute().use { response -> + Log.d(TAG, "PROPFIND $url -> ${response.code}") + if (!response.isSuccessful) { + Log.w(TAG, "PROPFIND $url failed: ${response.code} ${response.body?.string()}") + return emptyList() + } + + val doc = DocumentBuilderFactory.newInstance() + .apply { isNamespaceAware = true } + .newDocumentBuilder() + .parse(response.body!!.byteStream()) + + parsePropfindResponse(doc, username, cleanPath, skipSelf) + } + } catch (e: Exception) { + Log.e(TAG, "PROPFIND $url threw", e) + emptyList() + } + } + + private fun parsePropfindResponse( + doc: org.w3c.dom.Document, + username: String, + cleanPath: String, + skipSelf: Boolean, + ): List { + val marker = "/remote.php/dav/files/$username" + val selfPath = cleanPath.trimEnd('/') + val responses = doc.getElementsByTagNameNS("DAV:", "response") + val entries = mutableListOf() + for (i in 0 until responses.length) { + val responseEl = responses.item(i) as? Element ?: continue + val hrefRaw = responseEl.getElementsByTagNameNS("DAV:", "href") + .item(0)?.textContent ?: continue + val decodedHref = Uri.decode(hrefRaw) + val idx = decodedHref.indexOf(marker) + val hrefPath = if (idx >= 0) decodedHref.substring(idx + marker.length) else decodedHref + val hrefPathNorm = (if (hrefPath.isEmpty()) "/" else hrefPath).trimEnd('/') + if (skipSelf && hrefPathNorm == selfPath) continue // self entry, not a child + + val propEl = responseEl.getElementsByTagNameNS("DAV:", "prop").item(0) as? Element + ?: continue + val resourceTypeEl = + propEl.getElementsByTagNameNS("DAV:", "resourcetype").item(0) as? Element + val isFolder = + (resourceTypeEl?.getElementsByTagNameNS("DAV:", "collection")?.length ?: 0) > 0 + val etag = propEl.getElementsByTagNameNS("DAV:", "getetag") + .item(0)?.textContent?.trim('"') ?: "" + val lastModStr = + propEl.getElementsByTagNameNS("DAV:", "getlastmodified").item(0)?.textContent + val sizeStr = + propEl.getElementsByTagNameNS("DAV:", "getcontentlength").item(0)?.textContent + val fileId = propEl.getElementsByTagNameNS("http://owncloud.org/ns", "fileid") + .item(0)?.textContent ?: hrefPathNorm + + entries.add( + RemoteEntry( + path = hrefPathNorm, + fileId = fileId, + etag = etag, + lastModified = lastModStr?.let { runCatching { rfc1123.parse(it)?.time }.getOrNull() } ?: 0L, + size = sizeStr?.toLongOrNull() ?: 0L, + isFolder = isFolder, + ), + ) + } + Log.d(TAG, "PROPFIND $cleanPath -> ${entries.size} entries") + return entries + } + + /** Recursively walks [rootPath] (Depth-1 PROPFINDs, breadth-first) into a flat manifest. */ + fun walkRemoteTree( + serverUrl: String, + username: String, + authHeader: String, + rootPath: String, + ): List { + val result = mutableListOf() + val queue = ArrayDeque() + queue.add(rootPath) + while (queue.isNotEmpty()) { + val current = queue.removeFirst() + val children = propfindChildren(serverUrl, username, authHeader, current) + for (child in children) { + result.add(child) + if (child.isFolder) queue.add(child.path) + } + } + return result + } + + fun downloadFile( + serverUrl: String, + username: String, + authHeader: String, + remotePath: String, + destination: File, + ): Boolean { + val encodedPath = remotePath.split("/").joinToString("/") { Uri.encode(it) } + val url = URL("${serverUrl.trimEnd('/')}/remote.php/dav/files/$username$encodedPath") + val connection = url.openConnection() as HttpURLConnection + return try { + connection.requestMethod = "GET" + connection.setRequestProperty("Authorization", authHeader) + connection.connectTimeout = 15000 + connection.readTimeout = 30000 + connection.connect() + val code = connection.responseCode + if (code !in 200..299) { + Log.w(TAG, "GET $url failed: $code") + return false + } + + destination.parentFile?.mkdirs() + connection.inputStream.use { input -> + destination.outputStream().use { output -> input.copyTo(output) } + } + Log.d(TAG, "GET $url -> saved to ${destination.absolutePath} (${destination.length()} bytes)") + true + } catch (e: Exception) { + Log.e(TAG, "GET $url threw", e) + false + } finally { + connection.disconnect() + } + } + + fun uploadFile( + serverUrl: String, + username: String, + authHeader: String, + remotePath: String, + source: File, + ): Boolean { + val encodedPath = remotePath.split("/").joinToString("/") { Uri.encode(it) } + val url = URL("${serverUrl.trimEnd('/')}/remote.php/dav/files/$username$encodedPath") + val connection = url.openConnection() as HttpURLConnection + return try { + connection.requestMethod = "PUT" + connection.setRequestProperty("Authorization", authHeader) + connection.setFixedLengthStreamingMode(source.length()) + connection.doOutput = true + connection.connectTimeout = 15000 + connection.readTimeout = 60000 + connection.connect() + source.inputStream().use { input -> + connection.outputStream.use { output -> input.copyTo(output) } + } + connection.responseCode in 200..299 + } catch (e: Exception) { + false + } finally { + connection.disconnect() + } + } + + /** + * Diffs one synced folder's remote manifest against the persisted + * state, returning what to do for each item - callers own actually + * doing the GET/PUT/delete and updating state afterward, so this stays + * pure/testable-in-principle. Also returns entries no longer present + * remotely (deleted server-side, relative to the [priorFolderRelPaths] + * this folder previously produced) as [SyncAction.Delete]. + */ + fun diffFolder( + entries: List, + state: Map, + priorFolderFileIds: Set, + syncRoot: File, + ): List { + val actions = mutableListOf() + val seenFileIds = mutableSetOf() + + for (entry in entries) { + if (entry.isFolder) continue + seenFileIds.add(entry.fileId) + val relPath = entry.path.removePrefix("/") + val localFile = File(syncRoot, relPath) + val prior = state[entry.fileId] + + if (prior == null) { + // First time this folder's been walked with sync enabled - + // nothing locally recorded yet to protect, so just pull it. + actions.add(SyncAction.Download(entry)) + continue + } + + val serverChanged = entry.etag != prior.etag + val localExists = localFile.exists() + val localChanged = localExists && + (localFile.lastModified() != prior.localMTime || localFile.length() != prior.size) + + when { + !localExists && !serverChanged -> { + // User deleted the local mirror copy themselves and the + // server hasn't changed - respect that deletion rather + // than silently re-creating it. + actions.add(SyncAction.Delete(relPath, entry.fileId)) + } + serverChanged && localChanged -> actions.add(SyncAction.Conflict(entry, relPath)) + serverChanged -> actions.add(SyncAction.Download(entry)) + localChanged -> actions.add(SyncAction.Upload(relPath, entry.fileId)) + else -> {} // unchanged, nothing to do + } + } + + // Anything this folder had state for last time but that didn't show + // up in this walk at all was deleted server-side. + for (fileId in priorFolderFileIds) { + if (fileId !in seenFileIds) { + val prior = state[fileId] ?: continue + actions.add(SyncAction.Delete(prior.relPath, fileId)) + } + } + + Log.d( + TAG, + "diffFolder: ${entries.size} entries -> " + + "${actions.count { it is SyncAction.Download }} downloads, " + + "${actions.count { it is SyncAction.Upload }} uploads, " + + "${actions.count { it is SyncAction.Delete }} deletes, " + + "${actions.count { it is SyncAction.Conflict }} conflicts", + ) + return actions + } + + fun folderIsSyncedUnder(itemPath: String, syncedFolders: List): String? { + val normalizedItem = itemPath.trimEnd('/') + for (folder in syncedFolders) { + val normalizedFolder = folder.trimEnd('/') + if (normalizedItem == normalizedFolder || normalizedItem.startsWith("$normalizedFolder/")) { + return folder + } + } + return null + } +} diff --git a/android/app/src/main/kotlin/dev/ayushya/noo/SyncStatusBus.kt b/android/app/src/main/kotlin/dev/ayushya/noo/SyncStatusBus.kt new file mode 100644 index 0000000..dddbc16 --- /dev/null +++ b/android/app/src/main/kotlin/dev/ayushya/noo/SyncStatusBus.kt @@ -0,0 +1,80 @@ +package dev.ayushya.noo + +/** + * In-memory, in-process pub/sub for live device-sync status - + * [SyncWorker]/[ConflictResolveWorker] publish into this, `MainActivity`'s + * `dev.ayushya.noo/sync_service/status` EventChannel forwards it to Dart. + * Plain in-memory state is enough (no IPC/persistence needed) since the + * workers and the Activity always run in the same process; contrast with + * [SyncEngine]'s on-disk per-account state map, which *is* durable and is + * what actually answers "is this file synced" (this bus only ever tracks + * the transient "syncing right now" / "unresolved conflict" parts of that + * picture). + */ +object SyncStatusBus { + data class Conflict( + val accountId: String, + val fileId: String, + val remotePath: String, + val relPath: String, + val name: String, + ) + + data class Status( + val accountId: String?, + val syncing: Boolean, + val syncingFileIds: Set, + val conflicts: List, + ) + + @Volatile + private var current = Status(accountId = null, syncing = false, syncingFileIds = emptySet(), conflicts = emptyList()) + private val listeners = mutableListOf<(Status) -> Unit>() + + @Synchronized + fun subscribe(listener: (Status) -> Unit) { + listeners.add(listener) + listener(current) + } + + @Synchronized + fun unsubscribe(listener: (Status) -> Unit) { + listeners.remove(listener) + } + + fun snapshot(): Status = current + + @Synchronized + fun setSyncing(accountId: String, syncing: Boolean) { + current = current.copy(accountId = accountId, syncing = syncing) + if (!syncing) current = current.copy(syncingFileIds = emptySet()) + publish() + } + + @Synchronized + fun markFileSyncing(accountId: String, fileId: String, syncing: Boolean) { + val ids = current.syncingFileIds.toMutableSet() + if (syncing) ids.add(fileId) else ids.remove(fileId) + current = current.copy(accountId = accountId, syncingFileIds = ids) + publish() + } + + @Synchronized + fun addConflicts(accountId: String, newConflicts: List) { + if (newConflicts.isEmpty()) return + val existingIds = current.conflicts.map { it.fileId }.toSet() + val merged = current.conflicts + newConflicts.filter { it.fileId !in existingIds } + current = current.copy(accountId = accountId, conflicts = merged) + publish() + } + + @Synchronized + fun removeConflict(accountId: String, fileId: String) { + current = current.copy(accountId = accountId, conflicts = current.conflicts.filter { it.fileId != fileId }) + publish() + } + + private fun publish() { + listeners.toList().forEach { it(current) } + } +} diff --git a/android/app/src/main/kotlin/dev/ayushya/noo/SyncWorker.kt b/android/app/src/main/kotlin/dev/ayushya/noo/SyncWorker.kt new file mode 100644 index 0000000..78767c1 --- /dev/null +++ b/android/app/src/main/kotlin/dev/ayushya/noo/SyncWorker.kt @@ -0,0 +1,272 @@ +package dev.ayushya.noo + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import java.io.File + +/** + * The periodic (and manual "Sync now") device-sync job. Runs entirely + * independent of the Flutter engine (see [SyncEngine]'s doc comment) - + * enqueued/re-enqueued from `MainActivity.kt`'s + * `dev.ayushya.noo/sync_service` MethodChannel whenever the synced-folder + * list, account, or network setting changes, since a periodic + * `WorkRequest`'s input `Data` is fixed at enqueue time. + */ +class SyncWorker(appContext: Context, params: WorkerParameters) : + CoroutineWorker(appContext, params) { + + companion object { + const val KEY_ACCOUNT_ID = "accountId" + const val KEY_SERVER_URL = "serverUrl" + const val KEY_USERNAME = "username" + const val KEY_AUTH_HEADER = "authHeader" + const val KEY_FOLDERS = "folders" // JSON array of remote file/folder paths + + const val UNIQUE_PERIODIC_NAME = "noo_sync_periodic" + const val UNIQUE_ONE_OFF_NAME = "noo_sync_now" + + private const val CHANNEL_ID = "device_sync" + private const val SUMMARY_NOTIFICATION_ID = 4401 + private const val CONFLICT_NOTIFICATION_ID_BASE = 4500 + private const val TAG = "NooSync" + } + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + val accountId = inputData.getString(KEY_ACCOUNT_ID) ?: return@withContext Result.failure() + val serverUrl = inputData.getString(KEY_SERVER_URL) ?: return@withContext Result.failure() + val username = inputData.getString(KEY_USERNAME) ?: return@withContext Result.failure() + val authHeader = inputData.getString(KEY_AUTH_HEADER) ?: return@withContext Result.failure() + val foldersJson = inputData.getString(KEY_FOLDERS) ?: "[]" + val folders = (0 until JSONArray(foldersJson).length()).map { JSONArray(foldersJson).getString(it) } + Log.d(TAG, "doWork: account=$accountId server=$serverUrl user=$username folders=$folders") + if (folders.isEmpty()) return@withContext Result.success() + + val syncRoot = SyncEngine.syncRoot(applicationContext, accountId) + val state = SyncEngine.loadState(applicationContext, accountId).toMutableMap() + + var downloaded = 0 + var uploaded = 0 + var deleted = 0 + val conflicts = mutableListOf>() + + SyncStatusBus.setSyncing(accountId, true) + try { + for (path in folders) { + // A configured path can be a file or a folder now - check + // which before deciding whether to walk it recursively or + // just diff the single item. + val self = SyncEngine.propfindSelf(serverUrl, username, authHeader, path) + val entries = when { + self == null -> emptyList() + self.isFolder -> SyncEngine.walkRemoteTree(serverUrl, username, authHeader, path) + else -> listOf(self) + } + val pathPrefix = path.trimEnd('/') + "/" + val priorFileIdsForPath = state.filterValues { + it.relPath == path.trimStart('/') || it.relPath.startsWith(pathPrefix.trimStart('/')) + }.keys + + val actions = SyncEngine.diffFolder(entries, state, priorFileIdsForPath, syncRoot) + for (action in actions) { + when (action) { + is SyncEngine.SyncAction.Download -> { + SyncStatusBus.markFileSyncing(accountId, action.entry.fileId, true) + val relPath = action.entry.path.removePrefix("/") + val dest = File(syncRoot, relPath) + if (SyncEngine.downloadFile(serverUrl, username, authHeader, action.entry.path, dest)) { + state[action.entry.fileId] = SyncEngine.FileState( + relPath = relPath, + etag = action.entry.etag, + lastModified = action.entry.lastModified, + size = action.entry.size, + localMTime = dest.lastModified(), + ) + downloaded++ + } + SyncStatusBus.markFileSyncing(accountId, action.entry.fileId, false) + } + is SyncEngine.SyncAction.Upload -> { + SyncStatusBus.markFileSyncing(accountId, action.fileId, true) + val localFile = File(syncRoot, action.relPath) + val remotePath = "/${action.relPath}" + if (localFile.exists() && + SyncEngine.uploadFile(serverUrl, username, authHeader, remotePath, localFile) + ) { + val prior = state[action.fileId] + state[action.fileId] = SyncEngine.FileState( + relPath = action.relPath, + etag = prior?.etag ?: "", + lastModified = localFile.lastModified(), + size = localFile.length(), + localMTime = localFile.lastModified(), + ) + uploaded++ + } + SyncStatusBus.markFileSyncing(accountId, action.fileId, false) + } + is SyncEngine.SyncAction.Delete -> { + File(syncRoot, action.relPath).delete() + state.remove(action.fileId) + deleted++ + } + is SyncEngine.SyncAction.Conflict -> conflicts.add(action.entry to action.relPath) + } + } + } + } finally { + SyncStatusBus.setSyncing(accountId, false) + } + + SyncEngine.saveState(applicationContext, accountId, state) + Log.d( + TAG, + "doWork done: downloaded=$downloaded uploaded=$uploaded deleted=$deleted conflicts=${conflicts.size}", + ) + + if (downloaded > 0 || uploaded > 0 || deleted > 0) { + notifySummary(downloaded, uploaded, deleted) + } + if (conflicts.isNotEmpty()) { + SyncStatusBus.addConflicts( + accountId, + conflicts.map { (entry, relPath) -> + SyncStatusBus.Conflict( + accountId = accountId, + fileId = entry.fileId, + remotePath = entry.path, + relPath = relPath, + name = relPath.substringAfterLast('/'), + ) + }, + ) + notifyConflicts(accountId, serverUrl, username, authHeader, conflicts) + } + + Result.success() + } + + private fun notifySummary(downloaded: Int, uploaded: Int, deleted: Int) { + createChannel() + val parts = mutableListOf() + if (downloaded > 0) parts.add("$downloaded updated") + if (uploaded > 0) parts.add("$uploaded uploaded") + if (deleted > 0) parts.add("$deleted removed") + val text = parts.joinToString(", ") + + val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_notify_sync) + .setContentTitle("Noo sync") + .setContentText(text) + .setAutoCancel(true) + .build() + manager().notify(SUMMARY_NOTIFICATION_ID, notification) + } + + private fun notifyConflicts( + accountId: String, + serverUrl: String, + username: String, + authHeader: String, + conflicts: List>, + ) { + createChannel() + for ((index, conflict) in conflicts.withIndex()) { + val (entry, relPath) = conflict + val fileName = relPath.substringAfterLast('/') + val notificationId = CONFLICT_NOTIFICATION_ID_BASE + (relPath.hashCode() and 0xFFFF) + + val useLocalIntent = conflictActionIntent( + accountId, + serverUrl, + username, + authHeader, + entry.fileId, + entry.path, + relPath, + "local", + notificationId, + ) + val useServerIntent = conflictActionIntent( + accountId, + serverUrl, + username, + authHeader, + entry.fileId, + entry.path, + relPath, + "server", + notificationId, + ) + + val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_notify_error) + .setContentTitle("Sync conflict: $fileName") + .setContentText("Changed both on this device and on the server.") + .setAutoCancel(true) + .addAction(0, "Keep local", useLocalIntent) + .addAction(0, "Use server", useServerIntent) + .build() + manager().notify(notificationId, notification) + } + } + + private fun conflictActionIntent( + accountId: String, + serverUrl: String, + username: String, + authHeader: String, + fileId: String, + remotePath: String, + relPath: String, + resolution: String, + notificationId: Int, + ): PendingIntent { + val intent = Intent(applicationContext, SyncConflictReceiver::class.java).apply { + action = SyncConflictReceiver.ACTION_RESOLVE + putExtra(SyncConflictReceiver.EXTRA_ACCOUNT_ID, accountId) + putExtra(SyncConflictReceiver.EXTRA_SERVER_URL, serverUrl) + putExtra(SyncConflictReceiver.EXTRA_USERNAME, username) + putExtra(SyncConflictReceiver.EXTRA_AUTH_HEADER, authHeader) + putExtra(SyncConflictReceiver.EXTRA_FILE_ID, fileId) + putExtra(SyncConflictReceiver.EXTRA_REMOTE_PATH, remotePath) + putExtra(SyncConflictReceiver.EXTRA_REL_PATH, relPath) + putExtra(SyncConflictReceiver.EXTRA_RESOLUTION, resolution) + putExtra(SyncConflictReceiver.EXTRA_NOTIFICATION_ID, notificationId) + } + // Request code must be unique per (file, resolution) pair, else the + // two actions' PendingIntents collide and only one survives. + val requestCode = (relPath + resolution).hashCode() + return PendingIntent.getBroadcast( + applicationContext, + requestCode, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + private fun manager() = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + private fun createChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "Device sync", + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { description = "Updates and conflicts for folders synced to this device" } + manager().createNotificationChannel(channel) + } + } +} diff --git a/lib/models/sync_status.dart b/lib/models/sync_status.dart new file mode 100644 index 0000000..3283c58 --- /dev/null +++ b/lib/models/sync_status.dart @@ -0,0 +1,38 @@ +/// A pending device-sync conflict (a file changed both locally and on the +/// server since the last sync) - mirrors `SyncStatusBus.Conflict` on the +/// native side exactly, see `SyncEngine.kt`/`server.md`'s "Device sync" +/// section. +class SyncConflictInfo { + final String accountId; + final String fileId; + final String remotePath; + final String relPath; + final String name; + + const SyncConflictInfo({ + required this.accountId, + required this.fileId, + required this.remotePath, + required this.relPath, + required this.name, + }); + + factory SyncConflictInfo.fromMap(Map map) { + return SyncConflictInfo( + accountId: map['accountId'] as String? ?? '', + fileId: map['fileId'] as String? ?? '', + remotePath: map['remotePath'] as String? ?? '', + relPath: map['relPath'] as String? ?? '', + name: map['name'] as String? ?? '', + ); + } +} + +/// A single item's device-sync status, used to badge its tile in the +/// Files view. +enum SyncItemStatus { none, syncing, synced, conflict } + +/// The persistent header chip/panel's overall status (account-wide, not +/// scoped to whatever folder is currently browsed) - see +/// `ServerProvider.syncHeaderStatus`. +enum SyncHeaderStatus { off, syncing, done, alert } diff --git a/lib/providers/server_provider.dart b/lib/providers/server_provider.dart index effcdbe..573a9ea 100644 --- a/lib/providers/server_provider.dart +++ b/lib/providers/server_provider.dart @@ -14,11 +14,13 @@ import '../models/nextcloud_share.dart'; import '../models/nextcloud_sharee.dart'; import '../models/pick_request.dart'; import '../models/saved_account.dart'; +import '../models/sync_status.dart'; import '../services/account_store.dart'; import '../services/app_lock_service.dart'; import '../services/login_flow_service.dart'; import '../services/nextcloud_service.dart'; import '../services/pick_intent_service.dart'; +import '../services/sync_service.dart'; import '../theme/app_theme.dart'; enum LoginFlowStatus { idle, initiating, awaitingBrowser, error } @@ -97,6 +99,9 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { static const _prefLoginLockEnabled = 'ui_login_lock_enabled'; static const _prefLockAccountSwitching = 'ui_lock_account_switching'; static const _prefLockHiddenFiles = 'ui_lock_hidden_files'; + static const _prefSyncedFolders = 'ui_synced_folders'; + static const _prefSyncEverything = 'ui_sync_everything'; + static const _prefSyncOnCellular = 'ui_sync_on_cellular'; final Future _prefsFuture = SharedPreferences.getInstance(); @@ -127,6 +132,23 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { double _bottomBarBlur = 28; bool _tapTabToScrollTop = true; + // Device sync - which remote paths (files or folders, per account) get + // mirrored locally by the native SyncWorker, and whether its periodic + // background runs are allowed on cellular (default Wi-Fi-only). See + // SyncEngine.kt/server.md. + List _syncedPaths = []; + bool _syncEverything = false; + bool _syncOnCellular = false; + + // Live device-sync status, pushed from SyncStatusBus.kt via + // SyncService.statusStream (see _subscribeToSyncStatus) - account-wide, + // not scoped to whatever folder is currently browsed. + bool _isSyncingNow = false; + Set _syncingFileIds = {}; + Set _syncedFileIds = {}; + List _syncConflicts = []; + StreamSubscription? _syncStatusSub; + // App lock (PIN/biometric via the device's own credential, not our own // storage - see AppLockService). Global, not per-account: it guards // access to the app/its accounts, not any one account's content. @@ -255,6 +277,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { ServerProvider() { WidgetsBinding.instance.addObserver(this); _init(); + _subscribeToSyncStatus(); } Future _init() async { @@ -265,6 +288,29 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { await Future.wait([_loadPreferences(), _restoreSession()]); } + /// Live device-sync status for the rest of the app's lifetime (not + /// re-subscribed per account switch - each event carries its own + /// `accountId`, so events for an account that isn't currently active are + /// just ignored below). Seeded once via a one-shot snapshot right after + /// every successful login/account-switch instead (see + /// `_applyCredentialsForAccount`), since events only arrive on change. + void _subscribeToSyncStatus() { + _syncStatusSub = SyncService.statusStream.listen((snapshot) { + _applySyncStatus(snapshot); + }); + } + + void _applySyncStatus(SyncStatusSnapshot snapshot) { + if (snapshot.accountId != null && snapshot.accountId != _activeAccountId) { + return; + } + _isSyncingNow = snapshot.syncing; + _syncingFileIds = snapshot.syncingFileIds; + _syncedFileIds = snapshot.syncedFileIds; + _syncConflicts = snapshot.conflicts; + notifyListeners(); + } + /// Starts/stops the periodic folder-listing refresh as the app leaves and /// returns to the foreground - only relevant under [CachePolicy.interval]. /// Also re-locks the app on backgrounding when login lock is set up - a @@ -323,6 +369,29 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { bool get lockHiddenFiles => _lockHiddenFiles; bool get needsUnlock => _loginLockEnabled && !_isUnlocked; + List get syncedPaths => List.unmodifiable(_syncedPaths); + bool get syncEverything => _syncEverything; + bool get syncOnCellular => _syncOnCellular; + + bool get isSyncingNow => _isSyncingNow; + List get syncConflicts => List.unmodifiable(_syncConflicts); + + SyncHeaderStatus get syncHeaderStatus { + if (_syncConflicts.isNotEmpty) return SyncHeaderStatus.alert; + if (_isSyncingNow) return SyncHeaderStatus.syncing; + if (!_syncEverything && _syncedPaths.isEmpty) return SyncHeaderStatus.off; + return SyncHeaderStatus.done; + } + + SyncItemStatus syncStatusFor(NextcloudItem item) { + if (_syncingFileIds.contains(item.id)) return SyncItemStatus.syncing; + if (_syncConflicts.any((c) => c.fileId == item.id)) { + return SyncItemStatus.conflict; + } + if (_syncedFileIds.contains(item.id)) return SyncItemStatus.synced; + return SyncItemStatus.none; + } + String get currentFolderPath => _currentFolderPath; AppTab? get requestedTab => _requestedTab; @@ -621,6 +690,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { prefs.getBool(_prefLockAccountSwitching) ?? _lockAccountSwitching; _lockHiddenFiles = prefs.getBool(_prefLockHiddenFiles) ?? _lockHiddenFiles; + _syncOnCellular = prefs.getBool(_prefSyncOnCellular) ?? _syncOnCellular; _applyAccountPrefs(prefs, _activeAccountId); final savedOrderNames = prefs.getStringList(_prefTabOrder); @@ -709,6 +779,8 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { _photosSortAscending = true; _cachePolicy = CachePolicy.never; _cacheIntervalMinutes = 5; + _syncedPaths = []; + _syncEverything = false; return; } @@ -767,6 +839,19 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { orElse: () => CachePolicy.never, ); _cacheIntervalMinutes = prefs.getInt(k(_prefCacheIntervalMinutes)) ?? 5; + + final syncedFoldersJson = prefs.getString(k(_prefSyncedFolders)); + if (syncedFoldersJson != null) { + try { + _syncedPaths = (jsonDecode(syncedFoldersJson) as List).cast(); + } catch (e) { + debugPrint('[ServerProvider] Synced folders restore failed: $e'); + _syncedPaths = []; + } + } else { + _syncedPaths = []; + } + _syncEverything = prefs.getBool(k(_prefSyncEverything)) ?? false; } /// Verifies [appPassword] for [account] and, on success, makes it the @@ -806,6 +891,8 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { if (gen != _sessionGeneration) return false; _isLoading = false; notifyListeners(); + unawaited(SyncService.reschedule(this)); + unawaited(SyncService.getStatus().then(_applySyncStatus)); return true; } } catch (e) { @@ -1131,7 +1218,12 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { final prefs = await _prefsFuture; await _accountStore.saveActiveAccountId(prefs, null); _applyAccountPrefs(prefs, null); + _isSyncingNow = false; + _syncingFileIds = {}; + _syncedFileIds = {}; + _syncConflicts = []; notifyListeners(); + unawaited(SyncService.cancel()); } /// Ends the active session but keeps this account saved - unlike @@ -1473,6 +1565,93 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { _persistFolderSort(); } + void _persistSyncedPaths() { + _persistAccountPref( + _prefSyncedFolders, + (p, key) => p.setString(key, jsonEncode(_syncedPaths)), + ); + } + + bool isPathSynced(String path) => _syncedPaths.contains(path); + + void addSyncedPath(String path) { + if (_syncedPaths.contains(path)) return; + _syncedPaths = [..._syncedPaths, path]; + notifyListeners(); + _persistSyncedPaths(); + unawaited(SyncService.reschedule(this)); + } + + void removeSyncedPath(String path) { + if (!_syncedPaths.contains(path)) return; + _syncedPaths = _syncedPaths.where((f) => f != path).toList(); + notifyListeners(); + _persistSyncedPaths(); + unawaited(SyncService.reschedule(this)); + } + + void setSyncEverything(bool value) { + if (_syncEverything == value) return; + _syncEverything = value; + notifyListeners(); + _persistAccountPref(_prefSyncEverything, (p, key) => p.setBool(key, value)); + unawaited(SyncService.reschedule(this)); + } + + void setSyncOnCellular(bool value) { + if (_syncOnCellular == value) return; + _syncOnCellular = value; + notifyListeners(); + _prefsFuture.then((p) => p.setBool(_prefSyncOnCellular, value)); + unawaited(SyncService.reschedule(this)); + } + + /// In-app conflict resolution (the sync header's "Keep local"/"Use + /// server" buttons) - see `SyncService.resolveConflict`'s doc comment + /// for why this shares the exact same native path the notification + /// actions use. + Future resolveSyncConflict( + SyncConflictInfo conflict, { + required bool useLocal, + }) { + return SyncService.resolveConflict( + this, + conflict, + useLocal ? 'local' : 'server', + ); + } + + /// The local mirror path for [item] if it falls under a synced folder + /// *and* has actually been synced down already - a pure function of the + /// remote path once a folder's marked synced (mirrors + /// `SyncEngine.kt#syncRoot`'s `/sync//...` + /// layout exactly), so this never needs to read the native sync-state + /// SharedPreferences from Dart. Callers (Share/Download) use this to + /// skip a fresh WebDAV fetch when a local copy already exists. + Future localSyncedFilePath(NextcloudItem item) async { + final id = activeAccountId; + if (id == null) return null; + final itemPath = item.path.endsWith('/') + ? item.path.substring(0, item.path.length - 1) + : item.path; + final isSynced = + _syncEverything || + _syncedPaths.any((folder) { + final f = folder.endsWith('/') + ? folder.substring(0, folder.length - 1) + : folder; + return itemPath == f || itemPath.startsWith('$f/'); + }); + if (!isSynced) return null; + final base = await getExternalStorageDirectory(); + if (base == null) return null; + final relPath = item.path.startsWith('/') + ? item.path.substring(1) + : item.path; + final file = File(p.join(base.path, 'sync', id, relPath)); + return file.existsSync() ? file.path : null; + } + void setTabOrder(List order) { _tabOrder = order; notifyListeners(); @@ -2154,6 +2333,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { _pollTimer?.cancel(); _pollTimeoutTimer?.cancel(); _cacheRefreshTimer?.cancel(); + _syncStatusSub?.cancel(); super.dispose(); } } diff --git a/lib/services/account_store.dart b/lib/services/account_store.dart index 4582886..9dae77a 100644 --- a/lib/services/account_store.dart +++ b/lib/services/account_store.dart @@ -37,6 +37,8 @@ class AccountStore { 'ui_files_type_filter', 'ui_cache_policy', 'ui_cache_interval_minutes', + 'ui_synced_folders', + 'ui_sync_everything', ]; String accountPrefKey(String accountId, String baseKey) => diff --git a/lib/services/sync_service.dart b/lib/services/sync_service.dart new file mode 100644 index 0000000..626eff8 --- /dev/null +++ b/lib/services/sync_service.dart @@ -0,0 +1,144 @@ +import 'dart:convert'; +import 'package:flutter/services.dart'; +import '../models/sync_status.dart'; +import '../providers/server_provider.dart'; + +/// A snapshot of native device-sync status - see `SyncStatusBus.kt` (the +/// in-process pub/sub it mirrors) and `MainActivity.kt`'s `syncStatusMap`. +class SyncStatusSnapshot { + final String? accountId; + final bool syncing; + final Set syncingFileIds; + final Set syncedFileIds; + final List conflicts; + + const SyncStatusSnapshot({ + required this.accountId, + required this.syncing, + required this.syncingFileIds, + required this.syncedFileIds, + required this.conflicts, + }); + + factory SyncStatusSnapshot.fromMap(Map map) { + return SyncStatusSnapshot( + accountId: map['accountId'] as String?, + syncing: map['syncing'] as bool? ?? false, + syncingFileIds: ((map['syncingFileIds'] as List?) ?? const []) + .cast() + .toSet(), + syncedFileIds: ((map['syncedFileIds'] as List?) ?? const []) + .cast() + .toSet(), + conflicts: ((map['conflicts'] as List?) ?? const []) + .cast>() + .map(SyncConflictInfo.fromMap) + .toList(), + ); + } +} + +/// Talks to `SyncWorker`/`SyncEngine.kt`'s Android WorkManager-based device +/// sync (see `server.md`'s "Device sync" section for the full design) - +/// Dart's job here is only to gather what the native side needs, push a +/// fresh config whenever it changes, and listen for live status; the actual +/// PROPFIND-walk/diff/GET/PUT engine runs entirely in Kotlin, independent +/// of the Flutter engine. +class SyncService { + static const _channel = MethodChannel('dev.ayushya.noo/sync_service'); + static const _statusChannel = EventChannel( + 'dev.ayushya.noo/sync_service/status', + ); + + /// Cancels (if [ServerProvider.syncedPaths] is now empty) or re-enqueues + /// the periodic sync job with fresh account/credentials/path-list/ + /// network-constraint data - call this any time one of those changes, + /// since a periodic `WorkRequest`'s input is fixed at enqueue time and + /// can only be updated by re-enqueueing. + static Future reschedule(ServerProvider provider) async { + final accountId = provider.activeAccountId; + final service = provider.service; + final authHeader = service?.authHeaders['Authorization']; + if (accountId == null || service == null || authHeader == null) { + return cancel(); + } + + final paths = provider.syncEverything ? ['/'] : provider.syncedPaths; + await _channel.invokeMethod('reschedule', { + 'accountId': accountId, + 'serverUrl': provider.serverUrl, + 'username': provider.username, + 'authHeader': authHeader, + 'folders': jsonEncode(paths), + 'wifiOnly': !provider.syncOnCellular, + }); + } + + static Future cancel() async { + await _channel.invokeMethod('cancel'); + } + + /// Runs a one-off sync pass immediately (Settings' "Sync now"), + /// independent of the periodic schedule. + static Future syncNow(ServerProvider provider) async { + final accountId = provider.activeAccountId; + final service = provider.service; + final authHeader = service?.authHeaders['Authorization']; + if (accountId == null || service == null || authHeader == null) { + throw Exception('Not logged in.'); + } + + final paths = provider.syncEverything ? ['/'] : provider.syncedPaths; + await _channel.invokeMethod('syncNow', { + 'accountId': accountId, + 'serverUrl': provider.serverUrl, + 'username': provider.username, + 'authHeader': authHeader, + 'folders': jsonEncode(paths), + }); + } + + /// One-shot status snapshot - used to seed [ServerProvider]'s state right + /// after login/account-switch, before the first [statusStream] event + /// arrives. + static Future getStatus() async { + final result = await _channel.invokeMethod>( + 'getSyncStatus', + ); + return SyncStatusSnapshot.fromMap(result ?? const {}); + } + + /// Live status updates pushed from `SyncStatusBus` (Kotlin) - syncing + /// started/stopped, per-file progress, new/resolved conflicts. + static Stream get statusStream { + return _statusChannel.receiveBroadcastStream().map( + (event) => SyncStatusSnapshot.fromMap(event as Map), + ); + } + + /// In-app conflict resolution (the header's "Keep local"/"Use server" + /// buttons) - shares the exact same native `ConflictResolveWorker` + /// enqueue path the notification actions use, just triggered from Dart + /// instead of a `PendingIntent`. + static Future resolveConflict( + ServerProvider provider, + SyncConflictInfo conflict, + String resolution, + ) async { + final service = provider.service; + final authHeader = service?.authHeaders['Authorization']; + if (service == null || authHeader == null) { + throw Exception('Not logged in.'); + } + await _channel.invokeMethod('resolveConflict', { + 'accountId': conflict.accountId, + 'serverUrl': provider.serverUrl, + 'username': provider.username, + 'authHeader': authHeader, + 'fileId': conflict.fileId, + 'remotePath': conflict.remotePath, + 'relPath': conflict.relPath, + 'resolution': resolution, + }); + } +} diff --git a/lib/views/account_view.dart b/lib/views/account_view.dart index 4c47aeb..b0ffb83 100644 --- a/lib/views/account_view.dart +++ b/lib/views/account_view.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import '../models/app_tab.dart'; import '../models/saved_account.dart'; import '../providers/server_provider.dart'; +import '../services/sync_service.dart'; import '../theme/app_theme.dart'; import '../widgets/frosted_glass_container.dart'; import '../widgets/seek_bar_painter.dart'; @@ -237,6 +238,17 @@ class AccountView extends StatelessWidget { const _SecurityCard(), const SizedBox(height: 24), + // Device Sync Section + Text( + 'Device Sync', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 10), + const _DeviceSyncCard(), + const SizedBox(height: 24), + // Material You Design Settings Text( 'Material You Aesthetics', @@ -722,6 +734,120 @@ class _SecurityCard extends StatelessWidget { } } +class _DeviceSyncCard extends StatefulWidget { + const _DeviceSyncCard(); + + @override + State<_DeviceSyncCard> createState() => _DeviceSyncCardState(); +} + +class _DeviceSyncCardState extends State<_DeviceSyncCard> { + bool _syncingNow = false; + + Future _syncNow(ServerProvider provider) async { + setState(() => _syncingNow = true); + try { + await SyncService.syncNow(provider); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Could not start sync: $e'), + behavior: SnackBarBehavior.floating, + ), + ); + } + } finally { + if (mounted) setState(() => _syncingNow = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final provider = context.watch(); + final folders = provider.syncedPaths; + final everything = provider.syncEverything; + + return Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SwitchListTile( + secondary: const Icon(Icons.cloud_sync_rounded), + title: const Text('Sync everything'), + subtitle: const Text( + 'Mirror the whole account instead of picking folders', + ), + value: everything, + onChanged: provider.setSyncEverything, + ), + const Divider(height: 1, indent: 16, endIndent: 16), + if (everything) + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Text( + 'Every folder in this account is being synced to this ' + 'device.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ) + else if (folders.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Text( + 'No folders synced yet - select a folder in Files and use ' + '"Sync to device" to mirror it here for offline access.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ) + else + for (final folder in folders) ...[ + ListTile( + leading: const Icon(Icons.sync_rounded), + title: Text(folder), + trailing: IconButton( + icon: const Icon(Icons.close_rounded), + tooltip: 'Stop syncing', + onPressed: () => provider.removeSyncedPath(folder), + ), + ), + const Divider(height: 1, indent: 16, endIndent: 16), + ], + SwitchListTile( + secondary: const Icon(Icons.signal_cellular_alt_rounded), + title: const Text('Sync on cellular'), + subtitle: const Text('Off = background sync only runs on Wi-Fi'), + value: provider.syncOnCellular, + onChanged: provider.setSyncOnCellular, + ), + const Divider(height: 1, indent: 16, endIndent: 16), + ListTile( + leading: _syncingNow + ? const Padding( + padding: EdgeInsets.all(2), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : const Icon(Icons.sync_rounded), + title: const Text('Sync now'), + enabled: (everything || folders.isNotEmpty) && !_syncingNow, + onTap: () => _syncNow(provider), + ), + ], + ), + ); + } +} + class _BottomBarAppearanceCard extends StatefulWidget { const _BottomBarAppearanceCard(); diff --git a/lib/views/files_view.dart b/lib/views/files_view.dart index 3de3a9c..e4105fe 100644 --- a/lib/views/files_view.dart +++ b/lib/views/files_view.dart @@ -1,8 +1,10 @@ import 'package:file_picker/file_picker.dart'; +import 'package:file_saver/file_saver.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:path/path.dart' as p; import 'package:provider/provider.dart'; import 'package:share_plus/share_plus.dart'; import '../models/nextcloud_item.dart'; @@ -21,6 +23,7 @@ import '../widgets/share_sheet.dart'; import '../widgets/sort_menu_button.dart'; import '../widgets/sticky_header_delegate.dart'; import '../widgets/swipeable_item.dart'; +import '../widgets/sync_status_badge.dart'; import '../widgets/synced_header_scaffold.dart'; import 'file_viewer_screen.dart'; import 'move_copy_destination_picker.dart'; @@ -284,6 +287,18 @@ class _FilesViewState extends State label: 'Rename', onTap: () => _renameItem(provider, selected.single), ), + if (selected.length == 1) + SelectionAction( + icon: provider.isPathSynced(selected.single.path) + ? Icons.sync_rounded + : Icons.sync_outlined, + label: provider.isPathSynced(selected.single.path) + ? 'Stop syncing to device' + : 'Sync to device', + onTap: () => provider.isPathSynced(selected.single.path) + ? provider.removeSyncedPath(selected.single.path) + : provider.addSyncedPath(selected.single.path), + ), if (selected.length == 1) SelectionAction( icon: Icons.info_outline_rounded, @@ -803,6 +818,46 @@ class _FilesViewState extends State if (files.isEmpty) return; final messenger = ScaffoldMessenger.of(context); + + // Already mirrored locally by device sync (every selected file, not + // just some)? Save straight from the local copy instead of a fresh + // network fetch through DownloadService - see + // ServerProvider.localSyncedFilePath. + final localPaths = await Future.wait( + files.map(provider.localSyncedFilePath), + ); + if (localPaths.every((path) => path != null)) { + try { + for (var i = 0; i < files.length; i++) { + final ext = p.extension(files[i].name).replaceFirst('.', ''); + final baseName = p.basenameWithoutExtension(files[i].name); + await FileSaver.instance.saveFile( + name: baseName, + filePath: localPaths[i]!, + ext: ext, + ); + } + messenger.showSnackBar( + SnackBar( + content: Text( + files.length == 1 + ? 'Downloaded ${files.first.name}' + : 'Downloaded ${files.length} files', + ), + behavior: SnackBarBehavior.floating, + ), + ); + } catch (e) { + messenger.showSnackBar( + SnackBar( + content: Text('Could not save file(s): $e'), + behavior: SnackBarBehavior.floating, + ), + ); + } + return; + } + try { await DownloadService.startDownload(provider, files); messenger.showSnackBar( @@ -930,17 +985,29 @@ class _FilesViewState extends State padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ - SelectableThumbnail( - isSelected: isSelected, - size: 44, - checkmarkSize: 24, - child: ItemThumbnail( - item: item, - provider: provider, - size: 44, - borderRadius: 12, - iconSize: 22, - ), + Stack( + clipBehavior: Clip.none, + children: [ + SelectableThumbnail( + isSelected: isSelected, + size: 44, + checkmarkSize: 24, + child: ItemThumbnail( + item: item, + provider: provider, + size: 44, + borderRadius: 12, + iconSize: 22, + ), + ), + Positioned( + right: -2, + bottom: -2, + child: SyncStatusBadge( + status: provider.syncStatusFor(item), + ), + ), + ], ), const SizedBox(width: 14), Expanded( @@ -1036,12 +1103,21 @@ class _FilesViewState extends State onLongPress: provider.isPicking && !provider.pickRequest!.allowMultiple ? null : () => _toggleSelection(item), - child: SelectableThumbnail( - isSelected: isSelected, - checkmarkSize: 32, - child: isMedia - ? _buildMediaGridContent(context, item, provider) - : _buildPlainGridContent(context, item), + child: Stack( + children: [ + SelectableThumbnail( + isSelected: isSelected, + checkmarkSize: 32, + child: isMedia + ? _buildMediaGridContent(context, item, provider) + : _buildPlainGridContent(context, item), + ), + Positioned( + right: 6, + bottom: 6, + child: SyncStatusBadge(status: provider.syncStatusFor(item)), + ), + ], ), ), ); diff --git a/lib/widgets/share_sheet.dart b/lib/widgets/share_sheet.dart index ca414e1..0bba249 100644 --- a/lib/widgets/share_sheet.dart +++ b/lib/widgets/share_sheet.dart @@ -236,9 +236,17 @@ class _ShareSheetState extends State { setState(() => _isSharingFile = true); final messenger = ScaffoldMessenger.of(context); try { - final tempDir = await getTemporaryDirectory(); - final tempPath = p.join(tempDir.path, widget.item.name); - await provider.service!.downloadToFile(widget.item.path, tempPath); + // Already mirrored locally by device sync? Share that copy straight + // away instead of a fresh WebDAV fetch - see + // ServerProvider.localSyncedFilePath. + final tempPath = + await provider.localSyncedFilePath(widget.item) ?? + await () async { + final tempDir = await getTemporaryDirectory(); + final path = p.join(tempDir.path, widget.item.name); + await provider.service!.downloadToFile(widget.item.path, path); + return path; + }(); if (!mounted) return; await SharePlus.instance.share( ShareParams( diff --git a/lib/widgets/sync_status_badge.dart b/lib/widgets/sync_status_badge.dart new file mode 100644 index 0000000..8ca54df --- /dev/null +++ b/lib/widgets/sync_status_badge.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import '../models/sync_status.dart'; + +/// A small corner badge over a thumbnail showing device-sync status - a +/// `cloud_done` badge once a file's mirrored locally, `sync` while it's +/// actively being transferred. Nothing is drawn for `none`/`conflict` +/// (conflicts surface in the sync header, not as per-tile noise). +class SyncStatusBadge extends StatelessWidget { + final SyncItemStatus status; + + const SyncStatusBadge({super.key, required this.status}); + + @override + Widget build(BuildContext context) { + if (status != SyncItemStatus.syncing && status != SyncItemStatus.synced) { + return const SizedBox.shrink(); + } + final colorScheme = Theme.of(context).colorScheme; + final icon = status == SyncItemStatus.syncing + ? Icons.sync_rounded + : Icons.cloud_done_rounded; + + return Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: colorScheme.surface, + shape: BoxShape.circle, + border: Border.all(color: colorScheme.surface, width: 1.5), + ), + child: Icon(icon, size: 13, color: colorScheme.primary), + ); + } +} diff --git a/lib/widgets/synced_header_scaffold.dart b/lib/widgets/synced_header_scaffold.dart index 28628a7..7ef2695 100644 --- a/lib/widgets/synced_header_scaffold.dart +++ b/lib/widgets/synced_header_scaffold.dart @@ -1,7 +1,23 @@ import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../models/nextcloud_item.dart'; +import '../models/sync_status.dart'; import '../providers/server_provider.dart'; +/// Icon/label for the persistent header chip/panel - reflects device-sync +/// status (see `ServerProvider.syncHeaderStatus`), not the WebDAV +/// directory-listing refresh the pull gesture itself triggers (that has +/// its own, separate floating spinner bubble - see +/// `_SyncedHeaderScaffoldState`'s `_isRefreshing`). +(IconData, String) _syncHeaderDisplay(SyncHeaderStatus status) { + return switch (status) { + SyncHeaderStatus.off => (Icons.cloud_off_rounded, 'Sync off'), + SyncHeaderStatus.syncing => (Icons.cloud_sync_rounded, 'Syncing…'), + SyncHeaderStatus.done => (Icons.cloud_done_rounded, 'Synced'), + SyncHeaderStatus.alert => (Symbols.cloud_alert_rounded, 'Sync issue'), + }; +} + String formatBytes(int bytes) { if (bytes <= 0) return '0 B'; if (bytes < 1024) return '$bytes B'; @@ -188,7 +204,9 @@ class _SyncedHeaderScaffoldState extends State { stretch: widget.selectionBar == null, expandedHeight: widget.selectionBar != null ? kToolbarHeight - : (_headerLocked ? 190 : kToolbarHeight), + : (_headerLocked + ? 190.0 + provider.syncConflicts.length * 52.0 + : kToolbarHeight), collapsedHeight: kToolbarHeight, backgroundColor: colorScheme.surfaceContainer, surfaceTintColor: colorScheme.surfaceContainer, @@ -278,7 +296,11 @@ class _SyncStatusChip extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final quota = provider.quota; + final status = provider.syncHeaderStatus; + final (icon, label) = _syncHeaderDisplay(status); + final iconColor = status == SyncHeaderStatus.alert + ? colorScheme.error + : colorScheme.primary; return Padding( padding: const EdgeInsets.only(left: 12), @@ -293,16 +315,10 @@ class _SyncStatusChip extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon( - provider.isLoading - ? Icons.cloud_sync_rounded - : Icons.cloud_done_outlined, - color: colorScheme.primary, - size: 18, - ), + Icon(icon, color: iconColor, size: 18), const SizedBox(width: 6), Text( - quota != null ? formatQuota(quota) : 'Sync', + label, style: theme.textTheme.labelMedium?.copyWith( fontWeight: FontWeight.w700, ), @@ -348,6 +364,9 @@ class _SyncedStretchPanel extends StatelessWidget { // before the 100px lock threshold. final progress = forceVisible ? 1.0 : (stretch / 50).clamp(0.0, 1.0); final quota = provider.quota; + final status = provider.syncHeaderStatus; + final (_, statusLabel) = _syncHeaderDisplay(status); + final conflicts = provider.syncConflicts; return Stack( children: [ @@ -365,7 +384,7 @@ class _SyncedStretchPanel extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - provider.isLoading ? 'Syncing…' : 'Synced', + statusLabel, style: theme.textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w700, ), @@ -414,6 +433,17 @@ class _SyncedStretchPanel extends StatelessWidget { ], ), ), + if (conflicts.isNotEmpty) ...[ + const SizedBox(height: 10), + for (final conflict in conflicts) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _SyncConflictRow( + conflict: conflict, + provider: provider, + ), + ), + ], ], ), ), @@ -424,3 +454,93 @@ class _SyncedStretchPanel extends StatelessWidget { ); } } + +/// One row in the locked-open panel's conflicts section - a file that +/// changed both locally and on the server since the last sync, with the +/// same two resolutions the sync-conflict notification offers (see +/// `SyncWorker.notifyConflicts`/`ConflictResolveWorker` on the native +/// side), just triggered in-app instead. +class _SyncConflictRow extends StatefulWidget { + final SyncConflictInfo conflict; + final ServerProvider provider; + + const _SyncConflictRow({required this.conflict, required this.provider}); + + @override + State<_SyncConflictRow> createState() => _SyncConflictRowState(); +} + +class _SyncConflictRowState extends State<_SyncConflictRow> { + bool _resolving = false; + + Future _resolve(bool useLocal) async { + setState(() => _resolving = true); + try { + await widget.provider.resolveSyncConflict( + widget.conflict, + useLocal: useLocal, + ); + } catch (_) { + if (mounted) setState(() => _resolving = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: colorScheme.errorContainer.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Icon(Symbols.cloud_alert_rounded, color: colorScheme.error, size: 18), + const SizedBox(width: 10), + Expanded( + child: Text( + widget.conflict.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + if (_resolving) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + else ...[ + TextButton( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: () => _resolve(true), + child: const Text('Keep local'), + ), + TextButton( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: () => _resolve(false), + child: const Text('Use server'), + ), + ], + ], + ), + ); + } +}